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