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.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-26 17:28:13 -04:00
1 parent cbb87171e6
commit c8de72c810
2 files changed
+90

No files matched your search

@@ -37,6 +37,16 @@ import { ChatAgentWakeService } from "./chat-agent-wake-service";
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "division", "department", "project", "pod", "ad_hoc", "direct"]);
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["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;
@@ -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);