Fix agent assignment conversations
This commit is contained in:
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 : "";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user