From 86edfab4edb10b3528fb9e89cbd1b1e1c1a2c70f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 26 Jul 2026 20:30:04 -0700 Subject: [PATCH 1/8] feat(agent): add durable agent invocations --- TERMINOLOGY.md | 7 + packages/junior/src/chat/README.md | 11 +- .../src/chat/agent-dispatch/heartbeat.ts | 34 +- .../junior/src/chat/agent-dispatch/work.ts | 1 + .../src/chat/agent-invocations/README.md | 49 ++ .../src/chat/agent-invocations/store.ts | 392 ++++++++++ .../src/chat/agent-invocations/types.ts | 99 +++ .../junior/src/chat/agent-invocations/work.ts | 561 ++++++++++++++ packages/junior/src/chat/agent/request.ts | 1 + packages/junior/src/chat/app/production.ts | 16 +- .../junior/src/chat/conversations/README.md | 5 +- .../src/chat/conversations/sql/purge.ts | 17 +- .../src/chat/conversations/sql/store.ts | 46 ++ .../junior/src/chat/conversations/store.ts | 7 + .../junior/src/chat/state/turn-session.ts | 18 +- .../junior/src/chat/task-execution/README.md | 13 +- .../src/chat/task-execution/slack-work.ts | 4 +- .../junior/src/chat/task-execution/state.ts | 57 +- .../junior/src/chat/task-execution/store.ts | 3 - .../junior/src/chat/task-execution/worker.ts | 34 +- packages/junior/src/db/schema.ts | 8 + .../junior/src/db/schema/agent-invocations.ts | 100 +++ .../component/conversations/retention.test.ts | 28 + .../services/turn-session-record.test.ts | 1 + .../task-execution/conversation-work.test.ts | 38 +- .../integration/agent-invocation-work.test.ts | 693 ++++++++++++++++++ .../junior/tests/unit/agent-request.test.ts | 38 + 27 files changed, 2224 insertions(+), 57 deletions(-) create mode 100644 packages/junior/src/chat/agent-invocations/README.md create mode 100644 packages/junior/src/chat/agent-invocations/store.ts create mode 100644 packages/junior/src/chat/agent-invocations/types.ts create mode 100644 packages/junior/src/chat/agent-invocations/work.ts create mode 100644 packages/junior/src/db/schema/agent-invocations.ts create mode 100644 packages/junior/tests/integration/agent-invocation-work.test.ts create mode 100644 packages/junior/tests/unit/agent-request.test.ts diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md index 2fbec4372..70b508db6 100644 --- a/TERMINOLOGY.md +++ b/TERMINOLOGY.md @@ -46,6 +46,10 @@ Canonical words used across Junior's code and documentation. - **Session record**: the persisted read model for one resumable turn. - **Conversation execution**: mutable operational state for a conversation, such as mailbox state, worker lease, checkpoints, and activity status. +- **Agent binding**: a named reference, scoped to one parent agent + conversation, that reuses one child conversation and its history. +- **Agent invocation**: one retry-safe delegated task sent from a parent agent + conversation to a child conversation, including its durable terminal result. - **Reasoning level**: the configured or selected amount of model reasoning for a turn: `none`, `low`, `medium`, `high`, or `xhigh`. - **Reply**: a destination-visible message owned by delivery or reply-policy @@ -54,6 +58,9 @@ Canonical words used across Junior's code and documentation. ## Naming Guidance - Use `turn`, `run`, and `slice` only with the meanings above. +- Use `agent invocation` for delegated child work; do not shorten it to + `invocation` where it could be confused with a model or serverless + invocation. - Use `message` for platform chat content. Use `user_message`, `assistant_message`, and `tool_result` for replayable agent history. - Use `agent history item` when referring to those three native event types as diff --git a/packages/junior/src/chat/README.md b/packages/junior/src/chat/README.md index 4f708df9d..ef79442a5 100644 --- a/packages/junior/src/chat/README.md +++ b/packages/junior/src/chat/README.md @@ -9,7 +9,8 @@ file. 1. `ingress/` parses, classifies, and normalizes source events. 2. Mailbox-backed sources append work and send a queue nudge through - `task-execution/`. + `task-execution/`. `agent-invocations/` uses the same mailbox for + destinationless child work. 3. A worker acquires the conversation lease, drains pending input, and restores persisted conversation state. 4. `runtime/` prepares and orchestrates the run; `agent/` owns Pi execution. @@ -34,6 +35,8 @@ mailbox-backed provider. - `runtime/`: turn orchestration and provider-neutral delivery callbacks. - `agent-dispatch/`: plugin dispatch authority, mailbox adaptation, and plugin-facing outcome projection. +- `agent-invocations/`: durable parent/child bindings, delegated work, and + internal terminal results. - `agent/` and `pi/`: model execution and Pi state conversion. - `services/`: consumer-owned domain decisions. - `state/` and `conversations/`: persistence by concern. @@ -90,8 +93,10 @@ delegation without becoming the execution actor or a general task owner. back past delivered assistant output. - Unexpected failures propagate to the boundary that owns capture and fallback delivery. -- Actor, destination, conversation, and credential context remain explicit - across asynchronous boundaries. +- Actor, execution destination, conversation, and credential context remain + explicit across asynchronous boundaries. A destinationless child + conversation receives its bounded execution destination from its durable + agent invocation. Follow `../../../../policies/context-bound-systems.md`, `../../../../policies/provider-boundaries.md`, and the feature READMEs in diff --git a/packages/junior/src/chat/agent-dispatch/heartbeat.ts b/packages/junior/src/chat/agent-dispatch/heartbeat.ts index 5093f75dd..a4a8582ed 100644 --- a/packages/junior/src/chat/agent-dispatch/heartbeat.ts +++ b/packages/junior/src/chat/agent-dispatch/heartbeat.ts @@ -1,9 +1,11 @@ import { getPlugins } from "@/chat/plugins/agent-hooks"; -import { logException, logInfo } from "@/chat/logging"; +import { logException, logInfo, logWarn } from "@/chat/logging"; import { recoverConversationWork } from "@/chat/task-execution/heartbeat"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; import { createHeartbeatContext } from "./context"; +import { listPendingAgentInvocationMailboxAppends } from "@/chat/agent-invocations/store"; +import { enqueueAgentInvocation } from "@/chat/agent-invocations/work"; import { confirmDispatchMailboxAppend, getDispatchRecord, @@ -16,6 +18,7 @@ import { AGENT_DISPATCH_MAX_AGE_MS, enqueueAgentDispatch } from "./work"; const DEFAULT_PLUGIN_LIMIT = 25; const PLUGIN_HEARTBEAT_TIMEOUT_MS = 25_000; const DISPATCH_MAILBOX_APPEND_LIMIT = 100; +const AGENT_INVOCATION_MAILBOX_APPEND_LIMIT = 100; async function runWithTimeout( promise: Promise, timeoutMs: number, @@ -125,6 +128,31 @@ export async function recoverPendingDispatchMailboxAppends(args: { } } +/** Repair invocation creation that stopped before its idempotent mailbox append. */ +export async function recoverPendingAgentInvocationMailboxAppends(args: { + conversationWorkQueue: ConversationWorkQueue; + nowMs: number; +}): Promise { + const invocations = await listPendingAgentInvocationMailboxAppends( + AGENT_INVOCATION_MAILBOX_APPEND_LIMIT, + ); + for (const invocation of invocations) { + try { + await enqueueAgentInvocation(invocation, { + nowMs: args.nowMs, + queue: args.conversationWorkQueue, + }); + } catch { + logWarn( + "agent_invocation_mailbox_append_recovery_failed", + {}, + { "app.agent.invocation_id": invocation.invocationId }, + "Pending agent invocation mailbox append recovery will retry", + ); + } + } +} + /** Run the core heartbeat phases. */ export async function runHeartbeat(args: { conversationWorkQueue?: ConversationWorkQueue; @@ -139,6 +167,10 @@ export async function runHeartbeat(args: { conversationWorkQueue: queue, nowMs: args.nowMs, }); + await recoverPendingAgentInvocationMailboxAppends({ + conversationWorkQueue: queue, + nowMs: args.nowMs, + }); await runPluginHeartbeats({ conversationWorkQueue: queue, nowMs: args.nowMs, diff --git a/packages/junior/src/chat/agent-dispatch/work.ts b/packages/junior/src/chat/agent-dispatch/work.ts index 0d18a5518..fb162aabb 100644 --- a/packages/junior/src/chat/agent-dispatch/work.ts +++ b/packages/junior/src/chat/agent-dispatch/work.ts @@ -426,6 +426,7 @@ export function createAgentDispatchConversationWorker( ); } if ( + !context.destination || context.destination.platform !== dispatch.destination.platform || context.destination.teamId !== dispatch.destination.teamId || context.destination.channelId !== dispatch.destination.channelId diff --git a/packages/junior/src/chat/agent-invocations/README.md b/packages/junior/src/chat/agent-invocations/README.md new file mode 100644 index 000000000..78ff4c9ca --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/README.md @@ -0,0 +1,49 @@ +# Agent Invocations + +This module owns durable parent-to-child agent work. It gives delegated work a +stable identity, schedules it through the shared conversation mailbox, and +stores the terminal result for its parent to read later. + +## Records + +- An **agent binding** maps one name within a parent agent conversation to one + destinationless child conversation. Reusing the name reuses that child's + history. +- An **agent invocation** is one retry-safe task sent to a child. Its + `invocationId` is derived from the parent conversation and caller-supplied + idempotency key. +- An invocation without a name gets an invocation-scoped child conversation. +- Child conversation lineage is immutable. Recursive delegation is disabled + until depth, cancellation, and authority rules are defined. + +SQL owns bindings, invocation status, bounded execution authority, and terminal +results. The conversation event log and session record continue to own agent +history and resumable execution. Mailbox entries contain only the invocation +reference needed to join those records. Invocation content inherits the root +conversation's visibility and retention window and is deleted with that +conversation tree. + +## Execution + +1. Creation writes the invocation before attempting the mailbox append. +2. The idempotent mailbox append sends a normal conversation queue wake. +3. The invocation router recognizes destinationless child work and advances it + through the shared `AgentRunner`. +4. A cooperative yield keeps the same turn and invocation active for another + execution slice. +5. Completion writes the session record first, then projects the immutable + result or error onto the invocation. +6. The heartbeat repairs invocations left in `mailboxStatus: "pending"`. + +The child conversation has no provider destination. Each invocation carries +the actor, credential context, source, and destination that bound its tool +execution. Child output is an internal result; provider delivery remains owned +by the parent-facing runtime. + +## Current Boundary + +This slice provides durable storage and execution plumbing. It does not yet +expose model tools for creating or waiting on invocations, inject child results +into a parent turn, support recursive children, or implement cancellation. +Those behaviors should build on the invocation record rather than introducing +another scheduler or execution loop. diff --git a/packages/junior/src/chat/agent-invocations/store.ts b/packages/junior/src/chat/agent-invocations/store.ts new file mode 100644 index 000000000..982f8c8a6 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/store.ts @@ -0,0 +1,392 @@ +import { createHash } from "node:crypto"; +import { and, asc, eq, inArray } from "drizzle-orm"; +import { getConversationStore, getSqlExecutor } from "@/chat/db"; +import { juniorAgentBindings, juniorAgentInvocations } from "@/db/schema"; +import { + agentBindingSchema, + agentInvocationSchema, + createAgentInvocationSchema, + type AgentBinding, + type AgentInvocation, + type AgentInvocationStatus, + type CreateAgentInvocationInput, +} from "./types"; + +const CREATE_LOCK_PREFIX = "junior:agent_invocation:create"; +const TERMINAL_AGENT_INVOCATION_STATUSES = [ + "blocked", + "completed", + "failed", +] as const satisfies readonly AgentInvocationStatus[]; +const NON_TERMINAL_AGENT_INVOCATION_STATUSES = [ + "pending", + "running", + "awaiting_resume", +] as const satisfies readonly AgentInvocationStatus[]; + +function stableId(prefix: string, ...parts: string[]): string { + const digest = createHash("sha256") + .update(parts.join("\0")) + .digest("hex") + .slice(0, 32); + return `${prefix}:${digest}`; +} + +/** Return the stable identity for one retry-safe delegated task. */ +export function getAgentInvocationId( + parentConversationId: string, + idempotencyKey: string, +): string { + return stableId("agent-invocation", parentConversationId, idempotencyKey); +} + +/** Return the stable child identity for one invocation without a named binding. */ +export function getEphemeralAgentConversationId(invocationId: string): string { + return stableId("agent", invocationId); +} + +/** Return the stable child identity for one named binding. */ +export function getNamedAgentConversationId( + parentConversationId: string, + name: string, +): string { + return stableId("agent", parentConversationId, name); +} + +/** Return the stable turn identity advanced by one agent invocation. */ +export function getAgentInvocationTurnId(invocationId: string): string { + return `agent-invocation:${invocationId}`; +} + +/** Return the stable mailbox identity for one agent invocation. */ +export function getAgentInvocationMessageId(invocationId: string): string { + return `agent-invocation:${invocationId}:input`; +} + +function bindingFromRow( + row: typeof juniorAgentBindings.$inferSelect, +): AgentBinding { + return agentBindingSchema.parse({ + childConversationId: row.childConversationId, + createdAtMs: row.createdAt.getTime(), + name: row.name, + parentConversationId: row.parentConversationId, + ...(row.reasoningLevel ? { reasoningLevel: row.reasoningLevel } : {}), + updatedAtMs: row.updatedAt.getTime(), + }); +} + +function invocationFromRow( + row: typeof juniorAgentInvocations.$inferSelect, +): AgentInvocation { + return agentInvocationSchema.parse({ + actor: row.actor, + ...(row.agentName ? { agentName: row.agentName } : {}), + childConversationId: row.childConversationId, + createdAtMs: row.createdAt.getTime(), + ...(row.credentialContext + ? { credentialContext: row.credentialContext } + : {}), + destination: row.destination, + ...(row.destinationVisibility + ? { destinationVisibility: row.destinationVisibility } + : {}), + ...(row.errorMessage !== null ? { errorMessage: row.errorMessage } : {}), + idempotencyKey: row.idempotencyKey, + input: row.input, + invocationId: row.invocationId, + mailboxStatus: row.mailboxStatus, + parentConversationId: row.parentConversationId, + ...(row.reasoningLevel ? { reasoningLevel: row.reasoningLevel } : {}), + ...(row.result !== null ? { result: row.result } : {}), + source: row.source, + status: row.status, + ...(row.terminalAt ? { terminalAtMs: row.terminalAt.getTime() } : {}), + updatedAtMs: row.updatedAt.getTime(), + }); +} + +/** Require every durable creation input to match before replaying one key. */ +function sameCreateInput( + invocation: AgentInvocation, + input: ReturnType, +): boolean { + return ( + invocation.parentConversationId === input.parentConversationId && + invocation.idempotencyKey === input.idempotencyKey && + invocation.agentName === input.agentName && + invocation.input === input.input && + invocation.reasoningLevel === input.reasoningLevel && + JSON.stringify(invocation.actor) === JSON.stringify(input.actor) && + JSON.stringify(invocation.credentialContext) === + JSON.stringify(input.credentialContext) && + JSON.stringify(invocation.destination) === + JSON.stringify(input.destination) && + invocation.destinationVisibility === input.destinationVisibility && + JSON.stringify(invocation.source) === JSON.stringify(input.source) + ); +} + +/** Read one named child binding in its parent-agent scope. */ +export async function getAgentBinding(args: { + name: string; + parentConversationId: string; +}): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentBindings) + .where( + and( + eq(juniorAgentBindings.parentConversationId, args.parentConversationId), + eq(juniorAgentBindings.name, args.name), + ), + ); + return rows[0] ? bindingFromRow(rows[0]) : undefined; +} + +/** Read one durable agent invocation. */ +export async function getAgentInvocation( + invocationId: string, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where(eq(juniorAgentInvocations.invocationId, invocationId)); + return rows[0] ? invocationFromRow(rows[0]) : undefined; +} + +/** Resolve the single invocation that may resume for one child conversation. */ +export async function getActiveAgentInvocationForConversation( + childConversationId: string, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where( + and( + eq(juniorAgentInvocations.childConversationId, childConversationId), + inArray(juniorAgentInvocations.status, ["running", "awaiting_resume"]), + ), + ) + .orderBy(asc(juniorAgentInvocations.createdAt)) + .limit(2); + if (rows.length > 1) { + throw new Error( + `Child conversation ${childConversationId} has multiple active agent invocations`, + ); + } + return rows[0] ? invocationFromRow(rows[0]) : undefined; +} + +/** + * Create or replay one invocation, reusing named child conversations and + * keeping ephemeral child identities scoped to the invocation. + */ +export async function createAgentInvocation( + rawInput: CreateAgentInvocationInput, + nowMs = Date.now(), +): Promise<{ invocation: AgentInvocation; status: "created" | "existing" }> { + const input = createAgentInvocationSchema.parse(rawInput); + const invocationId = getAgentInvocationId( + input.parentConversationId, + input.idempotencyKey, + ); + const lockName = `${CREATE_LOCK_PREFIX}:${invocationId}`; + return await getSqlExecutor().withLock(lockName, async () => { + const existing = await getAgentInvocation(invocationId); + if (existing) { + if (!sameCreateInput(existing, input)) { + throw new Error( + `Agent invocation idempotency key was reused with different input for ${invocationId}`, + ); + } + return { invocation: existing, status: "existing" }; + } + + const childConversationId = input.agentName + ? getNamedAgentConversationId(input.parentConversationId, input.agentName) + : getEphemeralAgentConversationId(invocationId); + await getConversationStore().createChild({ + childConversationId, + parentConversationId: input.parentConversationId, + nowMs, + source: "internal", + }); + + if (input.agentName) { + await getSqlExecutor() + .db() + .insert(juniorAgentBindings) + .values({ + childConversationId, + createdAt: new Date(nowMs), + name: input.agentName, + parentConversationId: input.parentConversationId, + reasoningLevel: input.reasoningLevel ?? null, + updatedAt: new Date(nowMs), + }) + .onConflictDoNothing(); + const binding = await getAgentBinding({ + name: input.agentName, + parentConversationId: input.parentConversationId, + }); + if (!binding || binding.childConversationId !== childConversationId) { + throw new Error( + `Named agent binding did not resolve to ${childConversationId}`, + ); + } + if (binding.reasoningLevel !== input.reasoningLevel) { + throw new Error( + `Named agent binding policy changed for ${input.agentName}`, + ); + } + } + + await getSqlExecutor() + .db() + .insert(juniorAgentInvocations) + .values({ + invocationId, + idempotencyKey: input.idempotencyKey, + parentConversationId: input.parentConversationId, + childConversationId, + agentName: input.agentName ?? null, + input: input.input, + actor: input.actor, + credentialContext: input.credentialContext ?? null, + source: input.source, + destination: input.destination, + destinationVisibility: input.destinationVisibility ?? null, + reasoningLevel: input.reasoningLevel ?? null, + status: "pending", + mailboxStatus: "pending", + createdAt: new Date(nowMs), + updatedAt: new Date(nowMs), + terminalAt: null, + }) + .onConflictDoNothing(); + const invocation = await getAgentInvocation(invocationId); + if (!invocation || !sameCreateInput(invocation, input)) { + throw new Error(`Agent invocation creation raced for ${invocationId}`); + } + return { invocation, status: "created" }; + }); +} + +/** List bounded invocation records whose mailbox append still needs repair. */ +export async function listPendingAgentInvocationMailboxAppends( + limit = 100, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where(eq(juniorAgentInvocations.mailboxStatus, "pending")) + .orderBy(asc(juniorAgentInvocations.createdAt)) + .limit(limit); + return rows.map(invocationFromRow); +} + +/** Record that the invocation's idempotent mailbox append completed. */ +export async function markAgentInvocationMailboxAppended( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ mailboxStatus: "appended", updatedAt: new Date(nowMs) }) + .where(eq(juniorAgentInvocations.invocationId, invocationId)); +} + +/** Mark one non-terminal invocation as actively executing. */ +export async function markAgentInvocationRunning( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ status: "running", updatedAt: new Date(nowMs) }) + .where( + and( + eq(juniorAgentInvocations.invocationId, invocationId), + inArray( + juniorAgentInvocations.status, + NON_TERMINAL_AGENT_INVOCATION_STATUSES, + ), + ), + ); + return await getAgentInvocation(invocationId); +} + +/** Mark one invocation as waiting for another shared execution slice. */ +export async function markAgentInvocationAwaitingResume( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ status: "awaiting_resume", updatedAt: new Date(nowMs) }) + .where( + and( + eq(juniorAgentInvocations.invocationId, invocationId), + inArray( + juniorAgentInvocations.status, + NON_TERMINAL_AGENT_INVOCATION_STATUSES, + ), + ), + ); +} + +/** Persist one terminal invocation result without overwriting an earlier result. */ +export async function completeAgentInvocation( + args: + | { + invocationId: string; + result: string; + status: "completed"; + nowMs?: number; + } + | { + errorMessage: string; + invocationId: string; + status: "blocked" | "failed"; + nowMs?: number; + }, +): Promise { + const nowMs = args.nowMs ?? Date.now(); + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ + status: args.status, + result: args.status === "completed" ? args.result : null, + errorMessage: args.status === "completed" ? null : args.errorMessage, + terminalAt: new Date(nowMs), + updatedAt: new Date(nowMs), + }) + .where( + and( + eq(juniorAgentInvocations.invocationId, args.invocationId), + inArray( + juniorAgentInvocations.status, + NON_TERMINAL_AGENT_INVOCATION_STATUSES, + ), + ), + ); + return await getAgentInvocation(args.invocationId); +} + +/** Return whether an invocation already owns its immutable terminal result. */ +export function isTerminalAgentInvocation( + invocation: AgentInvocation, +): boolean { + return TERMINAL_AGENT_INVOCATION_STATUSES.includes( + invocation.status as (typeof TERMINAL_AGENT_INVOCATION_STATUSES)[number], + ); +} diff --git a/packages/junior/src/chat/agent-invocations/types.ts b/packages/junior/src/chat/agent-invocations/types.ts new file mode 100644 index 000000000..9899611d0 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/types.ts @@ -0,0 +1,99 @@ +import { + actorSchema, + destinationSchema, + sourceSchema, +} from "@sentry/junior-plugin-api"; +import { z } from "zod"; +import { credentialContextSchema } from "@/chat/credentials/context"; +import { TURN_REASONING_LEVELS } from "@/chat/reasoning-level"; +import { + AGENT_INVOCATION_MAILBOX_STATUSES, + AGENT_INVOCATION_STATUSES, +} from "@/db/schema/agent-invocations"; + +const exactStringSchema = z + .string() + .min(1) + .refine((value) => value === value.trim()); + +export const agentNameSchema = exactStringSchema.max(64); + +export const agentInvocationStatusSchema = z.enum(AGENT_INVOCATION_STATUSES); + +export const agentInvocationMailboxStatusSchema = z.enum( + AGENT_INVOCATION_MAILBOX_STATUSES, +); + +export const agentBindingSchema = z + .object({ + childConversationId: exactStringSchema, + createdAtMs: z.number().finite(), + name: agentNameSchema, + parentConversationId: exactStringSchema, + reasoningLevel: z.enum(TURN_REASONING_LEVELS).optional(), + updatedAtMs: z.number().finite(), + }) + .strict(); + +const agentInvocationBaseSchema = z + .object({ + actor: actorSchema, + agentName: agentNameSchema.optional(), + childConversationId: exactStringSchema, + createdAtMs: z.number().finite(), + credentialContext: credentialContextSchema.optional(), + destination: destinationSchema, + destinationVisibility: z.enum(["public", "private"]).optional(), + idempotencyKey: exactStringSchema, + input: exactStringSchema, + invocationId: exactStringSchema, + mailboxStatus: agentInvocationMailboxStatusSchema, + parentConversationId: exactStringSchema, + reasoningLevel: z.enum(TURN_REASONING_LEVELS).optional(), + source: sourceSchema, + updatedAtMs: z.number().finite(), + }) + .strict(); + +export const agentInvocationSchema = z.discriminatedUnion("status", [ + agentInvocationBaseSchema.extend({ + status: z.enum(["pending", "running", "awaiting_resume"]), + }), + agentInvocationBaseSchema.extend({ + result: z.string(), + status: z.literal("completed"), + terminalAtMs: z.number().finite(), + }), + agentInvocationBaseSchema.extend({ + errorMessage: z.string(), + status: z.enum(["blocked", "failed"]), + terminalAtMs: z.number().finite(), + }), +]); + +export const createAgentInvocationSchema = z + .object({ + actor: actorSchema, + agentName: agentNameSchema.optional(), + credentialContext: credentialContextSchema.optional(), + destination: destinationSchema, + destinationVisibility: z.enum(["public", "private"]).optional(), + idempotencyKey: exactStringSchema, + input: exactStringSchema, + parentConversationId: exactStringSchema, + reasoningLevel: z.enum(TURN_REASONING_LEVELS).optional(), + source: sourceSchema, + }) + .strict(); + +export type AgentBinding = z.output; +export type AgentInvocation = z.output; +export type AgentInvocationStatus = z.output< + typeof agentInvocationStatusSchema +>; +export type AgentInvocationMailboxStatus = z.output< + typeof agentInvocationMailboxStatusSchema +>; +export type CreateAgentInvocationInput = z.input< + typeof createAgentInvocationSchema +>; diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts new file mode 100644 index 000000000..fa5987258 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -0,0 +1,561 @@ +import type { StateAdapter } from "chat"; +import { z } from "zod"; +import type { ConversationStore } from "@/chat/conversations/store"; +import { openConversationProjection } from "@/chat/conversations/projection"; +import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; +import { getConversationEventStore } from "@/chat/db"; +import type { AgentRunner } from "@/chat/runtime/agent-runner"; +import { + getPersistedSandboxState, + getPersistedThreadState, + persistThreadStateById, +} from "@/chat/runtime/thread-state"; +import { coerceThreadArtifactsState } from "@/chat/state/artifacts"; +import type { ThreadArtifactsState } from "@/chat/state/artifacts"; +import { getAgentTurnSessionRecord } from "@/chat/state/turn-session"; +import { + getConversationTurnBoundaryError, + isTurnInputCommitLostError, + TurnInputCommitLostError, +} from "@/chat/runtime/turn"; +import { + persistCompletedSessionRecord, + persistYieldSessionRecord, +} from "@/chat/services/turn-session-record"; +import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; +import { PluginCredentialFailureError } from "@/chat/services/plugin-auth-orchestration"; +import { getAssistantMessageText } from "@/chat/services/turn-result"; +import { getTerminalAssistantMessages } from "@/chat/pi/transcript"; +import type { PiMessage } from "@/chat/pi/messages"; +import type { SandboxRef } from "@/chat/sandbox/ref"; +import { + appendAndEnqueueInboundMessage, + type InboundMessage, +} from "@/chat/task-execution/store"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; +import type { + ConversationWorkerContext, + ConversationWorkerResult, +} from "@/chat/task-execution/worker"; +import { + completeAgentInvocation, + createAgentInvocation, + getActiveAgentInvocationForConversation, + getAgentInvocation, + getAgentInvocationMessageId, + getAgentInvocationTurnId, + isTerminalAgentInvocation, + markAgentInvocationAwaitingResume, + markAgentInvocationMailboxAppended, + markAgentInvocationRunning, +} from "./store"; +import type { AgentInvocation, CreateAgentInvocationInput } from "./types"; + +const agentInvocationMailboxMetadataSchema = z + .object({ + agentInvocationId: z.string().min(1), + kind: z.literal("agent_invocation"), + }) + .strict(); + +interface AgentInvocationWorkOptions { + conversationStore?: ConversationStore; + nowMs?: number; + queue: ConversationWorkQueue; + state?: StateAdapter; +} + +/** Build destinationless mailbox work that references one durable invocation. */ +export function buildAgentInvocationInboundMessage( + invocation: AgentInvocation, + nowMs = Date.now(), +): InboundMessage { + return { + conversationId: invocation.childConversationId, + createdAtMs: invocation.createdAtMs, + delivery: "defer", + inboundMessageId: getAgentInvocationMessageId(invocation.invocationId), + input: { + authorId: invocation.invocationId, + text: "[agent invocation]", + metadata: { + agentInvocationId: invocation.invocationId, + kind: "agent_invocation", + }, + }, + receivedAtMs: nowMs, + source: "internal", + }; +} + +/** Append one invocation to its child mailbox and send the normal queue wake. */ +export async function enqueueAgentInvocation( + invocation: AgentInvocation, + options: AgentInvocationWorkOptions, +): Promise { + if (invocation.mailboxStatus === "appended") { + return; + } + const nowMs = options.nowMs ?? Date.now(); + await appendAndEnqueueInboundMessage({ + message: buildAgentInvocationInboundMessage(invocation, nowMs), + conversationStore: options.conversationStore, + nowMs, + queue: options.queue, + state: options.state, + }); + await markAgentInvocationMailboxAppended(invocation.invocationId, nowMs); +} + +/** Create or replay an invocation, then durably schedule its child work. */ +export async function createAndEnqueueAgentInvocation( + input: CreateAgentInvocationInput, + options: AgentInvocationWorkOptions, +): Promise<{ + invocation: AgentInvocation; + status: "created" | "existing"; +}> { + const created = await createAgentInvocation(input, options.nowMs); + await enqueueAgentInvocation(created.invocation, options); + return { + ...created, + invocation: + (await getAgentInvocation(created.invocation.invocationId)) ?? + created.invocation, + }; +} + +/** Require one invocation metadata reference for one leased mailbox attempt. */ +function invocationIdFromMessages( + messages: readonly InboundMessage[], +): string | undefined { + if (messages.length === 0) { + return undefined; + } + const parsed = messages.map((message) => + agentInvocationMailboxMetadataSchema.safeParse(message.input.metadata), + ); + if (parsed.every((result) => !result.success)) { + return undefined; + } + if (parsed.some((result) => !result.success)) { + throw new Error( + "Conversation mailbox mixes agent invocation and other input", + ); + } + const invocationIds = new Set( + parsed.map((result) => { + if (!result.success) { + throw new Error("Agent invocation mailbox metadata failed validation"); + } + return result.data.agentInvocationId; + }), + ); + if (invocationIds.size !== 1) { + throw new Error( + "Conversation mailbox contains multiple agent invocations in one attempt", + ); + } + return invocationIds.values().next().value; +} + +/** Resolve invocation execution from mailbox input or durable active state. */ +export async function resolveAgentInvocationId( + context: ConversationWorkerContext, +): Promise { + const mailboxInvocationId = invocationIdFromMessages( + context.attempt.messages, + ); + if (mailboxInvocationId) { + return mailboxInvocationId; + } + if (context.attempt.messages.length > 0) { + return undefined; + } + return (await getActiveAgentInvocationForConversation(context.conversationId)) + ?.invocationId; +} + +/** Project the terminal invocation into the idempotent turn lifecycle. */ +async function persistTerminalLifecycle( + invocation: AgentInvocation, +): Promise { + const lifecycle = new ConversationTurnLifecycleService( + getConversationEventStore(), + ); + const common = { + conversationId: invocation.childConversationId, + createdAtMs: + "terminalAtMs" in invocation + ? invocation.terminalAtMs + : invocation.updatedAtMs, + turnId: getAgentInvocationTurnId(invocation.invocationId), + }; + if (invocation.status === "completed") { + await lifecycle.complete({ + ...common, + outcome: invocation.result?.trim() ? "success" : "no_reply", + }); + } else if ( + invocation.status === "blocked" || + invocation.status === "failed" + ) { + await lifecycle.fail({ + ...common, + failureCode: "model_execution_failed", + }); + } +} + +/** Recover a terminal invocation from its authoritative completed session. */ +async function projectTerminalSession( + invocation: AgentInvocation, +): Promise { + const session = await getAgentTurnSessionRecord( + invocation.childConversationId, + getAgentInvocationTurnId(invocation.invocationId), + ); + if (!session) { + return undefined; + } + if (session.state === "awaiting_resume" || session.state === "running") { + return undefined; + } + const result = getTerminalAssistantMessages(session.piMessages) + .map(getAssistantMessageText) + .filter((text): text is string => text !== undefined) + .join("\n\n"); + const failed = session.state !== "completed" || Boolean(session.errorMessage); + return await completeAgentInvocation({ + invocationId: invocation.invocationId, + ...(failed + ? { + errorMessage: session.errorMessage ?? "Agent invocation failed", + status: "failed" as const, + } + : { + result, + status: "completed" as const, + }), + }); +} + +/** Re-park a stranded running session at its latest durable safe boundary. */ +async function recoverRunningSession( + invocation: AgentInvocation, +): Promise { + const session = await getAgentTurnSessionRecord( + invocation.childConversationId, + getAgentInvocationTurnId(invocation.invocationId), + ); + if (!session || session.state !== "running") { + return; + } + if (!session.modelId) { + throw new Error( + `Running agent invocation session is missing its model for ${invocation.invocationId}`, + ); + } + const parked = await persistYieldSessionRecord({ + conversationId: invocation.childConversationId, + currentSliceId: session.sliceId, + destination: session.destination, + errorMessage: "Recovered running agent invocation after worker loss", + logContext: {}, + messages: session.piMessages, + modelId: session.modelId, + actor: session.actor, + reasoningLevel: session.reasoningLevel, + sessionId: session.sessionId, + source: session.source, + surface: session.surface, + }); + if (!parked) { + throw new Error( + `Running agent invocation had no resumable boundary for ${invocation.invocationId}`, + ); + } + await markAgentInvocationAwaitingResume(invocation.invocationId); +} + +/** Classify authorization failures that terminally block background work. */ +function blockingInvocationError( + error: unknown, +): AuthorizationFlowDisabledError | PluginCredentialFailureError | undefined { + const cause = getConversationTurnBoundaryError(error)?.cause ?? error; + return cause instanceof AuthorizationFlowDisabledError || + cause instanceof PluginCredentialFailureError + ? cause + : undefined; +} + +function isInvocationInputCommitLost(error: unknown): boolean { + const cause = getConversationTurnBoundaryError(error)?.cause ?? error; + return isTurnInputCommitLostError(cause); +} + +/** Build the invocation consumer that advances work through the shared runner. */ +export function createAgentInvocationConversationWorker(options: { + agentRunner: AgentRunner; +}) { + return async ( + context: ConversationWorkerContext, + invocationId: string, + ): Promise => { + let invocation = await getAgentInvocation(invocationId); + if (!invocation) { + throw new Error(`Agent invocation is missing for ${invocationId}`); + } + if (invocation.childConversationId !== context.conversationId) { + throw new Error( + `Agent invocation ${invocationId} belongs to ${invocation.childConversationId}, not ${context.conversationId}`, + ); + } + if (context.destination) { + throw new Error( + `Agent invocation conversation ${context.conversationId} must not own a provider destination`, + ); + } + + let acknowledged = context.attempt.messages.length === 0; + const acknowledge = async (): Promise => { + if (acknowledged) { + return; + } + try { + await context.attempt.ack(); + } catch { + throw new TurnInputCommitLostError( + `Conversation work lease lost before invocation inbox ack for ${context.conversationId}`, + ); + } + acknowledged = true; + }; + + const turnId = getAgentInvocationTurnId(invocation.invocationId); + const lifecycle = new ConversationTurnLifecycleService( + getConversationEventStore(), + ); + let artifacts: ThreadArtifactsState; + let sandboxRef: SandboxRef | undefined; + let history: PiMessage[]; + try { + if (isTerminalAgentInvocation(invocation)) { + await persistTerminalLifecycle(invocation); + await acknowledge(); + return { status: "completed" }; + } + const projected = await projectTerminalSession(invocation); + if (projected && isTerminalAgentInvocation(projected)) { + await persistTerminalLifecycle(projected); + await acknowledge(); + return { status: "completed" }; + } + await recoverRunningSession(invocation); + invocation = + (await markAgentInvocationRunning(invocation.invocationId)) ?? + invocation; + await lifecycle.start({ + conversationId: invocation.childConversationId, + createdAtMs: invocation.createdAtMs, + inputMessageIds: [getAgentInvocationMessageId(invocation.invocationId)], + surface: "internal", + turnId, + }); + const [persisted, projection] = await Promise.all([ + getPersistedThreadState(invocation.childConversationId), + openConversationProjection({ + conversationId: invocation.childConversationId, + }), + ]); + artifacts = coerceThreadArtifactsState(persisted); + sandboxRef = getPersistedSandboxState(persisted); + history = projection.messages; + } catch (error) { + if (isInvocationInputCommitLost(error)) { + return { status: "lost_lease" }; + } + if (!context.attempt.isFinalAttempt) { + throw error; + } + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + errorMessage: + error instanceof Error ? error.message : "Agent invocation failed", + status: "failed", + }); + if (terminal) { + await persistTerminalLifecycle(terminal); + } + await acknowledge(); + return { status: "completed" }; + } + + let outcome; + try { + outcome = await options.agentRunner.run({ + conversationId: invocation.childConversationId, + turnId, + runId: invocation.invocationId, + input: { + messageText: invocation.input, + piMessages: history, + }, + routing: { + actor: invocation.actor, + credentialContext: invocation.credentialContext, + destination: invocation.destination, + destinationVisibility: invocation.destinationVisibility, + source: invocation.source, + surface: "internal", + }, + policy: { + authorizationFlowMode: "disabled", + reasoningLevel: invocation.reasoningLevel, + }, + state: { + artifactState: artifacts, + sandboxRef, + }, + durability: { + onInputCommitted: acknowledge, + shouldYield: context.shouldYield, + onArtifactStateUpdated: async (nextArtifacts) => { + artifacts = nextArtifacts; + await persistThreadStateById(invocation.childConversationId, { + artifacts, + sandboxRef, + }); + }, + onSandboxRefChanged: async (nextSandboxRef) => { + sandboxRef = nextSandboxRef; + await persistThreadStateById(invocation.childConversationId, { + artifacts, + sandboxRef, + }); + }, + }, + }); + } catch (error) { + if (isInvocationInputCommitLost(error)) { + return { status: "lost_lease" }; + } + const blocking = blockingInvocationError(error); + if (blocking) { + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + errorMessage: blocking.message, + status: "blocked", + }); + if (terminal) { + await persistTerminalLifecycle(terminal); + } + await acknowledge(); + return { status: "completed" }; + } + if (!context.attempt.isFinalAttempt) { + throw error; + } + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + errorMessage: + error instanceof Error ? error.message : "Agent invocation failed", + status: "failed", + }); + if (terminal) { + await persistTerminalLifecycle(terminal); + } + await acknowledge(); + return { status: "completed" }; + } + + if (outcome.status === "suspended") { + await markAgentInvocationAwaitingResume(invocation.invocationId); + return { status: "yielded" }; + } + if (outcome.status === "awaiting_auth") { + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + errorMessage: `Agent invocation requires ${outcome.providerDisplayName} authorization`, + status: "blocked", + }); + if (terminal) { + await persistTerminalLifecycle(terminal); + } + await acknowledge(); + return { status: "completed" }; + } + + const result = outcome.result; + const failed = result.diagnostics.outcome !== "success"; + await persistThreadStateById(invocation.childConversationId, { + artifacts: result.artifactStatePatch + ? { ...artifacts, ...result.artifactStatePatch } + : artifacts, + sandboxRef: result.sandboxRef ?? sandboxRef, + }); + if (result.piMessages?.length) { + await persistCompletedSessionRecord({ + conversationId: invocation.childConversationId, + currentDurationMs: result.diagnostics.durationMs, + currentUsage: result.diagnostics.usage, + destination: invocation.destination, + destinationVisibility: invocation.destinationVisibility, + ...(failed + ? { + errorMessage: + result.diagnostics.errorMessage ?? "Agent invocation failed", + } + : {}), + sessionId: turnId, + allMessages: result.piMessages, + modelId: result.diagnostics.modelId, + actor: invocation.actor, + reasoningLevel: result.diagnostics.reasoningLevel, + source: invocation.source, + surface: "internal", + }); + } + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + ...(failed + ? { + errorMessage: + result.diagnostics.errorMessage ?? "Agent invocation failed", + status: "failed" as const, + } + : { + result: result.text, + status: "completed" as const, + }), + }); + if (!terminal) { + throw new Error( + `Agent invocation disappeared during completion for ${invocation.invocationId}`, + ); + } + await persistTerminalLifecycle(terminal); + await acknowledge(); + return { status: "completed" }; + }; +} + +/** Route invocation-backed work before falling through to existing consumers. */ +export function createAgentInvocationWorkRouter(options: { + fallbackWorker: ( + context: ConversationWorkerContext, + ) => Promise; + invocationWorker: ( + context: ConversationWorkerContext, + invocationId: string, + ) => Promise; +}) { + return async ( + context: ConversationWorkerContext, + ): Promise => { + const invocationId = await resolveAgentInvocationId(context); + return invocationId + ? await options.invocationWorker(context, invocationId) + : await options.fallbackWorker(context); + }; +} diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index f27609daf..a0eb5285e 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -231,6 +231,7 @@ export function assertRunRoutingConsistency( } else if ( source.platform === "local" && destination.platform === "local" && + request.routing.surface !== "internal" && (source.conversationId !== request.conversationId || destination.conversationId !== request.conversationId) ) { diff --git a/packages/junior/src/chat/app/production.ts b/packages/junior/src/chat/app/production.ts index 50fca3edb..2d766f2bf 100644 --- a/packages/junior/src/chat/app/production.ts +++ b/packages/junior/src/chat/app/production.ts @@ -23,6 +23,10 @@ import { createAgentDispatchWorkRouter, createAgentDispatchConversationWorker, } from "@/chat/agent-dispatch/work"; +import { + createAgentInvocationConversationWorker, + createAgentInvocationWorkRouter, +} from "@/chat/agent-invocations/work"; import { getDispatchConversationId, getDispatchInputMessageIds, @@ -153,12 +157,18 @@ export function createProductionConversationWorkOptions(options: { }, runTurn: runtime.runDispatchTurn, }); + const providerWorker = createAgentDispatchWorkRouter({ + dispatchWorker, + fallbackWorker: slackWorker, + }); return { conversationStore, queue, - run: createAgentDispatchWorkRouter({ - dispatchWorker, - fallbackWorker: slackWorker, + run: createAgentInvocationWorkRouter({ + invocationWorker: createAgentInvocationConversationWorker({ + agentRunner, + }), + fallbackWorker: providerWorker, }), }; } diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index db342654b..0936cc68f 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -93,9 +93,10 @@ to `unknown`. ## Visibility And Retention Destination visibility is the privacy authority. Messages, agent-history -items, child conversations, and plugin projections inherit it. Retention +items, child conversations, agent invocations, and plugin projections inherit +it. Retention distinguishes expired content from redacted content and purges the complete -child tree. +child tree, including delegated input and terminal results. Every conversation row carries its owning `root_conversation_id`. Roots self-reference; descendants copy the root from their immediate parent when diff --git a/packages/junior/src/chat/conversations/sql/purge.ts b/packages/junior/src/chat/conversations/sql/purge.ts index 47b7d813a..e5d505f70 100644 --- a/packages/junior/src/chat/conversations/sql/purge.ts +++ b/packages/junior/src/chat/conversations/sql/purge.ts @@ -1,10 +1,11 @@ -import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import type { JuniorDestinationVisibility } from "@/db/schema/destinations"; import { juniorConversationEvents, juniorConversations, juniorDestinations, + juniorAgentInvocations, } from "@/db/schema"; import { withConversationEventLock } from "./event-lock"; import { resolveRootVisibility } from "./privacy"; @@ -104,6 +105,11 @@ export async function selectExpiredRoots( select 1 from junior_conversation_events events where events.conversation_id = tree.conversation_id ) + or exists ( + select 1 from junior_agent_invocations invocations + where invocations.parent_conversation_id = tree.conversation_id + or invocations.child_conversation_id = tree.conversation_id + ) or ( ${juniorDestinations.visibility} is distinct from 'public' and exists ( @@ -255,6 +261,15 @@ export async function purgeConversationTree( .db() .delete(juniorConversationEvents) .where(inArray(juniorConversationEvents.conversationId, ids)); + await executor + .db() + .delete(juniorAgentInvocations) + .where( + or( + inArray(juniorAgentInvocations.parentConversationId, ids), + inArray(juniorAgentInvocations.childConversationId, ids), + ), + ); await executor .db() .update(juniorConversations) diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index 01062d26d..042ef5293 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -479,6 +479,52 @@ function updateConversationUsage(args: { export class SqlStore implements ConversationStore { constructor(private readonly executor: JuniorSqlDatabase) {} + async createChild(args: { + childConversationId: string; + parentConversationId: string; + nowMs?: number; + source?: ConversationSource; + }): Promise { + const nowMs = args.nowMs ?? now(); + await this.withConversationMutation(args.childConversationId, async () => { + const existing = await this.get({ + conversationId: args.childConversationId, + }); + if (existing) { + if ( + existing.lineage?.parentConversationId !== args.parentConversationId + ) { + throw new Error( + `Conversation lineage changed for ${args.childConversationId}`, + ); + } + return; + } + const parent = await this.get({ + conversationId: args.parentConversationId, + }); + if (!parent) { + throw new Error( + `Conversation parent is missing for ${args.childConversationId}`, + ); + } + if (parent.lineage) { + throw new Error("Recursive agent delegation is not enabled"); + } + const child: Conversation = { + ...emptyConversation({ + conversationId: args.childConversationId, + nowMs, + source: args.source, + }), + lineage: { + parentConversationId: args.parentConversationId, + }, + }; + await this.upsertConversation({ conversation: child }); + }); + } + async get(args: { conversationId: string; }): Promise { diff --git a/packages/junior/src/chat/conversations/store.ts b/packages/junior/src/chat/conversations/store.ts index c1b5d7078..19a4ecc7b 100644 --- a/packages/junior/src/chat/conversations/store.ts +++ b/packages/junior/src/chat/conversations/store.ts @@ -58,6 +58,13 @@ export interface Conversation { /** Persist and read durable conversation metadata for reporting surfaces. */ export interface ConversationStore { + /** Create one destinationless child with immutable parent lineage. */ + createChild(args: { + childConversationId: string; + parentConversationId: string; + nowMs?: number; + source?: ConversationSource; + }): Promise; get(args: { conversationId: string }): Promise; /** Read persisted visibility for one destination. Missing rows fail closed. */ getDestinationVisibility(args: { diff --git a/packages/junior/src/chat/state/turn-session.ts b/packages/junior/src/chat/state/turn-session.ts index 70b52ffe3..960dec001 100644 --- a/packages/junior/src/chat/state/turn-session.ts +++ b/packages/junior/src/chat/state/turn-session.ts @@ -262,25 +262,31 @@ async function recordConversationActivityMetadata(args: { summary: AgentTurnSessionSummary; }): Promise { const conversationStore = args.conversationStore ?? getConversationStore(); - const source = - args.summary.destination?.platform === "local" + const conversation = await conversationStore.get({ + conversationId: args.summary.conversationId, + }); + const isChild = Boolean(conversation?.lineage); + const destination = isChild ? undefined : args.summary.destination; + const source = isChild + ? "internal" + : destination?.platform === "local" ? "local" : args.summary.surface; await conversationStore.recordActivity({ activityAtMs: args.summary.updatedAtMs, channelName: args.summary.channelName, conversationId: args.summary.conversationId, - destination: args.summary.destination, + destination, nowMs: args.nowMs, actor: sessionLogActor(args.summary.actor), source, - visibility: args.destinationVisibility, + visibility: isChild ? undefined : args.destinationVisibility, }); await conversationStore.recordExecution({ channelName: args.summary.channelName, conversationId: args.summary.conversationId, createdAtMs: args.summary.startedAtMs, - destination: args.summary.destination, + destination, execution: conversationExecutionFromSummary(args.summary), lastActivityAtMs: args.summary.updatedAtMs, metrics: { @@ -292,7 +298,7 @@ async function recordConversationActivityMetadata(args: { actor: sessionLogActor(args.summary.actor), source, updatedAtMs: args.nowMs, - visibility: args.destinationVisibility, + visibility: isChild ? undefined : args.destinationVisibility, }); } diff --git a/packages/junior/src/chat/task-execution/README.md b/packages/junior/src/chat/task-execution/README.md index f3920bbf5..b74d97447 100644 --- a/packages/junior/src/chat/task-execution/README.md +++ b/packages/junior/src/chat/task-execution/README.md @@ -11,7 +11,9 @@ the completed result. - A conversation mailbox contains normalized pending work with a durable delivery mode: `interrupt` or `defer`. - A queue payload identifies the conversation to wake; persisted conversation - work owns destination and delivery. + work owns delivery. Provider conversations own their destination, while + destinationless child work resolves bounded execution authority from its + durable agent invocation. - A lease grants one worker temporary execution ownership. - Dispatch projection updates take a short dispatch lock only while the conversation lease is already held. They never wait for conversation work, @@ -29,12 +31,15 @@ require a valid delivery value and reject invalid pending work. 1. Ingress appends mailbox work before sending a queue nudge. 2. The worker validates the queue callback and acquires the conversation lease. -3. While it owns the lease, the worker reloads durable state and runs the next +3. While it owns the lease, the worker reloads durable state and routes the next work: `interrupt` mailbox delivery first, then a paused turn, then `defer` mailbox delivery. Each iteration gets a fresh mailbox delivery attempt. New dispatch input identifies its dispatch in mailbox metadata; later slices restore that identifier from the turn session rather than queue payloads, conversation source, or conversation-id conventions. + Agent invocation input follows the same rule: the mailbox carries its + invocation ID, and an empty resume attempt resolves the active invocation + from SQL. 4. Runtime advances the turn until completion, auth pause, cooperative yield, or terminal failure, delivering and recording completed tool-free assistant messages as it advances. A requested turn resume is durable state, not an @@ -45,8 +50,8 @@ require a valid delivery value and reject invalid pending work. current worker observes it on its next state reload. 6. Before yielding, the worker commits a safe history boundary, sends another nudge, and releases the lease. -7. Terminal delivery or intentional no-reply completion records the delivered - turn before acknowledging work. +7. Accepted provider delivery, intentional no-reply completion, or a durable + internal result records the terminal turn before acknowledging work. New messages that arrive during a run remain durable. `interrupt` work is eligible at the next safe boundary, while `defer` work follows normal ordering diff --git a/packages/junior/src/chat/task-execution/slack-work.ts b/packages/junior/src/chat/task-execution/slack-work.ts index 5429720e7..0f88c600f 100644 --- a/packages/junior/src/chat/task-execution/slack-work.ts +++ b/packages/junior/src/chat/task-execution/slack-work.ts @@ -866,7 +866,7 @@ export function createSlackConversationWorker( try { if (route === "mention") { await options.runtime.handleNewMention(thread, latestMessage, { - destination: context.destination, + destination, messageContext, drainSteeringMessages, ack, @@ -878,7 +878,7 @@ export function createSlackConversationWorker( thread, latestMessage, { - destination: context.destination, + destination, messageContext, drainSteeringMessages, ack, diff --git a/packages/junior/src/chat/task-execution/state.ts b/packages/junior/src/chat/task-execution/state.ts index aa8065268..ca861c915 100644 --- a/packages/junior/src/chat/task-execution/state.ts +++ b/packages/junior/src/chat/task-execution/state.ts @@ -108,7 +108,7 @@ export const inboundMessageSchema = z conversationId: z.string().refine((value) => value.trim().length > 0), createdAtMs: z.number().finite(), delivery: inboundMessageDeliverySchema, - destination: destinationSchema, + destination: destinationSchema.optional(), inboundMessageId: z.string().refine((value) => value.trim().length > 0), injectedAtMs: z.number().finite().optional(), input: agentInputSchema, @@ -479,10 +479,11 @@ function normalizeConversation( } if ( execution.pendingMessages.length > 0 && - (!destination || - execution.pendingMessages.some( - (message) => !sameDestination(message.destination, destination), - )) + execution.pendingMessages.some((message) => + message.destination + ? !destination || !sameDestination(message.destination, destination) + : Boolean(destination), + ) ) { return undefined; } @@ -1007,6 +1008,22 @@ function assertSameConversationDestination(args: { ); } +function assertSameOptionalConversationDestination(args: { + conversationId: string; + current: Destination | undefined; + next: Destination | undefined; +}): void { + if (!args.current && !args.next) { + return; + } + if (args.current && args.next && sameDestination(args.current, args.next)) { + return; + } + throw new Error( + `Conversation destination changed for ${args.conversationId}`, + ); +} + function conversationWorkState( conversation: Conversation, ): ConversationWorkState { @@ -1071,26 +1088,32 @@ export async function appendInboundMessage(args: { return await withConversationMutation( { conversationId: args.message.conversationId, state: args.state }, async (state, lock) => { + const existing = await readConversation( + state, + args.message.conversationId, + ); const current = - (await readConversation(state, args.message.conversationId)) ?? + existing ?? emptyConversation({ conversationId: args.message.conversationId, destination: args.message.destination, nowMs, source: args.message.source, }); - assertSameConversationDestination({ - conversationId: args.message.conversationId, - current: current.destination, - next: args.message.destination, - }); + if (existing) { + assertSameOptionalConversationDestination({ + conversationId: args.message.conversationId, + current: current.destination, + next: args.message.destination, + }); + } const existingPending = current.execution.pendingMessages.some( (message) => message.inboundMessageId === args.message.inboundMessageId, ); - const existing = current.execution.inboundMessageIds.includes( + const existingMessage = current.execution.inboundMessageIds.includes( args.message.inboundMessageId, ); - if (existing) { + if (existingMessage) { if (!existingPending) { return { status: "duplicate" }; } @@ -1159,7 +1182,7 @@ export async function appendInboundMessage(args: { /** Mark a conversation runnable when there is no new mailbox message. */ export async function requestConversationWork(args: { conversationId: string; - destination: Destination; + destination?: Destination; nowMs?: number; state?: StateAdapter; }): Promise { @@ -1167,7 +1190,7 @@ export async function requestConversationWork(args: { return await withConversationMutation(args, async (state, lock) => { const existing = await readConversation(state, args.conversationId); if (existing) { - assertSameConversationDestination({ + assertSameOptionalConversationDestination({ conversationId: args.conversationId, current: existing.destination, next: args.destination, @@ -1605,7 +1628,7 @@ export async function ackMessages(args: { /** Mark the leased conversation as needing another queue-delivered slice. */ export async function requestConversationContinuation(args: { conversationId: string; - destination: Destination; + destination?: Destination; leaseToken: string; nowMs?: number; state?: StateAdapter; @@ -1616,7 +1639,7 @@ export async function requestConversationContinuation(args: { if (!current || current.execution.lease?.token !== args.leaseToken) { return false; } - assertSameConversationDestination({ + assertSameOptionalConversationDestination({ conversationId: args.conversationId, current: current.destination, next: args.destination, diff --git a/packages/junior/src/chat/task-execution/store.ts b/packages/junior/src/chat/task-execution/store.ts index 05c256c61..64d4c71af 100644 --- a/packages/junior/src/chat/task-execution/store.ts +++ b/packages/junior/src/chat/task-execution/store.ts @@ -168,9 +168,6 @@ export async function ensureConversationWake(args: { if (!conversation || !hasRunnableConversationWork(conversation)) { return { status: "no_work" }; } - if (!conversation.destination) { - return { status: "no_work" }; - } if ( args.replaceExistingWake !== true && conversation.execution.lease && diff --git a/packages/junior/src/chat/task-execution/worker.ts b/packages/junior/src/chat/task-execution/worker.ts index 79248a890..9e2bc5bc8 100644 --- a/packages/junior/src/chat/task-execution/worker.ts +++ b/packages/junior/src/chat/task-execution/worker.ts @@ -38,14 +38,14 @@ export interface ConversationWorkerContext { attempt: InboxAttempt; checkIn(): Promise; conversationId: string; - destination: Destination; + destination?: Destination; shouldYield(): boolean; } export interface InboxAttempt { ack(): Promise; conversationId: string; - destination: Destination; + destination?: Destination; drain( handle: (messages: InboundMessage[]) => Promise, ): Promise; @@ -122,7 +122,7 @@ function nudgeIdempotencyKey( async function requestLostLeaseRecovery(args: { conversationId: string; - destination: Destination; + destination?: Destination; leaseToken: string; nowMs: number; options: ProcessConversationWorkOptions; @@ -283,13 +283,6 @@ async function processConversationWorkInContext( } return { status: "no_work" }; } - if (!initial.destination) { - throw new ConversationQueueMessageRejectedError( - "invalid_record", - `Conversation work is missing destination context for ${conversationId}`, - { conversationId }, - ); - } const destination = initial.destination; const lease = await startConversationWork({ @@ -683,6 +676,27 @@ async function processConversationWorkInContext( nowMs: errorNowMs, state: options.state, }); + } else if (failure?.status === "recorded") { + await releaseConversationWork({ + conversationId, + leaseToken: lease.leaseToken, + conversationStore: options.conversationStore, + nowMs: errorNowMs, + state: options.state, + }); + await ensureConversationWake({ + conversationId, + conversationStore: options.conversationStore, + idempotencyKey: nudgeIdempotencyKey( + "error", + conversationId, + errorNowMs, + ), + nowMs: errorNowMs, + queue: options.queue, + replaceExistingWake: true, + state: options.state, + }); } else { const resumeRequested = await requestConversationContinuation({ conversationId, diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index caca3b57d..7db71d034 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -2,6 +2,10 @@ import { juniorConversationAnnotations } from "./schema/conversation-annotations import { juniorApiTokens } from "./schema/api-tokens"; import { juniorConversationEvents } from "./schema/conversation-events"; import { juniorConversations } from "./schema/conversations"; +import { + juniorAgentBindings, + juniorAgentInvocations, +} from "./schema/agent-invocations"; import { juniorDestinations } from "./schema/destinations"; import { juniorIdentities } from "./schema/identities"; import { juniorStats } from "./schema/stats"; @@ -10,6 +14,8 @@ import { juniorUsers } from "./schema/users"; export { juniorConversationAnnotations, juniorApiTokens, + juniorAgentBindings, + juniorAgentInvocations, juniorConversationEvents, juniorConversations, juniorDestinations, @@ -21,6 +27,8 @@ export { export const juniorSqlSchema = { juniorConversationAnnotations, juniorApiTokens, + juniorAgentBindings, + juniorAgentInvocations, juniorConversationEvents, juniorConversations, juniorDestinations, diff --git a/packages/junior/src/db/schema/agent-invocations.ts b/packages/junior/src/db/schema/agent-invocations.ts new file mode 100644 index 000000000..55949191a --- /dev/null +++ b/packages/junior/src/db/schema/agent-invocations.ts @@ -0,0 +1,100 @@ +import { + index, + jsonb, + pgTable, + primaryKey, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { Actor, Destination, Source } from "@sentry/junior-plugin-api"; +import type { ConversationPrivacy } from "@/chat/conversation-privacy"; +import type { CredentialContext } from "@/chat/credentials/context"; +import type { TurnReasoningLevel } from "@/chat/reasoning-level"; +import { juniorConversations } from "./conversations"; +import { timestamptz } from "./timestamps"; + +export const AGENT_INVOCATION_STATUSES = [ + "pending", + "running", + "awaiting_resume", + "blocked", + "completed", + "failed", +] as const; + +export const AGENT_INVOCATION_MAILBOX_STATUSES = [ + "pending", + "appended", +] as const; + +export const juniorAgentBindings = pgTable( + "junior_agent_bindings", + { + parentConversationId: text("parent_conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + name: text("name").notNull(), + childConversationId: text("child_conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + reasoningLevel: text("reasoning_level").$type(), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.parentConversationId, table.name], + }), + uniqueIndex("junior_agent_bindings_child_idx").on( + table.childConversationId, + ), + ], +); + +export const juniorAgentInvocations = pgTable( + "junior_agent_invocations", + { + invocationId: text("invocation_id").primaryKey(), + idempotencyKey: text("idempotency_key").notNull(), + parentConversationId: text("parent_conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + childConversationId: text("child_conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + agentName: text("agent_name"), + input: text("input").notNull(), + actor: jsonb("actor_json").$type().notNull(), + credentialContext: jsonb( + "credential_context_json", + ).$type(), + source: jsonb("source_json").$type().notNull(), + destination: jsonb("destination_json").$type().notNull(), + destinationVisibility: text( + "destination_visibility", + ).$type(), + reasoningLevel: text("reasoning_level").$type(), + status: text("status") + .$type<(typeof AGENT_INVOCATION_STATUSES)[number]>() + .notNull(), + mailboxStatus: text("mailbox_status") + .$type<(typeof AGENT_INVOCATION_MAILBOX_STATUSES)[number]>() + .notNull(), + result: text("result"), + errorMessage: text("error_message"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + terminalAt: timestamptz("terminal_at"), + }, + (table) => [ + uniqueIndex("junior_agent_invocations_idempotency_idx").on( + table.parentConversationId, + table.idempotencyKey, + ), + index("junior_agent_invocations_child_idx").on(table.childConversationId), + index("junior_agent_invocations_mailbox_idx").on( + table.mailboxStatus, + table.createdAt, + ), + ], +); diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index 9802dbeb2..39982b960 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -14,6 +14,7 @@ import { juniorConversationEvents, juniorConversations, juniorDestinations, + juniorAgentInvocations, } from "@/db/schema"; import type { JuniorDestinationVisibility } from "@/db/schema/destinations"; import type { JuniorSqlDatabase } from "@/db/db"; @@ -248,6 +249,30 @@ describe("retention purge job", () => { parentConversationId: "child", lastActivityAtMs: BASE_MS, }); + await fixture.sql + .db() + .insert(juniorAgentInvocations) + .values({ + actor: { name: "parent-agent", platform: "system" }, + childConversationId: "child", + createdAt: new Date(BASE_MS), + destination: { conversationId: "root", platform: "local" }, + destinationVisibility: "private", + idempotencyKey: "retained-invocation", + input: "private delegated input", + invocationId: "agent-invocation:retained", + mailboxStatus: "appended", + parentConversationId: "root", + result: "private delegated result", + source: { + conversationId: "root", + platform: "local", + type: "priv", + }, + status: "completed", + terminalAt: new Date(BASE_MS), + updatedAt: new Date(BASE_MS), + }); // The child is never a purge candidate on its own: it rides the root. const result = await runRetentionPurge(fixture.sql, { @@ -264,6 +289,9 @@ describe("retention purge job", () => { expect(await eventCount(fixture.sql, "root")).toBe(0); expect(await eventCount(fixture.sql, "child")).toBe(0); expect(await eventCount(fixture.sql, "grandchild")).toBe(0); + await expect( + fixture.sql.db().select().from(juniorAgentInvocations), + ).resolves.toEqual([]); }); it("uses the freshest activity in the tree for selection and destructive recheck", async () => { diff --git a/packages/junior/tests/component/services/turn-session-record.test.ts b/packages/junior/tests/component/services/turn-session-record.test.ts index cf1b32772..6e69b1453 100644 --- a/packages/junior/tests/component/services/turn-session-record.test.ts +++ b/packages/junior/tests/component/services/turn-session-record.test.ts @@ -57,6 +57,7 @@ function assistantMessage(text: string, timestamp: number): PiMessage { function failingConversationStore(): ConversationStore { return { + createChild: vi.fn(), get: vi.fn(), getDestinationVisibility: vi.fn(async () => undefined), recordActivity: vi.fn(async () => { diff --git a/packages/junior/tests/component/task-execution/conversation-work.test.ts b/packages/junior/tests/component/task-execution/conversation-work.test.ts index 6f3538421..c9a7d54c8 100644 --- a/packages/junior/tests/component/task-execution/conversation-work.test.ts +++ b/packages/junior/tests/component/task-execution/conversation-work.test.ts @@ -66,6 +66,7 @@ const CONVERSATION_WORK_STATE_KEY = `junior:conversation:${CONVERSATION_ID}`; function failingMetadataStore(): ConversationStore { return { + createChild: vi.fn(), get: vi.fn(async () => undefined), getDestinationVisibility: vi.fn(async () => undefined), recordActivity: vi.fn(), @@ -78,6 +79,7 @@ function failingMetadataStore(): ConversationStore { function metadataEventsStore(events: string[]): ConversationStore { return { + createChild: vi.fn(), get: vi.fn(async () => undefined), getDestinationVisibility: vi.fn(async () => undefined), recordActivity: vi.fn(), @@ -690,6 +692,24 @@ describe("conversation work execution", () => { ).rejects.toThrow(`Conversation record is invalid for ${CONVERSATION_ID}`); }); + it("rejects appending destinationless work to a provider conversation", async () => { + await appendInboundMessage({ + message: inboundMessage("m1"), + nowMs: 1_000, + }); + + await expect( + appendInboundMessage({ + message: { + ...inboundMessage("m2"), + destination: undefined, + source: "internal", + }, + nowMs: 2_000, + }), + ).rejects.toThrow("Conversation destination changed"); + }); + it("defers duplicate queue nudges while a conversation lease is active", async () => { const queue = createConversationWorkQueueTestAdapter(); await appendInboundMessage({ message: inboundMessage("m1"), nowMs: 1_000 }); @@ -849,6 +869,14 @@ describe("conversation work execution", () => { nowMs: 3_000, }), ).rejects.toThrow("Conversation destination changed"); + await expect( + requestConversationContinuation({ + conversationId: CONVERSATION_ID, + destination: undefined, + leaseToken: lease.leaseToken, + nowMs: 3_000, + }), + ).rejects.toThrow("Conversation destination changed"); await expect( getConversationWorkState({ conversationId: CONVERSATION_ID }), ).resolves.toMatchObject({ @@ -949,7 +977,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, @@ -989,7 +1017,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, @@ -1038,7 +1066,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, @@ -1153,7 +1181,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, @@ -1985,7 +2013,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts new file mode 100644 index 000000000..ee17cdf6d --- /dev/null +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -0,0 +1,693 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLocalSource } from "@sentry/junior-plugin-api"; +import type { PiMessage } from "@/chat/pi/messages"; +import { + completeAgentInvocation, + createAgentInvocation, + getAgentBinding, + getAgentInvocation, + getAgentInvocationTurnId, +} from "@/chat/agent-invocations/store"; +import { + createAgentInvocationConversationWorker, + createAgentInvocationWorkRouter, + createAndEnqueueAgentInvocation, +} from "@/chat/agent-invocations/work"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; +import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; +import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; +import { recoverPendingAgentInvocationMailboxAppends } from "@/chat/agent-dispatch/heartbeat"; +import { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS } from "@/chat/task-execution/store"; +import { + getAgentTurnSessionRecord, + upsertAgentTurnSessionRecord, +} from "@/chat/state/turn-session"; +import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; +import { createConfiguredJuniorSqlFixture } from "../fixtures/sql"; + +const parentConversationId = "local:test:parent-agent"; +const destination = { + conversationId: parentConversationId, + platform: "local", +} as const; +const invocationInput = { + actor: { name: "parent-agent", platform: "system" } as const, + destination, + destinationVisibility: "private" as const, + input: "Summarize the durable task.", + parentConversationId, + reasoningLevel: "medium" as const, + source: createLocalSource(parentConversationId), +}; + +async function prepareParentConversation() { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination, + nowMs: 1_000, + source: "local", + }); + return { conversationStore, fixture }; +} + +describe("agent invocation conversation work", () => { + afterEach(async () => { + await disconnectStateAdapter(); + vi.restoreAllMocks(); + }); + + it("keeps named bindings stable and idempotent invocation input immutable", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + try { + const first = await createAgentInvocation( + { + ...invocationInput, + agentName: "researcher", + idempotencyKey: "named-1", + }, + 2_000, + ); + const replay = await createAgentInvocation( + { + ...invocationInput, + agentName: "researcher", + idempotencyKey: "named-1", + }, + 3_000, + ); + const next = await createAgentInvocation( + { + ...invocationInput, + agentName: "researcher", + idempotencyKey: "named-2", + }, + 4_000, + ); + const ephemeral = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "ephemeral-1", + }, + 5_000, + ); + const ephemeralReplay = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "ephemeral-1", + }, + 6_000, + ); + const otherEphemeral = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "ephemeral-2", + }, + 7_000, + ); + + expect(first.status).toBe("created"); + expect(replay).toEqual({ + invocation: first.invocation, + status: "existing", + }); + expect(next.invocation.invocationId).not.toBe( + first.invocation.invocationId, + ); + expect(next.invocation.childConversationId).toBe( + first.invocation.childConversationId, + ); + expect(ephemeralReplay.invocation.childConversationId).toBe( + ephemeral.invocation.childConversationId, + ); + expect(otherEphemeral.invocation.childConversationId).not.toBe( + ephemeral.invocation.childConversationId, + ); + await expect( + getAgentBinding({ + name: "researcher", + parentConversationId, + }), + ).resolves.toMatchObject({ + childConversationId: first.invocation.childConversationId, + reasoningLevel: "medium", + }); + const child = await conversationStore.get({ + conversationId: first.invocation.childConversationId, + }); + expect(child).toMatchObject({ + lineage: { parentConversationId }, + source: "internal", + }); + expect(child).not.toHaveProperty("destination"); + await expect( + createAgentInvocation({ + ...invocationInput, + idempotencyKey: "named-1", + input: "Different input must not reuse the key.", + agentName: "researcher", + }), + ).rejects.toThrow("idempotency key was reused with different input"); + await expect( + createAgentInvocation({ + ...invocationInput, + idempotencyKey: "recursive", + parentConversationId: first.invocation.childConversationId, + }), + ).rejects.toThrow("Recursive agent delegation is not enabled"); + } finally { + await fixture.close(); + } + }); + + it("runs destinationless child work once and persists its terminal result", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "execute-1", + }, + { + conversationStore, + nowMs: 2_000, + queue, + state, + }, + ); + const run = vi.fn(async (request) => { + expect(request).toMatchObject({ + conversationId: created.invocation.childConversationId, + input: { messageText: invocationInput.input }, + policy: { + authorizationFlowMode: "disabled", + reasoningLevel: "medium", + }, + routing: { + actor: invocationInput.actor, + destination, + destinationVisibility: "private", + source: invocationInput.source, + surface: "internal", + }, + runId: created.invocation.invocationId, + }); + await request.durability.onInputCommitted?.(); + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + text: "Durable child result", + }); + }); + const fallbackWorker = vi.fn(async () => ({ + status: "completed" as const, + })); + const route = createAgentInvocationWorkRouter({ + fallbackWorker, + invocationWorker: createAgentInvocationConversationWorker({ + agentRunner: { run }, + }), + }); + const queueMessage = queue.takeMessage(); + + await expect( + processConversationQueueMessage(queueMessage, { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "completed" }); + await expect( + processConversationQueueMessage(queueMessage, { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "no_work" }); + + expect(run).toHaveBeenCalledOnce(); + expect(fallbackWorker).not.toHaveBeenCalled(); + await expect( + getAgentInvocation(created.invocation.invocationId), + ).resolves.toMatchObject({ + mailboxStatus: "appended", + result: "Durable child result", + status: "completed", + terminalAtMs: expect.any(Number), + }); + const completed = await getAgentInvocation( + created.invocation.invocationId, + ); + await completeAgentInvocation({ + errorMessage: "late conflicting failure", + invocationId: created.invocation.invocationId, + nowMs: Date.now() + 1_000, + status: "failed", + }); + await expect( + getAgentInvocation(created.invocation.invocationId), + ).resolves.toEqual(completed); + } finally { + await fixture.close(); + } + }); + + it("resumes a yielded invocation from durable child state", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "resume-1", + }, + { + conversationStore, + nowMs: 2_000, + queue, + state, + }, + ); + let runCount = 0; + const invocationWorker = createAgentInvocationConversationWorker({ + agentRunner: { + run: vi.fn(async (request) => { + runCount += 1; + await request.durability.onInputCommitted?.(); + if (runCount === 1) { + return { resumeVersion: 1, status: "suspended" as const }; + } + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + text: "Resumed child result", + }); + }), + }, + }); + const route = createAgentInvocationWorkRouter({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker, + }); + + await expect( + processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "yielded" }); + await expect( + getAgentInvocation(created.invocation.invocationId), + ).resolves.toMatchObject({ status: "awaiting_resume" }); + + await expect( + processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "completed" }); + expect(runCount).toBe(2); + await expect( + getAgentInvocation(created.invocation.invocationId), + ).resolves.toMatchObject({ + result: "Resumed child result", + status: "completed", + }); + } finally { + await fixture.close(); + } + }); + + it("repairs the durable creation-to-mailbox crash gap once", async () => { + const { fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "mailbox-repair-1", + }, + 2_000, + ); + + await recoverPendingAgentInvocationMailboxAppends({ + conversationWorkQueue: queue, + nowMs: 3_000, + }); + await recoverPendingAgentInvocationMailboxAppends({ + conversationWorkQueue: queue, + nowMs: 4_000, + }); + + expect(queue.sentRecords()).toHaveLength(1); + await expect( + getAgentInvocation(created.invocation.invocationId), + ).resolves.toMatchObject({ mailboxStatus: "appended" }); + } finally { + await fixture.close(); + } + }); + + it("loads prior history when a named child receives another invocation", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const first = await createAgentInvocation( + { + ...invocationInput, + agentName: "historian", + idempotencyKey: "history-1", + }, + 2_000, + ); + const priorMessages = [ + { + role: "user", + content: [{ type: "text", text: "First task" }], + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "text", text: "Remembered answer" }], + timestamp: 2, + }, + ] as unknown as PiMessage[]; + await upsertAgentTurnSessionRecord({ + actor: invocationInput.actor, + conversationId: first.invocation.childConversationId, + destination, + modelId: "test-model", + piMessages: priorMessages, + sessionId: getAgentInvocationTurnId(first.invocation.invocationId), + sliceId: 1, + source: invocationInput.source, + state: "completed", + surface: "internal", + }); + const next = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + agentName: "historian", + idempotencyKey: "history-2", + }, + { conversationStore, queue, state }, + ); + const run = vi.fn(async (request) => { + expect(request.input.piMessages).toEqual(priorMessages); + await request.durability.onInputCommitted?.(); + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + text: "Continued answer", + }); + }); + const route = createAgentInvocationWorkRouter({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: createAgentInvocationConversationWorker({ + agentRunner: { run }, + }), + }); + + await processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }); + + expect(run).toHaveBeenCalledOnce(); + await expect( + conversationStore.get({ + conversationId: next.invocation.childConversationId, + }), + ).resolves.not.toHaveProperty("destination"); + } finally { + await fixture.close(); + } + }); + + it("re-parks a stranded running child session before resuming", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "running-recovery-1", + }, + { conversationStore, queue, state }, + ); + const turnId = getAgentInvocationTurnId(created.invocation.invocationId); + await upsertAgentTurnSessionRecord({ + actor: invocationInput.actor, + conversationId: created.invocation.childConversationId, + destination, + modelId: "test-model", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: invocationInput.input }], + timestamp: 1, + }, + ], + sessionId: turnId, + sliceId: 1, + source: invocationInput.source, + state: "running", + surface: "internal", + }); + const run = vi.fn(async (request) => { + await expect( + getAgentTurnSessionRecord( + created.invocation.childConversationId, + turnId, + ), + ).resolves.toMatchObject({ state: "awaiting_resume" }); + await request.durability.onInputCommitted?.(); + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + text: "Recovered answer", + }); + }); + const route = createAgentInvocationWorkRouter({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: createAgentInvocationConversationWorker({ + agentRunner: { run }, + }), + }); + + await processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }); + + expect(run).toHaveBeenCalledOnce(); + await expect( + conversationStore.get({ + conversationId: created.invocation.childConversationId, + }), + ).resolves.not.toHaveProperty("destination"); + } finally { + await fixture.close(); + } + }); + + it("recovers a completed child session without rerunning the agent", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "completed-recovery-1", + }, + { conversationStore, queue, state }, + ); + await upsertAgentTurnSessionRecord({ + actor: invocationInput.actor, + conversationId: created.invocation.childConversationId, + destination, + modelId: "test-model", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: invocationInput.input }], + }, + { + role: "assistant", + content: [ + { type: "text", text: "Calling a tool" }, + { + type: "toolCall", + id: "tool-1", + name: "lookup", + arguments: {}, + }, + ], + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "lookup", + content: [{ type: "text", text: "tool output" }], + isError: false, + }, + { + role: "assistant", + content: [{ type: "text", text: "Recovered visible result" }], + }, + ] as unknown as PiMessage[], + sessionId: getAgentInvocationTurnId(created.invocation.invocationId), + sliceId: 1, + source: invocationInput.source, + state: "completed", + surface: "internal", + }); + const run = vi.fn(async () => { + throw new Error("completed sessions must not rerun"); + }); + const route = createAgentInvocationWorkRouter({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: createAgentInvocationConversationWorker({ + agentRunner: { run }, + }), + }); + + await expect( + processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "completed" }); + + expect(run).not.toHaveBeenCalled(); + await expect( + getAgentInvocation(created.invocation.invocationId), + ).resolves.toMatchObject({ + result: "Recovered visible result", + status: "completed", + }); + } finally { + await fixture.close(); + } + }); + + it("retries runner failures before persisting one final failure", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "failure-1", + }, + { conversationStore, queue, state }, + ); + const run = vi.fn(async () => { + throw new Error("model unavailable"); + }); + const deliveryAttempts: Array = []; + const invocationWorker = createAgentInvocationConversationWorker({ + agentRunner: { run }, + }); + const route = createAgentInvocationWorkRouter({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: async (context, invocationId) => { + deliveryAttempts.push(context.attempt.messages[0]?.attemptCount); + return await invocationWorker(context, invocationId); + }, + }); + + for ( + let attempt = 1; + attempt <= CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS; + attempt += 1 + ) { + await expect( + processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ + status: + attempt === CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS + ? "completed" + : "failed", + }); + } + + expect(run).toHaveBeenCalledTimes( + CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS, + ); + expect(deliveryAttempts).toEqual([undefined, 1, 2, 3, 4]); + await expect( + getAgentInvocation(created.invocation.invocationId), + ).resolves.toMatchObject({ + errorMessage: "model unavailable", + status: "failed", + }); + } finally { + await fixture.close(); + } + }); +}); diff --git a/packages/junior/tests/unit/agent-request.test.ts b/packages/junior/tests/unit/agent-request.test.ts new file mode 100644 index 000000000..efaf7e632 --- /dev/null +++ b/packages/junior/tests/unit/agent-request.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { createLocalSource } from "@sentry/junior-plugin-api"; +import { assertRunRoutingConsistency } from "@/chat/agent/request"; + +describe("agent run routing", () => { + it("allows an internal child run to borrow its parent's local route", () => { + expect(() => + assertRunRoutingConsistency({ + conversationId: "local:test:child", + routing: { + destination: { + conversationId: "local:test:parent", + platform: "local", + }, + source: createLocalSource("local:test:parent"), + surface: "internal", + }, + }), + ).not.toThrow(); + }); + + it("rejects the same local route mismatch outside internal work", () => { + expect(() => + assertRunRoutingConsistency({ + conversationId: "local:test:child", + routing: { + destination: { + conversationId: "local:test:parent", + platform: "local", + }, + source: createLocalSource("local:test:parent"), + }, + }), + ).toThrow( + "Local source, destination, and run conversation IDs do not match", + ); + }); +}); From 79a01ff06604958f6fa32641e6ac2477f16699c8 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 26 Jul 2026 21:41:57 -0700 Subject: [PATCH 2/8] feat(agent): expose spawnAgent tool --- packages/junior/src/app.ts | 10 + .../src/chat/agent-invocations/README.md | 14 +- .../src/chat/agent-invocations/errors.ts | 7 + .../src/chat/agent-invocations/spawn.ts | 87 +++++++ .../src/chat/agent-invocations/store.ts | 37 ++- .../junior/src/chat/agent-invocations/work.ts | 1 + packages/junior/src/chat/agent/request.ts | 53 ++++- packages/junior/src/chat/agent/tools.ts | 3 + packages/junior/src/chat/app/services.ts | 21 +- .../src/chat/conversations/sql/purge.ts | 215 +++++++++--------- .../src/chat/conversations/sql/store.ts | 95 ++++---- .../junior/src/chat/runtime/agent-runner.ts | 31 ++- packages/junior/src/chat/tools/index.ts | 5 + .../src/chat/tools/runtime/spawn-agent.ts | 84 +++++++ packages/junior/src/chat/tools/types.ts | 2 + packages/junior/src/cli/chat.ts | 12 +- .../integration/agent-invocation-work.test.ts | 100 ++++++++ .../junior/tests/unit/agent-request.test.ts | 16 ++ .../tests/unit/runtime/agent-runner.test.ts | 62 +++++ .../tests/unit/tools/spawn-agent.test.ts | 50 ++++ 20 files changed, 731 insertions(+), 174 deletions(-) create mode 100644 packages/junior/src/chat/agent-invocations/errors.ts create mode 100644 packages/junior/src/chat/agent-invocations/spawn.ts create mode 100644 packages/junior/src/chat/tools/runtime/spawn-agent.ts create mode 100644 packages/junior/tests/unit/runtime/agent-runner.test.ts create mode 100644 packages/junior/tests/unit/tools/spawn-agent.test.ts diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index e1ca482f3..87b5ca874 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -63,6 +63,10 @@ import { type VercelConversationWorkCallbackOptions, } from "@/chat/task-execution/vercel-callback"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; +import { + bindAgentSpawnControl, + createAgentInvocationCreator, +} from "@/chat/agent-invocations/spawn"; import { createVercelPluginTaskCallback, registerVercelPluginTaskDevConsumer, @@ -632,7 +636,13 @@ export async function createApp(options?: JuniorAppOptions): Promise { const waitUntil = options?.waitUntil ?? (await defaultWaitUntil()); const tracePropagation = { domains: sandboxEgressTracePropagationDomains }; + const conversationWorkQueue = getVercelConversationWorkQueue(); + const agentInvocationCreator = createAgentInvocationCreator({ + queue: conversationWorkQueue, + }); const agentRunner = createAgentRunner(executeAgentRun, { + bindSpawnAgent: (request) => + bindAgentSpawnControl(request, agentInvocationCreator), tracePropagation, }); const runtimeServiceOverrides = { diff --git a/packages/junior/src/chat/agent-invocations/README.md b/packages/junior/src/chat/agent-invocations/README.md index 78ff4c9ca..77753bb98 100644 --- a/packages/junior/src/chat/agent-invocations/README.md +++ b/packages/junior/src/chat/agent-invocations/README.md @@ -42,8 +42,12 @@ by the parent-facing runtime. ## Current Boundary -This slice provides durable storage and execution plumbing. It does not yet -expose model tools for creating or waiting on invocations, inject child results -into a parent turn, support recursive children, or implement cancellation. -Those behaviors should build on the invocation record rather than introducing -another scheduler or execution loop. +`spawnAgent` exposes durable creation to a parent agent. The tool receives only +the delegated task, optional child name, and optional reasoning level. The +runtime derives actor, credentials, destination, visibility, source, parent +conversation, and idempotency from the active tool call. + +This slice does not yet expose result recovery, inject child results into a +parent turn, support recursive children, or implement cancellation. Those +behaviors should build on the invocation record rather than introducing another +scheduler or execution loop. diff --git a/packages/junior/src/chat/agent-invocations/errors.ts b/packages/junior/src/chat/agent-invocations/errors.ts new file mode 100644 index 000000000..ffb451595 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/errors.ts @@ -0,0 +1,7 @@ +/** Named child cannot accept another invocation until its active work ends. */ +export class AgentInvocationBusyError extends Error { + constructor(name: string) { + super(`Named agent "${name}" already has active work`); + this.name = "AgentInvocationBusyError"; + } +} diff --git a/packages/junior/src/chat/agent-invocations/spawn.ts b/packages/junior/src/chat/agent-invocations/spawn.ts new file mode 100644 index 000000000..922774f20 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/spawn.ts @@ -0,0 +1,87 @@ +import type { StateAdapter } from "chat"; +import type { ConversationStore } from "@/chat/conversations/store"; +import type { + AgentRunRequest, + AgentSpawnControl, + AgentSpawnInput, + AgentSpawnResult, +} from "@/chat/agent/request"; +import { + actorFromRouting, + toolInvocationDestination, +} from "@/chat/agent/request"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; +import type { CreateAgentInvocationInput } from "./types"; +import { createAndEnqueueAgentInvocation } from "./work"; + +export interface AgentInvocationCreator { + create(input: CreateAgentInvocationInput): Promise; +} + +interface AgentInvocationCreatorOptions { + conversationStore?: ConversationStore; + queue: ConversationWorkQueue | (() => ConversationWorkQueue); + state?: StateAdapter; +} + +/** Create the narrow durable invocation capability used by agent runners. */ +export function createAgentInvocationCreator( + options: AgentInvocationCreatorOptions, +): AgentInvocationCreator { + return { + async create(input) { + const queue = + typeof options.queue === "function" ? options.queue() : options.queue; + const created = await createAndEnqueueAgentInvocation(input, { + ...options, + queue, + }); + return { + agentName: created.invocation.agentName, + childConversationId: created.invocation.childConversationId, + invocationId: created.invocation.invocationId, + replayed: created.status === "existing", + status: created.invocation.status, + }; + }, + }; +} + +/** Bind one run's runtime-owned authority to the model-safe spawn capability. */ +export function bindAgentSpawnControl( + request: AgentRunRequest, + creator: AgentInvocationCreator, +): AgentSpawnControl | undefined { + const actor = actorFromRouting(request.routing); + if (!actor) { + return undefined; + } + return { + async execute( + input: AgentSpawnInput, + options: { signal?: AbortSignal; toolCallId: string }, + ) { + options.signal?.throwIfAborted(); + return await creator.create({ + actor, + ...(input.name ? { agentName: input.name } : {}), + ...(request.routing.credentialContext + ? { credentialContext: request.routing.credentialContext } + : {}), + destination: toolInvocationDestination(request.routing), + ...(request.routing.destinationVisibility + ? { + destinationVisibility: request.routing.destinationVisibility, + } + : {}), + idempotencyKey: `${request.turnId}:${options.toolCallId}`, + input: input.task, + parentConversationId: request.conversationId, + ...(input.reasoningLevel + ? { reasoningLevel: input.reasoningLevel } + : {}), + source: request.routing.source, + }); + }, + }; +} diff --git a/packages/junior/src/chat/agent-invocations/store.ts b/packages/junior/src/chat/agent-invocations/store.ts index 982f8c8a6..97911a281 100644 --- a/packages/junior/src/chat/agent-invocations/store.ts +++ b/packages/junior/src/chat/agent-invocations/store.ts @@ -11,6 +11,7 @@ import { type AgentInvocationStatus, type CreateAgentInvocationInput, } from "./types"; +import { AgentInvocationBusyError } from "./errors"; const CREATE_LOCK_PREFIX = "junior:agent_invocation:create"; const TERMINAL_AGENT_INVOCATION_STATUSES = [ @@ -181,6 +182,27 @@ export async function getActiveAgentInvocationForConversation( return rows[0] ? invocationFromRow(rows[0]) : undefined; } +async function getNonTerminalAgentInvocationForConversation( + childConversationId: string, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where( + and( + eq(juniorAgentInvocations.childConversationId, childConversationId), + inArray( + juniorAgentInvocations.status, + NON_TERMINAL_AGENT_INVOCATION_STATUSES, + ), + ), + ) + .orderBy(asc(juniorAgentInvocations.createdAt)) + .limit(1); + return rows[0] ? invocationFromRow(rows[0]) : undefined; +} + /** * Create or replay one invocation, reusing named child conversations and * keeping ephemeral child identities scoped to the invocation. @@ -194,7 +216,12 @@ export async function createAgentInvocation( input.parentConversationId, input.idempotencyKey, ); - const lockName = `${CREATE_LOCK_PREFIX}:${invocationId}`; + const childConversationId = input.agentName + ? getNamedAgentConversationId(input.parentConversationId, input.agentName) + : getEphemeralAgentConversationId(invocationId); + const lockName = `${CREATE_LOCK_PREFIX}:${ + input.agentName ? childConversationId : invocationId + }`; return await getSqlExecutor().withLock(lockName, async () => { const existing = await getAgentInvocation(invocationId); if (existing) { @@ -206,9 +233,6 @@ export async function createAgentInvocation( return { invocation: existing, status: "existing" }; } - const childConversationId = input.agentName - ? getNamedAgentConversationId(input.parentConversationId, input.agentName) - : getEphemeralAgentConversationId(invocationId); await getConversationStore().createChild({ childConversationId, parentConversationId: input.parentConversationId, @@ -243,6 +267,11 @@ export async function createAgentInvocation( `Named agent binding policy changed for ${input.agentName}`, ); } + if ( + await getNonTerminalAgentInvocationForConversation(childConversationId) + ) { + throw new AgentInvocationBusyError(input.agentName); + } } await getSqlExecutor() diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index fa5987258..2d193bdea 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -410,6 +410,7 @@ export function createAgentInvocationConversationWorker(options: { surface: "internal", }, policy: { + agentSpawning: "disabled", authorizationFlowMode: "disabled", reasoningLevel: invocation.reasoningLevel, }, diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index a0eb5285e..f9836e566 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -31,6 +31,7 @@ import type { AgentTurnSurface } from "@/chat/state/turn-session"; import type { ToolExecutionReport } from "@/chat/tool-support/tool-execution-report"; import type { SlackActionToken } from "@/chat/slack/action-token"; import type { TurnReasoningLevel } from "@/chat/reasoning-level"; +import type { AgentInvocationStatus } from "@/chat/agent-invocations/types"; import type { ImageGenerateToolDeps, ViewImageToolDeps, @@ -62,6 +63,30 @@ export interface AgentRunSteeringMessage { userAttachments?: AgentRunAttachment[]; } +/** Model-safe input for one asynchronously delegated child-agent task. */ +export interface AgentSpawnInput { + name?: string; + reasoningLevel?: TurnReasoningLevel; + task: string; +} + +/** Stable projection returned after a child-agent task is durably scheduled. */ +export interface AgentSpawnResult { + agentName?: string; + childConversationId: string; + invocationId: string; + replayed: boolean; + status: AgentInvocationStatus; +} + +/** Runtime-bound child-agent capability exposed to model-facing tool wiring. */ +export interface AgentSpawnControl { + execute( + input: AgentSpawnInput, + options: { signal?: AbortSignal; toolCallId: string }, + ): Promise; +} + /** Carries the user-visible content and prior transcript for one agent-run slice. */ export interface AgentRunInput { actor?: AgentRunInstructionActor; @@ -101,6 +126,8 @@ export interface AgentRunRouting { /** Carries execution limits and dependency overrides for one run slice. */ export interface AgentRunPolicy { + /** Disable child-agent spawning for runs that are already delegated work. */ + agentSpawning?: "disabled"; /** Absolute wall-clock deadline for this host request, in milliseconds. */ turnDeadlineAtMs?: number; /** Cancels provider work when the owning host request is abandoned. */ @@ -165,6 +192,8 @@ export class RetryableDeliveryError extends Error { /** Carries durable-worker ports that commit or update resumable run state. */ export interface AgentRunDurability { + /** Schedule delegated work with authority bound by the active parent run. */ + spawnAgent?: AgentSpawnControl; onInputCommitted?: () => void | Promise; /** Return true when the durable worker should pause at the next Pi boundary. */ shouldYield?: () => boolean; @@ -228,16 +257,20 @@ export function assertRunRoutingConsistency( if (source.teamId !== destination.teamId) { throw new TypeError("Slack source and destination teams do not match"); } - } else if ( - source.platform === "local" && - destination.platform === "local" && - request.routing.surface !== "internal" && - (source.conversationId !== request.conversationId || - destination.conversationId !== request.conversationId) - ) { - throw new TypeError( - "Local source, destination, and run conversation IDs do not match", - ); + } else if (source.platform === "local" && destination.platform === "local") { + if (source.conversationId !== destination.conversationId) { + throw new TypeError( + "Local source and destination conversation IDs do not match", + ); + } + if ( + request.routing.surface !== "internal" && + destination.conversationId !== request.conversationId + ) { + throw new TypeError( + "Local source, destination, and run conversation IDs do not match", + ); + } } const actor = request.routing.dispatch?.actor ?? request.routing.actor; diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 2c38e469a..33c0a110d 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -265,6 +265,9 @@ export async function wireAgentTools( workspace: agentSandbox.workspace, supportsImageInput: args.supportsImageInput, surface: args.surface, + ...(args.durability.spawnAgent + ? { spawnAgent: args.durability.spawnAgent } + : {}), ...(args.requestHandoff ? { handoff: args.requestHandoff } : {}), }; const toolDestination = toolInvocationDestination(args.routing); diff --git a/packages/junior/src/chat/app/services.ts b/packages/junior/src/chat/app/services.ts index 651120d86..588643854 100644 --- a/packages/junior/src/chat/app/services.ts +++ b/packages/junior/src/chat/app/services.ts @@ -33,6 +33,11 @@ import { import { createAgentRunner } from "@/chat/runtime/agent-runner"; import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; import { getConversationEventStore } from "@/chat/db"; +import { + bindAgentSpawnControl, + createAgentInvocationCreator, +} from "@/chat/agent-invocations/spawn"; +import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; export interface JuniorRuntimeServices { conversationMemory: ConversationMemoryService; @@ -71,6 +76,16 @@ export function createJuniorRuntimeServices( downloadFile: overrides.visionContext?.downloadFile ?? downloadPrivateSlackFile, }); + const agentInvocationCreator = createAgentInvocationCreator({ + queue: getVercelConversationWorkQueue, + }); + const agentRunner = + overrides.replyExecutor?.agentRunner ?? + createAgentRunner(executeAgentRunImpl, { + bindSpawnAgent: (request) => + bindAgentSpawnControl(request, agentInvocationCreator), + tracePropagation: overrides.sandbox?.tracePropagation, + }); return { conversationMemory, @@ -78,11 +93,7 @@ export function createJuniorRuntimeServices( replyExecutor: { contextCompactor: overrides.replyExecutor?.contextCompactor ?? contextCompactor, - agentRunner: - overrides.replyExecutor?.agentRunner ?? - createAgentRunner(executeAgentRunImpl, { - tracePropagation: overrides.sandbox?.tracePropagation, - }), + agentRunner, getAwaitingAgentContinueRequest: overrides.replyExecutor?.getAwaitingAgentContinueRequest ?? getAwaitingAgentContinueRequest, diff --git a/packages/junior/src/chat/conversations/sql/purge.ts b/packages/junior/src/chat/conversations/sql/purge.ts index e5d505f70..f765da195 100644 --- a/packages/junior/src/chat/conversations/sql/purge.ts +++ b/packages/junior/src/chat/conversations/sql/purge.ts @@ -9,6 +9,7 @@ import { } from "@/db/schema"; import { withConversationEventLock } from "./event-lock"; import { resolveRootVisibility } from "./privacy"; +import { withConversationMutationLock } from "./store"; /** An expired root conversation selected for purge, with its resolved visibility. */ export interface ExpiredRoot { @@ -169,119 +170,123 @@ export async function purgeConversationTree( retention?: { privateWindowMs: number; publicWindowMs: number }; }, ): Promise { - return await executor.transaction(async () => { - const initialRoots = await executor - .db() - .select({ - conversationId: juniorConversations.conversationId, - parentConversationId: juniorConversations.parentConversationId, - }) - .from(juniorConversations) - .where(eq(juniorConversations.conversationId, args.rootConversationId)); - const initialRoot = initialRoots[0]; - if ( - !initialRoot || - (args.retention && initialRoot.parentConversationId !== null) - ) { - return { purged: false, conversations: 0 }; - } - const tree = await discoverConversationTree(executor, initialRoot); + return await withConversationMutationLock( + executor, + args.rootConversationId, + async () => { + const initialRoots = await executor + .db() + .select({ + conversationId: juniorConversations.conversationId, + parentConversationId: juniorConversations.parentConversationId, + }) + .from(juniorConversations) + .where(eq(juniorConversations.conversationId, args.rootConversationId)); + const initialRoot = initialRoots[0]; + if ( + !initialRoot || + (args.retention && initialRoot.parentConversationId !== null) + ) { + return { purged: false, conversations: 0 }; + } + const tree = await discoverConversationTree(executor, initialRoot); - return await withConversationEventLock( - executor, - args.rootConversationId, - async () => { - // Parent links are immutable and child creation is migration-only, so - // one unlocked traversal is authoritative. Acquire every event lock - // before row locks so a child writer can finish without deadlocking on - // its parent relationship. - for (const conversation of tree.slice(1)) { - await withConversationEventLock( - executor, - conversation.conversationId, - async () => undefined, - ); - } + return await withConversationEventLock( + executor, + args.rootConversationId, + async () => { + // Parent links are immutable, and the root mutation lock prevents live + // child creation after discovery. Acquire every event lock before row + // locks so a child writer can finish without deadlocking on its parent + // relationship. + for (const conversation of tree.slice(1)) { + await withConversationEventLock( + executor, + conversation.conversationId, + async () => undefined, + ); + } - const roots = await executor - .db() - .select({ - conversationId: juniorConversations.conversationId, - destinationId: juniorConversations.destinationId, - parentConversationId: juniorConversations.parentConversationId, - }) - .from(juniorConversations) - .where( - eq(juniorConversations.conversationId, args.rootConversationId), - ) - .for("update"); - const root = roots[0]; - if (!root || (args.retention && root.parentConversationId !== null)) { - return { purged: false, conversations: 0 }; - } - const resolvedScrubMetadata = args.scrubMetadataFromRootVisibility - ? (await resolveRootVisibility(executor, args.rootConversationId)) - .visibility !== "public" - : args.scrubMetadata; - const destinations = root.destinationId - ? await executor - .db() - .select({ visibility: juniorDestinations.visibility }) - .from(juniorDestinations) - .where(eq(juniorDestinations.id, root.destinationId)) - .for("share") - : []; - const isPublic = destinations[0]?.visibility === "public"; - const ids = tree.map((conversation) => conversation.conversationId); - if (args.retention) { - const currentActivity = await executor + const roots = await executor .db() .select({ conversationId: juniorConversations.conversationId, - lastActivityAt: juniorConversations.lastActivityAt, + destinationId: juniorConversations.destinationId, + parentConversationId: juniorConversations.parentConversationId, }) .from(juniorConversations) - .where(inArray(juniorConversations.conversationId, ids)); - if (currentActivity.length !== ids.length) { + .where( + eq(juniorConversations.conversationId, args.rootConversationId), + ) + .for("update"); + const root = roots[0]; + if (!root || (args.retention && root.parentConversationId !== null)) { return { purged: false, conversations: 0 }; } - const windowMs = isPublic - ? args.retention.publicWindowMs - : args.retention.privateWindowMs; - const effectiveLastActivityAt = Math.max( - ...currentActivity.map((conversation) => - conversation.lastActivityAt.getTime(), - ), - ); - if (effectiveLastActivityAt >= args.nowMs - windowMs) { - return { purged: false, conversations: 0 }; + const resolvedScrubMetadata = args.scrubMetadataFromRootVisibility + ? (await resolveRootVisibility(executor, args.rootConversationId)) + .visibility !== "public" + : args.scrubMetadata; + const destinations = root.destinationId + ? await executor + .db() + .select({ visibility: juniorDestinations.visibility }) + .from(juniorDestinations) + .where(eq(juniorDestinations.id, root.destinationId)) + .for("share") + : []; + const isPublic = destinations[0]?.visibility === "public"; + const ids = tree.map((conversation) => conversation.conversationId); + if (args.retention) { + const currentActivity = await executor + .db() + .select({ + conversationId: juniorConversations.conversationId, + lastActivityAt: juniorConversations.lastActivityAt, + }) + .from(juniorConversations) + .where(inArray(juniorConversations.conversationId, ids)); + if (currentActivity.length !== ids.length) { + return { purged: false, conversations: 0 }; + } + const windowMs = isPublic + ? args.retention.publicWindowMs + : args.retention.privateWindowMs; + const effectiveLastActivityAt = Math.max( + ...currentActivity.map((conversation) => + conversation.lastActivityAt.getTime(), + ), + ); + if (effectiveLastActivityAt >= args.nowMs - windowMs) { + return { purged: false, conversations: 0 }; + } } - } - await executor - .db() - .delete(juniorConversationEvents) - .where(inArray(juniorConversationEvents.conversationId, ids)); - await executor - .db() - .delete(juniorAgentInvocations) - .where( - or( - inArray(juniorAgentInvocations.parentConversationId, ids), - inArray(juniorAgentInvocations.childConversationId, ids), - ), - ); - await executor - .db() - .update(juniorConversations) - .set({ - transcriptPurgedAt: new Date(args.nowMs), - ...((args.retention ? !isPublic : resolvedScrubMetadata) - ? { title: null, channelName: null, actor: null } - : {}), - }) - .where(inArray(juniorConversations.conversationId, ids)); - return { purged: true, conversations: ids.length }; - }, - ); - }); + await executor + .db() + .delete(juniorConversationEvents) + .where(inArray(juniorConversationEvents.conversationId, ids)); + await executor + .db() + .delete(juniorAgentInvocations) + .where( + or( + inArray(juniorAgentInvocations.parentConversationId, ids), + inArray(juniorAgentInvocations.childConversationId, ids), + ), + ); + await executor + .db() + .update(juniorConversations) + .set({ + transcriptPurgedAt: new Date(args.nowMs), + ...((args.retention ? !isPublic : resolvedScrubMetadata) + ? { title: null, channelName: null, actor: null } + : {}), + }) + .where(inArray(juniorConversations.conversationId, ids)); + return { purged: true, conversations: ids.length }; + }, + ); + }, + ); } diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index 042ef5293..4385d5beb 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -50,6 +50,18 @@ interface DestinationUpsert { const CONVERSATION_MUTATION_LOCK_PREFIX = "junior_conversation"; +/** Serialize one conversation's durable mutations inside a SQL transaction. */ +export async function withConversationMutationLock( + executor: JuniorSqlDatabase, + conversationId: string, + callback: () => Promise, +): Promise { + return await executor.withLock( + `${CONVERSATION_MUTATION_LOCK_PREFIX}:${conversationId}`, + async () => await executor.transaction(callback), + ); +} + function now(): number { return Date.now(); } @@ -486,42 +498,48 @@ export class SqlStore implements ConversationStore { source?: ConversationSource; }): Promise { const nowMs = args.nowMs ?? now(); - await this.withConversationMutation(args.childConversationId, async () => { - const existing = await this.get({ - conversationId: args.childConversationId, - }); - if (existing) { - if ( - existing.lineage?.parentConversationId !== args.parentConversationId - ) { - throw new Error( - `Conversation lineage changed for ${args.childConversationId}`, - ); - } - return; - } - const parent = await this.get({ - conversationId: args.parentConversationId, - }); - if (!parent) { - throw new Error( - `Conversation parent is missing for ${args.childConversationId}`, - ); - } - if (parent.lineage) { - throw new Error("Recursive agent delegation is not enabled"); - } - const child: Conversation = { - ...emptyConversation({ - conversationId: args.childConversationId, - nowMs, - source: args.source, - }), - lineage: { - parentConversationId: args.parentConversationId, + await this.withConversationMutation(args.parentConversationId, async () => { + await this.withConversationMutation( + args.childConversationId, + async () => { + const existing = await this.get({ + conversationId: args.childConversationId, + }); + if (existing) { + if ( + existing.lineage?.parentConversationId !== + args.parentConversationId + ) { + throw new Error( + `Conversation lineage changed for ${args.childConversationId}`, + ); + } + return; + } + const parent = await this.get({ + conversationId: args.parentConversationId, + }); + if (!parent) { + throw new Error( + `Conversation parent is missing for ${args.childConversationId}`, + ); + } + if (parent.lineage) { + throw new Error("Recursive agent delegation is not enabled"); + } + const child: Conversation = { + ...emptyConversation({ + conversationId: args.childConversationId, + nowMs, + source: args.source, + }), + lineage: { + parentConversationId: args.parentConversationId, + }, + }; + await this.upsertConversation({ conversation: child }); }, - }; - await this.upsertConversation({ conversation: child }); + ); }); } @@ -746,9 +764,10 @@ export class SqlStore implements ConversationStore { conversationId: string, callback: () => Promise, ): Promise { - return await this.executor.withLock( - `${CONVERSATION_MUTATION_LOCK_PREFIX}:${conversationId}`, - async () => await this.executor.transaction(callback), + return await withConversationMutationLock( + this.executor, + conversationId, + callback, ); } diff --git a/packages/junior/src/chat/runtime/agent-runner.ts b/packages/junior/src/chat/runtime/agent-runner.ts index 1c5b4b39c..e8bc69f5c 100644 --- a/packages/junior/src/chat/runtime/agent-runner.ts +++ b/packages/junior/src/chat/runtime/agent-runner.ts @@ -1,4 +1,4 @@ -import type { AgentRunRequest } from "@/chat/agent/request"; +import type { AgentRunRequest, AgentSpawnControl } from "@/chat/agent/request"; import type { AgentRunOutcome } from "@/chat/runtime/agent-run-outcome"; import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; @@ -10,21 +10,40 @@ export interface AgentRunner { /** Adapt the Pi-facing agent-run executor behind the runtime-owned runner seam. */ export function createAgentRunner( run: AgentRunner["run"], - options?: { tracePropagation?: SandboxEgressTracePropagationConfig }, + options?: { + bindSpawnAgent?: ( + request: AgentRunRequest, + ) => AgentSpawnControl | undefined; + tracePropagation?: SandboxEgressTracePropagationConfig; + }, ): AgentRunner { const tracePropagation = options?.tracePropagation; - if (!tracePropagation) { + const bindSpawnAgent = options?.bindSpawnAgent; + if (!tracePropagation && !bindSpawnAgent) { return { run }; } return { - run: async (request) => - await run({ + run: async (request) => { + const spawnAgent = + bindSpawnAgent && request.policy?.agentSpawning !== "disabled" + ? bindSpawnAgent(request) + : undefined; + return await run({ ...request, policy: { ...request.policy, sandboxTracePropagation: request.policy?.sandboxTracePropagation ?? tracePropagation, }, - }), + ...(spawnAgent + ? { + durability: { + ...request.durability, + spawnAgent, + }, + } + : {}), + }); + }, }; } diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index 5f06fb2bd..566a292ea 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -14,6 +14,7 @@ import { createSearchMcpToolsTool } from "@/chat/tools/skill/search-mcp-tools"; import { createReadFileTool } from "@/chat/tools/sandbox/read-file"; import { createViewImageTool } from "@/chat/tools/sandbox/view-image"; import { createReportProgressTool } from "@/chat/tools/runtime/report-progress"; +import { createSpawnAgentTool } from "@/chat/tools/runtime/spawn-agent"; import { createResourceEventTools } from "@/chat/tools/resource-events"; import { createSlackChannelListMessagesTool } from "@/chat/slack/tools/channel-list-messages"; import { createSlackConversationSearchTool } from "@/chat/slack/tools/conversation-search"; @@ -155,6 +156,10 @@ export function createTools( tools.handoff = createHandoffTool(context.handoff); } + if (context.spawnAgent) { + tools.spawnAgent = createSpawnAgentTool(context.spawnAgent); + } + if (context.mcpToolManager) { tools.searchMcpTools = createSearchMcpToolsTool(context.mcpToolManager); tools.callMcpTool = createCallMcpToolTool(context.mcpToolManager); diff --git a/packages/junior/src/chat/tools/runtime/spawn-agent.ts b/packages/junior/src/chat/tools/runtime/spawn-agent.ts new file mode 100644 index 000000000..d9ac0b8a4 --- /dev/null +++ b/packages/junior/src/chat/tools/runtime/spawn-agent.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; +import { AgentInvocationBusyError } from "@/chat/agent-invocations/errors"; +import { + agentInvocationStatusSchema, + agentNameSchema, +} from "@/chat/agent-invocations/types"; +import { TURN_REASONING_LEVELS } from "@/chat/reasoning-level"; +import { juniorToolResultSchema } from "@/chat/tool-support/structured-result"; +import { zodTool } from "@/chat/tool-support/zod-tool"; +import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; +import type { ToolRuntimeContext } from "@/chat/tools/types"; + +export const SPAWN_AGENT_TOOL_NAME = "spawnAgent"; + +/** Create the asynchronous child-agent tool for one parent run. */ +export function createSpawnAgentTool( + spawnAgent: NonNullable, +) { + return zodTool({ + description: + "Start an asynchronous child agent on a delegated task. Give it a name to reuse the same child agent and its history on later calls; omit the name for a one-off agent. This call confirms durable scheduling, not task completion.", + inputSchema: z + .object({ + task: z.string().trim().min(1).describe("Task for the child agent"), + name: agentNameSchema + .nullable() + .optional() + .describe("Stable child-agent name, or omit for a one-off agent"), + reasoning_level: z + .enum(TURN_REASONING_LEVELS) + .nullable() + .optional() + .describe( + "Optional reasoning level; for a named agent this becomes its stable policy", + ), + }) + .strict(), + outputSchema: juniorToolResultSchema.extend({ + invocation_id: z.string().min(1), + child_conversation_id: z.string().min(1), + agent_name: agentNameSchema.optional(), + invocation_status: agentInvocationStatusSchema, + replayed: z.boolean(), + }), + execute: async (input, options) => { + if (!options.toolCallId) { + throw new ToolInputError("spawnAgent requires an active tool call ID"); + } + let result; + try { + result = await spawnAgent.execute( + { + task: input.task, + ...(input.name ? { name: input.name } : {}), + ...(input.reasoning_level + ? { reasoningLevel: input.reasoning_level } + : {}), + }, + { + ...(options.signal ? { signal: options.signal } : {}), + toolCallId: options.toolCallId, + }, + ); + } catch (error) { + if (error instanceof AgentInvocationBusyError) { + throw new ToolInputError( + `${error.message}. Wait for it to finish or use a different name.`, + { cause: error }, + ); + } + throw error; + } + return { + ok: true, + status: "success" as const, + invocation_id: result.invocationId, + child_conversation_id: result.childConversationId, + ...(result.agentName ? { agent_name: result.agentName } : {}), + invocation_status: result.status, + replayed: result.replayed, + }; + }, + }); +} diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index 44a6c45bf..40b5230e7 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -19,6 +19,7 @@ import type { LocalActor, Actor, SlackActor } from "@/chat/actor"; import type { SlackActionToken } from "@/chat/slack/action-token"; import type { ModelProfile } from "@/chat/model-profile"; import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; +import type { AgentSpawnControl } from "@/chat/agent/request"; interface HandoffControl { /** Non-empty catalog of configured targets. */ @@ -76,6 +77,7 @@ export interface ToolHooks { interface BaseToolRuntimeContext { handoff?: HandoffControl; + spawnAgent?: AgentSpawnControl; /** * Opaque Junior conversation/session identity for this turn. * Interactive Slack turns use `slack:{channelId}:{threadTs}`. diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index c517c19d9..ebc122a61 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -242,7 +242,17 @@ async function prepareLocalChatRun( const { createLocalOAuthState } = await import("@/chat/local/oauth-relay"); const { createLocalSandboxEgressSignalTransport } = await import("@/chat/local/sandbox-egress-signals"); - const agentRunner = createAgentRunner(executeAgentRun); + const { bindAgentSpawnControl, createAgentInvocationCreator } = + await import("@/chat/agent-invocations/spawn"); + const { getVercelConversationWorkQueue } = + await import("@/chat/task-execution/vercel-queue"); + const agentInvocationCreator = createAgentInvocationCreator({ + queue: getVercelConversationWorkQueue, + }); + const agentRunner = createAgentRunner(executeAgentRun, { + bindSpawnAgent: (request) => + bindAgentSpawnControl(request, agentInvocationCreator), + }); const oauthCallback = await startLocalOAuthCallbackServer(agentRunner); const deps: LocalAgentTurnDeps = { agentRunner, diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index ee17cdf6d..9126aa060 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -13,6 +13,12 @@ import { createAgentInvocationWorkRouter, createAndEnqueueAgentInvocation, } from "@/chat/agent-invocations/work"; +import { + bindAgentSpawnControl, + createAgentInvocationCreator, +} from "@/chat/agent-invocations/spawn"; +import { createSpawnAgentTool } from "@/chat/tools/runtime/spawn-agent"; +import type { AgentRunRequest } from "@/chat/agent/request"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { createSqlStore } from "@/chat/conversations/sql/store"; import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; @@ -80,6 +86,11 @@ describe("agent invocation conversation work", () => { }, 3_000, ); + await completeAgentInvocation({ + invocationId: first.invocation.invocationId, + result: "Finished.", + status: "completed", + }); const next = await createAgentInvocation( { ...invocationInput, @@ -164,6 +175,90 @@ describe("agent invocation conversation work", () => { } }); + it("derives spawn authority from the parent run and replays one tool call", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + try { + const request = { + conversationId: parentConversationId, + turnId: "parent-turn", + input: { + messageText: "Delegate the investigation.", + }, + routing: { + actor: { platform: "local", userId: "local-user" }, + credentialContext: { + actor: { type: "user", userId: "local-user" }, + }, + destination, + destinationVisibility: "private", + source: createLocalSource(parentConversationId), + }, + } satisfies AgentRunRequest; + const spawnAgent = bindAgentSpawnControl( + request, + createAgentInvocationCreator({ + conversationStore, + queue, + }), + ); + expect(spawnAgent).toBeDefined(); + const tool = createSpawnAgentTool(spawnAgent!); + const input = tool.prepareArguments!({ + task: "Inspect the failing checks.", + name: "reviewer", + reasoning_level: "high", + }); + + const first = await tool.execute!(input, { toolCallId: "call-1" }); + const replay = await tool.execute!(input, { toolCallId: "call-1" }); + + expect(first).toMatchObject({ + agent_name: "reviewer", + invocation_status: "pending", + replayed: false, + }); + expect(replay).toMatchObject({ + invocation_id: first.invocation_id, + child_conversation_id: first.child_conversation_id, + replayed: true, + }); + await expect( + getAgentInvocation(first.invocation_id), + ).resolves.toMatchObject({ + actor: { platform: "local", userId: "local-user" }, + agentName: "reviewer", + credentialContext: { + actor: { type: "user", userId: "local-user" }, + }, + destination, + destinationVisibility: "private", + idempotencyKey: "parent-turn:call-1", + input: "Inspect the failing checks.", + parentConversationId, + reasoningLevel: "high", + source: createLocalSource(parentConversationId), + }); + expect(queue.sentRecords()).toHaveLength(1); + await expect( + tool.execute!( + tool.prepareArguments!({ + task: "Start overlapping work.", + name: "reviewer", + reasoning_level: "high", + }), + { toolCallId: "call-2" }, + ), + ).rejects.toMatchObject({ + name: "ToolInputError", + message: + 'Named agent "reviewer" already has active work. Wait for it to finish or use a different name.', + }); + } finally { + await fixture.close(); + } + }); + it("runs destinationless child work once and persists its terminal result", async () => { const { conversationStore, fixture } = await prepareParentConversation(); const queue = createConversationWorkQueueTestAdapter(); @@ -417,6 +512,11 @@ describe("agent invocation conversation work", () => { state: "completed", surface: "internal", }); + await completeAgentInvocation({ + invocationId: first.invocation.invocationId, + result: "Remembered answer", + status: "completed", + }); const next = await createAndEnqueueAgentInvocation( { ...invocationInput, diff --git a/packages/junior/tests/unit/agent-request.test.ts b/packages/junior/tests/unit/agent-request.test.ts index efaf7e632..bc262e272 100644 --- a/packages/junior/tests/unit/agent-request.test.ts +++ b/packages/junior/tests/unit/agent-request.test.ts @@ -35,4 +35,20 @@ describe("agent run routing", () => { "Local source, destination, and run conversation IDs do not match", ); }); + + it("rejects contradictory local parent routing for internal child work", () => { + expect(() => + assertRunRoutingConsistency({ + conversationId: "local:test:child", + routing: { + destination: { + conversationId: "local:test:other-parent", + platform: "local", + }, + source: createLocalSource("local:test:parent"), + surface: "internal", + }, + }), + ).toThrow("Local source and destination conversation IDs do not match"); + }); }); diff --git a/packages/junior/tests/unit/runtime/agent-runner.test.ts b/packages/junior/tests/unit/runtime/agent-runner.test.ts new file mode 100644 index 000000000..57479ea91 --- /dev/null +++ b/packages/junior/tests/unit/runtime/agent-runner.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentRunRequest } from "@/chat/agent/request"; +import { createAgentRunner } from "@/chat/runtime/agent-runner"; + +const request = { + conversationId: "local:test:parent", + turnId: "turn-1", + input: { messageText: "Delegate this task." }, + routing: { + destination: { + conversationId: "local:test:parent", + platform: "local", + }, + source: { + conversationId: "local:test:parent", + platform: "local", + type: "priv", + }, + }, +} satisfies AgentRunRequest; + +describe("agent runner controls", () => { + it("binds spawnAgent into the active run durability context", async () => { + const spawnAgent = { execute: vi.fn() }; + const bindSpawnAgent = vi.fn(() => spawnAgent); + const run = vi.fn(async () => ({ + status: "suspended" as const, + resumeVersion: 1, + })); + const runner = createAgentRunner(run, { bindSpawnAgent }); + + await runner.run(request); + + expect(bindSpawnAgent).toHaveBeenCalledWith(request); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + durability: { spawnAgent }, + }), + ); + }); + + it("does not advertise recursive spawning to delegated runs", async () => { + const bindSpawnAgent = vi.fn(); + const run = vi.fn(async () => ({ + status: "suspended" as const, + resumeVersion: 1, + })); + const runner = createAgentRunner(run, { bindSpawnAgent }); + + await runner.run({ + ...request, + policy: { agentSpawning: "disabled" }, + }); + + expect(bindSpawnAgent).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledWith( + expect.not.objectContaining({ + durability: expect.objectContaining({ spawnAgent: expect.anything() }), + }), + ); + }); +}); diff --git a/packages/junior/tests/unit/tools/spawn-agent.test.ts b/packages/junior/tests/unit/tools/spawn-agent.test.ts new file mode 100644 index 000000000..c8b833a38 --- /dev/null +++ b/packages/junior/tests/unit/tools/spawn-agent.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSpawnAgentTool } from "@/chat/tools/runtime/spawn-agent"; +import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; + +describe("spawnAgent", () => { + it("normalizes nullable optional fields before invoking the runtime control", async () => { + const execute = vi.fn().mockResolvedValue({ + childConversationId: "agent:child", + invocationId: "agent-invocation:one", + replayed: false, + status: "pending", + }); + const tool = createSpawnAgentTool({ execute }); + + await expect( + tool.execute!( + tool.prepareArguments!({ + task: "Investigate the failing checks.", + name: null, + reasoning_level: null, + }), + { toolCallId: "call-1" }, + ), + ).resolves.toEqual({ + ok: true, + status: "success", + child_conversation_id: "agent:child", + invocation_id: "agent-invocation:one", + invocation_status: "pending", + replayed: false, + }); + expect(execute).toHaveBeenCalledWith( + { task: "Investigate the failing checks." }, + { toolCallId: "call-1" }, + ); + }); + + it("requires the runtime tool call id used for durable replay", async () => { + const tool = createSpawnAgentTool({ + execute: vi.fn(), + }); + + await expect( + tool.execute!( + tool.prepareArguments!({ task: "Investigate the failing checks." }), + {}, + ), + ).rejects.toBeInstanceOf(ToolInputError); + }); +}); From 5e1113fa45e982c00bc5bcdb0a7f0eaa3f40427a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 27 Jul 2026 08:50:07 -0700 Subject: [PATCH 3/8] fix(cli): run spawned agents locally --- packages/junior/src/chat/local/README.md | 4 ++ .../src/chat/local/conversation-work.ts | 64 +++++++++++++++++++ packages/junior/src/chat/local/runner.ts | 8 ++- packages/junior/src/cli/chat.ts | 40 ++++++++++-- .../cli/local-chat-composition.test.ts | 55 ++++++++++++++++ .../unit/local/conversation-work.test.ts | 48 ++++++++++++++ 6 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 packages/junior/src/chat/local/conversation-work.ts create mode 100644 packages/junior/tests/unit/local/conversation-work.test.ts diff --git a/packages/junior/src/chat/local/README.md b/packages/junior/src/chat/local/README.md index e77d6ff5a..63981e322 100644 --- a/packages/junior/src/chat/local/README.md +++ b/packages/junior/src/chat/local/README.md @@ -24,6 +24,10 @@ provider mailbox worker. and SQL persistence are not one transaction. A process death in that interval can strand a started turn; lifecycle history does not claim otherwise. - New CLI invocations do not promise restoration of prior interactive history. +- Child-agent work uses the shared invocation mailbox worker in the CLI + process. Prompt mode drains accepted child work before exit; interactive mode + drains it when the session exits. It does not depend on a Vercel Queue dev + consumer, and a process death can still strand process-local mailbox state. - Status and diagnostics go to stderr; assistant messages go to stdout in model message order. - Local file requests use paths named by the user. The adapter does not diff --git a/packages/junior/src/chat/local/conversation-work.ts b/packages/junior/src/chat/local/conversation-work.ts new file mode 100644 index 000000000..594d7f195 --- /dev/null +++ b/packages/junior/src/chat/local/conversation-work.ts @@ -0,0 +1,64 @@ +import type { + ConversationQueueMessage, + ConversationQueueSendOptions, + ConversationWorkQueue, +} from "@/chat/task-execution/queue"; + +export interface LocalConversationWork { + drain(): Promise; + queue: ConversationWorkQueue; +} + +/** + * Run local conversation work in this process and wait for every accepted wake. + * + * Delayed and follow-up wakes remain tracked so CLI shutdown cannot abandon + * child work that was accepted while an earlier wake was running. + */ +export function createLocalConversationWork( + processMessage: (message: ConversationQueueMessage) => Promise, +): LocalConversationWork { + const pending = new Set>(); + let firstError: unknown; + + function schedule( + message: ConversationQueueMessage, + options?: ConversationQueueSendOptions, + ): void { + const delayMs = Math.max(0, options?.delayMs ?? 0); + let work: Promise; + work = (async () => { + if (delayMs > 0) { + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + } + await processMessage(message); + })() + .catch((error: unknown) => { + firstError ??= error; + }) + .finally(() => { + pending.delete(work); + }); + pending.add(work); + } + + return { + queue: { + async send(message, options) { + schedule(message, options); + }, + }, + async drain() { + while (pending.size > 0) { + await Promise.all(pending); + } + if (firstError !== undefined) { + const error = firstError; + firstError = undefined; + throw error; + } + }, + }; +} diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 39833d1b3..51dec59e8 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -51,7 +51,7 @@ import { commitAcceptedReply, loadProjection, } from "@/chat/conversations/projection"; -import { getConversationEventStore } from "@/chat/db"; +import { getConversationEventStore, getConversationStore } from "@/chat/db"; import { ConversationTurnLifecycleService, type ConversationTurnLifecycle, @@ -207,6 +207,12 @@ async function runLocalAgentTurnInContext( new ConversationTurnLifecycleService(getConversationEventStore()); const now = deps.now ?? (() => Date.now()); + await getConversationStore().recordActivity({ + conversationId: input.conversationId, + destination, + nowMs: now(), + source: "local", + }); const persisted = await getPersistedThreadState(input.conversationId); const conversation = coerceThreadConversationState(persisted); await hydrateConversationMessages({ diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index ebc122a61..41734dd53 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -244,12 +244,36 @@ async function prepareLocalChatRun( await import("@/chat/local/sandbox-egress-signals"); const { bindAgentSpawnControl, createAgentInvocationCreator } = await import("@/chat/agent-invocations/spawn"); - const { getVercelConversationWorkQueue } = - await import("@/chat/task-execution/vercel-queue"); + const { + createAgentInvocationConversationWorker, + createAgentInvocationWorkRouter, + } = await import("@/chat/agent-invocations/work"); + const { createLocalConversationWork } = + await import("@/chat/local/conversation-work"); + const { processConversationWork } = + await import("@/chat/task-execution/worker"); + let agentRunner: ReturnType | undefined; + const localConversationWork = createLocalConversationWork(async (message) => { + if (!agentRunner) { + throw new Error("Local agent runner is not ready"); + } + const run = createAgentInvocationWorkRouter({ + fallbackWorker: async () => { + throw new Error("Local child queue received non-invocation work"); + }, + invocationWorker: createAgentInvocationConversationWorker({ + agentRunner, + }), + }); + await processConversationWork(message, { + queue: localConversationWork.queue, + run, + }); + }); const agentInvocationCreator = createAgentInvocationCreator({ - queue: getVercelConversationWorkQueue, + queue: localConversationWork.queue, }); - const agentRunner = createAgentRunner(executeAgentRun, { + agentRunner = createAgentRunner(executeAgentRun, { bindSpawnAgent: (request) => bindAgentSpawnControl(request, agentInvocationCreator), }); @@ -281,7 +305,13 @@ async function prepareLocalChatRun( }, }; return { - close: oauthCallback.close, + close: async () => { + try { + await localConversationWork.drain(); + } finally { + await oauthCallback.close(); + } + }, conversationId: newRunConversationId(), runLocalAgentTurn, deps, diff --git a/packages/junior/tests/component/cli/local-chat-composition.test.ts b/packages/junior/tests/component/cli/local-chat-composition.test.ts index dbaf4adc5..68205ce42 100644 --- a/packages/junior/tests/component/cli/local-chat-composition.test.ts +++ b/packages/junior/tests/component/cli/local-chat-composition.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentRunRequest } from "@/chat/agent/request"; import type { AgentRunResult } from "@/chat/services/turn-result"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { deliverAssistantMessagesForTest } from "../../fixtures/agent-runner"; @@ -140,4 +141,58 @@ export const plugins = { rmSync(tempDir, { force: true, recursive: true }); } }, 30_000); + + it("finishes spawned child work in process before prompt mode exits", async () => { + delete process.env.JUNIOR_STATE_ADAPTER; + const requests: AgentRunRequest[] = []; + executeAgentRunMock.mockImplementation(async (request) => { + requests.push(request); + if (request.policy?.agentSpawning === "disabled") { + await request.durability.onInputCommitted?.(); + return completedAgentRun(successReply("child finished")); + } + const spawnAgent = request.durability.spawnAgent; + if (!spawnAgent) { + throw new Error("parent run requires spawnAgent"); + } + const spawned = await spawnAgent.execute( + { + name: "local-test-child", + reasoningLevel: "medium", + task: "Finish the child task.", + }, + { toolCallId: "spawn-1" }, + ); + expect(spawned).toMatchObject({ status: "pending" }); + await deliverAssistantMessagesForTest(request, [ + { text: "child scheduled" }, + ]); + return completedAgentRun(successReply("child scheduled")); + }); + const output: string[] = []; + + const { runChat } = await import("@/cli/chat"); + await expect( + runChat( + ["-p", "delegate this"], + { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: (text) => { + output.push(text); + }, + }, + { pluginSet: null }, + ), + ).resolves.toBe(0); + + expect(executeAgentRunMock).toHaveBeenCalledTimes(2); + expect(requests[0]?.durability?.spawnAgent).toBeDefined(); + expect(requests[1]).toMatchObject({ + policy: { agentSpawning: "disabled" }, + }); + expect(requests[1]?.durability?.spawnAgent).toBeUndefined(); + expect(output).toEqual(["child scheduled\n"]); + }, 30_000); }); diff --git a/packages/junior/tests/unit/local/conversation-work.test.ts b/packages/junior/tests/unit/local/conversation-work.test.ts new file mode 100644 index 000000000..30b4ecb28 --- /dev/null +++ b/packages/junior/tests/unit/local/conversation-work.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLocalConversationWork } from "@/chat/local/conversation-work"; + +describe("local conversation work", () => { + it("drains accepted work and follow-up wakes", async () => { + const processed: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + let localWork: ReturnType; + localWork = createLocalConversationWork(async (message) => { + processed.push(message.conversationId); + if (message.conversationId === "first") { + await firstBlocked; + await localWork.queue.send({ conversationId: "follow-up" }); + } + }); + + await localWork.queue.send({ conversationId: "first" }); + const draining = localWork.drain(); + await vi.waitFor(() => { + expect(processed).toEqual(["first"]); + }); + let drained = false; + void draining.then(() => { + drained = true; + }); + await Promise.resolve(); + expect(drained).toBe(false); + + releaseFirst?.(); + await draining; + + expect(processed).toEqual(["first", "follow-up"]); + }); + + it("reports processing failures when drained", async () => { + const localWork = createLocalConversationWork(async () => { + throw new Error("local worker failed"); + }); + + await expect( + localWork.queue.send({ conversationId: "failed" }), + ).resolves.toBeUndefined(); + await expect(localWork.drain()).rejects.toThrow("local worker failed"); + }); +}); From 3a3d093180f225fee64e79714160e73e1ef0f3d2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 27 Jul 2026 09:28:47 -0700 Subject: [PATCH 4/8] fix(cli): preserve local agent wake ordering --- .../src/chat/local/conversation-work.ts | 28 +- .../cli/local-chat-composition.test.ts | 166 ++++++- .../agent-invocation-concurrency.test.ts | 429 ++++++++++++++++++ .../unit/local/conversation-work.test.ts | 62 ++- 4 files changed, 676 insertions(+), 9 deletions(-) create mode 100644 packages/junior/tests/integration/agent-invocation-concurrency.test.ts diff --git a/packages/junior/src/chat/local/conversation-work.ts b/packages/junior/src/chat/local/conversation-work.ts index 594d7f195..dcbc3d4bb 100644 --- a/packages/junior/src/chat/local/conversation-work.ts +++ b/packages/junior/src/chat/local/conversation-work.ts @@ -12,14 +12,17 @@ export interface LocalConversationWork { /** * Run local conversation work in this process and wait for every accepted wake. * - * Delayed and follow-up wakes remain tracked so CLI shutdown cannot abandon - * child work that was accepted while an earlier wake was running. + * Wakes start on a later event-loop turn so their producer can persist the + * accepted marker first. Idempotent, delayed, and follow-up wakes remain + * tracked so CLI shutdown cannot abandon accepted child work. */ export function createLocalConversationWork( processMessage: (message: ConversationQueueMessage) => Promise, ): LocalConversationWork { + const acceptedWakeIds = new Map(); const pending = new Set>(); let firstError: unknown; + let nextWakeId = 1; function schedule( message: ConversationQueueMessage, @@ -28,11 +31,9 @@ export function createLocalConversationWork( const delayMs = Math.max(0, options?.delayMs ?? 0); let work: Promise; work = (async () => { - if (delayMs > 0) { - await new Promise((resolve) => { - setTimeout(resolve, delayMs); - }); - } + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); await processMessage(message); })() .catch((error: unknown) => { @@ -47,7 +48,20 @@ export function createLocalConversationWork( return { queue: { async send(message, options) { + const idempotencyKey = options?.idempotencyKey; + const acceptedWakeId = idempotencyKey + ? acceptedWakeIds.get(idempotencyKey) + : undefined; + if (acceptedWakeId) { + return { messageId: acceptedWakeId }; + } + const messageId = `local-conversation-work:${nextWakeId}`; + nextWakeId += 1; + if (idempotencyKey) { + acceptedWakeIds.set(idempotencyKey, messageId); + } schedule(message, options); + return { messageId }; }, }, async drain() { diff --git a/packages/junior/tests/component/cli/local-chat-composition.test.ts b/packages/junior/tests/component/cli/local-chat-composition.test.ts index 68205ce42..3b7c9e2b1 100644 --- a/packages/junior/tests/component/cli/local-chat-composition.test.ts +++ b/packages/junior/tests/component/cli/local-chat-composition.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentRunRequest } from "@/chat/agent/request"; +import type { AgentRunRequest, AgentSpawnResult } from "@/chat/agent/request"; import type { AgentRunResult } from "@/chat/services/turn-result"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { deliverAssistantMessagesForTest } from "../../fixtures/agent-runner"; @@ -195,4 +195,168 @@ export const plugins = { expect(requests[1]?.durability?.spawnAgent).toBeUndefined(); expect(output).toEqual(["child scheduled\n"]); }, 30_000); + + it("runs successive work through the same completed named child", async () => { + delete process.env.JUNIOR_STATE_ADAPTER; + const childTasks: string[] = []; + const spawns: AgentSpawnResult[] = []; + const { getAgentInvocation } = + await import("@/chat/agent-invocations/store"); + executeAgentRunMock.mockImplementation(async (request) => { + if (request.policy?.agentSpawning === "disabled") { + childTasks.push(request.input.messageText); + await request.durability.onInputCommitted?.(); + return completedAgentRun( + successReply(`completed:${request.input.messageText}`), + ); + } + const spawnAgent = request.durability.spawnAgent; + if (!spawnAgent) { + throw new Error("parent run requires named child dependencies"); + } + const first = await spawnAgent.execute( + { + name: "reused-child", + task: "first named task", + }, + { toolCallId: "named-1" }, + ); + spawns.push(first); + await vi.waitFor( + async () => { + await expect( + getAgentInvocation(first.invocationId), + ).resolves.toMatchObject({ + status: "completed", + }); + }, + { timeout: 5_000 }, + ); + const second = await spawnAgent.execute( + { + name: "reused-child", + task: "second named task", + }, + { toolCallId: "named-2" }, + ); + spawns.push(second); + await deliverAssistantMessagesForTest(request, [ + { text: "named work scheduled" }, + ]); + return completedAgentRun(successReply("named work scheduled")); + }); + + const { runChat } = await import("@/cli/chat"); + await expect( + runChat( + ["-p", "reuse one named child"], + { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }, + { pluginSet: null }, + ), + ).resolves.toBe(0); + + expect(spawns).toHaveLength(2); + expect(spawns[1]?.childConversationId).toBe(spawns[0]?.childConversationId); + expect(spawns[1]?.invocationId).not.toBe(spawns[0]?.invocationId); + expect(childTasks).toEqual(["first named task", "second named task"]); + const secondSpawn = spawns[1]; + if (!secondSpawn) { + throw new Error("Expected second named spawn"); + } + await expect( + getAgentInvocation(secondSpawn.invocationId), + ).resolves.toMatchObject({ + result: "completed:second named task", + status: "completed", + }); + }, 30_000); + + it("runs independent spawned children concurrently and drains both", async () => { + delete process.env.JUNIOR_STATE_ADAPTER; + const spawns: AgentSpawnResult[] = []; + let activeChildren = 0; + let maxActiveChildren = 0; + let releaseBoth: (() => void) | undefined; + const bothStarted = new Promise((resolve) => { + releaseBoth = resolve; + }); + executeAgentRunMock.mockImplementation(async (request) => { + if (request.policy?.agentSpawning === "disabled") { + activeChildren += 1; + maxActiveChildren = Math.max(maxActiveChildren, activeChildren); + if (activeChildren === 2) { + releaseBoth?.(); + } + await bothStarted; + await request.durability.onInputCommitted?.(); + activeChildren -= 1; + return completedAgentRun( + successReply(`completed:${request.input.messageText}`), + ); + } + const spawnAgent = request.durability.spawnAgent; + if (!spawnAgent) { + throw new Error("parent run requires spawnAgent"); + } + spawns.push( + ...(await Promise.all([ + spawnAgent.execute( + { name: "parallel-a", task: "parallel task a" }, + { toolCallId: "parallel-1" }, + ), + spawnAgent.execute( + { name: "parallel-b", task: "parallel task b" }, + { toolCallId: "parallel-2" }, + ), + ])), + ); + await deliverAssistantMessagesForTest(request, [ + { text: "parallel work scheduled" }, + ]); + return completedAgentRun(successReply("parallel work scheduled")); + }); + + const { runChat } = await import("@/cli/chat"); + const { getAgentInvocation } = + await import("@/chat/agent-invocations/store"); + await expect( + runChat( + ["-p", "run two children in parallel"], + { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }, + { pluginSet: null }, + ), + ).resolves.toBe(0); + + expect(maxActiveChildren).toBe(2); + expect(spawns).toHaveLength(2); + expect(spawns[0]?.childConversationId).not.toBe( + spawns[1]?.childConversationId, + ); + await expect( + Promise.all( + spawns.map( + async (spawn) => await getAgentInvocation(spawn.invocationId), + ), + ), + ).resolves.toEqual([ + expect.objectContaining({ + result: "completed:parallel task a", + status: "completed", + }), + expect.objectContaining({ + result: "completed:parallel task b", + status: "completed", + }), + ]); + }, 30_000); }); diff --git a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts new file mode 100644 index 000000000..96c06f8cc --- /dev/null +++ b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts @@ -0,0 +1,429 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLocalSource } from "@sentry/junior-plugin-api"; +import type { AgentRunRequest } from "@/chat/agent/request"; +import type { PiMessage } from "@/chat/pi/messages"; +import { getAgentInvocation } from "@/chat/agent-invocations/store"; +import { + createAgentInvocationConversationWorker, + createAgentInvocationWorkRouter, + createAndEnqueueAgentInvocation, +} from "@/chat/agent-invocations/work"; +import type { CreateAgentInvocationInput } from "@/chat/agent-invocations/types"; +import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { persistRunningSessionRecord } from "@/chat/services/turn-session-record"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; +import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; +import { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS } from "@/chat/task-execution/store"; +import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; +import { createConfiguredJuniorSqlFixture } from "../fixtures/sql"; + +const parentConversationId = "local:test:agent-invocation-concurrency"; +const destination = { + conversationId: parentConversationId, + platform: "local", +} as const; +const baseInput = { + actor: { name: "parent-agent", platform: "system" } as const, + destination, + destinationVisibility: "private" as const, + input: "Do the delegated work.", + parentConversationId, + source: createLocalSource(parentConversationId), +}; + +async function successfulRun( + request: AgentRunRequest, + result: string, +): Promise> { + const timestamp = Date.now(); + const runningMessages = [ + ...(request.input.piMessages ?? []), + { + role: "user", + content: [{ type: "text", text: request.input.messageText }], + timestamp, + }, + ] as PiMessage[]; + const persisted = await persistRunningSessionRecord({ + conversationId: request.conversationId, + destination: request.routing.destination, + logContext: {}, + messages: runningMessages, + modelId: "integration-agent", + actor: request.routing.actor, + sessionId: request.turnId, + sliceId: 1, + source: request.routing.source, + surface: "internal", + }); + if (!persisted) { + throw new Error("Expected running child session to persist"); + } + const piMessages = [ + ...runningMessages, + { + role: "assistant", + content: [{ type: "text", text: result }], + timestamp: timestamp + 1, + }, + ] as PiMessage[]; + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "integration-agent", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + piMessages, + text: result, + }); +} + +async function createHarness( + run: ( + request: AgentRunRequest, + ) => Promise>, +) { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination, + nowMs: 1_000, + source: "local", + }); + const state = getStateAdapter(); + await state.connect(); + const queue = createConversationWorkQueueTestAdapter(); + const route = createAgentInvocationWorkRouter({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: createAgentInvocationConversationWorker({ + agentRunner: { run }, + }), + }); + + return { + async close() { + await fixture.close(); + }, + async drainQueuedWork() { + while (queue.hasQueuedMessages()) { + const batch = queue.queuedMessages(); + await Promise.all( + batch.map(async () => { + await processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }); + }), + ); + } + }, + processQueuedBatch: async () => { + const batch = queue.queuedMessages(); + await Promise.all( + batch.map(async () => { + await processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }); + }), + ); + }, + queue, + spawn: async ( + overrides: Partial & { + idempotencyKey: string; + }, + ) => + await createAndEnqueueAgentInvocation( + { ...baseInput, ...overrides }, + { conversationStore, queue, state }, + ), + }; +} + +describe("agent invocation identity and concurrency", () => { + afterEach(async () => { + await disconnectStateAdapter(); + vi.restoreAllMocks(); + }); + + it("gives each unnamed invocation a fresh child without inherited history", async () => { + const requests: AgentRunRequest[] = []; + const harness = await createHarness(async (request) => { + requests.push(request); + await request.durability?.onInputCommitted?.(); + return await successfulRun( + request, + `result:${request.input.messageText}`, + ); + }); + try { + const first = await harness.spawn({ + idempotencyKey: "fresh-1", + input: "first unnamed task", + }); + await harness.drainQueuedWork(); + const second = await harness.spawn({ + idempotencyKey: "fresh-2", + input: "second unnamed task", + }); + await harness.drainQueuedWork(); + + expect(second.invocation.childConversationId).not.toBe( + first.invocation.childConversationId, + ); + expect(requests).toHaveLength(2); + expect(requests.map((request) => request.input.piMessages)).toEqual([ + [], + [], + ]); + await expect( + getAgentInvocation(first.invocation.invocationId), + ).resolves.toMatchObject({ + result: "result:first unnamed task", + status: "completed", + }); + await expect( + getAgentInvocation(second.invocation.invocationId), + ).resolves.toMatchObject({ + result: "result:second unnamed task", + status: "completed", + }); + } finally { + await harness.close(); + } + }); + + it("reuses one named child and supplies its completed history", async () => { + const requests: AgentRunRequest[] = []; + const harness = await createHarness(async (request) => { + requests.push(request); + await request.durability?.onInputCommitted?.(); + return await successfulRun( + request, + `result:${request.input.messageText}`, + ); + }); + try { + const first = await harness.spawn({ + agentName: "researcher", + idempotencyKey: "named-1", + input: "first named task", + }); + await harness.drainQueuedWork(); + const second = await harness.spawn({ + agentName: "researcher", + idempotencyKey: "named-2", + input: "second named task", + }); + await harness.drainQueuedWork(); + + expect(second.invocation.childConversationId).toBe( + first.invocation.childConversationId, + ); + expect(requests).toHaveLength(2); + expect(requests[0]?.input.piMessages).toEqual([]); + expect(requests[1]?.input.piMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: "user" }), + expect.objectContaining({ role: "assistant" }), + ]), + ); + expect(JSON.stringify(requests[1]?.input.piMessages)).toContain( + "result:first named task", + ); + await expect( + getAgentInvocation(second.invocation.invocationId), + ).resolves.toMatchObject({ + result: "result:second named task", + status: "completed", + }); + } finally { + await harness.close(); + } + }); + + it("runs different named children in parallel without sharing history", async () => { + const started = new Set(); + const histories = new Map(); + let active = 0; + let maxActive = 0; + let release: (() => void) | undefined; + const blocked = new Promise((resolve) => { + release = resolve; + }); + let bothStarted: (() => void) | undefined; + const startedPromise = new Promise((resolve) => { + bothStarted = resolve; + }); + const harness = await createHarness(async (request) => { + started.add(request.input.messageText); + histories.set(request.input.messageText, request.input.piMessages); + active += 1; + maxActive = Math.max(maxActive, active); + if (started.size === 2) { + bothStarted?.(); + } + await blocked; + active -= 1; + await request.durability?.onInputCommitted?.(); + return await successfulRun( + request, + `result:${request.input.messageText}`, + ); + }); + try { + const [first, second] = await Promise.all([ + harness.spawn({ + agentName: "researcher", + idempotencyKey: "parallel-1", + input: "parallel first", + }), + harness.spawn({ + agentName: "reviewer", + idempotencyKey: "parallel-2", + input: "parallel second", + }), + ]); + const processing = harness.processQueuedBatch(); + await startedPromise; + + expect(maxActive).toBe(2); + expect(Object.fromEntries(histories)).toEqual({ + "parallel first": [], + "parallel second": [], + }); + expect(first.invocation.childConversationId).not.toBe( + second.invocation.childConversationId, + ); + release?.(); + await processing; + await expect( + getAgentInvocation(first.invocation.invocationId), + ).resolves.toMatchObject({ + result: "result:parallel first", + status: "completed", + }); + await expect( + getAgentInvocation(second.invocation.invocationId), + ).resolves.toMatchObject({ + result: "result:parallel second", + status: "completed", + }); + } finally { + release?.(); + await harness.close(); + } + }); + + it("coalesces concurrent replay and rejects overlapping work for one name", async () => { + const run = vi.fn(async (request: AgentRunRequest) => { + await request.durability?.onInputCommitted?.(); + return await successfulRun(request, "completed once"); + }); + const harness = await createHarness(run); + try { + const replayInput = { + agentName: "replayed", + idempotencyKey: "same-call", + input: "same task", + }; + const [first, replay] = await Promise.all([ + harness.spawn(replayInput), + harness.spawn(replayInput), + ]); + + expect(replay.invocation.invocationId).toBe( + first.invocation.invocationId, + ); + expect(harness.queue.sentRecords()).toHaveLength(1); + + const contention = await Promise.allSettled([ + harness.spawn({ + agentName: "busy", + idempotencyKey: "busy-1", + input: "first busy task", + }), + harness.spawn({ + agentName: "busy", + idempotencyKey: "busy-2", + input: "second busy task", + }), + ]); + expect( + contention.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + const rejected = contention.find( + (result) => result.status === "rejected", + ); + expect(rejected).toMatchObject({ + reason: expect.objectContaining({ + name: "AgentInvocationBusyError", + }), + status: "rejected", + }); + + await harness.drainQueuedWork(); + expect(run).toHaveBeenCalledTimes(2); + } finally { + await harness.close(); + } + }); + + it("keeps a successful parallel child independent from a failing sibling", async () => { + const attempts = new Map(); + const harness = await createHarness(async (request) => { + const task = request.input.messageText; + attempts.set(task, (attempts.get(task) ?? 0) + 1); + if (task === "failing sibling") { + throw new Error("sibling failed"); + } + await request.durability?.onInputCommitted?.(); + return await successfulRun(request, "successful sibling result"); + }); + try { + const [successful, failing] = await Promise.all([ + harness.spawn({ + idempotencyKey: "sibling-success", + input: "successful sibling", + }), + harness.spawn({ + idempotencyKey: "sibling-failure", + input: "failing sibling", + }), + ]); + await harness.drainQueuedWork(); + + expect(attempts.get("successful sibling")).toBe(1); + expect(attempts.get("failing sibling")).toBe( + CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS, + ); + await expect( + getAgentInvocation(successful.invocation.invocationId), + ).resolves.toMatchObject({ + result: "successful sibling result", + status: "completed", + }); + await expect( + getAgentInvocation(failing.invocation.invocationId), + ).resolves.toMatchObject({ + errorMessage: "sibling failed", + status: "failed", + }); + } finally { + await harness.close(); + } + }); +}); diff --git a/packages/junior/tests/unit/local/conversation-work.test.ts b/packages/junior/tests/unit/local/conversation-work.test.ts index 30b4ecb28..7e65269b3 100644 --- a/packages/junior/tests/unit/local/conversation-work.test.ts +++ b/packages/junior/tests/unit/local/conversation-work.test.ts @@ -2,6 +2,64 @@ import { describe, expect, it, vi } from "vitest"; import { createLocalConversationWork } from "@/chat/local/conversation-work"; describe("local conversation work", () => { + it("does not consume a wake before the producer finishes enqueueing it", async () => { + let processed = false; + const localWork = createLocalConversationWork(async () => { + processed = true; + }); + + await localWork.queue.send({ conversationId: "deferred-start" }); + + expect(processed).toBe(false); + await localWork.drain(); + expect(processed).toBe(true); + }); + + it("deduplicates accepted wakes by idempotency key", async () => { + const processed: string[] = []; + const localWork = createLocalConversationWork(async (message) => { + processed.push(message.conversationId); + }); + + const [first, replay] = await Promise.all([ + localWork.queue.send( + { conversationId: "replayed" }, + { idempotencyKey: "same-wake" }, + ), + localWork.queue.send( + { conversationId: "replayed" }, + { idempotencyKey: "same-wake" }, + ), + ]); + await localWork.drain(); + + expect(replay).toEqual(first); + expect(processed).toEqual(["replayed"]); + }); + + it("runs independent conversation wakes concurrently", async () => { + const started = new Set(); + let release: (() => void) | undefined; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const localWork = createLocalConversationWork(async (message) => { + started.add(message.conversationId); + await blocked; + }); + + await Promise.all([ + localWork.queue.send({ conversationId: "first" }), + localWork.queue.send({ conversationId: "second" }), + ]); + const draining = localWork.drain(); + await vi.waitFor(() => { + expect(started).toEqual(new Set(["first", "second"])); + }); + release?.(); + await draining; + }); + it("drains accepted work and follow-up wakes", async () => { const processed: string[] = []; let releaseFirst: (() => void) | undefined; @@ -42,7 +100,9 @@ describe("local conversation work", () => { await expect( localWork.queue.send({ conversationId: "failed" }), - ).resolves.toBeUndefined(); + ).resolves.toEqual({ + messageId: "local-conversation-work:1", + }); await expect(localWork.drain()).rejects.toThrow("local worker failed"); }); }); From 544df1c37a4cebee748e9a7397b16cfd2d81f95c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 27 Jul 2026 09:38:58 -0700 Subject: [PATCH 5/8] fix(agent): preserve named policy and retention --- .../src/chat/agent-invocations/README.md | 8 +-- .../src/chat/agent-invocations/store.ts | 40 +++++++++++---- .../src/chat/conversations/sql/purge.ts | 15 ++++++ .../component/conversations/retention.test.ts | 51 +++++++++++++++++++ .../agent-invocation-concurrency.test.ts | 4 ++ .../integration/agent-invocation-work.test.ts | 8 +++ 6 files changed, 112 insertions(+), 14 deletions(-) diff --git a/packages/junior/src/chat/agent-invocations/README.md b/packages/junior/src/chat/agent-invocations/README.md index 77753bb98..6f8409365 100644 --- a/packages/junior/src/chat/agent-invocations/README.md +++ b/packages/junior/src/chat/agent-invocations/README.md @@ -7,13 +7,15 @@ stores the terminal result for its parent to read later. ## Records - An **agent binding** maps one name within a parent agent conversation to one - destinationless child conversation. Reusing the name reuses that child's - history. + destinationless child conversation and its reasoning policy. Reusing the + name reuses that child's history; an omitted reasoning level inherits the + binding, while an explicit mismatch is rejected. - An **agent invocation** is one retry-safe task sent to a child. Its `invocationId` is derived from the parent conversation and caller-supplied idempotency key. - An invocation without a name gets an invocation-scoped child conversation. -- Child conversation lineage is immutable. Recursive delegation is disabled +- Child conversation lineage is immutable. Bindings and invocation content are + purged with their root conversation tree. Recursive delegation is disabled until depth, cancellation, and authority rules are defined. SQL owns bindings, invocation status, bounded execution authority, and terminal diff --git a/packages/junior/src/chat/agent-invocations/store.ts b/packages/junior/src/chat/agent-invocations/store.ts index 97911a281..7a8d84b01 100644 --- a/packages/junior/src/chat/agent-invocations/store.ts +++ b/packages/junior/src/chat/agent-invocations/store.ts @@ -223,9 +223,27 @@ export async function createAgentInvocation( input.agentName ? childConversationId : invocationId }`; return await getSqlExecutor().withLock(lockName, async () => { + const existingBinding = input.agentName + ? await getAgentBinding({ + name: input.agentName, + parentConversationId: input.parentConversationId, + }) + : undefined; + if ( + existingBinding && + input.reasoningLevel !== undefined && + input.reasoningLevel !== existingBinding.reasoningLevel + ) { + throw new Error( + `Named agent binding policy changed for ${input.agentName}`, + ); + } + const effectiveInput = existingBinding?.reasoningLevel + ? { ...input, reasoningLevel: existingBinding.reasoningLevel } + : input; const existing = await getAgentInvocation(invocationId); if (existing) { - if (!sameCreateInput(existing, input)) { + if (!sameCreateInput(existing, effectiveInput)) { throw new Error( `Agent invocation idempotency key was reused with different input for ${invocationId}`, ); @@ -249,7 +267,7 @@ export async function createAgentInvocation( createdAt: new Date(nowMs), name: input.agentName, parentConversationId: input.parentConversationId, - reasoningLevel: input.reasoningLevel ?? null, + reasoningLevel: effectiveInput.reasoningLevel ?? null, updatedAt: new Date(nowMs), }) .onConflictDoNothing(); @@ -262,7 +280,7 @@ export async function createAgentInvocation( `Named agent binding did not resolve to ${childConversationId}`, ); } - if (binding.reasoningLevel !== input.reasoningLevel) { + if (binding.reasoningLevel !== effectiveInput.reasoningLevel) { throw new Error( `Named agent binding policy changed for ${input.agentName}`, ); @@ -283,13 +301,13 @@ export async function createAgentInvocation( parentConversationId: input.parentConversationId, childConversationId, agentName: input.agentName ?? null, - input: input.input, - actor: input.actor, - credentialContext: input.credentialContext ?? null, - source: input.source, - destination: input.destination, - destinationVisibility: input.destinationVisibility ?? null, - reasoningLevel: input.reasoningLevel ?? null, + input: effectiveInput.input, + actor: effectiveInput.actor, + credentialContext: effectiveInput.credentialContext ?? null, + source: effectiveInput.source, + destination: effectiveInput.destination, + destinationVisibility: effectiveInput.destinationVisibility ?? null, + reasoningLevel: effectiveInput.reasoningLevel ?? null, status: "pending", mailboxStatus: "pending", createdAt: new Date(nowMs), @@ -298,7 +316,7 @@ export async function createAgentInvocation( }) .onConflictDoNothing(); const invocation = await getAgentInvocation(invocationId); - if (!invocation || !sameCreateInput(invocation, input)) { + if (!invocation || !sameCreateInput(invocation, effectiveInput)) { throw new Error(`Agent invocation creation raced for ${invocationId}`); } return { invocation, status: "created" }; diff --git a/packages/junior/src/chat/conversations/sql/purge.ts b/packages/junior/src/chat/conversations/sql/purge.ts index f765da195..719587109 100644 --- a/packages/junior/src/chat/conversations/sql/purge.ts +++ b/packages/junior/src/chat/conversations/sql/purge.ts @@ -5,6 +5,7 @@ import { juniorConversationEvents, juniorConversations, juniorDestinations, + juniorAgentBindings, juniorAgentInvocations, } from "@/db/schema"; import { withConversationEventLock } from "./event-lock"; @@ -111,6 +112,11 @@ export async function selectExpiredRoots( where invocations.parent_conversation_id = tree.conversation_id or invocations.child_conversation_id = tree.conversation_id ) + or exists ( + select 1 from junior_agent_bindings bindings + where bindings.parent_conversation_id = tree.conversation_id + or bindings.child_conversation_id = tree.conversation_id + ) or ( ${juniorDestinations.visibility} is distinct from 'public' and exists ( @@ -274,6 +280,15 @@ export async function purgeConversationTree( inArray(juniorAgentInvocations.childConversationId, ids), ), ); + await executor + .db() + .delete(juniorAgentBindings) + .where( + or( + inArray(juniorAgentBindings.parentConversationId, ids), + inArray(juniorAgentBindings.childConversationId, ids), + ), + ); await executor .db() .update(juniorConversations) diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index 39982b960..cef47b04f 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -14,6 +14,7 @@ import { juniorConversationEvents, juniorConversations, juniorDestinations, + juniorAgentBindings, juniorAgentInvocations, } from "@/db/schema"; import type { JuniorDestinationVisibility } from "@/db/schema/destinations"; @@ -411,6 +412,56 @@ describe("retention purge job", () => { expect(await eventCount(fixture.sql, "remaining-child")).toBe(0); }); + it("selects and purges a tree whose remaining content is only an agent binding", async () => { + const dest = await seedDestination(fixture.sql, "private"); + await seedConversation(fixture.sql, { + conversationId: "binding-root", + destinationId: dest, + lastActivityAtMs: BASE_MS, + title: null, + channelName: null, + withContent: false, + }); + await fixture.sql + .db() + .update(juniorConversations) + .set({ actor: null }) + .where(eq(juniorConversations.conversationId, "binding-root")); + await seedConversation(fixture.sql, { + conversationId: "binding-child", + parentConversationId: "binding-root", + lastActivityAtMs: BASE_MS, + title: null, + channelName: null, + withContent: false, + }); + await fixture.sql + .db() + .update(juniorConversations) + .set({ actor: null }) + .where(eq(juniorConversations.conversationId, "binding-child")); + await fixture.sql + .db() + .insert(juniorAgentBindings) + .values({ + childConversationId: "binding-child", + createdAt: new Date(BASE_MS), + name: "retained-name", + parentConversationId: "binding-root", + reasoningLevel: "high", + updatedAt: new Date(BASE_MS), + }); + + const result = await runRetentionPurge(fixture.sql, { + nowMs: BASE_MS + 30 * DAY_MS, + }); + + expect(result.purged).toBe(1); + await expect( + fixture.sql.db().select().from(juniorAgentBindings), + ).resolves.toEqual([]); + }); + it("purges up to the batch limit and leaves the remainder for the next run", async () => { const dest = await seedDestination(fixture.sql, "private"); await seedConversation(fixture.sql, { diff --git a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts index 96c06f8cc..7ad23b307 100644 --- a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts +++ b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts @@ -221,6 +221,7 @@ describe("agent invocation identity and concurrency", () => { agentName: "researcher", idempotencyKey: "named-1", input: "first named task", + reasoningLevel: "high", }); await harness.drainQueuedWork(); const second = await harness.spawn({ @@ -235,6 +236,8 @@ describe("agent invocation identity and concurrency", () => { ); expect(requests).toHaveLength(2); expect(requests[0]?.input.piMessages).toEqual([]); + expect(requests[0]?.policy?.reasoningLevel).toBe("high"); + expect(requests[1]?.policy?.reasoningLevel).toBe("high"); expect(requests[1]?.input.piMessages).toEqual( expect.arrayContaining([ expect.objectContaining({ role: "user" }), @@ -247,6 +250,7 @@ describe("agent invocation identity and concurrency", () => { await expect( getAgentInvocation(second.invocation.invocationId), ).resolves.toMatchObject({ + reasoningLevel: "high", result: "result:second named task", status: "completed", }); diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index 9126aa060..7cfcc047f 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -163,6 +163,14 @@ describe("agent invocation conversation work", () => { agentName: "researcher", }), ).rejects.toThrow("idempotency key was reused with different input"); + await expect( + createAgentInvocation({ + ...invocationInput, + agentName: "researcher", + idempotencyKey: "named-policy-change", + reasoningLevel: "high", + }), + ).rejects.toThrow("Named agent binding policy changed for researcher"); await expect( createAgentInvocation({ ...invocationInput, From 237523e35381722d4ff44aae9cf71437e9714197 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 27 Jul 2026 10:03:27 -0700 Subject: [PATCH 6/8] refactor(agent): simplify spawn wiring --- packages/junior/src/app.ts | 10 +- .../src/chat/agent-invocations/spawn.ts | 67 ++--- .../src/chat/agent-invocations/store.ts | 21 +- .../src/chat/agent-invocations/types.ts | 14 +- .../junior/src/chat/agent-invocations/work.ts | 28 +-- packages/junior/src/chat/agent/request.ts | 27 +- packages/junior/src/chat/app/production.ts | 8 +- packages/junior/src/chat/app/services.ts | 10 +- .../src/chat/local/conversation-work.ts | 45 ++-- .../junior/src/chat/runtime/agent-runner.ts | 6 +- .../src/chat/tools/runtime/spawn-agent.ts | 15 +- packages/junior/src/chat/tools/types.ts | 4 +- packages/junior/src/cli/chat.ts | 18 +- .../junior/src/db/schema/agent-invocations.ts | 7 - .../cli/local-chat-composition.test.ts | 44 ++-- .../component/conversations/retention.test.ts | 18 +- .../agent-invocation-concurrency.test.ts | 38 ++- .../integration/agent-invocation-work.test.ts | 238 ++++-------------- .../tests/unit/runtime/agent-runner.test.ts | 2 +- .../tests/unit/tools/spawn-agent.test.ts | 16 +- policies/interface-design.md | 2 + 21 files changed, 195 insertions(+), 443 deletions(-) diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 87b5ca874..3c6c4ac55 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -63,10 +63,7 @@ import { type VercelConversationWorkCallbackOptions, } from "@/chat/task-execution/vercel-callback"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; -import { - bindAgentSpawnControl, - createAgentInvocationCreator, -} from "@/chat/agent-invocations/spawn"; +import { bindSpawnAgent } from "@/chat/agent-invocations/spawn"; import { createVercelPluginTaskCallback, registerVercelPluginTaskDevConsumer, @@ -637,12 +634,9 @@ export async function createApp(options?: JuniorAppOptions): Promise { const waitUntil = options?.waitUntil ?? (await defaultWaitUntil()); const tracePropagation = { domains: sandboxEgressTracePropagationDomains }; const conversationWorkQueue = getVercelConversationWorkQueue(); - const agentInvocationCreator = createAgentInvocationCreator({ - queue: conversationWorkQueue, - }); const agentRunner = createAgentRunner(executeAgentRun, { bindSpawnAgent: (request) => - bindAgentSpawnControl(request, agentInvocationCreator), + bindSpawnAgent(request, { queue: conversationWorkQueue }), tracePropagation, }); const runtimeServiceOverrides = { diff --git a/packages/junior/src/chat/agent-invocations/spawn.ts b/packages/junior/src/chat/agent-invocations/spawn.ts index 922774f20..22fc9780c 100644 --- a/packages/junior/src/chat/agent-invocations/spawn.ts +++ b/packages/junior/src/chat/agent-invocations/spawn.ts @@ -2,67 +2,40 @@ import type { StateAdapter } from "chat"; import type { ConversationStore } from "@/chat/conversations/store"; import type { AgentRunRequest, - AgentSpawnControl, - AgentSpawnInput, - AgentSpawnResult, + SpawnAgent, + SpawnAgentInput, } from "@/chat/agent/request"; import { actorFromRouting, toolInvocationDestination, } from "@/chat/agent/request"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; -import type { CreateAgentInvocationInput } from "./types"; import { createAndEnqueueAgentInvocation } from "./work"; -export interface AgentInvocationCreator { - create(input: CreateAgentInvocationInput): Promise; -} - -interface AgentInvocationCreatorOptions { +type SpawnOptions = { conversationStore?: ConversationStore; queue: ConversationWorkQueue | (() => ConversationWorkQueue); state?: StateAdapter; -} - -/** Create the narrow durable invocation capability used by agent runners. */ -export function createAgentInvocationCreator( - options: AgentInvocationCreatorOptions, -): AgentInvocationCreator { - return { - async create(input) { - const queue = - typeof options.queue === "function" ? options.queue() : options.queue; - const created = await createAndEnqueueAgentInvocation(input, { - ...options, - queue, - }); - return { - agentName: created.invocation.agentName, - childConversationId: created.invocation.childConversationId, - invocationId: created.invocation.invocationId, - replayed: created.status === "existing", - status: created.invocation.status, - }; - }, - }; -} +}; /** Bind one run's runtime-owned authority to the model-safe spawn capability. */ -export function bindAgentSpawnControl( +export function bindSpawnAgent( request: AgentRunRequest, - creator: AgentInvocationCreator, -): AgentSpawnControl | undefined { + options: SpawnOptions, +): SpawnAgent | undefined { const actor = actorFromRouting(request.routing); if (!actor) { return undefined; } - return { - async execute( - input: AgentSpawnInput, - options: { signal?: AbortSignal; toolCallId: string }, - ) { - options.signal?.throwIfAborted(); - return await creator.create({ + return async ( + input: SpawnAgentInput, + call: { signal?: AbortSignal; toolCallId: string }, + ) => { + call.signal?.throwIfAborted(); + const queue = + typeof options.queue === "function" ? options.queue() : options.queue; + const invocation = await createAndEnqueueAgentInvocation( + { actor, ...(input.name ? { agentName: input.name } : {}), ...(request.routing.credentialContext @@ -74,14 +47,16 @@ export function bindAgentSpawnControl( destinationVisibility: request.routing.destinationVisibility, } : {}), - idempotencyKey: `${request.turnId}:${options.toolCallId}`, + idempotencyKey: `${request.turnId}:${call.toolCallId}`, input: input.task, parentConversationId: request.conversationId, ...(input.reasoningLevel ? { reasoningLevel: input.reasoningLevel } : {}), source: request.routing.source, - }); - }, + }, + { ...options, queue }, + ); + return { invocationId: invocation.invocationId }; }; } diff --git a/packages/junior/src/chat/agent-invocations/store.ts b/packages/junior/src/chat/agent-invocations/store.ts index 7a8d84b01..3bb12bf83 100644 --- a/packages/junior/src/chat/agent-invocations/store.ts +++ b/packages/junior/src/chat/agent-invocations/store.ts @@ -42,7 +42,7 @@ export function getAgentInvocationId( } /** Return the stable child identity for one invocation without a named binding. */ -export function getEphemeralAgentConversationId(invocationId: string): string { +export function getUnnamedAgentConversationId(invocationId: string): string { return stableId("agent", invocationId); } @@ -69,11 +69,9 @@ function bindingFromRow( ): AgentBinding { return agentBindingSchema.parse({ childConversationId: row.childConversationId, - createdAtMs: row.createdAt.getTime(), name: row.name, parentConversationId: row.parentConversationId, ...(row.reasoningLevel ? { reasoningLevel: row.reasoningLevel } : {}), - updatedAtMs: row.updatedAt.getTime(), }); } @@ -93,7 +91,6 @@ function invocationFromRow( ? { destinationVisibility: row.destinationVisibility } : {}), ...(row.errorMessage !== null ? { errorMessage: row.errorMessage } : {}), - idempotencyKey: row.idempotencyKey, input: row.input, invocationId: row.invocationId, mailboxStatus: row.mailboxStatus, @@ -114,7 +111,6 @@ function sameCreateInput( ): boolean { return ( invocation.parentConversationId === input.parentConversationId && - invocation.idempotencyKey === input.idempotencyKey && invocation.agentName === input.agentName && invocation.input === input.input && invocation.reasoningLevel === input.reasoningLevel && @@ -129,7 +125,7 @@ function sameCreateInput( } /** Read one named child binding in its parent-agent scope. */ -export async function getAgentBinding(args: { +async function getAgentBinding(args: { name: string; parentConversationId: string; }): Promise { @@ -205,12 +201,12 @@ async function getNonTerminalAgentInvocationForConversation( /** * Create or replay one invocation, reusing named child conversations and - * keeping ephemeral child identities scoped to the invocation. + * keeping unnamed child identities scoped to the invocation. */ export async function createAgentInvocation( rawInput: CreateAgentInvocationInput, nowMs = Date.now(), -): Promise<{ invocation: AgentInvocation; status: "created" | "existing" }> { +): Promise { const input = createAgentInvocationSchema.parse(rawInput); const invocationId = getAgentInvocationId( input.parentConversationId, @@ -218,7 +214,7 @@ export async function createAgentInvocation( ); const childConversationId = input.agentName ? getNamedAgentConversationId(input.parentConversationId, input.agentName) - : getEphemeralAgentConversationId(invocationId); + : getUnnamedAgentConversationId(invocationId); const lockName = `${CREATE_LOCK_PREFIX}:${ input.agentName ? childConversationId : invocationId }`; @@ -248,7 +244,7 @@ export async function createAgentInvocation( `Agent invocation idempotency key was reused with different input for ${invocationId}`, ); } - return { invocation: existing, status: "existing" }; + return existing; } await getConversationStore().createChild({ @@ -264,11 +260,9 @@ export async function createAgentInvocation( .insert(juniorAgentBindings) .values({ childConversationId, - createdAt: new Date(nowMs), name: input.agentName, parentConversationId: input.parentConversationId, reasoningLevel: effectiveInput.reasoningLevel ?? null, - updatedAt: new Date(nowMs), }) .onConflictDoNothing(); const binding = await getAgentBinding({ @@ -297,7 +291,6 @@ export async function createAgentInvocation( .insert(juniorAgentInvocations) .values({ invocationId, - idempotencyKey: input.idempotencyKey, parentConversationId: input.parentConversationId, childConversationId, agentName: input.agentName ?? null, @@ -319,7 +312,7 @@ export async function createAgentInvocation( if (!invocation || !sameCreateInput(invocation, effectiveInput)) { throw new Error(`Agent invocation creation raced for ${invocationId}`); } - return { invocation, status: "created" }; + return invocation; }); } diff --git a/packages/junior/src/chat/agent-invocations/types.ts b/packages/junior/src/chat/agent-invocations/types.ts index 9899611d0..3573cf6f6 100644 --- a/packages/junior/src/chat/agent-invocations/types.ts +++ b/packages/junior/src/chat/agent-invocations/types.ts @@ -18,20 +18,16 @@ const exactStringSchema = z export const agentNameSchema = exactStringSchema.max(64); -export const agentInvocationStatusSchema = z.enum(AGENT_INVOCATION_STATUSES); - -export const agentInvocationMailboxStatusSchema = z.enum( +const agentInvocationMailboxStatusSchema = z.enum( AGENT_INVOCATION_MAILBOX_STATUSES, ); export const agentBindingSchema = z .object({ childConversationId: exactStringSchema, - createdAtMs: z.number().finite(), name: agentNameSchema, parentConversationId: exactStringSchema, reasoningLevel: z.enum(TURN_REASONING_LEVELS).optional(), - updatedAtMs: z.number().finite(), }) .strict(); @@ -44,7 +40,6 @@ const agentInvocationBaseSchema = z credentialContext: credentialContextSchema.optional(), destination: destinationSchema, destinationVisibility: z.enum(["public", "private"]).optional(), - idempotencyKey: exactStringSchema, input: exactStringSchema, invocationId: exactStringSchema, mailboxStatus: agentInvocationMailboxStatusSchema, @@ -88,12 +83,7 @@ export const createAgentInvocationSchema = z export type AgentBinding = z.output; export type AgentInvocation = z.output; -export type AgentInvocationStatus = z.output< - typeof agentInvocationStatusSchema ->; -export type AgentInvocationMailboxStatus = z.output< - typeof agentInvocationMailboxStatusSchema ->; +export type AgentInvocationStatus = (typeof AGENT_INVOCATION_STATUSES)[number]; export type CreateAgentInvocationInput = z.input< typeof createAgentInvocationSchema >; diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index 2d193bdea..0bc5f1e6f 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -58,12 +58,12 @@ const agentInvocationMailboxMetadataSchema = z }) .strict(); -interface AgentInvocationWorkOptions { +type EnqueueOptions = { conversationStore?: ConversationStore; nowMs?: number; queue: ConversationWorkQueue; state?: StateAdapter; -} +}; /** Build destinationless mailbox work that references one durable invocation. */ export function buildAgentInvocationInboundMessage( @@ -91,7 +91,7 @@ export function buildAgentInvocationInboundMessage( /** Append one invocation to its child mailbox and send the normal queue wake. */ export async function enqueueAgentInvocation( invocation: AgentInvocation, - options: AgentInvocationWorkOptions, + options: EnqueueOptions, ): Promise { if (invocation.mailboxStatus === "appended") { return; @@ -110,19 +110,11 @@ export async function enqueueAgentInvocation( /** Create or replay an invocation, then durably schedule its child work. */ export async function createAndEnqueueAgentInvocation( input: CreateAgentInvocationInput, - options: AgentInvocationWorkOptions, -): Promise<{ - invocation: AgentInvocation; - status: "created" | "existing"; -}> { - const created = await createAgentInvocation(input, options.nowMs); - await enqueueAgentInvocation(created.invocation, options); - return { - ...created, - invocation: - (await getAgentInvocation(created.invocation.invocationId)) ?? - created.invocation, - }; + options: EnqueueOptions, +): Promise { + const invocation = await createAgentInvocation(input, options.nowMs); + await enqueueAgentInvocation(invocation, options); + return (await getAgentInvocation(invocation.invocationId)) ?? invocation; } /** Require one invocation metadata reference for one leased mailbox attempt. */ @@ -295,7 +287,7 @@ function isInvocationInputCommitLost(error: unknown): boolean { } /** Build the invocation consumer that advances work through the shared runner. */ -export function createAgentInvocationConversationWorker(options: { +export function createAgentInvocationWorker(options: { agentRunner: AgentRunner; }) { return async ( @@ -542,7 +534,7 @@ export function createAgentInvocationConversationWorker(options: { } /** Route invocation-backed work before falling through to existing consumers. */ -export function createAgentInvocationWorkRouter(options: { +export function routeAgentInvocationWork(options: { fallbackWorker: ( context: ConversationWorkerContext, ) => Promise; diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index f9836e566..8ad085518 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -31,7 +31,6 @@ import type { AgentTurnSurface } from "@/chat/state/turn-session"; import type { ToolExecutionReport } from "@/chat/tool-support/tool-execution-report"; import type { SlackActionToken } from "@/chat/slack/action-token"; import type { TurnReasoningLevel } from "@/chat/reasoning-level"; -import type { AgentInvocationStatus } from "@/chat/agent-invocations/types"; import type { ImageGenerateToolDeps, ViewImageToolDeps, @@ -64,28 +63,22 @@ export interface AgentRunSteeringMessage { } /** Model-safe input for one asynchronously delegated child-agent task. */ -export interface AgentSpawnInput { +export type SpawnAgentInput = { name?: string; reasoningLevel?: TurnReasoningLevel; task: string; -} +}; -/** Stable projection returned after a child-agent task is durably scheduled. */ -export interface AgentSpawnResult { - agentName?: string; - childConversationId: string; +/** Handle returned after a child-agent task is durably scheduled. */ +export type SpawnAgentResult = { invocationId: string; - replayed: boolean; - status: AgentInvocationStatus; -} +}; /** Runtime-bound child-agent capability exposed to model-facing tool wiring. */ -export interface AgentSpawnControl { - execute( - input: AgentSpawnInput, - options: { signal?: AbortSignal; toolCallId: string }, - ): Promise; -} +export type SpawnAgent = ( + input: SpawnAgentInput, + options: { signal?: AbortSignal; toolCallId: string }, +) => Promise; /** Carries the user-visible content and prior transcript for one agent-run slice. */ export interface AgentRunInput { @@ -193,7 +186,7 @@ export class RetryableDeliveryError extends Error { /** Carries durable-worker ports that commit or update resumable run state. */ export interface AgentRunDurability { /** Schedule delegated work with authority bound by the active parent run. */ - spawnAgent?: AgentSpawnControl; + spawnAgent?: SpawnAgent; onInputCommitted?: () => void | Promise; /** Return true when the durable worker should pause at the next Pi boundary. */ shouldYield?: () => boolean; diff --git a/packages/junior/src/chat/app/production.ts b/packages/junior/src/chat/app/production.ts index 2d766f2bf..2c7de029b 100644 --- a/packages/junior/src/chat/app/production.ts +++ b/packages/junior/src/chat/app/production.ts @@ -24,8 +24,8 @@ import { createAgentDispatchConversationWorker, } from "@/chat/agent-dispatch/work"; import { - createAgentInvocationConversationWorker, - createAgentInvocationWorkRouter, + createAgentInvocationWorker, + routeAgentInvocationWork, } from "@/chat/agent-invocations/work"; import { getDispatchConversationId, @@ -164,8 +164,8 @@ export function createProductionConversationWorkOptions(options: { return { conversationStore, queue, - run: createAgentInvocationWorkRouter({ - invocationWorker: createAgentInvocationConversationWorker({ + run: routeAgentInvocationWork({ + invocationWorker: createAgentInvocationWorker({ agentRunner, }), fallbackWorker: providerWorker, diff --git a/packages/junior/src/chat/app/services.ts b/packages/junior/src/chat/app/services.ts index 588643854..e452f1ba1 100644 --- a/packages/junior/src/chat/app/services.ts +++ b/packages/junior/src/chat/app/services.ts @@ -33,10 +33,7 @@ import { import { createAgentRunner } from "@/chat/runtime/agent-runner"; import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; import { getConversationEventStore } from "@/chat/db"; -import { - bindAgentSpawnControl, - createAgentInvocationCreator, -} from "@/chat/agent-invocations/spawn"; +import { bindSpawnAgent } from "@/chat/agent-invocations/spawn"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; export interface JuniorRuntimeServices { @@ -76,14 +73,11 @@ export function createJuniorRuntimeServices( downloadFile: overrides.visionContext?.downloadFile ?? downloadPrivateSlackFile, }); - const agentInvocationCreator = createAgentInvocationCreator({ - queue: getVercelConversationWorkQueue, - }); const agentRunner = overrides.replyExecutor?.agentRunner ?? createAgentRunner(executeAgentRunImpl, { bindSpawnAgent: (request) => - bindAgentSpawnControl(request, agentInvocationCreator), + bindSpawnAgent(request, { queue: getVercelConversationWorkQueue }), tracePropagation: overrides.sandbox?.tracePropagation, }); diff --git a/packages/junior/src/chat/local/conversation-work.ts b/packages/junior/src/chat/local/conversation-work.ts index dcbc3d4bb..a60377458 100644 --- a/packages/junior/src/chat/local/conversation-work.ts +++ b/packages/junior/src/chat/local/conversation-work.ts @@ -4,11 +4,6 @@ import type { ConversationWorkQueue, } from "@/chat/task-execution/queue"; -export interface LocalConversationWork { - drain(): Promise; - queue: ConversationWorkQueue; -} - /** * Run local conversation work in this process and wait for every accepted wake. * @@ -18,7 +13,7 @@ export interface LocalConversationWork { */ export function createLocalConversationWork( processMessage: (message: ConversationQueueMessage) => Promise, -): LocalConversationWork { +) { const acceptedWakeIds = new Map(); const pending = new Set>(); let firstError: unknown; @@ -45,25 +40,27 @@ export function createLocalConversationWork( pending.add(work); } - return { - queue: { - async send(message, options) { - const idempotencyKey = options?.idempotencyKey; - const acceptedWakeId = idempotencyKey - ? acceptedWakeIds.get(idempotencyKey) - : undefined; - if (acceptedWakeId) { - return { messageId: acceptedWakeId }; - } - const messageId = `local-conversation-work:${nextWakeId}`; - nextWakeId += 1; - if (idempotencyKey) { - acceptedWakeIds.set(idempotencyKey, messageId); - } - schedule(message, options); - return { messageId }; - }, + const queue: ConversationWorkQueue = { + async send(message, options) { + const idempotencyKey = options?.idempotencyKey; + const acceptedWakeId = idempotencyKey + ? acceptedWakeIds.get(idempotencyKey) + : undefined; + if (acceptedWakeId) { + return { messageId: acceptedWakeId }; + } + const messageId = `local-conversation-work:${nextWakeId}`; + nextWakeId += 1; + if (idempotencyKey) { + acceptedWakeIds.set(idempotencyKey, messageId); + } + schedule(message, options); + return { messageId }; }, + }; + + return { + queue, async drain() { while (pending.size > 0) { await Promise.all(pending); diff --git a/packages/junior/src/chat/runtime/agent-runner.ts b/packages/junior/src/chat/runtime/agent-runner.ts index e8bc69f5c..09a44ef2e 100644 --- a/packages/junior/src/chat/runtime/agent-runner.ts +++ b/packages/junior/src/chat/runtime/agent-runner.ts @@ -1,4 +1,4 @@ -import type { AgentRunRequest, AgentSpawnControl } from "@/chat/agent/request"; +import type { AgentRunRequest, SpawnAgent } from "@/chat/agent/request"; import type { AgentRunOutcome } from "@/chat/runtime/agent-run-outcome"; import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; @@ -11,9 +11,7 @@ export interface AgentRunner { export function createAgentRunner( run: AgentRunner["run"], options?: { - bindSpawnAgent?: ( - request: AgentRunRequest, - ) => AgentSpawnControl | undefined; + bindSpawnAgent?: (request: AgentRunRequest) => SpawnAgent | undefined; tracePropagation?: SandboxEgressTracePropagationConfig; }, ): AgentRunner { diff --git a/packages/junior/src/chat/tools/runtime/spawn-agent.ts b/packages/junior/src/chat/tools/runtime/spawn-agent.ts index d9ac0b8a4..a7d26bff0 100644 --- a/packages/junior/src/chat/tools/runtime/spawn-agent.ts +++ b/packages/junior/src/chat/tools/runtime/spawn-agent.ts @@ -1,9 +1,6 @@ import { z } from "zod"; import { AgentInvocationBusyError } from "@/chat/agent-invocations/errors"; -import { - agentInvocationStatusSchema, - agentNameSchema, -} from "@/chat/agent-invocations/types"; +import { agentNameSchema } from "@/chat/agent-invocations/types"; import { TURN_REASONING_LEVELS } from "@/chat/reasoning-level"; import { juniorToolResultSchema } from "@/chat/tool-support/structured-result"; import { zodTool } from "@/chat/tool-support/zod-tool"; @@ -37,10 +34,6 @@ export function createSpawnAgentTool( .strict(), outputSchema: juniorToolResultSchema.extend({ invocation_id: z.string().min(1), - child_conversation_id: z.string().min(1), - agent_name: agentNameSchema.optional(), - invocation_status: agentInvocationStatusSchema, - replayed: z.boolean(), }), execute: async (input, options) => { if (!options.toolCallId) { @@ -48,7 +41,7 @@ export function createSpawnAgentTool( } let result; try { - result = await spawnAgent.execute( + result = await spawnAgent( { task: input.task, ...(input.name ? { name: input.name } : {}), @@ -74,10 +67,6 @@ export function createSpawnAgentTool( ok: true, status: "success" as const, invocation_id: result.invocationId, - child_conversation_id: result.childConversationId, - ...(result.agentName ? { agent_name: result.agentName } : {}), - invocation_status: result.status, - replayed: result.replayed, }; }, }); diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index 40b5230e7..7450dd2cd 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -19,7 +19,7 @@ import type { LocalActor, Actor, SlackActor } from "@/chat/actor"; import type { SlackActionToken } from "@/chat/slack/action-token"; import type { ModelProfile } from "@/chat/model-profile"; import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; -import type { AgentSpawnControl } from "@/chat/agent/request"; +import type { SpawnAgent } from "@/chat/agent/request"; interface HandoffControl { /** Non-empty catalog of configured targets. */ @@ -77,7 +77,7 @@ export interface ToolHooks { interface BaseToolRuntimeContext { handoff?: HandoffControl; - spawnAgent?: AgentSpawnControl; + spawnAgent?: SpawnAgent; /** * Opaque Junior conversation/session identity for this turn. * Interactive Slack turns use `slack:{channelId}:{threadTs}`. diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 41734dd53..cb335415a 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -242,12 +242,9 @@ async function prepareLocalChatRun( const { createLocalOAuthState } = await import("@/chat/local/oauth-relay"); const { createLocalSandboxEgressSignalTransport } = await import("@/chat/local/sandbox-egress-signals"); - const { bindAgentSpawnControl, createAgentInvocationCreator } = - await import("@/chat/agent-invocations/spawn"); - const { - createAgentInvocationConversationWorker, - createAgentInvocationWorkRouter, - } = await import("@/chat/agent-invocations/work"); + const { bindSpawnAgent } = await import("@/chat/agent-invocations/spawn"); + const { createAgentInvocationWorker, routeAgentInvocationWork } = + await import("@/chat/agent-invocations/work"); const { createLocalConversationWork } = await import("@/chat/local/conversation-work"); const { processConversationWork } = @@ -257,11 +254,11 @@ async function prepareLocalChatRun( if (!agentRunner) { throw new Error("Local agent runner is not ready"); } - const run = createAgentInvocationWorkRouter({ + const run = routeAgentInvocationWork({ fallbackWorker: async () => { throw new Error("Local child queue received non-invocation work"); }, - invocationWorker: createAgentInvocationConversationWorker({ + invocationWorker: createAgentInvocationWorker({ agentRunner, }), }); @@ -270,12 +267,9 @@ async function prepareLocalChatRun( run, }); }); - const agentInvocationCreator = createAgentInvocationCreator({ - queue: localConversationWork.queue, - }); agentRunner = createAgentRunner(executeAgentRun, { bindSpawnAgent: (request) => - bindAgentSpawnControl(request, agentInvocationCreator), + bindSpawnAgent(request, { queue: localConversationWork.queue }), }); const oauthCallback = await startLocalOAuthCallbackServer(agentRunner); const deps: LocalAgentTurnDeps = { diff --git a/packages/junior/src/db/schema/agent-invocations.ts b/packages/junior/src/db/schema/agent-invocations.ts index 55949191a..40e6ccb08 100644 --- a/packages/junior/src/db/schema/agent-invocations.ts +++ b/packages/junior/src/db/schema/agent-invocations.ts @@ -38,8 +38,6 @@ export const juniorAgentBindings = pgTable( .notNull() .references(() => juniorConversations.conversationId), reasoningLevel: text("reasoning_level").$type(), - createdAt: timestamptz("created_at").notNull(), - updatedAt: timestamptz("updated_at").notNull(), }, (table) => [ primaryKey({ @@ -55,7 +53,6 @@ export const juniorAgentInvocations = pgTable( "junior_agent_invocations", { invocationId: text("invocation_id").primaryKey(), - idempotencyKey: text("idempotency_key").notNull(), parentConversationId: text("parent_conversation_id") .notNull() .references(() => juniorConversations.conversationId), @@ -87,10 +84,6 @@ export const juniorAgentInvocations = pgTable( terminalAt: timestamptz("terminal_at"), }, (table) => [ - uniqueIndex("junior_agent_invocations_idempotency_idx").on( - table.parentConversationId, - table.idempotencyKey, - ), index("junior_agent_invocations_child_idx").on(table.childConversationId), index("junior_agent_invocations_mailbox_idx").on( table.mailboxStatus, diff --git a/packages/junior/tests/component/cli/local-chat-composition.test.ts b/packages/junior/tests/component/cli/local-chat-composition.test.ts index 3b7c9e2b1..a166b60d6 100644 --- a/packages/junior/tests/component/cli/local-chat-composition.test.ts +++ b/packages/junior/tests/component/cli/local-chat-composition.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentRunRequest, AgentSpawnResult } from "@/chat/agent/request"; +import type { AgentRunRequest, SpawnAgentResult } from "@/chat/agent/request"; import type { AgentRunResult } from "@/chat/services/turn-result"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { deliverAssistantMessagesForTest } from "../../fixtures/agent-runner"; @@ -155,7 +155,7 @@ export const plugins = { if (!spawnAgent) { throw new Error("parent run requires spawnAgent"); } - const spawned = await spawnAgent.execute( + const spawned = await spawnAgent( { name: "local-test-child", reasoningLevel: "medium", @@ -163,7 +163,7 @@ export const plugins = { }, { toolCallId: "spawn-1" }, ); - expect(spawned).toMatchObject({ status: "pending" }); + expect(spawned.invocationId).toBeTruthy(); await deliverAssistantMessagesForTest(request, [ { text: "child scheduled" }, ]); @@ -199,7 +199,7 @@ export const plugins = { it("runs successive work through the same completed named child", async () => { delete process.env.JUNIOR_STATE_ADAPTER; const childTasks: string[] = []; - const spawns: AgentSpawnResult[] = []; + const spawns: SpawnAgentResult[] = []; const { getAgentInvocation } = await import("@/chat/agent-invocations/store"); executeAgentRunMock.mockImplementation(async (request) => { @@ -214,7 +214,7 @@ export const plugins = { if (!spawnAgent) { throw new Error("parent run requires named child dependencies"); } - const first = await spawnAgent.execute( + const first = await spawnAgent( { name: "reused-child", task: "first named task", @@ -232,7 +232,7 @@ export const plugins = { }, { timeout: 5_000 }, ); - const second = await spawnAgent.execute( + const second = await spawnAgent( { name: "reused-child", task: "second named task", @@ -261,16 +261,19 @@ export const plugins = { ).resolves.toBe(0); expect(spawns).toHaveLength(2); - expect(spawns[1]?.childConversationId).toBe(spawns[0]?.childConversationId); expect(spawns[1]?.invocationId).not.toBe(spawns[0]?.invocationId); expect(childTasks).toEqual(["first named task", "second named task"]); const secondSpawn = spawns[1]; if (!secondSpawn) { throw new Error("Expected second named spawn"); } - await expect( - getAgentInvocation(secondSpawn.invocationId), - ).resolves.toMatchObject({ + const [firstInvocation, secondInvocation] = await Promise.all( + spawns.map(async (spawn) => await getAgentInvocation(spawn.invocationId)), + ); + expect(secondInvocation?.childConversationId).toBe( + firstInvocation?.childConversationId, + ); + expect(secondInvocation).toMatchObject({ result: "completed:second named task", status: "completed", }); @@ -278,7 +281,7 @@ export const plugins = { it("runs independent spawned children concurrently and drains both", async () => { delete process.env.JUNIOR_STATE_ADAPTER; - const spawns: AgentSpawnResult[] = []; + const spawns: SpawnAgentResult[] = []; let activeChildren = 0; let maxActiveChildren = 0; let releaseBoth: (() => void) | undefined; @@ -305,11 +308,11 @@ export const plugins = { } spawns.push( ...(await Promise.all([ - spawnAgent.execute( + spawnAgent( { name: "parallel-a", task: "parallel task a" }, { toolCallId: "parallel-1" }, ), - spawnAgent.execute( + spawnAgent( { name: "parallel-b", task: "parallel task b" }, { toolCallId: "parallel-2" }, ), @@ -339,16 +342,13 @@ export const plugins = { expect(maxActiveChildren).toBe(2); expect(spawns).toHaveLength(2); - expect(spawns[0]?.childConversationId).not.toBe( - spawns[1]?.childConversationId, + const invocations = await Promise.all( + spawns.map(async (spawn) => await getAgentInvocation(spawn.invocationId)), ); - await expect( - Promise.all( - spawns.map( - async (spawn) => await getAgentInvocation(spawn.invocationId), - ), - ), - ).resolves.toEqual([ + expect(invocations[0]?.childConversationId).not.toBe( + invocations[1]?.childConversationId, + ); + expect(invocations).toEqual([ expect.objectContaining({ result: "completed:parallel task a", status: "completed", diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index cef47b04f..4bebb620b 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -259,7 +259,6 @@ describe("retention purge job", () => { createdAt: new Date(BASE_MS), destination: { conversationId: "root", platform: "local" }, destinationVisibility: "private", - idempotencyKey: "retained-invocation", input: "private delegated input", invocationId: "agent-invocation:retained", mailboxStatus: "appended", @@ -440,17 +439,12 @@ describe("retention purge job", () => { .update(juniorConversations) .set({ actor: null }) .where(eq(juniorConversations.conversationId, "binding-child")); - await fixture.sql - .db() - .insert(juniorAgentBindings) - .values({ - childConversationId: "binding-child", - createdAt: new Date(BASE_MS), - name: "retained-name", - parentConversationId: "binding-root", - reasoningLevel: "high", - updatedAt: new Date(BASE_MS), - }); + await fixture.sql.db().insert(juniorAgentBindings).values({ + childConversationId: "binding-child", + name: "retained-name", + parentConversationId: "binding-root", + reasoningLevel: "high", + }); const result = await runRetentionPurge(fixture.sql, { nowMs: BASE_MS + 30 * DAY_MS, diff --git a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts index 7ad23b307..176048e18 100644 --- a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts +++ b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts @@ -4,8 +4,8 @@ import type { AgentRunRequest } from "@/chat/agent/request"; import type { PiMessage } from "@/chat/pi/messages"; import { getAgentInvocation } from "@/chat/agent-invocations/store"; import { - createAgentInvocationConversationWorker, - createAgentInvocationWorkRouter, + createAgentInvocationWorker, + routeAgentInvocationWork, createAndEnqueueAgentInvocation, } from "@/chat/agent-invocations/work"; import type { CreateAgentInvocationInput } from "@/chat/agent-invocations/types"; @@ -101,9 +101,9 @@ async function createHarness( const state = getStateAdapter(); await state.connect(); const queue = createConversationWorkQueueTestAdapter(); - const route = createAgentInvocationWorkRouter({ + const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), - invocationWorker: createAgentInvocationConversationWorker({ + invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, }), }); @@ -181,22 +181,20 @@ describe("agent invocation identity and concurrency", () => { }); await harness.drainQueuedWork(); - expect(second.invocation.childConversationId).not.toBe( - first.invocation.childConversationId, - ); + expect(second.childConversationId).not.toBe(first.childConversationId); expect(requests).toHaveLength(2); expect(requests.map((request) => request.input.piMessages)).toEqual([ [], [], ]); await expect( - getAgentInvocation(first.invocation.invocationId), + getAgentInvocation(first.invocationId), ).resolves.toMatchObject({ result: "result:first unnamed task", status: "completed", }); await expect( - getAgentInvocation(second.invocation.invocationId), + getAgentInvocation(second.invocationId), ).resolves.toMatchObject({ result: "result:second unnamed task", status: "completed", @@ -231,9 +229,7 @@ describe("agent invocation identity and concurrency", () => { }); await harness.drainQueuedWork(); - expect(second.invocation.childConversationId).toBe( - first.invocation.childConversationId, - ); + expect(second.childConversationId).toBe(first.childConversationId); expect(requests).toHaveLength(2); expect(requests[0]?.input.piMessages).toEqual([]); expect(requests[0]?.policy?.reasoningLevel).toBe("high"); @@ -248,7 +244,7 @@ describe("agent invocation identity and concurrency", () => { "result:first named task", ); await expect( - getAgentInvocation(second.invocation.invocationId), + getAgentInvocation(second.invocationId), ).resolves.toMatchObject({ reasoningLevel: "high", result: "result:second named task", @@ -309,19 +305,17 @@ describe("agent invocation identity and concurrency", () => { "parallel first": [], "parallel second": [], }); - expect(first.invocation.childConversationId).not.toBe( - second.invocation.childConversationId, - ); + expect(first.childConversationId).not.toBe(second.childConversationId); release?.(); await processing; await expect( - getAgentInvocation(first.invocation.invocationId), + getAgentInvocation(first.invocationId), ).resolves.toMatchObject({ result: "result:parallel first", status: "completed", }); await expect( - getAgentInvocation(second.invocation.invocationId), + getAgentInvocation(second.invocationId), ).resolves.toMatchObject({ result: "result:parallel second", status: "completed", @@ -349,9 +343,7 @@ describe("agent invocation identity and concurrency", () => { harness.spawn(replayInput), ]); - expect(replay.invocation.invocationId).toBe( - first.invocation.invocationId, - ); + expect(replay.invocationId).toBe(first.invocationId); expect(harness.queue.sentRecords()).toHaveLength(1); const contention = await Promise.allSettled([ @@ -415,13 +407,13 @@ describe("agent invocation identity and concurrency", () => { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS, ); await expect( - getAgentInvocation(successful.invocation.invocationId), + getAgentInvocation(successful.invocationId), ).resolves.toMatchObject({ result: "successful sibling result", status: "completed", }); await expect( - getAgentInvocation(failing.invocation.invocationId), + getAgentInvocation(failing.invocationId), ).resolves.toMatchObject({ errorMessage: "sibling failed", status: "failed", diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index 7cfcc047f..77f84f7f5 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -4,19 +4,15 @@ import type { PiMessage } from "@/chat/pi/messages"; import { completeAgentInvocation, createAgentInvocation, - getAgentBinding, getAgentInvocation, getAgentInvocationTurnId, } from "@/chat/agent-invocations/store"; import { - createAgentInvocationConversationWorker, - createAgentInvocationWorkRouter, + createAgentInvocationWorker, + routeAgentInvocationWork, createAndEnqueueAgentInvocation, } from "@/chat/agent-invocations/work"; -import { - bindAgentSpawnControl, - createAgentInvocationCreator, -} from "@/chat/agent-invocations/spawn"; +import { bindSpawnAgent } from "@/chat/agent-invocations/spawn"; import { createSpawnAgentTool } from "@/chat/tools/runtime/spawn-agent"; import type { AgentRunRequest } from "@/chat/agent/request"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; @@ -87,7 +83,7 @@ describe("agent invocation conversation work", () => { 3_000, ); await completeAgentInvocation({ - invocationId: first.invocation.invocationId, + invocationId: first.invocationId, result: "Finished.", status: "completed", }); @@ -99,56 +95,39 @@ describe("agent invocation conversation work", () => { }, 4_000, ); - const ephemeral = await createAgentInvocation( + const unnamed = await createAgentInvocation( { ...invocationInput, - idempotencyKey: "ephemeral-1", + idempotencyKey: "unnamed-1", }, 5_000, ); - const ephemeralReplay = await createAgentInvocation( + const unnamedReplay = await createAgentInvocation( { ...invocationInput, - idempotencyKey: "ephemeral-1", + idempotencyKey: "unnamed-1", }, 6_000, ); - const otherEphemeral = await createAgentInvocation( + const otherUnnamed = await createAgentInvocation( { ...invocationInput, - idempotencyKey: "ephemeral-2", + idempotencyKey: "unnamed-2", }, 7_000, ); - expect(first.status).toBe("created"); - expect(replay).toEqual({ - invocation: first.invocation, - status: "existing", - }); - expect(next.invocation.invocationId).not.toBe( - first.invocation.invocationId, - ); - expect(next.invocation.childConversationId).toBe( - first.invocation.childConversationId, + expect(replay).toEqual(first); + expect(next.invocationId).not.toBe(first.invocationId); + expect(next.childConversationId).toBe(first.childConversationId); + expect(unnamedReplay.childConversationId).toBe( + unnamed.childConversationId, ); - expect(ephemeralReplay.invocation.childConversationId).toBe( - ephemeral.invocation.childConversationId, + expect(otherUnnamed.childConversationId).not.toBe( + unnamed.childConversationId, ); - expect(otherEphemeral.invocation.childConversationId).not.toBe( - ephemeral.invocation.childConversationId, - ); - await expect( - getAgentBinding({ - name: "researcher", - parentConversationId, - }), - ).resolves.toMatchObject({ - childConversationId: first.invocation.childConversationId, - reasoningLevel: "medium", - }); const child = await conversationStore.get({ - conversationId: first.invocation.childConversationId, + conversationId: first.childConversationId, }); expect(child).toMatchObject({ lineage: { parentConversationId }, @@ -175,7 +154,7 @@ describe("agent invocation conversation work", () => { createAgentInvocation({ ...invocationInput, idempotencyKey: "recursive", - parentConversationId: first.invocation.childConversationId, + parentConversationId: first.childConversationId, }), ).rejects.toThrow("Recursive agent delegation is not enabled"); } finally { @@ -203,13 +182,10 @@ describe("agent invocation conversation work", () => { source: createLocalSource(parentConversationId), }, } satisfies AgentRunRequest; - const spawnAgent = bindAgentSpawnControl( - request, - createAgentInvocationCreator({ - conversationStore, - queue, - }), - ); + const spawnAgent = bindSpawnAgent(request, { + conversationStore, + queue, + }); expect(spawnAgent).toBeDefined(); const tool = createSpawnAgentTool(spawnAgent!); const input = tool.prepareArguments!({ @@ -221,16 +197,8 @@ describe("agent invocation conversation work", () => { const first = await tool.execute!(input, { toolCallId: "call-1" }); const replay = await tool.execute!(input, { toolCallId: "call-1" }); - expect(first).toMatchObject({ - agent_name: "reviewer", - invocation_status: "pending", - replayed: false, - }); - expect(replay).toMatchObject({ - invocation_id: first.invocation_id, - child_conversation_id: first.child_conversation_id, - replayed: true, - }); + expect(first.invocation_id).toBeTruthy(); + expect(replay.invocation_id).toBe(first.invocation_id); await expect( getAgentInvocation(first.invocation_id), ).resolves.toMatchObject({ @@ -241,7 +209,6 @@ describe("agent invocation conversation work", () => { }, destination, destinationVisibility: "private", - idempotencyKey: "parent-turn:call-1", input: "Inspect the failing checks.", parentConversationId, reasoningLevel: "high", @@ -287,7 +254,7 @@ describe("agent invocation conversation work", () => { ); const run = vi.fn(async (request) => { expect(request).toMatchObject({ - conversationId: created.invocation.childConversationId, + conversationId: created.childConversationId, input: { messageText: invocationInput.input }, policy: { authorizationFlowMode: "disabled", @@ -300,7 +267,7 @@ describe("agent invocation conversation work", () => { source: invocationInput.source, surface: "internal", }, - runId: created.invocation.invocationId, + runId: created.invocationId, }); await request.durability.onInputCommitted?.(); return completedAgentRun({ @@ -319,9 +286,9 @@ describe("agent invocation conversation work", () => { const fallbackWorker = vi.fn(async () => ({ status: "completed" as const, })); - const route = createAgentInvocationWorkRouter({ + const route = routeAgentInvocationWork({ fallbackWorker, - invocationWorker: createAgentInvocationConversationWorker({ + invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, }), }); @@ -347,25 +314,23 @@ describe("agent invocation conversation work", () => { expect(run).toHaveBeenCalledOnce(); expect(fallbackWorker).not.toHaveBeenCalled(); await expect( - getAgentInvocation(created.invocation.invocationId), + getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ mailboxStatus: "appended", result: "Durable child result", status: "completed", terminalAtMs: expect.any(Number), }); - const completed = await getAgentInvocation( - created.invocation.invocationId, - ); + const completed = await getAgentInvocation(created.invocationId); await completeAgentInvocation({ errorMessage: "late conflicting failure", - invocationId: created.invocation.invocationId, + invocationId: created.invocationId, nowMs: Date.now() + 1_000, status: "failed", }); - await expect( - getAgentInvocation(created.invocation.invocationId), - ).resolves.toEqual(completed); + await expect(getAgentInvocation(created.invocationId)).resolves.toEqual( + completed, + ); } finally { await fixture.close(); } @@ -390,7 +355,7 @@ describe("agent invocation conversation work", () => { }, ); let runCount = 0; - const invocationWorker = createAgentInvocationConversationWorker({ + const invocationWorker = createAgentInvocationWorker({ agentRunner: { run: vi.fn(async (request) => { runCount += 1; @@ -413,7 +378,7 @@ describe("agent invocation conversation work", () => { }), }, }); - const route = createAgentInvocationWorkRouter({ + const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), invocationWorker, }); @@ -427,7 +392,7 @@ describe("agent invocation conversation work", () => { }), ).resolves.toMatchObject({ status: "yielded" }); await expect( - getAgentInvocation(created.invocation.invocationId), + getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ status: "awaiting_resume" }); await expect( @@ -440,7 +405,7 @@ describe("agent invocation conversation work", () => { ).resolves.toMatchObject({ status: "completed" }); expect(runCount).toBe(2); await expect( - getAgentInvocation(created.invocation.invocationId), + getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ result: "Resumed child result", status: "completed", @@ -475,105 +440,13 @@ describe("agent invocation conversation work", () => { expect(queue.sentRecords()).toHaveLength(1); await expect( - getAgentInvocation(created.invocation.invocationId), + getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ mailboxStatus: "appended" }); } finally { await fixture.close(); } }); - it("loads prior history when a named child receives another invocation", async () => { - const { conversationStore, fixture } = await prepareParentConversation(); - const queue = createConversationWorkQueueTestAdapter(); - const state = getStateAdapter(); - await state.connect(); - try { - const first = await createAgentInvocation( - { - ...invocationInput, - agentName: "historian", - idempotencyKey: "history-1", - }, - 2_000, - ); - const priorMessages = [ - { - role: "user", - content: [{ type: "text", text: "First task" }], - timestamp: 1, - }, - { - role: "assistant", - content: [{ type: "text", text: "Remembered answer" }], - timestamp: 2, - }, - ] as unknown as PiMessage[]; - await upsertAgentTurnSessionRecord({ - actor: invocationInput.actor, - conversationId: first.invocation.childConversationId, - destination, - modelId: "test-model", - piMessages: priorMessages, - sessionId: getAgentInvocationTurnId(first.invocation.invocationId), - sliceId: 1, - source: invocationInput.source, - state: "completed", - surface: "internal", - }); - await completeAgentInvocation({ - invocationId: first.invocation.invocationId, - result: "Remembered answer", - status: "completed", - }); - const next = await createAndEnqueueAgentInvocation( - { - ...invocationInput, - agentName: "historian", - idempotencyKey: "history-2", - }, - { conversationStore, queue, state }, - ); - const run = vi.fn(async (request) => { - expect(request.input.piMessages).toEqual(priorMessages); - await request.durability.onInputCommitted?.(); - return completedAgentRun({ - diagnostics: { - assistantMessageCount: 1, - modelId: "test-model", - outcome: "success", - toolCalls: [], - toolErrorCount: 0, - toolResultCount: 0, - usedPrimaryText: true, - }, - text: "Continued answer", - }); - }); - const route = createAgentInvocationWorkRouter({ - fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), - invocationWorker: createAgentInvocationConversationWorker({ - agentRunner: { run }, - }), - }); - - await processConversationQueueMessage(queue.takeMessage(), { - conversationStore, - queue, - run: route, - state, - }); - - expect(run).toHaveBeenCalledOnce(); - await expect( - conversationStore.get({ - conversationId: next.invocation.childConversationId, - }), - ).resolves.not.toHaveProperty("destination"); - } finally { - await fixture.close(); - } - }); - it("re-parks a stranded running child session before resuming", async () => { const { conversationStore, fixture } = await prepareParentConversation(); const queue = createConversationWorkQueueTestAdapter(); @@ -587,10 +460,10 @@ describe("agent invocation conversation work", () => { }, { conversationStore, queue, state }, ); - const turnId = getAgentInvocationTurnId(created.invocation.invocationId); + const turnId = getAgentInvocationTurnId(created.invocationId); await upsertAgentTurnSessionRecord({ actor: invocationInput.actor, - conversationId: created.invocation.childConversationId, + conversationId: created.childConversationId, destination, modelId: "test-model", piMessages: [ @@ -608,10 +481,7 @@ describe("agent invocation conversation work", () => { }); const run = vi.fn(async (request) => { await expect( - getAgentTurnSessionRecord( - created.invocation.childConversationId, - turnId, - ), + getAgentTurnSessionRecord(created.childConversationId, turnId), ).resolves.toMatchObject({ state: "awaiting_resume" }); await request.durability.onInputCommitted?.(); return completedAgentRun({ @@ -627,9 +497,9 @@ describe("agent invocation conversation work", () => { text: "Recovered answer", }); }); - const route = createAgentInvocationWorkRouter({ + const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), - invocationWorker: createAgentInvocationConversationWorker({ + invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, }), }); @@ -644,7 +514,7 @@ describe("agent invocation conversation work", () => { expect(run).toHaveBeenCalledOnce(); await expect( conversationStore.get({ - conversationId: created.invocation.childConversationId, + conversationId: created.childConversationId, }), ).resolves.not.toHaveProperty("destination"); } finally { @@ -667,7 +537,7 @@ describe("agent invocation conversation work", () => { ); await upsertAgentTurnSessionRecord({ actor: invocationInput.actor, - conversationId: created.invocation.childConversationId, + conversationId: created.childConversationId, destination, modelId: "test-model", piMessages: [ @@ -699,7 +569,7 @@ describe("agent invocation conversation work", () => { content: [{ type: "text", text: "Recovered visible result" }], }, ] as unknown as PiMessage[], - sessionId: getAgentInvocationTurnId(created.invocation.invocationId), + sessionId: getAgentInvocationTurnId(created.invocationId), sliceId: 1, source: invocationInput.source, state: "completed", @@ -708,9 +578,9 @@ describe("agent invocation conversation work", () => { const run = vi.fn(async () => { throw new Error("completed sessions must not rerun"); }); - const route = createAgentInvocationWorkRouter({ + const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), - invocationWorker: createAgentInvocationConversationWorker({ + invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, }), }); @@ -726,7 +596,7 @@ describe("agent invocation conversation work", () => { expect(run).not.toHaveBeenCalled(); await expect( - getAgentInvocation(created.invocation.invocationId), + getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ result: "Recovered visible result", status: "completed", @@ -753,10 +623,10 @@ describe("agent invocation conversation work", () => { throw new Error("model unavailable"); }); const deliveryAttempts: Array = []; - const invocationWorker = createAgentInvocationConversationWorker({ + const invocationWorker = createAgentInvocationWorker({ agentRunner: { run }, }); - const route = createAgentInvocationWorkRouter({ + const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), invocationWorker: async (context, invocationId) => { deliveryAttempts.push(context.attempt.messages[0]?.attemptCount); @@ -789,7 +659,7 @@ describe("agent invocation conversation work", () => { ); expect(deliveryAttempts).toEqual([undefined, 1, 2, 3, 4]); await expect( - getAgentInvocation(created.invocation.invocationId), + getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ errorMessage: "model unavailable", status: "failed", diff --git a/packages/junior/tests/unit/runtime/agent-runner.test.ts b/packages/junior/tests/unit/runtime/agent-runner.test.ts index 57479ea91..db481de3a 100644 --- a/packages/junior/tests/unit/runtime/agent-runner.test.ts +++ b/packages/junior/tests/unit/runtime/agent-runner.test.ts @@ -21,7 +21,7 @@ const request = { describe("agent runner controls", () => { it("binds spawnAgent into the active run durability context", async () => { - const spawnAgent = { execute: vi.fn() }; + const spawnAgent = vi.fn(); const bindSpawnAgent = vi.fn(() => spawnAgent); const run = vi.fn(async () => ({ status: "suspended" as const, diff --git a/packages/junior/tests/unit/tools/spawn-agent.test.ts b/packages/junior/tests/unit/tools/spawn-agent.test.ts index c8b833a38..6aa9b2c3d 100644 --- a/packages/junior/tests/unit/tools/spawn-agent.test.ts +++ b/packages/junior/tests/unit/tools/spawn-agent.test.ts @@ -4,13 +4,10 @@ import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; describe("spawnAgent", () => { it("normalizes nullable optional fields before invoking the runtime control", async () => { - const execute = vi.fn().mockResolvedValue({ - childConversationId: "agent:child", + const spawnAgent = vi.fn().mockResolvedValue({ invocationId: "agent-invocation:one", - replayed: false, - status: "pending", }); - const tool = createSpawnAgentTool({ execute }); + const tool = createSpawnAgentTool(spawnAgent); await expect( tool.execute!( @@ -24,21 +21,16 @@ describe("spawnAgent", () => { ).resolves.toEqual({ ok: true, status: "success", - child_conversation_id: "agent:child", invocation_id: "agent-invocation:one", - invocation_status: "pending", - replayed: false, }); - expect(execute).toHaveBeenCalledWith( + expect(spawnAgent).toHaveBeenCalledWith( { task: "Investigate the failing checks." }, { toolCallId: "call-1" }, ); }); it("requires the runtime tool call id used for durable replay", async () => { - const tool = createSpawnAgentTool({ - execute: vi.fn(), - }); + const tool = createSpawnAgentTool(vi.fn()); await expect( tool.execute!( diff --git a/policies/interface-design.md b/policies/interface-design.md index 34855b50f..5718eddc3 100644 --- a/policies/interface-design.md +++ b/policies/interface-design.md @@ -46,6 +46,8 @@ Interfaces should expose the smallest useful capability while keeping ownership, arguments, options, or dependencies. Call the owning capability directly unless the intermediate function enforces an invariant, translates a boundary, or owns a lifecycle transition. +- TODO(dcramer): Review why this policy did not prevent the one-method + creator/control wrappers in #1065 and add an enforceable check if practical. - If a helper exists only to hide parameter threading, inline it or move the repeated call shape to the owner that actually defines the contract. From 23e8ee2155834c6d633062a326cde6610d246673 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 27 Jul 2026 16:56:33 -0700 Subject: [PATCH 7/8] fix(agent): terminalize invocation validation failures --- .../junior/src/chat/agent-invocations/work.ts | 20 +++---- .../integration/agent-invocation-work.test.ts | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index 0bc5f1e6f..7d6e48fc4 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -298,16 +298,6 @@ export function createAgentInvocationWorker(options: { if (!invocation) { throw new Error(`Agent invocation is missing for ${invocationId}`); } - if (invocation.childConversationId !== context.conversationId) { - throw new Error( - `Agent invocation ${invocationId} belongs to ${invocation.childConversationId}, not ${context.conversationId}`, - ); - } - if (context.destination) { - throw new Error( - `Agent invocation conversation ${context.conversationId} must not own a provider destination`, - ); - } let acknowledged = context.attempt.messages.length === 0; const acknowledge = async (): Promise => { @@ -332,6 +322,16 @@ export function createAgentInvocationWorker(options: { let sandboxRef: SandboxRef | undefined; let history: PiMessage[]; try { + if (invocation.childConversationId !== context.conversationId) { + throw new Error( + `Agent invocation ${invocationId} belongs to ${invocation.childConversationId}, not ${context.conversationId}`, + ); + } + if (context.destination) { + throw new Error( + `Agent invocation conversation ${context.conversationId} must not own a provider destination`, + ); + } if (isTerminalAgentInvocation(invocation)) { await persistTerminalLifecycle(invocation); await acknowledge(); diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index 77f84f7f5..d6a5efbe7 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -8,6 +8,7 @@ import { getAgentInvocationTurnId, } from "@/chat/agent-invocations/store"; import { + buildAgentInvocationInboundMessage, createAgentInvocationWorker, routeAgentInvocationWork, createAndEnqueueAgentInvocation, @@ -22,6 +23,7 @@ import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; import { recoverPendingAgentInvocationMailboxAppends } from "@/chat/agent-dispatch/heartbeat"; import { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS } from "@/chat/task-execution/store"; +import type { ConversationWorkerContext } from "@/chat/task-execution/worker"; import { getAgentTurnSessionRecord, upsertAgentTurnSessionRecord, @@ -668,4 +670,59 @@ describe("agent invocation conversation work", () => { await fixture.close(); } }); + + it("persists invariant failures on the final delivery attempt", async () => { + const { fixture } = await prepareParentConversation(); + try { + const created = await createAgentInvocation({ + ...invocationInput, + agentName: "researcher", + idempotencyKey: "invalid-child-1", + }); + const run = vi.fn(); + const ack = vi.fn(); + const worker = createAgentInvocationWorker({ agentRunner: { run } }); + const context = { + attempt: { + ack, + conversationId: created.childConversationId, + destination, + drain: vi.fn(), + isFinalAttempt: true, + messages: [buildAgentInvocationInboundMessage(created)], + }, + checkIn: vi.fn(), + conversationId: created.childConversationId, + destination, + shouldYield: () => false, + } satisfies ConversationWorkerContext; + + await expect(worker(context, created.invocationId)).resolves.toEqual({ + status: "completed", + }); + + expect(run).not.toHaveBeenCalled(); + expect(ack).toHaveBeenCalledOnce(); + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + errorMessage: expect.stringContaining( + "must not own a provider destination", + ), + status: "failed", + }); + await expect( + createAgentInvocation({ + ...invocationInput, + agentName: "researcher", + idempotencyKey: "invalid-child-2", + }), + ).resolves.toMatchObject({ + childConversationId: created.childConversationId, + status: "pending", + }); + } finally { + await fixture.close(); + } + }); }); From b3aea1f771f060da75c223455ea929805a99ca7f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 30 Jul 2026 10:47:42 -0700 Subject: [PATCH 8/8] fix(agent): align invocations with current runtime --- .../junior/migrations/0012_sour_vargas.sql | 36 + .../junior/migrations/meta/0012_snapshot.json | 1449 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + .../src/chat/agent-dispatch/heartbeat.ts | 13 +- .../junior/src/chat/agent-invocations/work.ts | 5 +- .../agent-invocation-concurrency.test.ts | 1 - 6 files changed, 1499 insertions(+), 12 deletions(-) create mode 100644 packages/junior/migrations/0012_sour_vargas.sql create mode 100644 packages/junior/migrations/meta/0012_snapshot.json diff --git a/packages/junior/migrations/0012_sour_vargas.sql b/packages/junior/migrations/0012_sour_vargas.sql new file mode 100644 index 000000000..f5e4a021e --- /dev/null +++ b/packages/junior/migrations/0012_sour_vargas.sql @@ -0,0 +1,36 @@ +CREATE TABLE "junior_agent_bindings" ( + "parent_conversation_id" text NOT NULL, + "name" text NOT NULL, + "child_conversation_id" text NOT NULL, + "reasoning_level" text, + CONSTRAINT "junior_agent_bindings_parent_conversation_id_name_pk" PRIMARY KEY("parent_conversation_id","name") +); +--> statement-breakpoint +CREATE TABLE "junior_agent_invocations" ( + "invocation_id" text PRIMARY KEY NOT NULL, + "parent_conversation_id" text NOT NULL, + "child_conversation_id" text NOT NULL, + "agent_name" text, + "input" text NOT NULL, + "actor_json" jsonb NOT NULL, + "credential_context_json" jsonb, + "source_json" jsonb NOT NULL, + "destination_json" jsonb NOT NULL, + "destination_visibility" text, + "reasoning_level" text, + "status" text NOT NULL, + "mailbox_status" text NOT NULL, + "result" text, + "error_message" text, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + "terminal_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "junior_agent_bindings" ADD CONSTRAINT "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("parent_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_agent_bindings" ADD CONSTRAINT "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("child_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_agent_invocations" ADD CONSTRAINT "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("parent_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_agent_invocations" ADD CONSTRAINT "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("child_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "junior_agent_bindings_child_idx" ON "junior_agent_bindings" USING btree ("child_conversation_id");--> statement-breakpoint +CREATE INDEX "junior_agent_invocations_child_idx" ON "junior_agent_invocations" USING btree ("child_conversation_id");--> statement-breakpoint +CREATE INDEX "junior_agent_invocations_mailbox_idx" ON "junior_agent_invocations" USING btree ("mailbox_status","created_at"); \ No newline at end of file diff --git a/packages/junior/migrations/meta/0012_snapshot.json b/packages/junior/migrations/meta/0012_snapshot.json new file mode 100644 index 000000000..e5b34f51f --- /dev/null +++ b/packages/junior/migrations/meta/0012_snapshot.json @@ -0,0 +1,1449 @@ +{ + "id": "dd2cb406-092d-4827-9a80-953bf6d43d5b", + "prevId": "d68ca73e-2d45-4adf-a955-7226d68dddcf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": ["parent_conversation_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": ["conversation_id", "plugin", "kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": ["date", "namespace", "metric", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index ace62000a..ffd59023e 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1785271740428, "tag": "0011_sour_golden_guardian", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1785433307522, + "tag": "0012_sour_vargas", + "breakpoints": true } ] } diff --git a/packages/junior/src/chat/agent-dispatch/heartbeat.ts b/packages/junior/src/chat/agent-dispatch/heartbeat.ts index a4a8582ed..75e42549f 100644 --- a/packages/junior/src/chat/agent-dispatch/heartbeat.ts +++ b/packages/junior/src/chat/agent-dispatch/heartbeat.ts @@ -1,5 +1,5 @@ import { getPlugins } from "@/chat/plugins/agent-hooks"; -import { logException, logInfo, logWarn } from "@/chat/logging"; +import { logException, logInfo } from "@/chat/logging"; import { recoverConversationWork } from "@/chat/task-execution/heartbeat"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; @@ -142,13 +142,10 @@ export async function recoverPendingAgentInvocationMailboxAppends(args: { nowMs: args.nowMs, queue: args.conversationWorkQueue, }); - } catch { - logWarn( - "agent_invocation_mailbox_append_recovery_failed", - {}, - { "app.agent.invocation_id": invocation.invocationId }, - "Pending agent invocation mailbox append recovery will retry", - ); + } catch (error) { + logException(error, "agent.invocation.mailbox.append_recovery.failed", { + "app.agent.invocation_id": invocation.invocationId, + }); } } } diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index 7d6e48fc4..f628ac711 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -24,7 +24,7 @@ import { } from "@/chat/services/turn-session-record"; import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; import { PluginCredentialFailureError } from "@/chat/services/plugin-auth-orchestration"; -import { getAssistantMessageText } from "@/chat/services/turn-result"; +import { getAssistantReplyText } from "@/chat/services/assistant-reply"; import { getTerminalAssistantMessages } from "@/chat/pi/transcript"; import type { PiMessage } from "@/chat/pi/messages"; import type { SandboxRef } from "@/chat/sandbox/ref"; @@ -214,7 +214,7 @@ async function projectTerminalSession( return undefined; } const result = getTerminalAssistantMessages(session.piMessages) - .map(getAssistantMessageText) + .map(getAssistantReplyText) .filter((text): text is string => text !== undefined) .join("\n\n"); const failed = session.state !== "completed" || Boolean(session.errorMessage); @@ -253,7 +253,6 @@ async function recoverRunningSession( currentSliceId: session.sliceId, destination: session.destination, errorMessage: "Recovered running agent invocation after worker loss", - logContext: {}, messages: session.piMessages, modelId: session.modelId, actor: session.actor, diff --git a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts index 176048e18..22dde0624 100644 --- a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts +++ b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts @@ -49,7 +49,6 @@ async function successfulRun( const persisted = await persistRunningSessionRecord({ conversationId: request.conversationId, destination: request.routing.destination, - logContext: {}, messages: runningMessages, modelId: "integration-agent", actor: request.routing.actor,