From 0575712e02dafee7f389f36e7f13a29c329803aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 29 May 2026 21:08:04 -0400 Subject: [PATCH] Run the CEO agent autonomously with a real tool-use loop The new app reached a real LLM but never ran an agent: the kickoff task_run sat queued forever and runTask was a single text completion with no tools. This adds the missing runtime: - provider-tool-turn: OpenAI Responses + Anthropic Messages tool-use turns - agent-tools + deliverable-writer: hire_agent, create_task, update_task_status, write_deliverable (workspace-scoped, traversal-safe) - agent-run-loop: real tool-use loop, capped iterations - task-queue-worker: claims queued runs in a transaction and executes them task-execution now drives the loop; a background worker auto-runs the bootstrap CEO run so it hires its team, delegates tasks, and writes the first deliverable into the bound workspace. Autonomy is gated by AGENTCOMPANY_NEW_AUTORUN (tests run with it off to stay hermetic). --- .../src/main/app/task-queue-worker.ts | 71 +++++++ AgentCompany-New/src/main/index.ts | 6 + .../src/main/services/agent-run-loop.ts | 63 ++++++ .../src/main/services/agent-tools.ts | 190 ++++++++++++++++++ .../src/main/services/deliverable-writer.ts | 24 +++ .../main/services/provider-response-runner.ts | 70 +++++++ .../src/main/services/provider-service.ts | 7 + .../src/main/services/provider-tool-turn.ts | 164 +++++++++++++++ .../main/services/task-execution-service.ts | 141 +++++++++---- AgentCompany-New/tests/e2e/desktop.spec.ts | 1 + .../tests/e2e/screenshots.spec.ts | 2 +- .../tests/integration/autonomous-run.test.ts | 101 ++++++++++ .../tests/unit/agent-run-loop.test.ts | 135 +++++++++++++ .../tests/unit/task-execution-service.test.ts | 54 ++--- .../tests/unit/task-queue-worker.test.ts | 53 +++++ 15 files changed, 1008 insertions(+), 74 deletions(-) create mode 100644 AgentCompany-New/src/main/app/task-queue-worker.ts create mode 100644 AgentCompany-New/src/main/services/agent-run-loop.ts create mode 100644 AgentCompany-New/src/main/services/agent-tools.ts create mode 100644 AgentCompany-New/src/main/services/deliverable-writer.ts create mode 100644 AgentCompany-New/src/main/services/provider-tool-turn.ts create mode 100644 AgentCompany-New/tests/integration/autonomous-run.test.ts create mode 100644 AgentCompany-New/tests/unit/agent-run-loop.test.ts create mode 100644 AgentCompany-New/tests/unit/task-queue-worker.test.ts diff --git a/AgentCompany-New/src/main/app/task-queue-worker.ts b/AgentCompany-New/src/main/app/task-queue-worker.ts new file mode 100644 index 00000000..185d6964 --- /dev/null +++ b/AgentCompany-New/src/main/app/task-queue-worker.ts @@ -0,0 +1,71 @@ +import type { BrowserWindow } from "electron"; +import type { DatabaseConnection } from "../database/connection"; +import { IPC_CHANNELS } from "../../shared/ipc"; +import { TaskExecutionService } from "../services/task-execution-service"; + +const DEFAULT_INTERVAL_MS = 5_000; + +export interface TaskQueueWorkerInput { + connection: DatabaseConnection; + mainWindow: BrowserWindow; + intervalMs?: number; +} + +export interface QueuedRun { + id: string; + org_id: string; +} + +/** + * Atomically claims the oldest queued task_run by transitioning it + * `queued` -> `running` in one transaction; returns the claimed run or null. + */ +export function claimNextQueuedRun(connection: DatabaseConnection): QueuedRun | null { + return connection.transaction(() => { + const row = connection.db + .prepare("select id, org_id from task_runs where state = 'queued' order by created_at asc limit 1") + .get() as QueuedRun | undefined; + if (!row) return null; + const result = connection.db + .prepare("update task_runs set state = 'running', started_at = coalesce(started_at, ?) where id = ? and state = 'queued'") + .run(new Date().toISOString(), row.id); + return result.changes === 1 ? row : null; + }); +} + +/** + * Autonomous executor: every tick it claims the oldest `queued` task_run (the + * bootstrap CEO run, plus any future heartbeat-created runs) and executes its + * real agent loop. The claim is a single transaction (`queued` -> `running`, + * verified by row changes) so a run is never executed twice. + */ +export function startTaskQueueWorker(input: TaskQueueWorkerInput): () => void { + const taskExecution = new TaskExecutionService(input.connection.db); + const intervalMs = input.intervalMs ?? DEFAULT_INTERVAL_MS; + let running = false; + + const tick = async (): Promise => { + if (running) return; + running = true; + try { + const claimed = claimNextQueuedRun(input.connection); + if (!claimed) return; + try { + await taskExecution.executeRun(claimed.org_id, claimed.id); + } catch { + // executeRun records the failure on the run row; the UI reflects it below. + } + if (!input.mainWindow.isDestroyed()) { + input.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" }); + } + } finally { + running = false; + } + }; + + const interval = setInterval(() => { + void tick(); + }, intervalMs); + void tick(); + return () => clearInterval(interval); +} diff --git a/AgentCompany-New/src/main/index.ts b/AgentCompany-New/src/main/index.ts index 79a86a81..3e3db4ba 100644 --- a/AgentCompany-New/src/main/index.ts +++ b/AgentCompany-New/src/main/index.ts @@ -5,6 +5,7 @@ import { DatabaseConnection } from "./database/connection"; import { createMainWindow } from "./app/create-window"; import { registerIpcHandlers } from "./app/ipc-handlers"; import { startProviderBackgroundWorker } from "./app/provider-background-worker"; +import { startTaskQueueWorker } from "./app/task-queue-worker"; const __dirname = dirname(fileURLToPath(import.meta.url)); const hasSingleInstanceLock = app.requestSingleInstanceLock(); @@ -31,6 +32,10 @@ app.whenReady().then(() => { db: connection.db, mainWindow: win, }); + const autonomousExecution = process.env.AGENTCOMPANY_NEW_AUTORUN !== "0"; + const stopTaskQueueWorker = autonomousExecution + ? startTaskQueueWorker({ connection, mainWindow: win }) + : () => {}; app.on("second-instance", () => { if (win.isMinimized()) win.restore(); @@ -39,6 +44,7 @@ app.whenReady().then(() => { app.on("before-quit", () => { stopProviderBackgroundWorker(); + stopTaskQueueWorker(); connection.close(); }); }); diff --git a/AgentCompany-New/src/main/services/agent-run-loop.ts b/AgentCompany-New/src/main/services/agent-run-loop.ts new file mode 100644 index 00000000..de7338fb --- /dev/null +++ b/AgentCompany-New/src/main/services/agent-run-loop.ts @@ -0,0 +1,63 @@ +import type { ProviderService } from "./provider-service"; +import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools"; +import type { AgentTurnMessage } from "./provider-tool-turn"; + +const MAX_ITERATIONS = 12; + +export interface AgentRunLoopInput { + orgId: string; + providerId: string; + workspaceId: string | null; + instructions: string; + initialUserTurn: string; + toolContext: AgentToolContext; +} + +export interface AgentRunLoopResult { + finalText: string; + iterations: number; + toolCallCount: number; + lastRequestId: string; +} + +export interface AgentRunLoopDeps { + providers: Pick; + tools: AgentToolDeps; +} + +/** + * Drives a real tool-use loop against the live provider: prompt the model with + * the agent tools, execute every tool call it makes, feed results back, and + * repeat until the model returns a turn with no tool calls (it is done) or the + * iteration cap is hit (which throws rather than silently truncating). + */ +export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopInput): Promise { + const messages: AgentTurnMessage[] = [{ role: "user", text: input.initialUserTurn }]; + let toolCallCount = 0; + let lastRequestId = ""; + + for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration += 1) { + const turn = await deps.providers.runToolTurn({ + orgId: input.orgId, + providerId: input.providerId, + workspaceId: input.workspaceId, + instructions: input.instructions, + messages, + tools: AGENT_TOOLS, + }); + lastRequestId = turn.requestId; + + if (turn.toolCalls.length === 0) { + return { finalText: turn.text, iterations: iteration, toolCallCount, lastRequestId }; + } + + messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls }); + const results = turn.toolCalls.map((call) => { + toolCallCount += 1; + return { toolCallId: call.id, toolName: call.name, output: dispatchAgentTool(deps.tools, input.toolContext, call) }; + }); + messages.push({ role: "tool", results }); + } + + throw new Error(`Agent run did not converge within ${MAX_ITERATIONS} iterations.`); +} diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts new file mode 100644 index 00000000..229972dd --- /dev/null +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -0,0 +1,190 @@ +import type { DatabaseSync } from "node:sqlite"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, type RiskLevel } from "../../shared/domain"; +import type { OrganizationService } from "./organization-service"; +import { writeDeliverable } from "./deliverable-writer"; +import type { JsonRecord } from "./provider-http"; +import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; + +const HIRE_BUDGET_CENTS = 25000; +const HEARTBEAT_INTERVAL_SEC = 120; + +export interface AgentToolContext { + orgId: string; + /** The acting agent (the CEO during team-building). */ + agent: { id: string; modelProviderId: string | null; modelId: string | null }; + taskProjectId: string | null; + taskGoalId: string | null; + workspace: { id: string; localPath: string } | null; +} + +export interface AgentToolDeps { + db: DatabaseSync; + organization: OrganizationService; +} + +/** The tools the acting agent may call during a run. */ +export const AGENT_TOOLS: ToolDefinition[] = [ + { + name: "hire_agent", + description: + "Add one direct report to your team. During team building, call this 3-5 times to staff the roles you need before delegating work.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + name: { type: "string", description: "Full name of the new agent." }, + role: { type: "string", description: "Their role/title, e.g. 'Growth Marketer'." }, + department: { type: "string", description: "Department, e.g. 'Marketing' or 'Operations'." }, + model: { type: "string", description: "Optional model id; defaults to your own model." }, + }, + required: ["name", "role", "department"], + }, + }, + { + name: "create_task", + description: "Create a task for the team and optionally assign it to one of your reports by name.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + description: { type: "string" }, + assignee_agent_name: { type: "string", description: "Name of the agent to assign, if any." }, + risk: { type: "string", enum: ["low", "medium", "high"] }, + }, + required: ["title", "description"], + }, + }, + { + name: "update_task_status", + description: "Move a task to a new status.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + task_id: { type: "string" }, + status: { type: "string", enum: ["backlog", "in_progress", "review", "done"] }, + }, + required: ["task_id", "status"], + }, + }, + { + name: "write_deliverable", + description: "Write a deliverable file into the company workspace folder (relative path). Use this to produce real output.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + path: { type: "string", description: "Relative path inside the workspace, e.g. 'plan/business-plan.md'." }, + content: { type: "string" }, + }, + required: ["path", "content"], + }, + }, +]; + +export function dispatchAgentTool(deps: AgentToolDeps, ctx: AgentToolContext, call: ToolCall): string { + try { + switch (call.name) { + case "hire_agent": + return hireAgent(deps, ctx, call.arguments); + case "create_task": + return createTask(deps, ctx, call.arguments); + case "update_task_status": + return updateTaskStatus(deps, ctx, call.arguments); + case "write_deliverable": + return writeDeliverableTool(ctx, call.arguments); + default: + return `ERROR: unknown tool "${call.name}".`; + } + } catch (error) { + return `ERROR: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { + if (!ctx.agent.modelProviderId) throw new Error("Acting agent has no model provider to assign hires."); + const name = requireString(args.name, "name"); + const role = requireString(args.role, "role"); + const department = requireString(args.department, "department"); + const model = optionalString(args.model) ?? ctx.agent.modelId; + const agentId = deps.organization.createAgent({ + orgId: ctx.orgId, + name, + role, + department, + modelProviderId: ctx.agent.modelProviderId, + modelId: model, + budgetCents: HIRE_BUDGET_CENTS, + managerAgentId: ctx.agent.id, + roleLevel: "staff", + workspaceId: ctx.workspace?.id ?? null, + heartbeatEnabled: true, + heartbeatIntervalSec: HEARTBEAT_INTERVAL_SEC, + }); + return `Hired ${name} as ${role} in ${department} (agent_id=${agentId}).`; +} + +function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { + const title = requireString(args.title, "title"); + const description = requireString(args.description, "description"); + const assigneeName = optionalString(args.assignee_agent_name); + const assigneeId = assigneeName ? resolveAgentByName(deps.db, ctx.orgId, assigneeName) : null; + if (assigneeName && !assigneeId) throw new Error(`No agent named "${assigneeName}" — hire them first.`); + const taskId = deps.organization.createTask({ + orgId: ctx.orgId, + title, + description, + status: TASK_STATUS.BACKLOG, + assigneeAgentId: assigneeId, + risk: normalizeRisk(args.risk), + projectId: ctx.taskProjectId, + goalId: ctx.taskGoalId, + workspaceId: ctx.workspace?.id ?? null, + priority: TASK_PRIORITY.NORMAL, + requiresFilesystem: false, + }); + return `Created task "${title}" (task_id=${taskId})${assigneeId ? ` assigned to ${assigneeName}` : " (unassigned)"}.`; +} + +function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { + const taskId = requireString(args.task_id, "task_id"); + const status = normalizeStatus(args.status); + deps.organization.updateTaskStatus(ctx.orgId, taskId, status); + return `Task ${taskId} moved to ${status}.`; +} + +function writeDeliverableTool(ctx: AgentToolContext, args: JsonRecord): string { + if (!ctx.workspace) throw new Error("No workspace is bound to this task; cannot write deliverables."); + const path = requireString(args.path, "path"); + const content = typeof args.content === "string" ? args.content : ""; + const written = writeDeliverable({ workspaceRoot: ctx.workspace.localPath, relativePath: path, content }); + return `Wrote deliverable ${written} (${content.length} chars).`; +} + +function resolveAgentByName(db: DatabaseSync, orgId: string, name: string): string | null { + const row = db.prepare("select id from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1") + .get(orgId, name.trim()) as { id: string } | undefined; + return row?.id ?? null; +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(`"${field}" is required.`); + return value.trim(); +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function normalizeRisk(value: unknown): RiskLevel { + if (value === RISK_LEVEL.MEDIUM) return RISK_LEVEL.MEDIUM; + if (value === RISK_LEVEL.HIGH) return RISK_LEVEL.HIGH; + return RISK_LEVEL.LOW; +} + +function normalizeStatus(value: unknown): string { + const allowed = [TASK_STATUS.BACKLOG, TASK_STATUS.IN_PROGRESS, TASK_STATUS.REVIEW, TASK_STATUS.DONE] as string[]; + if (typeof value === "string" && allowed.includes(value)) return value; + throw new Error(`Invalid status. Use one of: ${allowed.join(", ")}.`); +} diff --git a/AgentCompany-New/src/main/services/deliverable-writer.ts b/AgentCompany-New/src/main/services/deliverable-writer.ts new file mode 100644 index 00000000..f797a66c --- /dev/null +++ b/AgentCompany-New/src/main/services/deliverable-writer.ts @@ -0,0 +1,24 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +/** + * Writes an agent deliverable into the run's bound workspace folder. + * + * The target path is resolved against the workspace root and rejected if it + * escapes that root (path traversal / absolute paths), so an agent can only + * write inside the folder the operator bound to the company. + */ +export function writeDeliverable(input: { workspaceRoot: string; relativePath: string; content: string }): string { + const root = resolve(input.workspaceRoot); + const requested = input.relativePath.trim().replace(/^[\\/]+/, ""); + if (!requested) throw new Error("Deliverable path is required."); + if (isAbsolute(requested)) throw new Error("Deliverable path must be relative to the workspace."); + const target = resolve(root, requested); + const rel = relative(root, target); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error("Deliverable path escapes the workspace root."); + } + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, input.content, "utf8"); + return rel.split(sep).join("/"); +} diff --git a/AgentCompany-New/src/main/services/provider-response-runner.ts b/AgentCompany-New/src/main/services/provider-response-runner.ts index 058cd581..865e7611 100644 --- a/AgentCompany-New/src/main/services/provider-response-runner.ts +++ b/AgentCompany-New/src/main/services/provider-response-runner.ts @@ -12,6 +12,8 @@ import type { ProviderRateLimitRegistry } from "./provider-rate-limit-registry"; import type { ProviderRequestStore } from "./provider-request-store"; import type { ProviderResponseObserver } from "./provider-response-observer"; import { hashPrompt, readProviderResponseId, requireStreamText } from "./provider-response-utils"; +import { requireApiKeyProfile } from "./provider-auth-profiles"; +import { selectToolTurn, type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn"; import { HEALTH_STATE_HEALTHY, WORKSPACE_SCOPES, @@ -37,6 +39,8 @@ export interface ProviderResponseRunnerDeps { responseObserver: ProviderResponseObserver; requestStore: ProviderRequestStore; background: ProviderBackgroundService; + /** Test-injectable override for the provider's tool-use HTTP turn. */ + toolTurn?: ToolTurnFn; } export class ProviderResponseRunner { @@ -91,6 +95,72 @@ export class ProviderResponseRunner { } } + async runToolTurn(input: { + orgId: string; + providerId: string; + workspaceId: string | null; + instructions: string | null; + messages: AgentTurnMessage[]; + tools: ToolDefinition[]; + }): Promise { + const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId); + const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null); + this.enforcePolicy(input.orgId, provider, PROVIDER_EXECUTION_MODE.FOREGROUND, workspaceId); + if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) throw new Error("Agent tool execution requires an API-key provider."); + const model = provider.model; + if (!model) throw new Error("Provider model is required."); + const owner: ProviderRow & { model: string } = { ...provider, model }; + const adapter = requireAdapter(this.deps.adapters, provider.provider_key, provider.auth_type); + const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key); + const credential = loadCredential(this.deps.authBroker, input.orgId, provider); + const apiKey = requireApiKeyProfile(adapter.parseAuthProfile(credential.secret), provider.provider_key).apiKey; + const promptChars = JSON.stringify(input.messages).length; + const requestId = this.deps.requestStore.create({ + orgId: input.orgId, + provider: owner, + model, + workspaceId, + promptSha256: hashPrompt(JSON.stringify(input.messages)), + inputCharCount: promptChars, + executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND, + createdAt: new Date().toISOString(), + }); + const startedAt = Date.now(); + try { + const result = await turn({ apiKey, model, instructions: input.instructions, messages: input.messages, tools: input.tools }); + this.deps.requestStore.markSucceeded({ + orgId: input.orgId, + requestId, + responseText: result.text, + rawResponseJson: result.rawResponseJson, + outputCharCount: result.text.length, + externalResponseId: readProviderResponseId(result.rawResponseJson), + completedAt: new Date().toISOString(), + }); + this.deps.responseObserver.recordSuccess({ + orgId: input.orgId, + provider: owner, + requestId, + executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND, + startedAt, + action: "response_created", + }); + this.deps.requestStore.recordUsage(input.orgId, provider.id, promptChars, result.text.length); + return { ...result, requestId }; + } catch (error) { + this.deps.responseObserver.recordFailure({ + orgId: input.orgId, + provider: owner, + requestId, + executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND, + startedAt, + inputCharCount: promptChars, + error, + }); + throw error; + } + } + async streamResponse(input: StreamProviderResponseInput, onEvent: (event: ProviderStreamRendererEvent) => void): Promise { const execution = this.prepareExecution(input); const requestId = this.createRequestRecord(execution); diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index 78a637fd..df046495 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -14,6 +14,7 @@ import { ProviderResponseObserver } from "./provider-response-observer"; import { ProviderRequestStore } from "./provider-request-store"; import { ProviderResponseRunner, type ProviderResponseCreateInput } from "./provider-response-runner"; import { launchProviderSignInWindow, type LaunchProviderSignInWindowInput } from "./provider-sign-in-window"; +import type { ToolTurnFn } from "./provider-tool-turn"; import { AUTH_STATE_CONFIGURED, AUTH_STATE_REVOKED, @@ -48,6 +49,7 @@ export class ProviderService { private readonly adapters: ProviderAdapterRegistry = providerAdapters, vault: ProviderSecretVault = new SecretVault(), private readonly launchSignIn: (input: LaunchProviderSignInWindowInput) => void = launchProviderSignInWindow, + toolTurn?: ToolTurnFn, ) { this.audit = new AuditService(db); this.authBroker = new AuthBrokerService(db, vault); @@ -66,6 +68,7 @@ export class ProviderService { responseObserver: this.responseObserver, requestStore: this.requestStore, background: this.background, + toolTurn, }); } @@ -81,6 +84,10 @@ export class ProviderService { return this.runner.cancelResponse(input); } + runToolTurn(input: Parameters[0]): ReturnType { + return this.runner.runToolTurn(input); + } + saveCredential(input: SaveProviderCredentialInput): void { const provider = this.db.prepare("select * from providers where id = ? and org_id = ?") .get(input.providerId, input.orgId) as { provider_key: string; auth_type: ProviderAuthType } | undefined; diff --git a/AgentCompany-New/src/main/services/provider-tool-turn.ts b/AgentCompany-New/src/main/services/provider-tool-turn.ts new file mode 100644 index 00000000..bae2b41c --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turn.ts @@ -0,0 +1,164 @@ +/** + * Provider-agnostic single turn of a tool-use ("function calling") conversation. + * + * The agent loop maintains a normalized conversation ({@link AgentTurnMessage}[]) + * and the requested {@link ToolDefinition}s; these helpers translate that into the + * provider's wire format (OpenAI Responses API or Anthropic Messages API), call + * the live endpoint, and parse the assistant's reply plus any tool calls back into + * the normalized shape. + */ +import { isRecord, postJson, type JsonRecord } from "./provider-http"; + +export interface ToolDefinition { + name: string; + description: string; + /** JSON Schema for the tool's arguments. */ + parameters: JsonRecord; +} + +export interface ToolCall { + id: string; + name: string; + arguments: JsonRecord; +} + +export type AgentTurnMessage = + | { role: "user"; text: string } + | { role: "assistant"; text: string; toolCalls: ToolCall[] } + | { role: "tool"; results: Array<{ toolCallId: string; toolName: string; output: string }> }; + +export interface AgentTurnResult { + text: string; + toolCalls: ToolCall[]; + rawResponseJson: string; +} + +export interface ProviderToolTurnInput { + apiKey: string; + model: string; + instructions: string | null; + messages: AgentTurnMessage[]; + tools: ToolDefinition[]; +} + +function parseArguments(value: unknown): JsonRecord { + if (isRecord(value)) return value; + if (typeof value === "string" && value.trim()) { + try { + const parsed = JSON.parse(value) as unknown; + if (isRecord(parsed)) return parsed; + } catch { + return {}; + } + } + return {}; +} + +// ---- OpenAI Responses API ---- + +function toOpenAiInput(messages: AgentTurnMessage[]): unknown[] { + const items: unknown[] = []; + for (const message of messages) { + if (message.role === "user") { + items.push({ role: "user", content: message.text }); + } else if (message.role === "assistant") { + for (const call of message.toolCalls) { + items.push({ type: "function_call", call_id: call.id, name: call.name, arguments: JSON.stringify(call.arguments) }); + } + } else { + for (const result of message.results) { + items.push({ type: "function_call_output", call_id: result.toolCallId, output: result.output }); + } + } + } + return items; +} + +function parseOpenAiResult(data: JsonRecord): AgentTurnResult { + const output = Array.isArray(data.output) ? data.output : []; + const textParts: string[] = []; + const toolCalls: ToolCall[] = []; + for (const item of output) { + if (!isRecord(item)) continue; + if (item.type === "function_call") { + const callId = typeof item.call_id === "string" ? item.call_id : typeof item.id === "string" ? item.id : ""; + const name = typeof item.name === "string" ? item.name : ""; + if (callId && name) toolCalls.push({ id: callId, name, arguments: parseArguments(item.arguments) }); + } else if (item.type === "message" && Array.isArray(item.content)) { + for (const block of item.content) { + if (isRecord(block) && block.type === "output_text" && typeof block.text === "string") textParts.push(block.text); + } + } + } + return { text: textParts.join("\n").trim(), toolCalls, rawResponseJson: JSON.stringify(data) }; +} + +export async function runOpenAiToolTurn(input: ProviderToolTurnInput): Promise { + const body: JsonRecord = { + model: input.model, + input: toOpenAiInput(input.messages), + tools: input.tools.map((tool) => ({ type: "function", name: tool.name, description: tool.description, parameters: tool.parameters })), + tool_choice: "auto", + ...(input.instructions ? { instructions: input.instructions } : {}), + }; + const response = await postJson("https://api.openai.com/v1/responses", { authorization: `Bearer ${input.apiKey}` }, body); + return parseOpenAiResult(response.data); +} + +// ---- Anthropic Messages API ---- + +function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] { + const out: unknown[] = []; + for (const message of messages) { + if (message.role === "user") { + out.push({ role: "user", content: [{ type: "text", text: message.text }] }); + } else if (message.role === "assistant") { + const content: unknown[] = []; + if (message.text) content.push({ type: "text", text: message.text }); + for (const call of message.toolCalls) content.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments }); + out.push({ role: "assistant", content }); + } else { + out.push({ role: "user", content: message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output })) }); + } + } + return out; +} + +function parseAnthropicResult(data: JsonRecord): AgentTurnResult { + const content = Array.isArray(data.content) ? data.content : []; + const textParts: string[] = []; + const toolCalls: ToolCall[] = []; + for (const block of content) { + if (!isRecord(block)) continue; + if (block.type === "text" && typeof block.text === "string") textParts.push(block.text); + else if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") { + toolCalls.push({ id: block.id, name: block.name, arguments: isRecord(block.input) ? block.input : {} }); + } + } + return { text: textParts.join("\n").trim(), toolCalls, rawResponseJson: JSON.stringify(data) }; +} + +export async function runAnthropicToolTurn(input: ProviderToolTurnInput): Promise { + const body: JsonRecord = { + model: input.model, + max_tokens: 4096, + tools: input.tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters })), + messages: toAnthropicMessages(input.messages), + ...(input.instructions ? { system: input.instructions } : {}), + }; + const response = await postJson("https://api.anthropic.com/v1/messages", { "x-api-key": input.apiKey, "anthropic-version": "2023-06-01" }, body); + return parseAnthropicResult(response.data); +} + +export type ToolTurnFn = (input: ProviderToolTurnInput) => Promise; + +/** Provider keys that can drive a native tool-use loop, by wire protocol. */ +export function selectToolTurn(providerKey: string): ToolTurnFn { + if (providerKey === "openai") return runOpenAiToolTurn; + if (providerKey === "anthropic") return runAnthropicToolTurn; + throw new Error(`${providerKey} does not support agent tool execution; assign the agent an OpenAI or Anthropic provider.`); +} + +export function supportsToolTurn(providerKey: string): boolean { + return providerKey === "openai" || providerKey === "anthropic"; +} diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index f2fd7715..97df9d35 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -1,10 +1,13 @@ import type { DatabaseSync } from "node:sqlite"; import type { RunTaskInput } from "../../shared/commands"; -import { APPROVAL_STATE, PROVIDER_EXECUTION_MODE, RISK_LEVEL } from "../../shared/domain"; +import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; import { ProviderService } from "./provider-service"; +import { OrganizationService } from "./organization-service"; import { SkillService } from "./skill-service"; +import { runAgentLoop } from "./agent-run-loop"; +import type { AgentToolContext } from "./agent-tools"; const TASK_RUN_STATE = { RUNNING: "running", @@ -82,8 +85,19 @@ interface TaskRunIsolation { secretScopeJson: string; } +interface RunRow { + task_id: string; + agent_id: string; + provider_id: string; + execution_context_id: string; + service_identity_id: string; + workspace_mount_json: string; + secret_scope_json: string; +} + export class TaskExecutionService { private readonly audit: AuditService; + private readonly organization: OrganizationService; constructor( private readonly db: DatabaseSync, @@ -91,6 +105,7 @@ export class TaskExecutionService { private readonly skills: SkillService = new SkillService(db, ""), ) { this.audit = new AuditService(db); + this.organization = new OrganizationService(db); } async runTask(input: RunTaskInput): Promise { @@ -101,35 +116,79 @@ export class TaskExecutionService { const workspace = this.workspace(task.workspace_id); const isolation = this.createIsolationContext(input.orgId, task, agent, provider, workspace); const runId = this.createRun(input.orgId, task.id, agent.id, provider.id, isolation); + return this.executeAgentRun({ orgId: input.orgId, runId, task, agent, provider, workspace, isolation }); + } + + /** + * Executes an existing `running` task_run (claimed by the autonomous queue + * worker). System-initiated, so it does not re-gate on the manual execution + * approval — the operator configured the company to run autonomously. + */ + async executeRun(orgId: string, runId: string): Promise { + const run = this.db.prepare(` + select task_id, agent_id, provider_id, execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json + from task_runs where id = ? and org_id = ? + `).get(runId, orgId) as RunRow | undefined; + if (!run) throw new Error("Task run was not found."); + const task = this.requireTask(orgId, run.task_id); + const agent = this.requireAssignedAgent(orgId, task); + const provider = this.requireAgentProvider(orgId, agent); + const workspace = this.workspace(task.workspace_id); + const isolation: TaskRunIsolation = { + executionContextId: run.execution_context_id, + serviceIdentityId: run.service_identity_id, + workspaceMountJson: run.workspace_mount_json, + secretScopeJson: run.secret_scope_json, + }; + return this.executeAgentRun({ orgId, runId, task, agent, provider, workspace, isolation }); + } + + private async executeAgentRun(args: { + orgId: string; + runId: string; + task: TaskExecutionTaskRow; + agent: TaskExecutionAgentRow; + provider: TaskExecutionProviderRow; + workspace: WorkspaceRow | null; + isolation: TaskRunIsolation; + }): Promise { + const { orgId, runId, task, agent, provider, workspace, isolation } = args; let providerRequestId: string | null = null; let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null; - try { - skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(input.orgId, agent.id, runId); - const requestId = await this.providers.createResponse({ - orgId: input.orgId, - providerId: provider.id, - workspaceId: task.workspace_id, - prompt: buildTaskPrompt({ - task, - agent, - provider, - project: this.project(task.project_id), - goal: this.goal(task.goal_id), - workspace, - isolation, - skillRuntimeSnapshot, - }), - instructions: buildAgentInstructions(agent), - executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND, - onRequestCreated: (requestId) => { - providerRequestId = requestId; - this.linkProviderRequest(input.orgId, runId, requestId); + skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId); + const toolContext: AgentToolContext = { + orgId, + agent: { id: agent.id, modelProviderId: agent.model_provider_id, modelId: agent.model_id }, + taskProjectId: task.project_id, + taskGoalId: task.goal_id, + workspace: workspace ? { id: workspace.id, localPath: workspace.local_path } : null, + }; + const result = await runAgentLoop( + { providers: this.providers, tools: { db: this.db, organization: this.organization } }, + { + orgId, + providerId: provider.id, + workspaceId: task.workspace_id, + instructions: buildAgentInstructions(agent), + initialUserTurn: buildTaskPrompt({ + task, + agent, + provider, + project: this.project(task.project_id), + goal: this.goal(task.goal_id), + workspace, + isolation, + skillRuntimeSnapshot, + hasReports: this.agentHasReports(orgId, agent.id), + }), + toolContext, }, - }); - this.completeRun(input.orgId, runId, requestId); + ); + providerRequestId = result.lastRequestId; + this.completeRun(orgId, runId, result.lastRequestId); this.audit.record({ - orgId: input.orgId, + orgId, entityType: "task_run", entityId: runId, actorType: "system", @@ -139,7 +198,9 @@ export class TaskExecutionService { taskId: task.id, agentId: agent.id, providerId: provider.id, - providerRequestId: requestId, + providerRequestId: result.lastRequestId, + iterations: result.iterations, + toolCalls: result.toolCallCount, executionContextId: isolation.executionContextId, serviceIdentityId: isolation.serviceIdentityId, skillRuntimeSnapshotId: skillRuntimeSnapshot?.id ?? null, @@ -148,9 +209,9 @@ export class TaskExecutionService { return runId; } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.failRun(input.orgId, runId, providerRequestId, message); + this.failRun(orgId, runId, providerRequestId, message); this.audit.record({ - orgId: input.orgId, + orgId, entityType: "task_run", entityId: runId, actorType: "system", @@ -252,9 +313,10 @@ export class TaskExecutionService { return runId; } - private linkProviderRequest(orgId: string, runId: string, providerRequestId: string): void { - this.db.prepare("update task_runs set provider_request_id = ? where id = ? and org_id = ?") - .run(providerRequestId, runId, orgId); + private agentHasReports(orgId: string, agentId: string): boolean { + const row = this.db.prepare("select 1 from agents where org_id = ? and manager_agent_id = ? and lifecycle = 'active' limit 1") + .get(orgId, agentId); + return Boolean(row); } private completeRun(orgId: string, runId: string, providerRequestId: string): void { @@ -345,10 +407,9 @@ export class TaskExecutionService { function buildAgentInstructions(agent: TaskExecutionAgentRow): string { return [ - `You are ${agent.name}, ${agent.title || agent.role}.`, - `Department: ${agent.department}.`, - `Role level: ${agent.role_level}.`, - "Return an auditable execution result with concrete output and next action.", + `You are ${agent.name}, ${agent.title || agent.role} (${agent.department}, ${agent.role_level}) operating an autonomous AI company.`, + "You act by calling tools: hire_agent staffs your team, create_task delegates work and assigns it by agent name, update_task_status tracks progress, and write_deliverable produces real files in the company workspace.", + "Take concrete action with the tools instead of only describing a plan. When your team is staffed, the work is delegated, and the first operating deliverable is written, give a brief closing summary with no further tool calls.", ].join("\n"); } @@ -361,6 +422,7 @@ function buildTaskPrompt(input: { workspace: WorkspaceRow | null; isolation: TaskRunIsolation; skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null; + hasReports: boolean; }): string { const lines = [ "Execute this AgentCompany task.", @@ -399,6 +461,13 @@ function buildTaskPrompt(input: { `Skill revisions: ${input.skillRuntimeSnapshot.source_revision_ids}`, ); } - lines.push("", "Deliverable: completed work, decisions made, evidence needed, and next action."); + if (!input.hasReports) { + lines.push( + "", + "Your team is currently empty. Hire 3-5 direct reports for the roles this goal needs (hire_agent), create and assign their first tasks (create_task), and write the initial operating plan into the workspace (write_deliverable). Then summarize.", + ); + } else { + lines.push("", "Deliver completed work: make decisions, delegate or update tasks, and write real files with write_deliverable where appropriate."); + } return lines.join("\n"); } diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index ab86a932..e916aaf2 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -13,6 +13,7 @@ test("onboards, hires, and creates work in the Electron shell", async () => { env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, + AGENTCOMPANY_NEW_AUTORUN: "0", }, }); diff --git a/AgentCompany-New/tests/e2e/screenshots.spec.ts b/AgentCompany-New/tests/e2e/screenshots.spec.ts index 7fe3ba7e..a58f5cbb 100644 --- a/AgentCompany-New/tests/e2e/screenshots.spec.ts +++ b/AgentCompany-New/tests/e2e/screenshots.spec.ts @@ -9,7 +9,7 @@ test("captures every page for visual review", async () => { mkdirSync(profileDir, { recursive: true }); const app = await electron.launch({ args: [resolve("out/main/index.js")], - env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir }, + env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0" }, }); const outDir = resolve("test-results/screenshots"); mkdirSync(outDir, { recursive: true }); diff --git a/AgentCompany-New/tests/integration/autonomous-run.test.ts b/AgentCompany-New/tests/integration/autonomous-run.test.ts new file mode 100644 index 00000000..15768e11 --- /dev/null +++ b/AgentCompany-New/tests/integration/autonomous-run.test.ts @@ -0,0 +1,101 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OnboardingService } from "../../src/main/services/onboarding-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { TaskExecutionService } from "../../src/main/services/task-execution-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { claimNextQueuedRun } from "../../src/main/app/task-queue-worker"; +import type { AgentTurnResult, ProviderToolTurnInput, ToolCall } from "../../src/main/services/provider-tool-turn"; + +const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +function call(name: string, args: Record): ToolCall { + return { id: `call_${randomUUID()}`, name, arguments: args }; +} + +/** Scripts the CEO: staff two reports, delegate a task, write the plan, then stop. */ +function scriptedCeo(): (input: ProviderToolTurnInput) => Promise { + const script: Array<{ text: string; toolCalls: ToolCall[] }> = [ + { text: "Staffing.", toolCalls: [ + call("hire_agent", { name: "Mara Quinn", role: "Growth Marketer", department: "Marketing" }), + call("hire_agent", { name: "Owen Diaz", role: "Operations Lead", department: "Operations" }), + ] }, + { text: "Delegating.", toolCalls: [ + call("create_task", { title: "Launch the booking site", description: "Ship landing + booking flow.", assignee_agent_name: "Mara Quinn" }), + ] }, + { text: "Writing the plan.", toolCalls: [ + call("write_deliverable", { path: "plan/operating-plan.md", content: "# OrbitOps Operating Plan\n\n- Validate demand\n- Launch booking site\n" }), + ] }, + { text: "Team staffed, work delegated, plan written.", toolCalls: [] }, + ]; + let index = 0; + return async () => { + const turn = script[index] ?? { text: "done", toolCalls: [] }; + index += 1; + return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: JSON.stringify({ id: `resp_${index}` }) }; + }; +} + +describe("autonomous bootstrap run", () => { + test("the queued kickoff run autonomously hires, delegates, and writes a deliverable", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-auto-${randomUUID()}.db`)); + const workspacePath = join(tmpdir(), `ac-auto-ws-${randomUUID()}`); + mkdirSync(workspacePath, { recursive: true }); + + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, scriptedCeo()); + const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); + const snapshot = new SnapshotService(connection.db); + + let orgId = ""; + connection.transaction(() => { + const summary = onboarding.bootstrap({ + companyName: "OrbitOps AI", + companyDescription: "AI-run operations company.", + employeeName: "Yang", + workspacePath, + executionMode: "ai_ceo", + autoApproveHires: true, + reviewDeliverables: false, + goalTitle: "Stand up a local service business in 30 days.", + goalDescription: "Validate the market and win the first paying customers.", + providerKey: "openai", + providerSecret: "sk-test-key", + providerModel: "gpt-5.4", + }); + orgId = summary.orgId; + }); + + // The bootstrap created exactly one queued run; the worker claims it. + const claimed = claimNextQueuedRun(connection); + expect(claimed?.org_id).toBe(orgId); + expect(claimNextQueuedRun(connection)).toBeNull(); + + const taskExecution = new TaskExecutionService(connection.db, providers); + await taskExecution.executeRun(claimed!.org_id, claimed!.id); + + const state = snapshot.load(); + const agentNames = state.agents.filter((a) => a.orgId === orgId).map((a) => a.name).sort(); + expect(agentNames).toEqual(["CEO", "Mara Quinn", "Owen Diaz"]); + + const delegated = state.tasks.find((t) => t.orgId === orgId && t.title === "Launch the booking site"); + expect(delegated).toBeTruthy(); + const mara = state.agents.find((a) => a.orgId === orgId && a.name === "Mara Quinn"); + expect(delegated?.assigneeAgentId).toBe(mara?.id); + + const deliverable = join(workspacePath, "plan", "operating-plan.md"); + expect(existsSync(deliverable)).toBe(true); + expect(readFileSync(deliverable, "utf8")).toContain("OrbitOps Operating Plan"); + + const run = state.taskRuns.find((r) => r.id === claimed!.id); + expect(run?.state).toBe("completed"); + connection.close(); + }); +}); diff --git a/AgentCompany-New/tests/unit/agent-run-loop.test.ts b/AgentCompany-New/tests/unit/agent-run-loop.test.ts new file mode 100644 index 00000000..c238e825 --- /dev/null +++ b/AgentCompany-New/tests/unit/agent-run-loop.test.ts @@ -0,0 +1,135 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { runAgentLoop } from "../../src/main/services/agent-run-loop"; +import type { AgentToolContext } from "../../src/main/services/agent-tools"; +import type { AgentTurnResult, ToolCall } from "../../src/main/services/provider-tool-turn"; + +interface ScriptedTurn { + text: string; + toolCalls: ToolCall[]; +} + +function fakeProvider(script: ScriptedTurn[]) { + let index = 0; + return { + runToolTurn: async (): Promise => { + const turn = script[index] ?? { text: "done", toolCalls: [] }; + index += 1; + return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: "{}", requestId: `req_${index}` }; + }, + }; +} + +function call(name: string, args: Record): ToolCall { + return { id: `call_${randomUUID()}`, name, arguments: args }; +} + +describe("agent run loop (real tool dispatch)", () => { + test("the CEO hires, creates a task, writes a deliverable, then converges", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-loop-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const workspaceRoot = join(tmpdir(), `ac-ws-${randomUUID()}`); + mkdirSync(workspaceRoot, { recursive: true }); + + let orgId = ""; + let ceoId = ""; + let providerId = ""; + const workspaceId = "wsp_test"; + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "OrbitOps", employeeName: "Yang", mission: "Run an AI company.", goal: "Win customers.", + }); + providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + connection.db.prepare(` + insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at) + values (?, ?, 'Default Workspace', ?, '', '', null, 1, datetime('now'), datetime('now')) + `).run(workspaceId, orgId, workspaceRoot); + ceoId = organization.createAgent({ + orgId, name: "CEO", role: "CEO", title: "Chief Executive Officer", department: "Executive", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, + roleLevel: "c_suite", workspaceId, + }); + }); + + const toolContext: AgentToolContext = { + orgId, + agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" }, + taskProjectId: null, + taskGoalId: null, + workspace: { id: workspaceId, localPath: workspaceRoot }, + }; + + const script: ScriptedTurn[] = [ + { text: "Staffing the team.", toolCalls: [ + call("hire_agent", { name: "Alex Rivera", role: "Growth Marketer", department: "Marketing" }), + call("hire_agent", { name: "Sam Lee", role: "Operations Lead", department: "Operations" }), + ] }, + { text: "Delegating.", toolCalls: [ + call("create_task", { title: "Build the booking site", description: "Ship a landing + booking flow.", assignee_agent_name: "Alex Rivera" }), + ] }, + { text: "Writing the plan.", toolCalls: [ + call("write_deliverable", { path: "plan/operating-plan.md", content: "# Operating Plan\n\n1. Validate demand.\n2. Launch booking site.\n" }), + ] }, + { text: "Team staffed, work delegated, plan written.", toolCalls: [] }, + ]; + + const result = await runAgentLoop( + { providers: fakeProvider(script), tools: { db: connection.db, organization } }, + { orgId, providerId, workspaceId, instructions: "You are the CEO.", initialUserTurn: "Start the company.", toolContext }, + ); + + expect(result.iterations).toBe(4); + expect(result.toolCallCount).toBe(4); + expect(result.finalText).toContain("Team staffed"); + + const agentNames = (connection.db.prepare("select name from agents where org_id = ? order by name").all(orgId) as Array<{ name: string }>).map((r) => r.name); + expect(agentNames).toEqual(["Alex Rivera", "CEO", "Sam Lee"]); + + const task = connection.db.prepare("select title, assignee_agent_id from tasks where org_id = ?").get(orgId) as { title: string; assignee_agent_id: string } | undefined; + expect(task?.title).toBe("Build the booking site"); + const alexId = (connection.db.prepare("select id from agents where org_id = ? and name = 'Alex Rivera'").get(orgId) as { id: string }).id; + expect(task?.assignee_agent_id).toBe(alexId); + + const deliverablePath = join(workspaceRoot, "plan", "operating-plan.md"); + expect(existsSync(deliverablePath)).toBe(true); + expect(readFileSync(deliverablePath, "utf8")).toContain("Operating Plan"); + + connection.close(); + }); + + test("rejects deliverable paths that escape the workspace", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-loop2-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const workspaceRoot = join(tmpdir(), `ac-ws2-${randomUUID()}`); + mkdirSync(workspaceRoot, { recursive: true }); + let orgId = ""; + let ceoId = ""; + let providerId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + ceoId = organization.createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null }); + }); + const ctx: AgentToolContext = { + orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" }, + taskProjectId: null, taskGoalId: null, workspace: { id: "wsp", localPath: workspaceRoot }, + }; + const script: ScriptedTurn[] = [ + { text: "escape", toolCalls: [call("write_deliverable", { path: "../escape.txt", content: "x" })] }, + { text: "done", toolCalls: [] }, + ]; + const result = await runAgentLoop( + { providers: fakeProvider(script), tools: { db: connection.db, organization } }, + { orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx }, + ); + // The tool returns an error string to the model; nothing is written outside the root. + expect(existsSync(join(workspaceRoot, "..", "escape.txt"))).toBe(false); + expect(result.toolCallCount).toBe(1); + connection.close(); + }); +}); diff --git a/AgentCompany-New/tests/unit/task-execution-service.test.ts b/AgentCompany-New/tests/unit/task-execution-service.test.ts index 1ade2d46..04ef3168 100644 --- a/AgentCompany-New/tests/unit/task-execution-service.test.ts +++ b/AgentCompany-New/tests/unit/task-execution-service.test.ts @@ -7,7 +7,8 @@ import { DatabaseConnection } from "../../src/main/database/connection"; import { ApprovalService } from "../../src/main/services/approval-service"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ProviderService } from "../../src/main/services/provider-service"; -import { providerAdapters, type ProviderAdapterRegistry } from "../../src/main/services/provider-adapters"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import type { AgentTurnResult, ProviderToolTurnInput } from "../../src/main/services/provider-tool-turn"; import { SkillService } from "../../src/main/services/skill-service"; import { SnapshotService } from "../../src/main/services/snapshot-service"; import { TaskExecutionService } from "../../src/main/services/task-execution-service"; @@ -33,21 +34,12 @@ describe("task execution service", () => { const organization = new OrganizationService(connection.db); const snapshot = new SnapshotService(connection.db); let capturedPrompt = ""; - const adapters: ProviderAdapterRegistry = { - ...providerAdapters, - openai: { - ...providerAdapters.openai, - createResponse: async ({ prompt, model }) => { - capturedPrompt = prompt; - return { - text: `executed:${model}`, - rawResponseJson: JSON.stringify({ id: "resp-task-1" }), - rateLimitState: null, - }; - }, - }, + const fakeToolTurn = async (input: ProviderToolTurnInput): Promise => { + const first = input.messages[0]; + if (first?.role === "user") capturedPrompt = first.text; + return { text: "executed:gpt-5.4", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-task-1" }) }; }; - const providers = new ProviderService(connection.db, adapters, testVault); + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn); const skills = new SkillService(connection.db, join(tmpdir(), `agentcompany-task-skills-${randomUUID()}`)); const execution = new TaskExecutionService(connection.db, providers); const skillDir = join(tmpdir(), `agentcompany-task-skill-${randomUUID()}`); @@ -149,18 +141,12 @@ describe("task execution service", () => { const organization = new OrganizationService(connection.db); const approvals = new ApprovalService(connection.db); const snapshot = new SnapshotService(connection.db); - const adapters: ProviderAdapterRegistry = { - ...providerAdapters, - openai: { - ...providerAdapters.openai, - createResponse: async ({ model }) => ({ - text: `executed:${model}`, - rawResponseJson: JSON.stringify({ id: "resp-task-approved" }), - rateLimitState: null, - }), - }, - }; - const providers = new ProviderService(connection.db, adapters, testVault); + const fakeToolTurn = async (): Promise => ({ + text: "executed:gpt-5.4", + toolCalls: [], + rawResponseJson: JSON.stringify({ id: "resp-task-approved" }), + }); + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let employeeId = ""; @@ -231,16 +217,10 @@ describe("task execution service", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); const snapshot = new SnapshotService(connection.db); - const adapters: ProviderAdapterRegistry = { - ...providerAdapters, - openai: { - ...providerAdapters.openai, - createResponse: async () => { - throw new Error("provider rejected task"); - }, - }, + const fakeToolTurn = async (): Promise => { + throw new Error("provider rejected task"); }; - const providers = new ProviderService(connection.db, adapters, testVault); + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let taskId = ""; @@ -282,7 +262,7 @@ describe("task execution service", () => { await expect(execution.runTask({ orgId, taskId })).rejects.toThrow("provider rejected task"); const state = snapshot.load(); const run = state.taskRuns[0]; - const request = state.providerRequests.find((item) => item.id === run?.providerRequestId); + const request = state.providerRequests.find((item) => item.state === "failed"); expect(run).toMatchObject({ taskId, state: "failed", errorMessage: "provider rejected task" }); expect(request).toMatchObject({ state: "failed", errorMessage: "provider rejected task" }); diff --git a/AgentCompany-New/tests/unit/task-queue-worker.test.ts b/AgentCompany-New/tests/unit/task-queue-worker.test.ts new file mode 100644 index 00000000..74636e4f --- /dev/null +++ b/AgentCompany-New/tests/unit/task-queue-worker.test.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { claimNextQueuedRun } from "../../src/main/app/task-queue-worker"; + +function seedQueuedRun(connection: DatabaseConnection, orgId: string, taskId: string, agentId: string, providerId: string): string { + const runId = `run_${randomUUID()}`; + connection.db.prepare(` + insert into task_runs( + id, org_id, task_id, agent_id, provider_id, provider_request_id, + execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json, + trigger, state, created_at, started_at, completed_at, error_message + ) values (?, ?, ?, ?, ?, null, ?, null, '[]', '{}', 'bootstrap', 'queued', datetime('now'), null, null, null) + `).run(runId, orgId, taskId, agentId, providerId, `ctx_${randomUUID()}`); + return runId; +} + +describe("task queue worker claim", () => { + test("claims queued runs once each, in order, then returns null", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-queue-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let agentId = ""; + let providerId = ""; + let taskId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + agentId = organization.createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null }); + taskId = organization.createTask({ + orgId, title: "Kickoff", description: "Start.", status: TASK_STATUS.BACKLOG, assigneeAgentId: agentId, + risk: RISK_LEVEL.LOW, projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false, + }); + }); + const first = seedQueuedRun(connection, orgId, taskId, agentId, providerId); + const second = seedQueuedRun(connection, orgId, taskId, agentId, providerId); + + const claimedA = claimNextQueuedRun(connection); + const claimedB = claimNextQueuedRun(connection); + const claimedC = claimNextQueuedRun(connection); + + expect([claimedA?.id, claimedB?.id].sort()).toEqual([first, second].sort()); + expect(claimedC).toBeNull(); + + const states = (connection.db.prepare("select state from task_runs where org_id = ? order by created_at").all(orgId) as Array<{ state: string }>).map((r) => r.state); + expect(states).toEqual(["running", "running"]); + connection.close(); + }); +});