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).
This commit is contained in:
1 parent
1c7b81830e
commit
0575712e02
15 files changed
+1008
-74
No files matched your search
@@ -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<void> => {
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ProviderService, "runToolTurn">;
|
||||
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<AgentRunLoopResult> {
|
||||
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.`);
|
||||
}
|
||||
@@ -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(", ")}.`);
|
||||
}
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -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<AgentTurnResult & { requestId: string }> {
|
||||
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<string> {
|
||||
const execution = this.prepareExecution(input);
|
||||
const requestId = this.createRequestRecord(execution);
|
||||
|
||||
@@ -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<ProviderResponseRunner["runToolTurn"]>[0]): ReturnType<ProviderResponseRunner["runToolTurn"]> {
|
||||
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;
|
||||
|
||||
@@ -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<AgentTurnResult> {
|
||||
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<AgentTurnResult> {
|
||||
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<AgentTurnResult>;
|
||||
|
||||
/** 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";
|
||||
}
|
||||
@@ -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<string> {
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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");
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string, unknown>): 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<AgentTurnResult> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<AgentTurnResult & { requestId: string }> => {
|
||||
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<string, unknown>): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<AgentTurnResult> => {
|
||||
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<AgentTurnResult> => ({
|
||||
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<AgentTurnResult> => {
|
||||
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" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user