From a95859a9f740cf7e9d957216df77d37a93b85dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 7 Jul 2026 14:19:08 -0400 Subject: [PATCH] Brief agents on their connectors, skills, and the CEO's grant authority; CEO predicate follows role_level --- src/main/services/task-execution-service.ts | 11 +- src/main/services/task-prompts.ts | 37 +++++- tests/unit/agent-instructions.test.ts | 127 ++++++++++++++++++++ 3 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 tests/unit/agent-instructions.test.ts diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index 5ff8d86e..b6ae93f9 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -24,6 +24,8 @@ import { type WorkspaceRow, } from "./task-prompts"; import { loadComposioTools } from "./composio/composio-tool-bridge"; +import { connectorDisplayName } from "./composio/connector-brands"; +import { ConnectorGrantsService } from "./composio/connector-grants"; const TASK_RUN_STATE = { QUEUED: "queued", @@ -122,8 +124,13 @@ export class TaskExecutionService { }; const chatContext = this.loadChatContext(orgId, isolation); const inboxContext = chatContext ? null : this.loadInboxContext(orgId, agent.id); - const isCeo = !agent.manager_agent_id; + // The CEO is the c_suite role — the same predicate the grant system + // enforces — not "has no manager", which any board hire can match. + const isCeo = agent.role_level === "c_suite"; const hasReports = this.agentHasReports(orgId, agent.id); + const connectorNames = new ConnectorGrantsService(this.db) + .toolkitsForAgent(orgId, agent.id) + .map(connectorDisplayName); const externalTools = await loadComposioTools(this.db, orgId, agent.id); const result = await runAgentLoop( { @@ -135,7 +142,7 @@ export class TaskExecutionService { orgId, providerId: provider.id, workspaceId: task.workspace_id, - instructions: buildAgentInstructions({ agent, companyName: this.orgName(orgId), isCeo, hasReports, externalToolCount: externalTools.length }), + instructions: buildAgentInstructions({ agent, companyName: this.orgName(orgId), isCeo, hasReports, connectorNames }), initialUserTurn: buildTaskPrompt({ task, agent, diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index bd9ca00a..69a25fae 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -76,7 +76,8 @@ export interface AgentInstructionContext { companyName: string; isCeo: boolean; hasReports: boolean; - externalToolCount: number; + /** Display names of the connected toolkits this agent carries in this run. */ + connectorNames: string[]; } /** @@ -91,11 +92,43 @@ export function buildAgentInstructions(input: AgentInstructionContext): string { `You are ${agent.name}, ${agent.title || agent.role} in ${agent.department} at ${input.companyName}. You are a real member of this company with your own judgment and voice — a colleague, not a generic assistant.`, roleVoice(input), "Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.", - `Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name, update_task_status tracks it, write_deliverable produces real files, create_document writes versioned company documents (PRDs, specs, memos, reports), read_messages catches you up on what teammates said, and send_message is how you talk to everyone.${input.externalToolCount > 0 ? ` You also have ${input.externalToolCount} external tools (composio_* prefixed) for interacting with services like GitHub, Slack, Gmail, etc. — use them when your work involves those platforms.` : ""}`, + `Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name, update_task_status tracks it, write_deliverable produces real files, create_document writes versioned company documents (PRDs, specs, memos, reports), read_messages catches you up on what teammates said, and send_message is how you talk to everyone.`, + capabilityBriefing(input), "Talk like a person on a team, not a status bot: address teammates by @name, say what you genuinely think, ask when you are unsure, and reply when someone asks you something. Before starting work, check your messages with read_messages to see if anyone needs you. Post where the work lives — 'engineering', 'product', or otherwise 'general' — and keep a back-and-forth in one thread by passing the thread_id a previous send_message returned. Hand work to a teammate with /handoff @Name and the file_id write_deliverable gives you.", ].join("\n"); } +/** + * What this agent can reach beyond the built-in tools, and how access moves: + * the CEO carries everything and hands out grants; everyone else works with + * what they were granted and knows where to ask for more. + */ +function capabilityBriefing(input: AgentInstructionContext): string { + const lines: string[] = []; + if (input.connectorNames.length > 0) { + lines.push( + `Connected external services you carry in this run: ${input.connectorNames.join(", ")}. Their tools arrive with the composio_ prefix — use them directly whenever the work touches those platforms instead of describing what you would do.`, + ); + } + lines.push( + "list_connectors shows every service the company has connected and who holds it; list_skills shows the company's skill library. Check them before planning work that depends on an external platform or a playbook.", + ); + if (input.isCeo) { + lines.push( + "You are the CEO: you automatically carry every connected service and every company skill. Access for everyone else is yours to manage — when you delegate work that needs an external service or a skill, grant it to that teammate first with grant_connector or grant_skill (match the tool to the job), and take it back with revoke_connector or revoke_skill when the work is done. A teammate without the grant cannot use the tool, so grant before you assign.", + ); + } else if (input.connectorNames.length === 0) { + lines.push( + "You currently hold no connector grants. If this work needs an external service, message the CEO to grant it to you (they run grant_connector) rather than working around it.", + ); + } else { + lines.push( + "If the work needs a service you do not carry yet, message the CEO to grant it to you (they run grant_connector).", + ); + } + return lines.join(" "); +} + function roleVoice(input: AgentInstructionContext): string { if (input.isCeo) { return "You run this company. Think in outcomes, make the call when it is yours to make, and put the right person on each piece instead of doing everything yourself — then tell the team the why, not just the what."; diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts new file mode 100644 index 00000000..f64f793f --- /dev/null +++ b/tests/unit/agent-instructions.test.ts @@ -0,0 +1,127 @@ +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 { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import type { AgentTurnResult, ProviderToolTurnInput } from "../../src/main/services/provider-tool-turn"; +import { ConnectorGrantsService } from "../../src/main/services/composio/connector-grants"; +import { TaskExecutionService } from "../../src/main/services/task-execution-service"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; +import { buildAgentInstructions, type TaskExecutionAgentRow } from "../../src/main/services/task-prompts"; + +function agentRow(overrides: Partial): TaskExecutionAgentRow { + return { + id: "agt_1", + name: "Ava", + role: "Engineer", + department: "Engineering", + title: "", + role_level: "staff", + model_provider_id: "prv_1", + model_id: "gpt-5.4", + manager_agent_id: "agt_ceo", + ...overrides, + }; +} + +describe("agent instructions", () => { + test("the CEO is briefed on its grant authority over connectors and skills", () => { + const text = buildAgentInstructions({ + agent: agentRow({ name: "CEO", role: "CEO", role_level: "c_suite", manager_agent_id: null }), + companyName: "OrbitOps", + isCeo: true, + hasReports: false, + connectorNames: ["GitHub", "Slack"], + }); + expect(text).toContain("every connected service and every company skill"); + expect(text).toContain("grant_connector"); + expect(text).toContain("grant_skill"); + expect(text).toContain("grant before you assign"); + expect(text).toContain("GitHub, Slack"); + expect(text).toContain("You run this company"); + }); + + test("staff with grants are told exactly which services they carry", () => { + const text = buildAgentInstructions({ + agent: agentRow({}), + companyName: "OrbitOps", + isCeo: false, + hasReports: false, + connectorNames: ["Stripe"], + }); + expect(text).toContain("Connected external services you carry in this run: Stripe"); + expect(text).toContain("composio_ prefix"); + expect(text).toContain("list_connectors"); + expect(text).toContain("list_skills"); + expect(text).not.toContain("You run this company"); + }); + + test("staff without grants are pointed at the CEO for access", () => { + const text = buildAgentInstructions({ + agent: agentRow({}), + companyName: "OrbitOps", + isCeo: false, + hasReports: false, + connectorNames: [], + }); + expect(text).toContain("You currently hold no connector grants"); + expect(text).toContain("message the CEO"); + expect(text).not.toContain("grant before you assign"); + }); +}); + +describe("agent instruction wiring", () => { + const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), + }; + + test("a manager-less staff hire is not the CEO, and carries its granted toolkit by name", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-instr-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let capturedInstructions = ""; + const fakeToolTurn = async (input: ProviderToolTurnInput): Promise => { + capturedInstructions = input.instructions ?? ""; + return { text: "done", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-1" }) }; + }; + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn); + const execution = new TaskExecutionService(connection.db, providers); + + let orgId = ""; + let agentId = ""; + let taskId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g", + }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + providers.saveCredential({ orgId, providerId, secret: "sk-test", model: "gpt-5.4" }); + agentId = organization.createAgent({ + orgId, name: "Ada", role: "Engineer", department: "Engineering", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null, + }); + const now = new Date().toISOString(); + connection.db.prepare(` + insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) + values ('ca_github', ?, 'github', 'GitHub', 'ac_github', 'ACTIVE', ?, ?) + `).run(orgId, now, now); + new ConnectorGrantsService(connection.db).grant({ + orgId, agentId, toolkitSlug: "github", grantedByType: "employee", grantedById: null, + }); + taskId = organization.createTask({ + orgId, title: "Ship it", description: "d", status: TASK_STATUS.IN_PROGRESS, + assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, projectId: null, goalId: null, + workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false, + }); + }); + + await execution.runTask({ orgId, taskId }); + + expect(capturedInstructions).not.toContain("You run this company"); + expect(capturedInstructions).toContain("Connected external services you carry in this run: GitHub"); + expect(capturedInstructions).toContain("list_connectors"); + }); +});