diff --git a/packages/app/src/electro-bridge/ipc/agent-host-api.ts b/packages/app/src/electro-bridge/ipc/agent-host-api.ts index 885cf8d3..f66313aa 100644 --- a/packages/app/src/electro-bridge/ipc/agent-host-api.ts +++ b/packages/app/src/electro-bridge/ipc/agent-host-api.ts @@ -12,6 +12,7 @@ export const AGENT_HOST_CHANNELS = { LIST_TASKS: "agent-host:list-tasks", CONTROL_TASK: "agent-host:control-task", REDIRECT_TASK: "agent-host:redirect-task", + RECORD_OUTPUT: "agent-host:record-output", CANCEL: "agent-host:cancel", RESPOND: "agent-host:respond", REQUEST: "agent-host:request", @@ -35,6 +36,8 @@ export function createAgentHostAPI( invoke(AGENT_HOST_CHANNELS.CONTROL_TASK, taskId, action), redirectTask: (taskId, instruction) => invoke(AGENT_HOST_CHANNELS.REDIRECT_TASK, taskId, instruction), + recordOutput: (jobId, messageId) => + invoke(AGENT_HOST_CHANNELS.RECORD_OUTPUT, jobId, messageId), cancel: (jobId) => invoke(AGENT_HOST_CHANNELS.CANCEL, jobId), respond: (response) => invoke(AGENT_HOST_CHANNELS.RESPOND, response), onRequest: (callback) => { diff --git a/packages/app/src/electro-bridge/ipc/agent-host-context.test.ts b/packages/app/src/electro-bridge/ipc/agent-host-context.test.ts index 26d627b3..7ba00da0 100644 --- a/packages/app/src/electro-bridge/ipc/agent-host-context.test.ts +++ b/packages/app/src/electro-bridge/ipc/agent-host-context.test.ts @@ -139,6 +139,7 @@ describe("Agent Host IPC", () => { resumeTask: vi.fn(async () => true), cancelTask: vi.fn(async () => true), redirectTask: vi.fn(async () => ({ id: "job-2" })), + recordOutput: vi.fn(async () => true), } as unknown as AgentHost; const { handlers, ipc } = mainIPC(); setupAgentHostIPC( @@ -166,11 +167,19 @@ describe("Agent Host IPC", () => { "Show the diff first" as never, ), ).toEqual({ success: true, job: { id: "job-2" } }); + expect( + await handlers.get(AGENT_HOST_CHANNELS.RECORD_OUTPUT)?.( + event(sender), + "job-2" as never, + "message-2" as never, + ), + ).toEqual({ success: true, recorded: true }); expect(host.listTasks).toHaveBeenCalledWith("agent:fizz"); expect(host.pauseTask).toHaveBeenCalledWith("task-1"); expect(host.redirectTask).toHaveBeenCalledWith( "task-1", "Show the diff first", ); + expect(host.recordOutput).toHaveBeenCalledWith("job-2", "message-2"); }); }); diff --git a/packages/app/src/electro-bridge/ipc/agent-host-context.ts b/packages/app/src/electro-bridge/ipc/agent-host-context.ts index 6c141e8f..f0a4c4ca 100644 --- a/packages/app/src/electro-bridge/ipc/agent-host-context.ts +++ b/packages/app/src/electro-bridge/ipc/agent-host-context.ts @@ -42,6 +42,7 @@ export function setupAgentHostIPC( AGENT_HOST_CHANNELS.LIST_TASKS, AGENT_HOST_CHANNELS.CONTROL_TASK, AGENT_HOST_CHANNELS.REDIRECT_TASK, + AGENT_HOST_CHANNELS.RECORD_OUTPUT, AGENT_HOST_CHANNELS.CANCEL, AGENT_HOST_CHANNELS.RESPOND, ]) { @@ -183,6 +184,35 @@ export function setupAgentHostIPC( }, ); + mainIPC.handle( + AGENT_HOST_CHANNELS.RECORD_OUTPUT, + async (event, jobId: unknown, messageId: unknown) => { + if (!allowed(event, options)) { + return { + success: false, + error: "Agent Host IPC sender is not allowed.", + }; + } + if (!options.host) { + return { success: false, error: "Agent Host is unavailable." }; + } + if (typeof jobId !== "string" || !jobId) { + return { success: false, error: "A job id is required." }; + } + if (typeof messageId !== "string" || !messageId) { + return { success: false, error: "A message id is required." }; + } + try { + return { + success: true, + recorded: await options.host.recordOutput(jobId, messageId), + }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + ); + mainIPC.handle(AGENT_HOST_CHANNELS.CANCEL, async (event, jobId: unknown) => { if (!allowed(event, options)) { return { success: false, error: "Agent Host IPC sender is not allowed." }; diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts index 55e45096..eda101d6 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts @@ -330,6 +330,17 @@ describe("local AI IPC", () => { ...baseRequest, agent: { id: "../fizz", memberId: "agent:../fizz" }, }, + { + ...baseRequest, + agentHost: { + jobId: "job-1", + taskId: "task-1", + channelKind: "channel", + collaborationTargets: [ + { agentId: "reviewer", memberId: "agent:someone-else" }, + ], + }, + }, { ...baseRequest, options: { temperature: Number.NaN } }, { ...baseRequest, options: { maxOutputTokens: 0 } }, { diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.ts index 743153f0..4937db7e 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -247,6 +247,34 @@ function validateRequest(request: unknown): request is LocalAIChatRequest { } } + if (request.agentHost !== undefined) { + if (!isRecord(request.agentHost)) return false; + if ( + !isValidIdentifier(request.agentHost.jobId) || + !isValidIdentifier(request.agentHost.taskId) || + (request.agentHost.channelKind !== "channel" && + request.agentHost.channelKind !== "dm") || + !isOptionalString(request.agentHost.roomContext, MAX_MESSAGE_CHARS) + ) { + return false; + } + const targets = request.agentHost.collaborationTargets; + if ( + targets !== undefined && + (!Array.isArray(targets) || + targets.length > 16 || + !targets.every( + (target) => + isRecord(target) && + isValidIdentifier(target.agentId) && + isValidIdentifier(target.memberId) && + target.memberId === `agent:${target.agentId}`, + )) + ) { + return false; + } + } + if (request.options !== undefined) { if (!isRecord(request.options)) return false; if (!isOptionalString(request.options.cwd, MAX_CWD_CHARS)) return false; diff --git a/packages/app/src/electron/agent-host/host.test.ts b/packages/app/src/electron/agent-host/host.test.ts index 24e6692a..70ce1925 100644 --- a/packages/app/src/electron/agent-host/host.test.ts +++ b/packages/app/src/electron/agent-host/host.test.ts @@ -353,4 +353,141 @@ describe("AgentHost", () => { host.redirectTask(job.taskId, "Change direction", "agent:b"), ).rejects.toThrow("not found for this agent"); }); + + it("delegates bounded child tasks, waits for Dexie result receipts, and replays idempotently", async () => { + const gates = new Map>>(); + const execute = vi.fn((job: AgentHostJob) => { + const gate = deferred(); + gates.set(job.id, gate); + return gate.promise; + }); + let nextId = 0; + const host = new AgentHost({ + repository: new InMemoryAgentHostJobRepository(), + executor: { execute }, + maxConcurrency: 3, + createId: () => `structured-${++nextId}`, + }); + const [source] = await host.enqueue(dispatch("c1", ["agent:a"])); + await vi.waitFor(async () => + expect((await host.listJobs())[0].status).toBe("running"), + ); + + const request = { + sourceJobId: source.id, + sourceTaskId: source.taskId, + callerMemberId: "agent:a", + idempotencyKey: "delegate-1", + inputHash: "same-input", + ttlSeconds: 60, + delegates: ["b", "c"].map((id) => ({ + target: { agentId: id, memberId: `agent:${id}` }, + brief: { + objective: `Research ${id}`, + acceptanceCriteria: ["Post evidence"], + contextMessageIds: [source.triggerMessageId], + outputContract: { format: "text" as const, description: "Findings" }, + }, + })), + }; + const created = await host.delegateTask(request); + const replay = await host.delegateTask(request); + expect(replay.operationId).toBe(created.operationId); + expect(replay.jobs.map((job) => job.id)).toEqual( + created.jobs.map((job) => job.id), + ); + await expect( + host.delegateTask({ ...request, inputHash: "changed-input" }), + ).rejects.toThrow("different input"); + await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(3)); + + const outcomePromise = host.waitForDelegation(created.operationId, { + strategy: "all", + cancelRemainingOnSatisfied: true, + timeoutMs: 5_000, + }); + for (const child of created.jobs) { + expect( + await host.recordOutput(child.id, `message:${child.agentId}`), + ).toBe(true); + gates.get(child.id)?.resolve(); + } + const outcome = await outcomePromise; + + expect(outcome.joinStatus).toBe("satisfied"); + expect(outcome.jobs).toEqual([ + expect.objectContaining({ + parentTaskId: source.taskId, + status: "completed", + outputMessageIds: ["message:b"], + }), + expect.objectContaining({ + parentTaskId: source.taskId, + status: "completed", + outputMessageIds: ["message:c"], + }), + ]); + expect((await host.listTasks("agent:a"))[0].id).toBe(source.taskId); + gates.get(source.id)?.resolve(); + }); + + it("hands off the stable task identity and removes control from the former owner", async () => { + const gates = new Map>>(); + const execute = vi.fn((job: AgentHostJob) => { + const gate = deferred(); + gates.set(job.id, gate); + return gate.promise; + }); + let nextId = 0; + const host = new AgentHost({ + repository: new InMemoryAgentHostJobRepository(), + executor: { execute }, + maxConcurrency: 2, + createId: () => `handoff-${++nextId}`, + }); + const [source] = await host.enqueue(dispatch("c1", ["agent:a"])); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + const request = { + sourceJobId: source.id, + sourceTaskId: source.taskId, + callerMemberId: "agent:a", + idempotencyKey: "handoff-1", + inputHash: "same-input", + target: { agentId: "b", memberId: "agent:b" }, + brief: { + objective: "Own the rest of the task", + acceptanceCriteria: ["Finish it"], + contextMessageIds: [], + outputContract: { format: "text" as const, description: "Result" }, + }, + }; + const committed = await host.handoffTask(request); + await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); + + expect(committed.job).toMatchObject({ + taskId: source.taskId, + parentJobId: source.id, + agentMemberId: "agent:b", + collaboration: { kind: "handoff", fromMemberId: "agent:a" }, + }); + expect(await host.listTasks("agent:a")).toEqual([]); + expect(await host.listTasks("agent:b")).toEqual([ + expect.objectContaining({ id: source.taskId, agentMemberId: "agent:b" }), + ]); + expect(await host.pauseTask(source.taskId, "agent:a")).toBe(false); + expect((await host.handoffTask(request)).job.id).toBe(committed.job.id); + await expect( + host.handoffTask({ + ...request, + sourceJobId: committed.job.id, + callerMemberId: "agent:b", + idempotencyKey: "handoff-back", + inputHash: "handoff-back", + target: { agentId: "a", memberId: "agent:a" }, + }), + ).rejects.toThrow("repeat an agent"); + + gates.get(committed.job.id)?.resolve(); + gates.get(source.id)?.resolve(); + }); }); diff --git a/packages/app/src/electron/agent-host/host.ts b/packages/app/src/electron/agent-host/host.ts index a5addd9a..a1869e9f 100644 --- a/packages/app/src/electron/agent-host/host.ts +++ b/packages/app/src/electron/agent-host/host.ts @@ -1,8 +1,11 @@ import { randomUUID } from "node:crypto"; import type { + AgentHostCollaboration, AgentHostDispatch, AgentHostEvent, AgentHostJob, + AgentHostStructuredTaskBrief, + AgentHostTarget, AgentHostTaskSummary, } from "@/shared/types/agent-host"; import type { AgentHostJobRepository } from "./repository"; @@ -24,6 +27,44 @@ export interface AgentHostOptions { startPaused?: boolean; } +export interface AgentHostDelegationRequest { + sourceJobId: string; + sourceTaskId: string; + callerMemberId: string; + idempotencyKey: string; + inputHash: string; + delegates: Array<{ + target: AgentHostTarget; + brief: AgentHostStructuredTaskBrief; + maxOutputTokens?: number; + }>; + ttlSeconds?: number; +} + +export interface AgentHostHandoffRequest { + sourceJobId: string; + sourceTaskId: string; + callerMemberId: string; + idempotencyKey: string; + inputHash: string; + target: AgentHostTarget; + brief: AgentHostStructuredTaskBrief; + ttlSeconds?: number; +} + +export interface AgentHostDelegationJoin { + strategy: "all" | "any" | "quorum"; + quorum?: number; + cancelRemainingOnSatisfied: boolean; + timeoutMs: number; +} + +export interface AgentHostDelegationOutcome { + operationId: string; + joinStatus: "satisfied" | "partial" | "expired"; + jobs: AgentHostJob[]; +} + type Listener = (event: AgentHostEvent) => void; const TERMINAL = new Set([ @@ -43,6 +84,7 @@ const MAX_CONTEXT_MESSAGES = 500; const MAX_DISPATCH_HOPS = 20; const MAX_CONTROL_INSTRUCTIONS = 100; const MAX_CONTROL_INSTRUCTION_LENGTH = 4_000; +const MAX_STRUCTURED_TASK_DEPTH = 4; /** * One colleague works on one thing at a time, wherever the work came from. @@ -126,7 +168,6 @@ export function summarizeAgentHostTasks( ): AgentHostTaskSummary[] { const grouped = new Map(); for (const job of jobs) { - if (agentMemberId && job.agentMemberId !== agentMemberId) continue; const runs = grouped.get(job.taskId); if (runs) runs.push(job); else grouped.set(job.taskId, [job]); @@ -144,6 +185,13 @@ export function summarizeAgentHostTasks( agentId: current.agentId, agentMemberId: current.agentMemberId, currentJobId: current.id, + parentTaskId: current.parentTaskId, + collaboration: current.collaboration + ? structuredClone(current.collaboration) + : undefined, + outputMessageIds: current.outputMessageIds + ? [...current.outputMessageIds] + : undefined, status: current.status, runCount: runs.length, controlInstructions: [...current.controlInstructions], @@ -154,6 +202,7 @@ export function summarizeAgentHostTasks( error: current.error, }; }) + .filter((task) => !agentMemberId || task.agentMemberId === agentMemberId) .sort( (left, right) => right.updatedAt.localeCompare(left.updatedAt) || @@ -288,6 +337,299 @@ export class AgentHost { return created; } + delegateTask(request: AgentHostDelegationRequest): Promise<{ + operationId: string; + jobs: AgentHostJob[]; + }> { + return this.serializeTaskControl(request.sourceTaskId, () => + this.delegateTaskNow(request), + ); + } + + private async delegateTaskNow(request: AgentHostDelegationRequest): Promise<{ + operationId: string; + jobs: AgentHostJob[]; + }> { + await this.ready; + const replay = this.findCollaboration( + "delegation", + request.sourceJobId, + request.idempotencyKey, + request.inputHash, + ); + if (replay.length > 0) { + return { + operationId: replay[0].collaboration?.operationId as string, + jobs: replay.map((job) => structuredClone(job)), + }; + } + const source = this.structuredTaskSource(request); + const sourcePath = source.collaboration?.path ?? [source.agentMemberId]; + const depth = (source.collaboration?.depth ?? 0) + 1; + if (depth > MAX_STRUCTURED_TASK_DEPTH) { + throw new Error( + `Structured task depth cannot exceed ${MAX_STRUCTURED_TASK_DEPTH}.`, + ); + } + for (const { target } of request.delegates) { + if (sourcePath.includes(target.memberId)) { + throw new Error( + `Delegating to ${target.memberId} would repeat an agent already in this task path.`, + ); + } + } + + const operationId = this.createId(); + const now = this.now().toISOString(); + const expiresAt = request.ttlSeconds + ? new Date( + new Date(now).getTime() + request.ttlSeconds * 1_000, + ).toISOString() + : undefined; + const offeredAgentMemberIds = request.delegates.map( + ({ target }) => target.memberId, + ); + const jobs: AgentHostJob[] = []; + for (const delegate of request.delegates) { + const id = this.createId(); + const collaboration: AgentHostCollaboration = { + kind: "delegation", + operationId, + idempotencyKey: request.idempotencyKey, + inputHash: request.inputHash, + sourceTaskId: source.taskId, + sourceJobId: source.id, + fromMemberId: source.agentMemberId, + depth, + path: [...sourcePath, delegate.target.memberId], + brief: structuredClone(delegate.brief), + expiresAt, + }; + const job: AgentHostJob = { + id, + taskId: id, + parentTaskId: source.taskId, + channelId: source.channelId, + channelKind: source.channelKind, + conversationId: source.conversationId, + triggerMessageId: source.triggerMessageId, + contextMessageIds: [...source.contextMessageIds], + mode: "direct", + offeredAgentMemberIds: [...offeredAgentMemberIds], + agentId: delegate.target.agentId, + agentMemberId: delegate.target.memberId, + chain: { + hops: source.chain.hops, + invoked: source.chain.invoked.includes(delegate.target.memberId) + ? [...source.chain.invoked] + : [...source.chain.invoked, delegate.target.memberId], + }, + controlInstructions: [], + collaboration, + outputMessageIds: [], + maxOutputTokens: delegate.maxOutputTokens, + status: "queued", + attempts: 0, + createdAt: now, + updatedAt: now, + }; + this.jobs.set(id, job); + await this.repository.put(job); + this.emit({ type: "job", job }); + jobs.push(structuredClone(job)); + } + this.scheduleDrain(); + return { operationId, jobs }; + } + + handoffTask(request: AgentHostHandoffRequest): Promise<{ + operationId: string; + job: AgentHostJob; + }> { + return this.serializeTaskControl(request.sourceTaskId, () => + this.handoffTaskNow(request), + ); + } + + private async handoffTaskNow(request: AgentHostHandoffRequest): Promise<{ + operationId: string; + job: AgentHostJob; + }> { + await this.ready; + const replay = this.findCollaboration( + "handoff", + request.sourceJobId, + request.idempotencyKey, + request.inputHash, + ); + if (replay.length > 0) { + return { + operationId: replay[0].collaboration?.operationId as string, + job: structuredClone(replay[0]), + }; + } + const source = this.structuredTaskSource(request); + const sourcePath = source.collaboration?.path ?? [source.agentMemberId]; + const depth = (source.collaboration?.depth ?? 0) + 1; + if (depth > MAX_STRUCTURED_TASK_DEPTH) { + throw new Error( + `Structured task depth cannot exceed ${MAX_STRUCTURED_TASK_DEPTH}.`, + ); + } + if (sourcePath.includes(request.target.memberId)) { + throw new Error( + `Handing off to ${request.target.memberId} would repeat an agent already in this task path.`, + ); + } + + const operationId = this.createId(); + const id = this.createId(); + const now = this.now().toISOString(); + const expiresAt = request.ttlSeconds + ? new Date( + new Date(now).getTime() + request.ttlSeconds * 1_000, + ).toISOString() + : undefined; + const collaboration: AgentHostCollaboration = { + kind: "handoff", + operationId, + idempotencyKey: request.idempotencyKey, + inputHash: request.inputHash, + sourceTaskId: source.taskId, + sourceJobId: source.id, + fromMemberId: source.agentMemberId, + depth, + path: [...sourcePath, request.target.memberId], + brief: structuredClone(request.brief), + expiresAt, + }; + const successor: AgentHostJob = { + id, + taskId: source.taskId, + parentTaskId: source.parentTaskId, + parentJobId: source.id, + channelId: source.channelId, + channelKind: source.channelKind, + conversationId: source.conversationId, + triggerMessageId: source.triggerMessageId, + contextMessageIds: [...source.contextMessageIds], + mode: "direct", + offeredAgentMemberIds: [request.target.memberId], + agentId: request.target.agentId, + agentMemberId: request.target.memberId, + chain: { + hops: source.chain.hops, + invoked: source.chain.invoked.includes(request.target.memberId) + ? [...source.chain.invoked] + : [...source.chain.invoked, request.target.memberId], + }, + controlInstructions: [...source.controlInstructions], + collaboration, + outputMessageIds: [], + maxOutputTokens: source.maxOutputTokens, + status: "queued", + attempts: 0, + createdAt: now, + updatedAt: now, + }; + this.jobs.set(id, successor); + await this.repository.put(successor); + this.emit({ type: "job", job: successor }); + this.scheduleDrain(); + return { operationId, job: structuredClone(successor) }; + } + + async recordOutput(jobId: string, messageId: string): Promise { + await this.ready; + if (!IDENTIFIER.test(messageId)) return false; + const job = this.jobs.get(jobId); + if (!job || TERMINAL.has(job.status)) return false; + const outputMessageIds = job.outputMessageIds ?? []; + if (outputMessageIds.includes(messageId)) return true; + job.outputMessageIds = [...outputMessageIds, messageId]; + job.updatedAt = this.now().toISOString(); + await this.repository.put(job); + this.emit({ type: "job", job }); + return true; + } + + async waitForDelegation( + operationId: string, + join: AgentHostDelegationJoin, + ): Promise { + await this.ready; + const jobsForOperation = () => + this.currentCollaborationJobs( + [...this.jobs.values()].filter( + (job) => + job.collaboration?.kind === "delegation" && + job.collaboration.operationId === operationId, + ), + ); + if (jobsForOperation().length === 0) { + throw new Error(`Delegation ${operationId} was not found.`); + } + const needed = + join.strategy === "all" + ? jobsForOperation().length + : join.strategy === "any" + ? 1 + : (join.quorum ?? 1); + + const decision = (): "satisfied" | "partial" | undefined => { + const jobs = jobsForOperation(); + const completed = jobs.filter((job) => job.status === "completed").length; + const pending = jobs.filter((job) => !TERMINAL.has(job.status)).length; + if (completed >= needed) return "satisfied"; + if (completed + pending < needed) return "partial"; + return undefined; + }; + + let dispose: () => void = () => undefined; + let timer: ReturnType | undefined; + const joinStatus = await new Promise<"satisfied" | "partial" | "expired">( + (resolve) => { + let settled = false; + const finish = (status: "satisfied" | "partial" | "expired") => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + dispose(); + resolve(status); + }; + const check = () => { + const status = decision(); + if (status) finish(status); + }; + dispose = this.subscribe((event) => { + if ( + event.type === "job" && + event.job.collaboration?.operationId === operationId + ) { + check(); + } + }); + timer = setTimeout(() => finish("expired"), join.timeoutMs); + check(); + }, + ); + + const shouldCancel = + joinStatus !== "satisfied" || join.cancelRemainingOnSatisfied; + if (shouldCancel) { + await Promise.all( + jobsForOperation() + .filter((job) => !TERMINAL.has(job.status)) + .map((job) => this.cancel(job.id)), + ); + } + return { + operationId, + joinStatus, + jobs: jobsForOperation().map((job) => structuredClone(job)), + }; + } + async cancel(jobId: string): Promise { await this.ready; const job = this.jobs.get(jobId); @@ -415,6 +757,7 @@ export class AgentHost { const successor: AgentHostJob = { id, taskId: source.taskId, + parentTaskId: source.parentTaskId, parentJobId: source.id, channelId: source.channelId, channelKind: source.channelKind, @@ -427,6 +770,13 @@ export class AgentHost { agentMemberId: source.agentMemberId, chain: structuredClone(source.chain), controlInstructions: [...source.controlInstructions, normalized], + collaboration: source.collaboration + ? structuredClone(source.collaboration) + : undefined, + outputMessageIds: source.outputMessageIds + ? [...source.outputMessageIds] + : undefined, + maxOutputTokens: source.maxOutputTokens, status: "queued", attempts: 0, createdAt: now, @@ -460,12 +810,66 @@ export class AgentHost { taskId: string, agentMemberId?: string, ): AgentHostJob | undefined { - const jobs = [...this.jobs.values()].filter( + const jobs = [...this.jobs.values()].filter((job) => job.taskId === taskId); + const current = jobs.length > 0 ? currentJob(jobs) : undefined; + return current && + (!agentMemberId || current.agentMemberId === agentMemberId) + ? current + : undefined; + } + + private structuredTaskSource(request: { + sourceJobId: string; + sourceTaskId: string; + callerMemberId: string; + }): AgentHostJob { + const source = this.jobs.get(request.sourceJobId); + const current = this.taskCurrentJob( + request.sourceTaskId, + request.callerMemberId, + ); + if ( + !source || + source !== current || + source.taskId !== request.sourceTaskId || + source.agentMemberId !== request.callerMemberId || + source.status !== "running" + ) { + throw new Error( + "The structured task caller no longer owns the current running task.", + ); + } + return source; + } + + private findCollaboration( + kind: AgentHostCollaboration["kind"], + sourceJobId: string, + idempotencyKey: string, + inputHash: string, + ): AgentHostJob[] { + const matches = [...this.jobs.values()].filter( (job) => - job.taskId === taskId && - (!agentMemberId || job.agentMemberId === agentMemberId), + job.collaboration?.kind === kind && + job.collaboration.sourceJobId === sourceJobId && + job.collaboration.idempotencyKey === idempotencyKey, ); - return jobs.length > 0 ? currentJob(jobs) : undefined; + if (matches.some((job) => job.collaboration?.inputHash !== inputHash)) { + throw new Error( + `Idempotency key ${idempotencyKey} was already used with different input.`, + ); + } + return this.currentCollaborationJobs(matches).sort(byCreation); + } + + private currentCollaborationJobs(jobs: AgentHostJob[]): AgentHostJob[] { + const byTask = new Map(); + for (const job of jobs) { + const runs = byTask.get(job.taskId); + if (runs) runs.push(job); + else byTask.set(job.taskId, [job]); + } + return [...byTask.values()].map(currentJob); } private async serializeTaskControl( diff --git a/packages/app/src/electron/agent-host/repository.test.ts b/packages/app/src/electron/agent-host/repository.test.ts index 44bbb17f..b582b15d 100644 --- a/packages/app/src/electron/agent-host/repository.test.ts +++ b/packages/app/src/electron/agent-host/repository.test.ts @@ -53,6 +53,40 @@ describe("JsonAgentHostJobRepository", () => { expect(await repository.list()).toEqual([job("1", "completed")]); }); + it("persists structured task provenance and Dexie result receipts", async () => { + const directory = await mkdtemp(join(tmpdir(), "convera-agent-host-")); + temporaryDirectories.push(directory); + const repository = new JsonAgentHostJobRepository({ + path: join(directory, "jobs.json"), + }); + const delegated: AgentHostJob = { + ...job("1", "completed"), + parentTaskId: "parent-task", + outputMessageIds: ["message-result"], + maxOutputTokens: 2_000, + collaboration: { + kind: "delegation", + operationId: "delegation-1", + idempotencyKey: "delegate-1", + inputHash: "hash-1", + sourceTaskId: "parent-task", + sourceJobId: "parent-job", + fromMemberId: "agent:planner", + depth: 1, + path: ["agent:planner", "agent:a"], + brief: { + objective: "Review the implementation", + acceptanceCriteria: ["Post blockers"], + contextMessageIds: ["message-1"], + outputContract: { format: "text", description: "Review" }, + }, + }, + }; + + await repository.put(delegated); + expect(await repository.list()).toEqual([delegated]); + }); + it("prunes only the oldest terminal jobs", async () => { const directory = await mkdtemp(join(tmpdir(), "convera-agent-host-")); temporaryDirectories.push(directory); diff --git a/packages/app/src/electron/agent-host/repository.ts b/packages/app/src/electron/agent-host/repository.ts index c7b7d8c3..cd827b5d 100644 --- a/packages/app/src/electron/agent-host/repository.ts +++ b/packages/app/src/electron/agent-host/repository.ts @@ -25,6 +25,31 @@ const statusSchema = z.enum([ "interrupted", ]); +const structuredTaskBriefSchema = z.object({ + objective: z.string().min(1).max(4_000), + acceptanceCriteria: z.array(z.string().min(1).max(1_000)).min(1).max(16), + contextMessageIds: z.array(z.string().min(1).max(256)).max(64), + outputContract: z.object({ + format: z.enum(["text", "json", "artifact"]), + description: z.string().min(1).max(4_000), + resultSchema: z.record(z.unknown()).optional(), + }), +}); + +const collaborationSchema = z.object({ + kind: z.enum(["delegation", "handoff"]), + operationId: z.string().min(1).max(256), + idempotencyKey: z.string().min(1).max(128), + inputHash: z.string().min(1).max(128), + sourceTaskId: z.string().min(1).max(256), + sourceJobId: z.string().min(1).max(256), + fromMemberId: z.string().min(1).max(256), + depth: z.number().int().min(1).max(4), + path: z.array(z.string().min(1).max(256)).min(2).max(5), + brief: structuredTaskBriefSchema, + expiresAt: z.string().datetime().optional(), +}); + const legacyJobSchema = z.object({ id: z.string().min(1), channelId: z.string().min(1), @@ -52,9 +77,13 @@ const versionTwoJobSchema = legacyJobSchema.extend({ const jobSchema = versionTwoJobSchema.extend({ taskId: z.string().min(1), + parentTaskId: z.string().min(1).optional(), parentJobId: z.string().min(1).optional(), channelKind: z.enum(["channel", "dm"]), controlInstructions: z.array(z.string().min(1).max(4_000)).max(100), + collaboration: collaborationSchema.optional(), + outputMessageIds: z.array(z.string().min(1).max(256)).max(100).optional(), + maxOutputTokens: z.number().int().min(1).max(1_000_000).optional(), }); const legacyStateSchema = z.object({ diff --git a/packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts b/packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts index 66014f8d..7c23f70f 100644 --- a/packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts +++ b/packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts @@ -23,7 +23,10 @@ const task: AgentHostTaskSummary = { updatedAt: new Date().toISOString(), }; -function input(channelKind: "channel" | "dm"): LocalAiTurnHookInput { +function input( + channelKind: "channel" | "dm", + collaborationTargets?: Array<{ agentId: string; memberId: string }>, +): LocalAiTurnHookInput { return { request: { requestId: "request", @@ -35,7 +38,12 @@ function input(channelKind: "channel" | "dm"): LocalAiTurnHookInput { message: { role: "user", content: "Control the task" }, }, agent: { id: "fizz", memberId: "agent:fizz" }, - agentHost: { jobId: "dm-job", taskId: "dm-task", channelKind }, + agentHost: { + jobId: "dm-job", + taskId: "dm-task", + channelKind, + collaborationTargets, + }, }, prepared: {} as LocalAiTurnHookInput["prepared"], requestInteraction: async () => ({}), @@ -154,4 +162,160 @@ describe("Agent Host task tools", () => { "agent:fizz", ); }); + + it("registers distinct delegate and handoff tools only with another channel colleague", async () => { + const host = { + listTasks: vi.fn(async () => []), + } as unknown as AgentHost; + const hooks = withAgentHostTools({}, () => host); + const channel = await hooks.prepareTurnContext?.( + input("channel", [ + { agentId: "fizz", memberId: "agent:fizz" }, + { agentId: "reviewer", memberId: "agent:reviewer" }, + ]), + ); + const dm = await hooks.prepareTurnContext?.( + input("dm", [ + { agentId: "fizz", memberId: "agent:fizz" }, + { agentId: "reviewer", memberId: "agent:reviewer" }, + ]), + ); + + expect(channel?.additionalTools?.map((tool) => tool.qualifiedName)).toEqual( + ["task:manage_task", "task:delegate_task", "task:handoff_task"], + ); + expect(channel?.systemContext).toContain("bounded specialist work"); + expect(dm?.additionalTools?.map((tool) => tool.qualifiedName)).toEqual([ + "task:manage_task", + ]); + }); + + it("executes delegation through Host and returns final Dexie message receipts", async () => { + const child = { + id: "child-job", + taskId: "child-task", + agentId: "reviewer", + agentMemberId: "agent:reviewer", + status: "completed", + outputMessageIds: ["message-result"], + collaboration: { expiresAt: new Date(Date.now() + 60_000).toISOString() }, + } as AgentHostJob; + const host = { + listTasks: vi.fn(async () => []), + delegateTask: vi.fn(async () => ({ + operationId: "delegation-1", + jobs: [child], + })), + waitForDelegation: vi.fn(async () => ({ + operationId: "delegation-1", + joinStatus: "satisfied", + jobs: [child], + })), + } as unknown as AgentHost; + const prepared = await withAgentHostTools( + {}, + () => host, + ).prepareTurnContext?.( + input("channel", [ + { agentId: "fizz", memberId: "agent:fizz" }, + { agentId: "reviewer", memberId: "agent:reviewer" }, + ]), + ); + const tool = prepared?.additionalTools?.find( + (candidate) => candidate.qualifiedName === "task:delegate_task", + ); + + expect( + await tool?.execute({ + idempotency_key: "delegate-1", + delegates: [ + { + assignee_member_id: "agent:reviewer", + objective: "Review the implementation", + acceptance_criteria: ["Report blockers"], + context_refs: [{ kind: "message", message_id: "message-1" }], + output_contract: { + format: "text", + description: "Post a concise review", + }, + }, + ], + join: { strategy: "all" }, + ttl_seconds: 60, + }), + ).toMatchObject({ + ok: true, + delegation_id: "delegation-1", + join_status: "satisfied", + child_tasks: [ + { + task_id: "child-task", + status: "completed", + result_message_ids: ["message-result"], + }, + ], + }); + expect(host.delegateTask).toHaveBeenCalledWith( + expect.objectContaining({ + sourceJobId: "dm-job", + sourceTaskId: "dm-task", + callerMemberId: "agent:fizz", + delegates: [ + expect.objectContaining({ + target: { agentId: "reviewer", memberId: "agent:reviewer" }, + }), + ], + }), + ); + }); + + it("commits an authorized handoff and refuses unsupported receiver acceptance", async () => { + const successor = { + id: "successor", + taskId: "dm-task", + agentId: "reviewer", + agentMemberId: "agent:reviewer", + status: "queued", + } as AgentHostJob; + const host = { + listTasks: vi.fn(async () => []), + handoffTask: vi.fn(async () => ({ + operationId: "handoff-1", + job: successor, + })), + } as unknown as AgentHost; + const prepared = await withAgentHostTools( + {}, + () => host, + ).prepareTurnContext?.( + input("channel", [ + { agentId: "fizz", memberId: "agent:fizz" }, + { agentId: "reviewer", memberId: "agent:reviewer" }, + ]), + ); + const tool = prepared?.additionalTools?.find( + (candidate) => candidate.qualifiedName === "task:handoff_task", + ); + const base = { + idempotency_key: "handoff-1", + to_member_id: "agent:reviewer", + reason: "The reviewer should own the final decision.", + }; + + expect( + await tool?.execute({ ...base, acceptance: "required" }), + ).toMatchObject({ + ok: false, + error: { code: "HANDOFF_ACCEPTANCE_UNAVAILABLE" }, + }); + expect(await tool?.execute(base)).toEqual({ + ok: true, + handoff_id: "handoff-1", + task_id: "dm-task", + from_member_id: "agent:fizz", + to_member_id: "agent:reviewer", + status: "committed", + }); + expect(host.handoffTask).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/app/src/electron/ai/__tests__/structured-task-contracts.test.ts b/packages/app/src/electron/ai/__tests__/structured-task-contracts.test.ts new file mode 100644 index 00000000..ecdf5121 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/structured-task-contracts.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import { + delegateTaskInputSchema, + delegateTaskJsonSchema, + handoffTaskInputSchema, + handoffTaskJsonSchema, + trustedStructuredTaskContext, +} from "../structured-task-contracts"; + +function request(): LocalAIChatRequest { + return { + requestId: "request-1", + conversationId: "conversation-1", + turnId: "turn-1", + providerId: "codex-cli", + operation: { + kind: "append", + message: { role: "user", content: "Delegate the research." }, + }, + agent: { id: "fizz", memberId: "agent:fizz" }, + agentHost: { + jobId: "job-1", + taskId: "task-1", + channelKind: "channel", + collaborationTargets: [ + { agentId: "fizz", memberId: "agent:fizz" }, + { agentId: "reviewer", memberId: "agent:reviewer" }, + ], + roomContext: "Private per-turn context that must not enter the contract.", + }, + }; +} + +const outputContract = { + format: "json" as const, + description: "Return findings with citations.", + result_schema: { + type: "object", + required: ["findings"], + }, +}; + +describe("structured task tool contracts", () => { + it("accepts a bounded parallel delegation and applies join defaults", () => { + const parsed = delegateTaskInputSchema.parse({ + idempotency_key: "task-1:research:v1", + delegates: [ + { + assignee_member_id: "agent:researcher", + objective: "Find the primary sources.", + acceptance_criteria: ["At least two official sources"], + context_refs: [{ kind: "message", message_id: "message-1" }], + output_contract: outputContract, + }, + { + assignee_member_id: "agent:reviewer", + objective: "Review the claims.", + acceptance_criteria: ["Identify unsupported claims"], + output_contract: outputContract, + }, + ], + join: { strategy: "quorum", quorum: 2 }, + ttl_seconds: 900, + }); + + expect(parsed.join.cancel_remaining_on_satisfied).toBe(true); + expect(parsed.delegates).toHaveLength(2); + }); + + it("rejects invalid joins, duplicate assignees, and caller-controlled identity", () => { + const base = { + idempotency_key: "delegation-1", + delegates: [ + { + assignee_member_id: "agent:researcher", + objective: "Research it.", + acceptance_criteria: ["Return evidence"], + output_contract: outputContract, + }, + ], + }; + + expect(() => + delegateTaskInputSchema.parse({ + ...base, + delegates: [...base.delegates, ...base.delegates], + join: { strategy: "all" }, + }), + ).toThrow("Each assignee may appear only once"); + expect(() => + delegateTaskInputSchema.parse({ + ...base, + join: { strategy: "quorum", quorum: 2 }, + }), + ).toThrow("Quorum cannot exceed"); + expect(() => + delegateTaskInputSchema.parse({ + ...base, + join: { strategy: "all" }, + task_id: "some-other-task", + }), + ).toThrow(); + }); + + it("keeps handoff intent distinct from task identity and defaults to acceptance", () => { + const parsed = handoffTaskInputSchema.parse({ + idempotency_key: "task-1:handoff:v1", + to_member_id: "agent:reviewer", + reason: "The task now needs a security owner.", + context_refs: [{ kind: "artifact", artifact_id: "artifact-1" }], + }); + + expect(parsed.acceptance).toBe("auto_if_authorized"); + expect(parsed).not.toHaveProperty("task_id"); + expect(() => + handoffTaskInputSchema.parse({ + ...parsed, + to_member_id: "human:owner", + }), + ).toThrow("Expected an agent member id"); + }); + + it("publishes provider schemas without trusted identity fields", () => { + for (const schema of [delegateTaskJsonSchema, handoffTaskJsonSchema]) { + const serialized = JSON.stringify(schema); + expect(serialized).not.toContain("task_id"); + expect(serialized).not.toContain("job_id"); + expect(serialized).not.toContain("callerMemberId"); + expect(schema).toMatchObject({ type: "object" }); + } + }); +}); + +describe("trusted structured task context", () => { + it("derives immutable ownership context from the Host-bound request", () => { + const context = trustedStructuredTaskContext(request()); + + expect(context).toEqual({ + contractVersion: 1, + taskId: "task-1", + jobId: "job-1", + conversationId: "conversation-1", + channelKind: "channel", + callerAgentId: "fizz", + callerMemberId: "agent:fizz", + targets: [ + { agentId: "fizz", memberId: "agent:fizz" }, + { agentId: "reviewer", memberId: "agent:reviewer" }, + ], + }); + expect(Object.isFrozen(context)).toBe(true); + expect(Object.isFrozen(context?.targets)).toBe(true); + expect(JSON.stringify(context)).not.toContain("roomContext"); + }); + + it("is unavailable outside a complete Agent Host execution identity", () => { + const plain = request(); + plain.agentHost = undefined; + expect(trustedStructuredTaskContext(plain)).toBeUndefined(); + + const anonymous = request(); + anonymous.agent = undefined; + expect(trustedStructuredTaskContext(anonymous)).toBeUndefined(); + + const human = request(); + human.agent = { id: "human", memberId: "human:owner" }; + expect(trustedStructuredTaskContext(human)).toBeUndefined(); + }); +}); diff --git a/packages/app/src/electron/ai/agent-host-tools.ts b/packages/app/src/electron/ai/agent-host-tools.ts index df533c9b..51437ab3 100644 --- a/packages/app/src/electron/ai/agent-host-tools.ts +++ b/packages/app/src/electron/ai/agent-host-tools.ts @@ -1,6 +1,22 @@ +import { createHash } from "node:crypto"; import type { AgentHost } from "@/electron/agent-host/host"; -import type { AgentTool } from "./agent-tools"; +import { shapeForSchema, type AgentTool } from "./agent-tools"; import type { LocalAiTurnHooks, PreparedLocalAiTurnContext } from "./runtime"; +import { + delegateTaskInputSchema, + delegateTaskJsonSchema, + handoffTaskInputSchema, + handoffTaskJsonSchema, + trustedStructuredTaskContext, + type DelegateTaskInput, + type DelegateTaskToolResult, + type HandoffTaskInput, + type HandoffTaskToolResult, + type StructuredTaskContextRef, + type StructuredTaskOutputContract, + type StructuredTaskToolFailure, + type TrustedStructuredTaskContext, +} from "./structured-task-contracts"; import { z } from "zod"; const actionSchema = z.enum([ @@ -60,7 +76,11 @@ function jsonSchema(): Record { }; } -function failure(code: string, message: string, recovery: string) { +function failure( + code: string, + message: string, + recovery: string, +): StructuredTaskToolFailure { return { ok: false, error: { code, message, recovery } }; } @@ -70,6 +90,12 @@ const CONTROL_DESCRIPTION = const READ_ONLY_DESCRIPTION = "Look up this agent's own background tasks. Use list to see every task and inspect for one task's detail, including its run count and latest guidance. Only list and inspect work here: pause, resume, cancel, and redirect are refused outside the agent's direct conversation with the user, because lifecycle changes carry the user's private guidance and one of the listed tasks is the run currently speaking."; +const DELEGATE_DESCRIPTION = + "Delegate one or more bounded, independent subtasks to other agents in this channel while retaining ownership of the current task. Use workspace:read_channel first when you need exact agent member ids. The call waits for the requested all/any/quorum join and returns each child task's final status plus Dexie message ids containing its posted result. Use this for parallel specialist work that you will integrate; do not use it to transfer the whole task. Artifact and cross-agent memory references are refused until an authoritative resolver exists. Errors include recovery guidance and never mean work was created unless ok is true."; + +const HANDOFF_DESCRIPTION = + "Transfer ownership of the entire current task to one other agent in this channel. Use workspace:read_channel first when you need the exact member id. This is not delegation: on ok=true the successor keeps the same task_id, the caller is no longer the owner, and the caller should stop working without posting a completion message. Receiver-acceptance workflows are not available without the Task DAG adapter, so use acceptance=auto_if_authorized; acceptance=required returns an actionable error. The result identifies the committed successor run."; + function taskTool( host: AgentHost, agentMemberId: string, @@ -177,6 +203,243 @@ function taskTool( }; } +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +function inputHash(kind: "delegate" | "handoff", input: unknown): string { + return createHash("sha256") + .update(`${kind}:${canonicalJson(input)}`) + .digest("hex"); +} + +function resolvedTarget( + context: TrustedStructuredTaskContext, + memberId: string, +) { + return context.targets.find( + (target) => + target.memberId === memberId && + target.memberId !== context.callerMemberId, + ); +} + +function contextMessageIds(refs: StructuredTaskContextRef[] | undefined) { + return (refs ?? []).flatMap((ref) => + ref.kind === "message" ? [ref.message_id] : [], + ); +} + +function unsupportedContract( + refs: StructuredTaskContextRef[] | undefined, + output?: StructuredTaskOutputContract, +) { + const unsupportedRef = refs?.find((ref) => ref.kind !== "message"); + if (unsupportedRef) { + return failure( + "CONTEXT_REFERENCE_UNAVAILABLE", + `${unsupportedRef.kind} references cannot be resolved across agent sandboxes yet.`, + "Retry with message references only, or post the necessary content in the shared channel before delegating.", + ); + } + if ( + output?.format === "artifact" || + (output?.required_artifact_kinds?.length ?? 0) > 0 + ) { + return failure( + "ARTIFACT_OUTPUT_UNAVAILABLE", + "Delegated artifact output needs an authoritative artifact registry, which is not wired yet.", + "Retry with output_contract.format=text or json and have the worker post its result to the channel.", + ); + } + return undefined; +} + +function structuredTaskFailure(code: string, error: unknown, recovery: string) { + return failure( + code, + error instanceof Error ? error.message : String(error), + recovery, + ); +} + +function delegateTaskTool( + host: AgentHost, + context: TrustedStructuredTaskContext, +): AgentTool { + return { + name: "delegate_task", + qualifiedName: "task:delegate_task", + description: DELEGATE_DESCRIPTION, + inputSchema: delegateTaskJsonSchema, + inputShape: shapeForSchema(delegateTaskJsonSchema), + inputValidator: delegateTaskInputSchema, + execute: async (raw): Promise => { + const input = delegateTaskInputSchema.parse(raw) as DelegateTaskInput; + for (const delegate of input.delegates) { + if (!resolvedTarget(context, delegate.assignee_member_id)) { + return failure( + "TARGET_NOT_AVAILABLE", + `${delegate.assignee_member_id} is not another runnable agent in this channel.`, + "Call workspace:read_channel, choose an agent member_id from this channel's roster, and retry.", + ); + } + const unsupported = unsupportedContract( + delegate.context_refs, + delegate.output_contract, + ); + if (unsupported) return unsupported; + if ( + delegate.budget?.max_turns !== undefined || + delegate.budget?.max_tool_calls !== undefined + ) { + return failure( + "BUDGET_LIMIT_UNAVAILABLE", + "This runtime can enforce max_output_tokens but not per-child max_turns or max_tool_calls.", + "Retry without max_turns/max_tool_calls, or wait for the Task DAG budget adapter.", + ); + } + } + + try { + const created = await host.delegateTask({ + sourceJobId: context.jobId, + sourceTaskId: context.taskId, + callerMemberId: context.callerMemberId, + idempotencyKey: input.idempotency_key, + inputHash: inputHash("delegate", input), + ttlSeconds: input.ttl_seconds ?? 300, + delegates: input.delegates.map((delegate) => ({ + target: resolvedTarget(context, delegate.assignee_member_id) as { + agentId: string; + memberId: string; + }, + brief: { + objective: delegate.objective, + acceptanceCriteria: [...delegate.acceptance_criteria], + contextMessageIds: contextMessageIds(delegate.context_refs), + outputContract: { + format: delegate.output_contract.format, + description: delegate.output_contract.description, + resultSchema: delegate.output_contract.result_schema, + }, + }, + maxOutputTokens: delegate.budget?.max_output_tokens, + })), + }); + const expiresAt = created.jobs[0]?.collaboration?.expiresAt; + const remainingMs = expiresAt + ? Math.max(0, new Date(expiresAt).getTime() - Date.now()) + : (input.ttl_seconds ?? 300) * 1_000; + const outcome = await host.waitForDelegation(created.operationId, { + strategy: input.join.strategy, + quorum: input.join.quorum, + cancelRemainingOnSatisfied: input.join.cancel_remaining_on_satisfied, + timeoutMs: remainingMs, + }); + return { + ok: true, + delegation_id: outcome.operationId, + parent_task_id: context.taskId, + child_tasks: outcome.jobs.map((job) => ({ + task_id: job.taskId, + assignee_member_id: job.agentMemberId, + status: job.status, + result_message_ids: [...(job.outputMessageIds ?? [])], + ...(job.error ? { error: job.error } : {}), + })), + join_status: outcome.joinStatus, + }; + } catch (error) { + return structuredTaskFailure( + "DELEGATION_FAILED", + error, + "Check that the current task is still running, use a fresh idempotency_key only for changed input, and retry once.", + ); + } + }, + }; +} + +function handoffTaskTool( + host: AgentHost, + context: TrustedStructuredTaskContext, +): AgentTool { + return { + name: "handoff_task", + qualifiedName: "task:handoff_task", + description: HANDOFF_DESCRIPTION, + inputSchema: handoffTaskJsonSchema, + inputShape: shapeForSchema(handoffTaskJsonSchema), + inputValidator: handoffTaskInputSchema, + execute: async (raw): Promise => { + const input = handoffTaskInputSchema.parse(raw) as HandoffTaskInput; + const target = resolvedTarget(context, input.to_member_id); + if (!target) { + return failure( + "TARGET_NOT_AVAILABLE", + `${input.to_member_id} is not another runnable agent in this channel.`, + "Call workspace:read_channel, choose an agent member_id from this channel's roster, and retry.", + ); + } + const unsupported = unsupportedContract(input.context_refs); + if (unsupported) return unsupported; + if (input.acceptance === "required") { + return failure( + "HANDOFF_ACCEPTANCE_UNAVAILABLE", + "Receiver acceptance requires the Task DAG adapter and cannot be represented by Agent Host alone.", + "Retry with acceptance=auto_if_authorized only if transferring this current task to that channel colleague is intended.", + ); + } + + try { + const result = await host.handoffTask({ + sourceJobId: context.jobId, + sourceTaskId: context.taskId, + callerMemberId: context.callerMemberId, + idempotencyKey: input.idempotency_key, + inputHash: inputHash("handoff", input), + target, + ttlSeconds: input.ttl_seconds ?? 300, + brief: { + objective: `Continue ownership of task ${context.taskId}.`, + acceptanceCriteria: [input.reason], + contextMessageIds: contextMessageIds(input.context_refs), + outputContract: { + format: "text", + description: + "Complete the transferred task and post the result in the current channel.", + }, + }, + }); + return { + ok: true, + handoff_id: result.operationId, + task_id: result.job.taskId, + from_member_id: context.callerMemberId, + to_member_id: result.job.agentMemberId, + status: "committed", + }; + } catch (error) { + return structuredTaskFailure( + "HANDOFF_FAILED", + error, + "Check that the current task is still running, use a fresh idempotency_key only for changed input, and retry once.", + ); + } + }, + }; +} + export function withAgentHostTools( hooks: LocalAiTurnHooks, getHost: () => AgentHost | undefined, @@ -192,6 +455,13 @@ export function withAgentHostTools( const channelKind = input.request.agentHost?.channelKind; if (!memberId || !host || !channelKind) return prepared; const canControl = channelKind === "dm"; + const structuredContext = trustedStructuredTaskContext(input.request); + const canCollaborate = + channelKind === "channel" && + structuredContext !== undefined && + structuredContext.targets.some( + (target) => target.memberId !== structuredContext.callerMemberId, + ); return { ...prepared, systemContext: [ @@ -203,12 +473,21 @@ export function withAgentHostTools( canControl ? "This is your private direct conversation with the user. You may inspect and control your background channel tasks with task:manage_task. Work state comes from that tool; do not guess from chat history." : "You may look up your own background tasks with task:manage_task (list and inspect only). Work state comes from that tool; do not guess from chat history.", + canCollaborate + ? "For bounded specialist work, task:delegate_task keeps you in control and waits for results. For a complete ownership transfer, task:handoff_task keeps the task id but makes the recipient the owner; after a successful handoff, stop working on that task. Use workspace:read_channel to obtain exact colleague member ids." + : undefined, ] .filter(Boolean) .join("\n\n"), additionalTools: [ ...(prepared?.additionalTools ?? []), taskTool(host, memberId, canControl), + ...(canCollaborate && structuredContext + ? [ + delegateTaskTool(host, structuredContext), + handoffTaskTool(host, structuredContext), + ] + : []), ], }; }, diff --git a/packages/app/src/electron/ai/structured-task-contracts.ts b/packages/app/src/electron/ai/structured-task-contracts.ts new file mode 100644 index 00000000..1e9e40bb --- /dev/null +++ b/packages/app/src/electron/ai/structured-task-contracts.ts @@ -0,0 +1,296 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +export const STRUCTURED_TASK_CONTRACT_VERSION = 1 as const; + +const opaqueIdSchema = z.string().trim().min(1).max(256); +const agentMemberIdSchema = opaqueIdSchema.refine( + (value) => value.startsWith("agent:"), + "Expected an agent member id.", +); +const idempotencyKeySchema = z + .string() + .trim() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9._:-]+$/) + .describe( + "Stable key for this exact request. Reuse it only when retrying the same operation with the same input.", + ); + +export const structuredTaskContextRefSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("message"), + message_id: opaqueIdSchema.describe( + "Workspace message id. Visibility is checked when the task system resolves it.", + ), + }) + .strict(), + z + .object({ + kind: z.literal("artifact"), + artifact_id: opaqueIdSchema.describe( + "Opaque artifact id already available to the calling agent.", + ), + }) + .strict(), + z + .object({ + kind: z.literal("memory"), + memory_id: opaqueIdSchema.describe( + "Opaque, explicitly shareable memory reference; never a raw filesystem path.", + ), + }) + .strict(), +]); + +export const structuredTaskOutputContractSchema = z + .object({ + format: z + .enum(["text", "json", "artifact"]) + .describe("Primary form the worker must return."), + description: z + .string() + .trim() + .min(1) + .max(4_000) + .describe("Concrete requirements for a result to count as complete."), + result_schema: z + .record(z.unknown()) + .refine((value) => JSON.stringify(value).length <= 16_000, { + message: "Result schema must serialize to at most 16000 characters.", + }) + .optional() + .describe( + "Optional JSON Schema-like object for structured output. It is data, not an instruction to the task runtime.", + ), + required_artifact_kinds: z + .array(z.string().trim().min(1).max(64)) + .max(16) + .optional() + .describe("Artifact kinds that must accompany a successful result."), + }) + .strict(); + +const structuredTaskBudgetSchema = z + .object({ + max_turns: z.number().int().min(1).max(32).optional(), + max_tool_calls: z.number().int().min(1).max(200).optional(), + max_output_tokens: z.number().int().min(1).max(1_000_000).optional(), + }) + .strict(); + +const delegateSpecSchema = z + .object({ + assignee_member_id: agentMemberIdSchema.describe( + "Member id of the colleague who should own this child task.", + ), + objective: z.string().trim().min(1).max(4_000), + acceptance_criteria: z + .array(z.string().trim().min(1).max(1_000)) + .min(1) + .max(16), + context_refs: z.array(structuredTaskContextRefSchema).max(64).optional(), + output_contract: structuredTaskOutputContractSchema, + budget: structuredTaskBudgetSchema.optional(), + }) + .strict(); + +const joinPolicySchema = z + .object({ + strategy: z.enum(["all", "any", "quorum"]), + quorum: z.number().int().min(1).optional(), + cancel_remaining_on_satisfied: z.boolean().default(true), + }) + .strict(); + +export const delegateTaskInputSchema = z + .object({ + idempotency_key: idempotencyKeySchema, + delegates: z.array(delegateSpecSchema).min(1).max(4), + join: joinPolicySchema, + ttl_seconds: z.number().int().min(30).max(3_600).optional(), + }) + .strict() + .superRefine((input, context) => { + const assignees = input.delegates.map( + (delegate) => delegate.assignee_member_id, + ); + if (new Set(assignees).size !== assignees.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["delegates"], + message: "Each assignee may appear only once in one delegation call.", + }); + } + + if (input.join.strategy === "quorum") { + if (input.join.quorum === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["join", "quorum"], + message: "A quorum join requires quorum.", + }); + } else if (input.join.quorum > input.delegates.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["join", "quorum"], + message: "Quorum cannot exceed the number of delegates.", + }); + } + } else if (input.join.quorum !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["join", "quorum"], + message: "quorum is only valid with strategy=quorum.", + }); + } + }); + +export const handoffTaskInputSchema = z + .object({ + idempotency_key: idempotencyKeySchema, + to_member_id: agentMemberIdSchema.describe( + "Member id of the colleague who should become the task owner.", + ), + reason: z + .string() + .trim() + .min(1) + .max(2_000) + .describe( + "Why ownership should move; this does not replace task intent.", + ), + context_refs: z.array(structuredTaskContextRefSchema).max(64).optional(), + acceptance: z + .enum(["required", "auto_if_authorized"]) + .default("auto_if_authorized"), + ttl_seconds: z.number().int().min(30).max(3_600).optional(), + }) + .strict(); + +export type DelegateTaskInput = z.infer; +export type HandoffTaskInput = z.infer; +export type StructuredTaskContextRef = z.infer< + typeof structuredTaskContextRefSchema +>; +export type StructuredTaskOutputContract = z.infer< + typeof structuredTaskOutputContractSchema +>; + +/** + * Identity supplied by Agent Host, never by model tool arguments. + * + * Build this only after the Host executor has rebound the request to its + * persisted job. Tool inputs deliberately contain no task, job, caller, or + * conversation ids, so a model cannot claim another task or another owner. + */ +export interface TrustedStructuredTaskContext { + readonly contractVersion: typeof STRUCTURED_TASK_CONTRACT_VERSION; + readonly taskId: string; + readonly jobId: string; + readonly conversationId: string; + readonly channelKind: "channel" | "dm"; + readonly callerAgentId: string; + readonly callerMemberId: string; + readonly targets: ReadonlyArray<{ + readonly agentId: string; + readonly memberId: string; + }>; +} + +export function trustedStructuredTaskContext( + request: LocalAIChatRequest, +): TrustedStructuredTaskContext | undefined { + const taskId = request.agentHost?.taskId.trim(); + const jobId = request.agentHost?.jobId.trim(); + const conversationId = request.conversationId.trim(); + const callerAgentId = request.agent?.id?.trim(); + const callerMemberId = request.agent?.memberId?.trim(); + const targets = request.agentHost?.collaborationTargets ?? []; + if ( + !taskId || + !jobId || + !conversationId || + !callerAgentId || + !callerMemberId || + !callerMemberId.startsWith("agent:") || + !request.agentHost + ) { + return undefined; + } + + return Object.freeze({ + contractVersion: STRUCTURED_TASK_CONTRACT_VERSION, + taskId, + jobId, + conversationId, + channelKind: request.agentHost.channelKind, + callerAgentId, + callerMemberId, + targets: Object.freeze( + targets.map((target) => + Object.freeze({ + agentId: target.agentId.trim(), + memberId: target.memberId.trim(), + }), + ), + ), + }); +} + +function jsonSchema(schema: z.ZodTypeAny): Record { + return zodToJsonSchema(schema, { + $refStrategy: "none", + target: "jsonSchema7", + }) as Record; +} + +/** JSON schemas exposed to providers when the tools are wired later. */ +export const delegateTaskJsonSchema = jsonSchema(delegateTaskInputSchema); +export const handoffTaskJsonSchema = jsonSchema(handoffTaskInputSchema); + +export type DelegateTaskToolResult = + | { + ok: true; + delegation_id: string; + parent_task_id: string; + child_tasks: Array<{ + task_id: string; + assignee_member_id: string; + status: + | "queued" + | "running" + | "paused" + | "completed" + | "failed" + | "cancelled" + | "interrupted"; + result_message_ids: string[]; + error?: string; + }>; + join_status: "satisfied" | "partial" | "expired"; + } + | StructuredTaskToolFailure; + +export type HandoffTaskToolResult = + | { + ok: true; + handoff_id: string; + task_id: string; + from_member_id: string; + to_member_id: string; + status: "proposed" | "committed"; + } + | StructuredTaskToolFailure; + +export interface StructuredTaskToolFailure { + ok: false; + error: { + code: string; + message: string; + recovery?: string; + }; +} diff --git a/packages/app/src/electron/web-bridge/server.ts b/packages/app/src/electron/web-bridge/server.ts index d15ab97c..77cdc1cd 100644 --- a/packages/app/src/electron/web-bridge/server.ts +++ b/packages/app/src/electron/web-bridge/server.ts @@ -38,6 +38,7 @@ const ALLOWED_INVOKE_CHANNELS = new Set([ "agent-host:list-tasks", "agent-host:control-task", "agent-host:redirect-task", + "agent-host:record-output", "agent-host:cancel", "agent-host:respond", "local-ai:get-conversation-runtime-state", diff --git a/packages/app/src/renderer/components/chat/AgentContextPanel.tsx b/packages/app/src/renderer/components/chat/AgentContextPanel.tsx index fcd95afa..015ac3f1 100644 --- a/packages/app/src/renderer/components/chat/AgentContextPanel.tsx +++ b/packages/app/src/renderer/components/chat/AgentContextPanel.tsx @@ -8,6 +8,7 @@ import { useAgentHostTasks } from "@/renderer/libs/hooks/use-agent-host-jobs"; import { taskStatusLabel } from "@/renderer/libs/agent-tasks"; import { memberIdForAgent } from "@/renderer/libs/db"; import { useChannels } from "@/renderer/libs/stores/channel-store"; +import { useMembers } from "@/renderer/libs/stores/member-store"; import { motion } from "framer-motion"; import { Activity, @@ -56,6 +57,7 @@ function AgentWorkSection({ agentId }: { agentId: string }) { const agentMemberId = memberIdForAgent(agentId); const { tasks, error, control, redirect } = useAgentHostTasks(agentMemberId); const channels = useChannels(); + const members = useMembers(); const [guidingTaskId, setGuidingTaskId] = useState(); const [instruction, setInstruction] = useState(""); const [busy, setBusy] = useState(); @@ -64,6 +66,10 @@ function AgentWorkSection({ agentId }: { agentId: string }) { new Map((channels ?? []).map((channel) => [channel.id, channel.name])), [channels], ); + const memberNames = useMemo( + () => new Map((members ?? []).map((member) => [member.id, member.name])), + [members], + ); const visibleTasks = tasks .filter((task) => task.channelKind !== "dm") .slice(0, 20); @@ -115,6 +121,11 @@ function AgentWorkSection({ agentId }: { agentId: string }) { channelNames.get(task.channelId) ?? task.channelId; const latestGuidance = task.controlInstructions.at(-1); const guiding = guidingTaskId === task.id; + const collaboration = task.collaboration; + const sourceName = collaboration + ? (memberNames.get(collaboration.fromMemberId) ?? + collaboration.fromMemberId) + : undefined; return (
+ {collaboration && ( +
+

+ {collaboration.kind === "delegation" + ? `Delegated by ${sourceName}` + : `Handed off by ${sourceName}`} +

+

+ {collaboration.brief.objective} +

+ {(task.outputMessageIds?.length ?? 0) > 0 && ( +

+ {task.outputMessageIds?.length} delivered result + {task.outputMessageIds?.length === 1 ? "" : "s"} +

+ )} +
+ )} {latestGuidance && (

Guidance: {latestGuidance} diff --git a/packages/app/src/renderer/libs/agent-host-service.test.ts b/packages/app/src/renderer/libs/agent-host-service.test.ts index d99570e1..f42f293b 100644 --- a/packages/app/src/renderer/libs/agent-host-service.test.ts +++ b/packages/app/src/renderer/libs/agent-host-service.test.ts @@ -17,6 +17,7 @@ import { } from "./db/database"; import { dispatchAgentHostOffers, + formatCollaborationBrief, formatTaskGuidance, RendererAgentHostService, } from "./agent-host-service"; @@ -164,6 +165,36 @@ describe("RendererAgentHostService", () => { ); }); + it("turns structured task metadata into per-turn delivery guidance", () => { + expect( + formatCollaborationBrief({ + ...job, + collaboration: { + kind: "delegation", + operationId: "delegation-1", + idempotencyKey: "key-1", + inputHash: "hash-1", + sourceTaskId: "parent-task", + sourceJobId: "parent-job", + fromMemberId: "agent:planner", + depth: 1, + path: ["agent:planner", member.id], + brief: { + objective: "Review the implementation", + acceptanceCriteria: ["Report blockers"], + contextMessageIds: ["human-message"], + outputContract: { + format: "text", + description: "Post a concise review", + }, + }, + }, + }), + ).toMatch( + /Delegated subtask from agent:planner[\s\S]*Review the implementation[\s\S]*workspace:send_message/, + ); + }); + it("prepares a concurrent tool-only turn from the frozen message boundary", async () => { const service = new RendererAgentHostService(); const before = await db.messages.count(); @@ -182,6 +213,9 @@ describe("RendererAgentHostService", () => { ], }, agent: { id: agent.id, memberId: member.id }, + agentHost: { + collaborationTargets: [{ agentId: agent.id, memberId: member.id }], + }, }); // The room rides the per-turn channel, not the persona: folding it into // the prompt changed the session's context fingerprint on every move @@ -196,6 +230,34 @@ describe("RendererAgentHostService", () => { expect(await db.pendingTurns.count()).toBe(0); }); + it("records a successful workspace message as a Host result receipt", async () => { + const recordOutput = vi.fn(async () => ({ + success: true, + recorded: true, + })); + Object.assign(window, { + agentHost: { enqueue, recordOutput } as unknown as IAgentHostAPI, + }); + const service = new RendererAgentHostService(); + + await ( + service as unknown as { + recordWorkspaceOutput( + jobId: string, + response: { value: string }, + ): Promise; + } + ).recordWorkspaceOutput(job.id, { + value: JSON.stringify({ + ok: true, + kind: "send_message", + messageId: "result-message", + }), + }); + + expect(recordOutput).toHaveBeenCalledWith(job.id, "result-message"); + }); + it("shows typing only while the speech tool is open, never on being offered", async () => { useTypingStore.setState({ typing: {} }); const service = new RendererAgentHostService(); diff --git a/packages/app/src/renderer/libs/agent-host-service.ts b/packages/app/src/renderer/libs/agent-host-service.ts index 6c02a5ea..2c208712 100644 --- a/packages/app/src/renderer/libs/agent-host-service.ts +++ b/packages/app/src/renderer/libs/agent-host-service.ts @@ -299,9 +299,10 @@ export class RendererAgentHostService { // it had just left. const systemPrompt = agent.systemPrompt ?? ""; const taskGuidance = formatTaskGuidance(job.controlInstructions); - const turnContext = taskGuidance - ? `${roomContext}\n\n${taskGuidance}` - : roomContext; + const collaborationBrief = formatCollaborationBrief(job); + const turnContext = [roomContext, taskGuidance, collaborationBrief] + .filter(Boolean) + .join("\n\n"); // The shared transcript advances every time a colleague posts, so this // actor's binding is usually behind by the time its next offer arrives. // A bootstrap cannot clear that state — only a rebase resets the session @@ -351,8 +352,16 @@ export class RendererAgentHostService { jobId: job.id, taskId: job.taskId, channelKind: job.channelKind, + collaborationTargets: members.flatMap((member) => + member.kind === "agent" && member.agentId + ? [{ agentId: member.agentId, memberId: member.id }] + : [], + ), roomContext: turnContext, }, + ...(job.maxOutputTokens + ? { options: { maxOutputTokens: job.maxOutputTokens } } + : {}), }, }; this.active.set(job.id, { job, requestId, typing: new Set() }); @@ -386,6 +395,31 @@ export class RendererAgentHostService { } } + private async recordWorkspaceOutput( + jobId: string, + response: LocalAIInteractionResponse, + ): Promise { + if (typeof response.value !== "string") return; + try { + const result = JSON.parse(response.value) as { + ok?: unknown; + kind?: unknown; + messageId?: unknown; + }; + if ( + result.ok !== true || + result.kind !== "send_message" || + typeof result.messageId !== "string" + ) { + return; + } + await window.agentHost?.recordOutput(jobId, result.messageId); + } catch { + // The provider still needs the original workspace result. Output + // indexing is a receipt, never the authority for the Dexie message. + } + } + private async handleStream( jobId: string, event: LocalAIStreamEvent, @@ -442,7 +476,20 @@ export class RendererAgentHostService { }, } : event; - if (handleWorkspaceQueryInteraction(workspaceEvent, respond)) return; + const workspaceRespond = async (response: LocalAIInteractionResponse) => { + if ( + workspaceEvent.name === "workspace:query" && + typeof workspaceEvent.input === "object" && + workspaceEvent.input !== null && + "kind" in workspaceEvent.input && + workspaceEvent.input.kind === "send_message" + ) { + await this.recordWorkspaceOutput(active.job.id, response); + } + await respond(response); + }; + if (handleWorkspaceQueryInteraction(workspaceEvent, workspaceRespond)) + return; useUserInputStore.getState().registerInteraction(event, respond); return; } @@ -462,3 +509,28 @@ export function formatTaskGuidance(instructions: string[]): string { ), ].join("\n"); } + +export function formatCollaborationBrief(job: AgentHostJob): string { + const collaboration = job.collaboration; + if (!collaboration) return ""; + const { brief } = collaboration; + const heading = + collaboration.kind === "delegation" + ? `Delegated subtask from ${collaboration.fromMemberId}. The parent retains task ownership and is waiting for your result.` + : `Task ownership was transferred to you by ${collaboration.fromMemberId}. You now own task ${job.taskId}.`; + return [ + heading, + `Objective: ${brief.objective}`, + "Acceptance criteria:", + ...brief.acceptanceCriteria.map( + (criterion, index) => `${index + 1}. ${criterion}`, + ), + brief.contextMessageIds.length > 0 + ? `Relevant workspace message ids: ${brief.contextMessageIds.join(", ")}` + : undefined, + `Output contract (${brief.outputContract.format}): ${brief.outputContract.description}`, + "Post the completed result in this channel with workspace:send_message. Your ordinary turn output is private and does not count as delivery.", + ] + .filter(Boolean) + .join("\n"); +} diff --git a/packages/app/src/shared/types/agent-host.ts b/packages/app/src/shared/types/agent-host.ts index 4baedfea..82627426 100644 --- a/packages/app/src/shared/types/agent-host.ts +++ b/packages/app/src/shared/types/agent-host.ts @@ -27,6 +27,31 @@ export interface AgentHostTarget { memberId: string; } +export interface AgentHostStructuredTaskBrief { + objective: string; + acceptanceCriteria: string[]; + contextMessageIds: string[]; + outputContract: { + format: "text" | "json" | "artifact"; + description: string; + resultSchema?: Record; + }; +} + +export interface AgentHostCollaboration { + kind: "delegation" | "handoff"; + operationId: string; + idempotencyKey: string; + inputHash: string; + sourceTaskId: string; + sourceJobId: string; + fromMemberId: string; + depth: number; + path: string[]; + brief: AgentHostStructuredTaskBrief; + expiresAt?: string; +} + export interface AgentHostDispatch { channelId: string; channelKind: AgentHostChannelKind; @@ -45,6 +70,8 @@ export interface AgentHostDispatch { export interface AgentHostJob { id: string; taskId: string; + /** A delegated child task. Redirect runs use parentJobId instead. */ + parentTaskId?: string; parentJobId?: string; channelId: string; channelKind: AgentHostChannelKind; @@ -57,6 +84,10 @@ export interface AgentHostJob { agentMemberId: string; chain: AgentHostChain; controlInstructions: string[]; + collaboration?: AgentHostCollaboration; + /** Renderer/Dexie-owned result messages posted by this run. */ + outputMessageIds?: string[]; + maxOutputTokens?: number; status: AgentHostJobStatus; attempts: number; requestId?: string; @@ -77,6 +108,9 @@ export interface AgentHostTaskSummary { agentId: string; agentMemberId: string; currentJobId: string; + parentTaskId?: string; + collaboration?: AgentHostCollaboration; + outputMessageIds?: string[]; status: AgentHostJobStatus; runCount: number; controlInstructions: string[]; @@ -153,6 +187,10 @@ export interface IAgentHostAPI { taskId: string, instruction: string, ): Promise<{ success: boolean; job?: AgentHostJob; error?: string }>; + recordOutput( + jobId: string, + messageId: string, + ): Promise<{ success: boolean; recorded?: boolean; error?: string }>; cancel( jobId: string, ): Promise<{ success: boolean; cancelled?: boolean; error?: string }>; diff --git a/packages/app/src/shared/types/local-ai.ts b/packages/app/src/shared/types/local-ai.ts index 81f23a5d..4f72f89b 100644 --- a/packages/app/src/shared/types/local-ai.ts +++ b/packages/app/src/shared/types/local-ai.ts @@ -94,6 +94,15 @@ export interface LocalAIChatRequest { jobId: string; taskId: string; channelKind: "channel" | "dm"; + /** + * Renderer-resolved channel colleagues available to structured task tools. + * This is trusted runtime metadata, not model-authored tool input and not + * injected into the provider prompt. + */ + collaborationTargets?: Array<{ + agentId: string; + memberId: string; + }>; /** * Where the agent is standing this turn: room name, description, who is * present, whether it may pass.