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");
|
||||
}
|
||||
Reference in new issue
Block a user