From c8de72c810ce6b55c85a7392da6e265a8467ec75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 26 Jun 2026 17:28:13 -0400 Subject: [PATCH] Bound autonomous agent-to-agent chat wake loops per thread An @mention by an agent wakes the mentioned teammate by creating a task run, and the woken agent's reply can mention back, an unbounded A->B->A loop whose only backstop was the per-provider monthly request cap (which, once tripped, fails every agent on that provider). Before enriching agent conversation this had to be made safe. Cap the agent-authored messages a thread may accumulate since its last human/board message; past the cap, agent-triggered wakes pause until a person re-engages. A human message always wakes mentions and resets the budget. Counted from chat_messages, so no schema change. --- .../src/main/services/chat-service.ts | 33 +++++++++++ .../tests/unit/chat-governance.test.ts | 57 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 3c578bba..66ee0a0c 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -37,6 +37,16 @@ import { ChatAgentWakeService } from "./chat-agent-wake-service"; const SPACE_TYPES = new Set(["company", "division", "department", "project", "pod", "ad_hoc", "direct"]); const SPACE_VISIBILITIES = new Set(["workspace", "restricted"]); +/** + * How many agent-authored messages a thread may accumulate after the last human + * message before agent-triggered @mention wake-ups stop. Two agents replying to + * each other each post a message, so this bounds an autonomous exchange to a + * finite length and hands control back to a person instead of letting the + * company talk to itself forever. A human (or board) message in the thread + * always wakes the mentioned agents and resets this budget. + */ +const MAX_AUTONOMOUS_THREAD_AGENT_TURNS = 16; + export class ChatService { private readonly audit: AuditService; private readonly approvals: ApprovalService; @@ -365,6 +375,10 @@ export class ChatService { const agentMentions = mentions.filter( (m) => m.target.type === "agent" && !(input.senderType === "agent" && input.senderId === m.target.id), ); + if (agentMentions.length === 0) return; + // An agent's own message must not keep waking teammates once the thread has + // run its autonomous budget; only a human re-entering the thread can. + if (input.senderType === "agent" && this.autonomousThreadTurnsExhausted(input.orgId, threadId)) return; for (const mention of agentMentions) { try { this.agentWake.wakeFromMention(input, mention.target.id, messageId, threadId, now); @@ -374,6 +388,25 @@ export class ChatService { } } + /** + * True once a thread holds MAX_AUTONOMOUS_THREAD_AGENT_TURNS agent messages + * since the last human or board message — the point past which agent↔agent + * wake-ups would loop without human involvement. Counting only the messages + * after the last human turn lets a person re-engage and grant a fresh budget. + */ + private autonomousThreadTurnsExhausted(orgId: string, threadId: string): boolean { + const row = this.db.prepare(` + select count(*) as turns + from chat_messages + where org_id = ? and thread_id = ? and sender_type = 'agent' + and created_at > coalesce(( + select max(created_at) from chat_messages + where org_id = ? and thread_id = ? and sender_type in ('employee', 'board') + ), '') + `).get(orgId, threadId, orgId, threadId) as { turns: number }; + return row.turns >= MAX_AUTONOMOUS_THREAD_AGENT_TURNS; + } + private requireSpace(orgId: string, spaceId: string): SpaceRow { const space = this.db.prepare("select id, org_id, visibility, space_type from chat_spaces where id = ? and org_id = ?") .get(spaceId, orgId) as SpaceRow | undefined; diff --git a/AgentCompany-New/tests/unit/chat-governance.test.ts b/AgentCompany-New/tests/unit/chat-governance.test.ts index d5f58ada..d648b29a 100644 --- a/AgentCompany-New/tests/unit/chat-governance.test.ts +++ b/AgentCompany-New/tests/unit/chat-governance.test.ts @@ -370,6 +370,63 @@ describe("chat governance", () => { connection.close(); }); + test("bounds autonomous agent-to-agent wake loops until a human re-engages", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const providers = new ProviderService(connection.db, providerAdapters, testVault); + const chat = new ChatService(connection.db); + let orgId = ""; + let employeeId = ""; + let speakerId = ""; + let targetId = ""; + let spaceId = ""; + let threadId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + 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-live-provider", model: "gpt-5.4" }); + speakerId = organization.createAgent({ orgId, name: "Alice", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 10000, managerAgentId: null }); + targetId = organization.createAgent({ orgId, name: "Bob", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 10000, managerAgentId: null }); + spaceId = chat.createSpace({ orgId, spaceType: "project", name: "engineering", topic: "Eng", visibility: "workspace" }); + }); + + const wakeRunCount = (): number => (connection.db.prepare( + "select count(*) as n from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'", + ).get(orgId, targetId) as { n: number }).n; + + // Alice (an agent) keeps @mentioning Bob in one thread. Each post is an + // autonomous turn; only the first batch wakes Bob, then the budget is spent. + connection.transaction(() => { + const first = chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "agent", senderId: speakerId, bodyMd: "@Bob turn 1", attachmentFileIds: [] }); + threadId = (connection.db.prepare("select thread_id from chat_messages where id = ?").get(first) as { thread_id: string }).thread_id; + for (let turn = 2; turn <= 40; turn += 1) { + chat.sendMessage({ orgId, spaceId, threadId, senderType: "agent", senderId: speakerId, bodyMd: `@Bob turn ${turn}`, attachmentFileIds: [] }); + } + }); + const capped = wakeRunCount(); + // A real exchange happened, but it is finite — far below the 40 posts. + expect(capped).toBeGreaterThanOrEqual(10); + expect(capped).toBeLessThanOrEqual(16); + + // A human re-enters the thread: their message wakes Bob and grants the + // agents a fresh budget, so the next agent turn wakes him again. + connection.transaction(() => { + chat.sendMessage({ orgId, spaceId, threadId, senderType: "employee", senderId: employeeId, bodyMd: "Keep going @Bob", attachmentFileIds: [] }); + chat.sendMessage({ orgId, spaceId, threadId, senderType: "agent", senderId: speakerId, bodyMd: "@Bob resuming", attachmentFileIds: [] }); + }); + expect(wakeRunCount()).toBeGreaterThan(capped); + + connection.close(); + }); + test("records board interventions and enforces thread control", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);