Fix agent assignment conversations

This commit is contained in:
ZacharyZhang-NY committed 2026-06-26 19:27:28 -04:00
1 parent bd63d42aa7
commit 745c22ec77
4 files changed
+184 -30

No files matched your search

@@ -1,5 +1,6 @@
import type { ProviderService } from "./provider-service";
import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools";
import { canonicalMentionHandle } from "./mention-handles";
import type { AgentTurnMessage } from "./provider-tool-turn";
const MAX_ITERATIONS = 12;
@@ -62,7 +63,13 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
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) };
return {
toolCallId: call.id,
toolName: call.name,
output: dispatchAgentTool(deps.tools, input.toolContext, call, {
announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls),
}),
};
});
messages.push({ role: "tool", results });
deps.onToolResults?.();
@@ -70,3 +77,18 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
throw new Error(`Agent run did not converge within ${MAX_ITERATIONS} iterations.`);
}
function shouldAnnounceTaskAssignment(call: { name: string; arguments: Record<string, unknown> }, toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>): boolean {
if (call.name !== "create_task") return true;
const assigneeName = stringArg(call.arguments.assignee_agent_name);
if (!assigneeName) return true;
const handle = `@${canonicalMentionHandle(assigneeName)}`;
return !toolCalls.some((toolCall) => {
if (toolCall.name !== "send_message") return false;
return stringArg(toolCall.arguments.body).toLowerCase().includes(handle);
});
}
function stringArg(value: unknown): string {
return typeof value === "string" ? value : "";
}
+101 -23
View File
@@ -6,6 +6,7 @@ import type { ChatService } from "./chat-service";
import { writeDeliverable } from "./deliverable-writer";
import { DocumentService } from "./document-service";
import { recordWorkspaceFile } from "./files/file-object-store";
import { canonicalMentionHandle } from "./mention-handles";
import type { JsonRecord } from "./provider-http";
import type { ToolCall, ToolDefinition } from "./provider-tool-turn";
@@ -27,6 +28,10 @@ export interface AgentToolDeps {
chat: ChatService;
}
export interface AgentToolDispatchOptions {
announceTaskAssignments?: boolean;
}
/** The tools the acting agent may call during a run. */
export const AGENT_TOOLS: ToolDefinition[] = [
{
@@ -121,13 +126,18 @@ export const AGENT_TOOLS: ToolDefinition[] = [
},
];
export function dispatchAgentTool(deps: AgentToolDeps, ctx: AgentToolContext, call: ToolCall): string {
export function dispatchAgentTool(
deps: AgentToolDeps,
ctx: AgentToolContext,
call: ToolCall,
options: AgentToolDispatchOptions = {},
): string {
try {
switch (call.name) {
case "hire_agent":
return hireAgent(deps, ctx, call.arguments);
case "create_task":
return createTask(deps, ctx, call.arguments);
return createTask(deps, ctx, call.arguments, options);
case "update_task_status":
return updateTaskStatus(deps, ctx, call.arguments);
case "write_deliverable":
@@ -167,26 +177,32 @@ function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord)
return `Hired ${name} as ${role} in ${department} (agent_id=${agentId}).`;
}
function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord, options: AgentToolDispatchOptions): 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)"}.`;
const assignee = assigneeName ? requireRunnableAgentByName(deps.db, ctx.orgId, assigneeName) : null;
const create = () => {
const taskId = deps.organization.createTask({
orgId: ctx.orgId,
title,
description,
status: TASK_STATUS.BACKLOG,
assigneeAgentId: assignee?.id ?? null,
risk: normalizeRisk(args.risk),
projectId: ctx.taskProjectId,
goalId: ctx.taskGoalId,
workspaceId: ctx.workspace?.id ?? null,
priority: TASK_PRIORITY.NORMAL,
requiresFilesystem: false,
});
const assignmentThread = options.announceTaskAssignments !== false && assignee && assignee.id !== ctx.agent.id
? postAssignmentMessage(deps, ctx, { taskId, title, description, assignee })
: null;
return { taskId, assignmentThread };
};
const { taskId, assignmentThread } = assignee ? withSavepoint(deps.db, "agent_create_task_assignment", create) : create();
return `Created task "${title}" (task_id=${taskId})${assignee ? ` assigned to ${assignee.name}` : " (unassigned)"}${assignmentThread ? ` in thread_id="${assignmentThread}"` : ""}.`;
}
function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
@@ -303,10 +319,72 @@ function createDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: Js
return `Created document "${title}" (doc_id=${docId}, type=${docType}, chars=${content.length}).`;
}
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;
interface RunnableAssignmentAgent {
id: string;
name: string;
department: string;
}
function requireRunnableAgentByName(db: DatabaseSync, orgId: string, name: string): RunnableAssignmentAgent {
const row = db.prepare(`
select a.id, a.name, a.department
from agents a
join providers p on p.id = a.model_provider_id and p.org_id = a.org_id
join service_identities si on si.agent_id = a.id and si.org_id = a.org_id
where a.org_id = ?
and lower(a.name) = lower(?)
and a.lifecycle = 'active'
and p.enabled = 1
limit 1
`).get(orgId, name.trim()) as RunnableAssignmentAgent | undefined;
if (!row) throw new Error(`No runnable agent named "${name}" with an enabled provider and service identity.`);
return row;
}
function postAssignmentMessage(
deps: AgentToolDeps,
ctx: AgentToolContext,
input: { taskId: string; title: string; description: string; assignee: RunnableAssignmentAgent },
): string {
const channel = assignmentChannel(input.assignee.department);
const body = [
`@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`,
input.description,
].join(" ");
const space = deps.db.prepare(
"select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1",
).get(ctx.orgId, channel) as { id: string } | undefined;
if (!space) throw new Error(`No channel named "${channel}".`);
const messageId = deps.chat.sendMessage({
orgId: ctx.orgId,
spaceId: space.id,
threadId: null,
senderType: "agent",
senderId: ctx.agent.id,
bodyMd: body,
attachmentFileIds: [],
});
const row = deps.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string };
return row.thread_id;
}
function assignmentChannel(department: string): string {
const normalized = department.trim().toLowerCase();
if (normalized === "engineering" || normalized === "product") return normalized;
return "general";
}
function withSavepoint<T>(db: DatabaseSync, name: string, operation: () => T): T {
db.exec(`savepoint ${name}`);
try {
const result = operation();
db.exec(`release savepoint ${name}`);
return result;
} catch (error) {
db.exec(`rollback to savepoint ${name}`);
db.exec(`release savepoint ${name}`);
throw error;
}
}
function requireString(value: unknown, field: string): string {
@@ -131,7 +131,7 @@ describe("autonomous bootstrap run", () => {
// The CEO posts a file handoff in the company channel, so chat carries the
// actual artifact and recipient mentions instead of a plain status note.
const general = state.chatSpaces.find((s) => s.orgId === orgId && s.name === "general");
const kickoff = state.chatMessages.find((m) => m.orgId === orgId && m.spaceId === general?.id);
const kickoff = state.chatMessages.find((m) => m.orgId === orgId && m.spaceId === general?.id && m.bodyMd.includes("/handoff"));
expect(kickoff).toMatchObject({ senderType: "agent", senderId: ceo?.id });
expect(kickoff?.bodyMd).toContain("/handoff @Mara-Quinn");
expect(state.chatAttachments).toContainEqual(expect.objectContaining({ messageId: kickoff?.id, fileId: planFile?.id }));
@@ -7,6 +7,7 @@ import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { ChatService } from "../../src/main/services/chat-service";
import { runAgentLoop } from "../../src/main/services/agent-run-loop";
import { canonicalMentionHandle } from "../../src/main/services/mention-handles";
import type { AgentToolContext } from "../../src/main/services/agent-tools";
import type { AgentTurnResult, ToolCall } from "../../src/main/services/provider-tool-turn";
@@ -46,6 +47,7 @@ describe("agent run loop (real tool dispatch)", () => {
organizationName: "OrbitOps", employeeName: "Yang", mission: "Run an AI company.", goal: "Win customers.",
});
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
connection.db.prepare("update providers set enabled = 1 where id = ?").run(providerId);
connection.db.prepare(`
insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at)
values (?, ?, 'Default Workspace', ?, '', '', null, 1, datetime('now'), datetime('now'))
@@ -108,14 +110,13 @@ describe("agent run loop (real tool dispatch)", () => {
expect(existsSync(deliverablePath)).toBe(true);
expect(readFileSync(deliverablePath, "utf8")).toContain("Operating Plan");
// The CEO's coordination message lands in #general so the channel is no longer blank.
const generalSpaceId = (connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'general'").get(orgId) as { id: string }).id;
// The assignment message and the CEO's coordination message both land in chat.
const messages = connection.db.prepare(
"select sender_type, sender_id, body_md, space_id from chat_messages where org_id = ?",
).all(orgId) as Array<{ sender_type: string; sender_id: string; body_md: string; space_id: string }>;
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({ sender_type: "agent", sender_id: ceoId, space_id: generalSpaceId });
expect(messages[0]?.body_md).toContain("operating plan is live");
expect(messages).toHaveLength(2);
expect(messages).toContainEqual(expect.objectContaining({ sender_type: "agent", sender_id: ceoId, body_md: expect.stringContaining(`@${canonicalMentionHandle("Alex Rivera")}`) }));
expect(messages).toContainEqual(expect.objectContaining({ sender_type: "agent", sender_id: ceoId, body_md: expect.stringContaining("operating plan is live") }));
connection.close();
});
@@ -151,6 +152,59 @@ describe("agent run loop (real tool dispatch)", () => {
connection.close();
});
test("delegating a task opens an assignment thread for the assignee", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop-assign-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
let orgId = "";
let ceoId = "";
let providerId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g",
});
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
connection.db.prepare("update providers set enabled = 1 where id = ?").run(providerId);
ceoId = organization.createAgent({
orgId, name: "CEO", role: "CEO", department: "Executive",
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
});
});
const ctx: AgentToolContext = {
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
taskProjectId: null, taskGoalId: null, workspace: null,
};
const script: ScriptedTurn[] = [
{ text: "staff", toolCalls: [call("hire_agent", { name: "Alex Rivera", role: "Engineer", department: "Engineering" })] },
{ text: "delegate", toolCalls: [call("create_task", { title: "Build the booking API", description: "Ship the booking API.", assignee_agent_name: "Alex Rivera" })] },
{ text: "done", toolCalls: [] },
];
await runAgentLoop(
{ providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
{ orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx },
);
const alex = connection.db.prepare("select id from agents where org_id = ? and name = 'Alex Rivera'").get(orgId) as { id: string };
const assignment = connection.db.prepare(`
select m.body_md, m.thread_id, s.name as space_name
from chat_messages m
join chat_spaces s on s.id = m.space_id
where m.org_id = ?
`).get(orgId) as { body_md: string; thread_id: string; space_name: string } | undefined;
expect(assignment).toMatchObject({ space_name: "engineering" });
expect(assignment?.body_md).toContain(`@${canonicalMentionHandle("Alex Rivera")}`);
expect(assignment?.body_md).toContain("Build the booking API");
const wake = connection.db.prepare(`
select state, trigger, secret_scope_json
from task_runs
where org_id = ? and agent_id = ? and trigger = 'chat_mention'
`).get(orgId, alex.id) as { state: string; trigger: string; secret_scope_json: string } | undefined;
expect(wake).toMatchObject({ state: "queued", trigger: "chat_mention" });
expect(JSON.parse(wake?.secret_scope_json ?? "{}")).toMatchObject({ sourceThreadId: assignment?.thread_id });
connection.close();
});
test("a first turn with no text and no tool calls fails the run instead of completing a no-op", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop3-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);