refactor: router has zero side effects — agent controls everything via tools
Removed: auto in_progress, auto in_review, selfAssignedTasks tracking. Router only forwards events to agent session and posts replies. Agent decides status changes, task flow, etc. through MCP tools.
This commit is contained in:
parent
e58c39dc0c
commit
64eca81f5a
197
src/router.ts
197
src/router.ts
@ -14,10 +14,8 @@ export interface TaskTracker {
|
|||||||
|
|
||||||
export class EventRouter {
|
export class EventRouter {
|
||||||
private log = logger.child({ component: 'event-router' });
|
private log = logger.child({ component: 'event-router' });
|
||||||
private activeTasks = 0;
|
|
||||||
private trackerTools: ToolDefinition[];
|
private trackerTools: ToolDefinition[];
|
||||||
/** Tasks taken via tool call (agent already knows about them — skip auto-processing) */
|
|
||||||
private selfAssignedTasks = new Set<string>();
|
|
||||||
constructor(
|
constructor(
|
||||||
private config: AgentConfig,
|
private config: AgentConfig,
|
||||||
private client: TrackerClient,
|
private client: TrackerClient,
|
||||||
@ -26,7 +24,7 @@ export class EventRouter {
|
|||||||
this.trackerTools = createTrackerTools({
|
this.trackerTools = createTrackerTools({
|
||||||
trackerClient: client,
|
trackerClient: client,
|
||||||
agentSlug: config.slug,
|
agentSlug: config.slug,
|
||||||
selfAssignedTasks: this.selfAssignedTasks,
|
selfAssignedTasks: new Set(), // kept for ToolContext compat, no longer used in router
|
||||||
});
|
});
|
||||||
this.log.info({ toolCount: this.trackerTools.length }, 'Tracker tools registered');
|
this.log.info({ toolCount: this.trackerTools.length }, 'Tracker tools registered');
|
||||||
}
|
}
|
||||||
@ -39,8 +37,6 @@ export class EventRouter {
|
|||||||
await this.handleTaskAssigned(event.data);
|
await this.handleTaskAssigned(event.data);
|
||||||
break;
|
break;
|
||||||
case 'message.new':
|
case 'message.new':
|
||||||
await this.handleMessageNew(event.data);
|
|
||||||
break;
|
|
||||||
case 'chat.message':
|
case 'chat.message':
|
||||||
await this.handleMessageNew(event.data);
|
await this.handleMessageNew(event.data);
|
||||||
break;
|
break;
|
||||||
@ -49,51 +45,76 @@ export class EventRouter {
|
|||||||
case 'agent.status':
|
case 'agent.status':
|
||||||
case 'agent.online':
|
case 'agent.online':
|
||||||
case 'agent.offline':
|
case 'agent.offline':
|
||||||
this.log.info({ event: event.event, data: event.data }, 'Informational event');
|
this.log.info({ event: event.event }, 'Informational event, skipping');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
this.log.warn({ event: event.event }, 'Unknown event type, ignoring');
|
this.log.warn({ event: event.event }, 'Unknown event type, ignoring');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* task.assigned — notify agent via session, no side effects.
|
||||||
|
* Agent decides what to do (change status, start work, etc.) via tools.
|
||||||
|
*/
|
||||||
private async handleTaskAssigned(data: Record<string, unknown>): Promise<void> {
|
private async handleTaskAssigned(data: Record<string, unknown>): Promise<void> {
|
||||||
// Protocol: data = { task: TaskOut } or data IS the task
|
|
||||||
const task = (data.task as TrackerTask) || (data as unknown as TrackerTask);
|
const task = (data.task as TrackerTask) || (data as unknown as TrackerTask);
|
||||||
if (!task?.id) {
|
if (!task?.id) {
|
||||||
this.log.error({ data }, 'task.assigned event missing task data');
|
this.log.error({ data }, 'task.assigned event missing task data');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if agent took this task itself via tool call (already in conversation context)
|
this.log.info('│ TASK ASSIGNED: %s — %s', task.key || task.id, task.title);
|
||||||
if (this.selfAssignedTasks.has(task.id)) {
|
|
||||||
this.selfAssignedTasks.delete(task.id);
|
// Build human-readable prompt — agent decides what to do
|
||||||
this.log.info('│ TASK %s self-assigned via tool, skipping auto-processing', task.key);
|
const prompt = [
|
||||||
|
`Тебе назначена задача: ${task.key || ''} — ${task.title}`,
|
||||||
|
task.description ? `\nОписание: ${task.description}` : '',
|
||||||
|
task.priority ? `Приоритет: ${task.priority}` : '',
|
||||||
|
'',
|
||||||
|
'Ознакомься с задачей. Если готов — возьми в работу (обнови статус через update_task). Если нужна информация — спроси.',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
await this.runAndReply(prompt, task.id ? { task_id: task.id } : undefined);
|
||||||
|
this.log.info('└── TASK ASSIGNED handled: %s', task.key || task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* message.new / chat.message — forward to agent session, reply to same context.
|
||||||
|
*/
|
||||||
|
private async handleMessageNew(data: Record<string, unknown>): Promise<void> {
|
||||||
|
const content = (data.content as string) || '';
|
||||||
|
const authorSlug = (data.author_slug as string) || (data.sender_slug as string) || '';
|
||||||
|
const taskId = data.task_id as string | undefined;
|
||||||
|
const chatId = data.chat_id as string | undefined;
|
||||||
|
|
||||||
|
// Don't respond to own messages
|
||||||
|
if (authorSlug === this.config.slug) {
|
||||||
|
this.log.debug('Ignoring own message');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeTasks >= this.config.maxConcurrentTasks) {
|
if (!content) {
|
||||||
this.log.warn({ taskId: task.id, activeTasks: this.activeTasks }, 'Max concurrent tasks reached, skipping');
|
this.log.warn({ data }, 'message.new event missing content');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeTasks++;
|
this.log.info('│ MESSAGE from @%s: "%s"', authorSlug, content.slice(0, 200));
|
||||||
this.taskTracker.addTask(task.id);
|
|
||||||
this.log.info('│ TASK ASSIGNED: %s — %s', task.key, task.title);
|
|
||||||
this.log.info('│ Priority: %s | Status: %s', task.priority || '-', task.status || '-');
|
|
||||||
if (task.description) this.log.info('│ Description: %s', task.description.slice(0, 200));
|
|
||||||
|
|
||||||
try {
|
const replyCtx = taskId ? { task_id: taskId } : chatId ? { chat_id: chatId } : undefined;
|
||||||
// Update status → in_progress
|
await this.runAndReply(content, replyCtx);
|
||||||
this.log.info('│ → Updating task status to in_progress...');
|
this.log.info('└── MESSAGE handled');
|
||||||
await this.client.updateTask(task.id, { status: 'in_progress' }).catch((err) => {
|
}
|
||||||
this.log.warn({ err, taskId: task.id }, 'Failed to update task status to in_progress');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build prompt from task
|
/**
|
||||||
const prompt = buildPromptFromTask(task);
|
* Run agent with prompt and send reply to the appropriate context.
|
||||||
|
* No side effects — agent controls everything via tools.
|
||||||
// Run agent and collect output
|
*/
|
||||||
|
private async runAndReply(
|
||||||
|
prompt: string,
|
||||||
|
replyCtx?: { task_id?: string; chat_id?: string },
|
||||||
|
): Promise<void> {
|
||||||
let collectedText = '';
|
let collectedText = '';
|
||||||
|
|
||||||
for await (const msg of runAgent(prompt, {
|
for await (const msg of runAgent(prompt, {
|
||||||
workDir: this.config.workDir,
|
workDir: this.config.workDir,
|
||||||
sessionId: this.config.sessionId,
|
sessionId: this.config.sessionId,
|
||||||
@ -108,118 +129,20 @@ export class EventRouter {
|
|||||||
if (msg.type === 'text') {
|
if (msg.type === 'text') {
|
||||||
collectedText += msg.content;
|
collectedText += msg.content;
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
this.log.error({ taskId: task.id, error: msg.content }, 'Agent error during task');
|
this.log.error({ error: msg.content }, 'Agent error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post result as comment to task
|
if (collectedText.trim() && replyCtx) {
|
||||||
if (collectedText.trim()) {
|
const payload = {
|
||||||
this.log.info('│ → Sending result comment (%d chars)...', collectedText.trim().length);
|
content: collectedText.trim(),
|
||||||
this.log.info('│ Result preview: %s', collectedText.trim().slice(0, 300));
|
task_id: replyCtx.task_id,
|
||||||
await this.client.sendMessage({ task_id: task.id, content: collectedText.trim() }, this.config.slug).catch((err) => {
|
chat_id: replyCtx.chat_id,
|
||||||
this.log.error({ err, taskId: task.id }, 'Failed to add comment');
|
};
|
||||||
|
|
||||||
|
await this.client.sendMessage(payload, this.config.slug).catch((err) => {
|
||||||
|
this.log.error({ err, replyCtx }, 'Failed to send reply');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status → in_review
|
|
||||||
this.log.info('│ → Updating task status to in_review...');
|
|
||||||
await this.client.updateTask(task.id, { status: 'in_review' }).catch((err) => {
|
|
||||||
this.log.warn({ err, taskId: task.id }, 'Failed to update task status to in_review');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.log.info('└── TASK DONE: %s (%d chars output)', task.key, collectedText.length);
|
|
||||||
} catch (err) {
|
|
||||||
this.log.error({ err, taskId: task.id }, 'Task processing failed');
|
|
||||||
|
|
||||||
await this.client.sendMessage({
|
|
||||||
task_id: task.id,
|
|
||||||
content: `Agent error: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
}, this.config.slug).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
this.activeTasks--;
|
|
||||||
this.taskTracker.removeTask(task.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMessageNew(data: Record<string, unknown>): Promise<void> {
|
|
||||||
// Protocol: message.new → { id, chat_id, task_id, author_slug, content, mentions, ... }
|
|
||||||
const content = (data.content as string) || '';
|
|
||||||
const authorSlug = (data.author_slug as string) || (data.sender_slug as string) || '';
|
|
||||||
const taskId = data.task_id as string | undefined;
|
|
||||||
const chatId = data.chat_id as string | undefined;
|
|
||||||
const mentions = (data.mentions as string[]) || [];
|
|
||||||
|
|
||||||
// Don't respond to own messages
|
|
||||||
if (authorSlug === this.config.slug) {
|
|
||||||
this.log.debug('Ignoring own message');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
this.log.warn({ data }, 'message.new event missing content');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if agent is mentioned (for filtered modes)
|
|
||||||
const isMentioned = mentions.includes(this.config.slug);
|
|
||||||
this.log.info('│ MESSAGE from @%s: "%s"', authorSlug, content.slice(0, 200));
|
|
||||||
this.log.info('│ Context: %s | Mentioned: %s', taskId ? `task=${taskId}` : chatId ? `chat=${chatId}` : 'none', isMentioned);
|
|
||||||
|
|
||||||
let collectedText = '';
|
|
||||||
for await (const msg of runAgent(content, {
|
|
||||||
workDir: this.config.workDir,
|
|
||||||
sessionId: this.config.sessionId,
|
|
||||||
model: this.config.model,
|
|
||||||
provider: this.config.provider,
|
|
||||||
systemPrompt: this.config.prompt || undefined,
|
|
||||||
skillsDir: this.config.agentHome,
|
|
||||||
sessionDir: path.join(this.config.agentHome, 'sessions'),
|
|
||||||
allowedPaths: this.config.allowedPaths,
|
|
||||||
customTools: this.trackerTools,
|
|
||||||
})) {
|
|
||||||
if (msg.type === 'text') {
|
|
||||||
collectedText += msg.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply to the same context (task comment or chat message)
|
|
||||||
if (collectedText.trim()) {
|
|
||||||
this.log.info('│ → Sending reply (%d chars): %s', collectedText.trim().length, collectedText.trim().slice(0, 200));
|
|
||||||
if (taskId) {
|
|
||||||
await this.client.sendMessage({ task_id: taskId, content: collectedText.trim() }, this.config.slug).catch((err) => {
|
|
||||||
this.log.error({ err, taskId }, 'Failed to send task comment reply');
|
|
||||||
});
|
|
||||||
} else if (chatId) {
|
|
||||||
await this.client.sendMessage({ chat_id: chatId, content: collectedText.trim() }, this.config.slug).catch((err) => {
|
|
||||||
this.log.error({ err, chatId }, 'Failed to send chat reply');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.log.info('└── MESSAGE REPLIED');
|
|
||||||
} else {
|
|
||||||
this.log.info('└── MESSAGE PROCESSED (no reply)');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPromptFromTask(task: TrackerTask): string {
|
|
||||||
const parts = [`# Задача: ${task.key} — ${task.title}`, ''];
|
|
||||||
|
|
||||||
if (task.description) {
|
|
||||||
parts.push(task.description, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.priority) {
|
|
||||||
parts.push(`Приоритет: ${task.priority}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.files?.length) {
|
|
||||||
parts.push('', 'Прикреплённые файлы:');
|
|
||||||
for (const f of task.files) {
|
|
||||||
parts.push(`- ${f.name} (${f.url})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push('', 'Выполни задачу. После завершения опиши что было сделано.');
|
|
||||||
|
|
||||||
return parts.join('\n');
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user