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:
ZacharyZhang-NY committed 2026-05-29 21:08:04 -04:00
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);
}
+6
View File
@@ -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");
}