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:
Markov 2026-02-24 11:57:19 +01:00
parent e58c39dc0c
commit 64eca81f5a

View File

@ -14,10 +14,8 @@ export interface TaskTracker {
export class EventRouter {
private log = logger.child({ component: 'event-router' });
private activeTasks = 0;
private trackerTools: ToolDefinition[];
/** Tasks taken via tool call (agent already knows about them — skip auto-processing) */
private selfAssignedTasks = new Set<string>();
constructor(
private config: AgentConfig,
private client: TrackerClient,
@ -26,7 +24,7 @@ export class EventRouter {
this.trackerTools = createTrackerTools({
trackerClient: client,
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');
}
@ -39,8 +37,6 @@ export class EventRouter {
await this.handleTaskAssigned(event.data);
break;
case 'message.new':
await this.handleMessageNew(event.data);
break;
case 'chat.message':
await this.handleMessageNew(event.data);
break;
@ -49,105 +45,47 @@ export class EventRouter {
case 'agent.status':
case 'agent.online':
case 'agent.offline':
this.log.info({ event: event.event, data: event.data }, 'Informational event');
this.log.info({ event: event.event }, 'Informational event, skipping');
break;
default:
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> {
// Protocol: data = { task: TaskOut } or data IS the task
const task = (data.task as TrackerTask) || (data as unknown as TrackerTask);
if (!task?.id) {
this.log.error({ data }, 'task.assigned event missing task data');
return;
}
// Skip if agent took this task itself via tool call (already in conversation context)
if (this.selfAssignedTasks.has(task.id)) {
this.selfAssignedTasks.delete(task.id);
this.log.info('│ TASK %s self-assigned via tool, skipping auto-processing', task.key);
return;
}
this.log.info('│ TASK ASSIGNED: %s — %s', task.key || task.id, task.title);
if (this.activeTasks >= this.config.maxConcurrentTasks) {
this.log.warn({ taskId: task.id, activeTasks: this.activeTasks }, 'Max concurrent tasks reached, skipping');
return;
}
// Build human-readable prompt — agent decides what to do
const prompt = [
`Тебе назначена задача: ${task.key || ''}${task.title}`,
task.description ? `\nОписание: ${task.description}` : '',
task.priority ? `Приоритет: ${task.priority}` : '',
'',
'Ознакомься с задачей. Если готов — возьми в работу (обнови статус через update_task). Если нужна информация — спроси.',
].filter(Boolean).join('\n');
this.activeTasks++;
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 {
// Update status → in_progress
this.log.info('│ → Updating task status to in_progress...');
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 and collect output
let collectedText = '';
for await (const msg of runAgent(prompt, {
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;
} else if (msg.type === 'error') {
this.log.error({ taskId: task.id, error: msg.content }, 'Agent error during task');
}
}
// Post result as comment to task
if (collectedText.trim()) {
this.log.info('│ → Sending result comment (%d chars)...', collectedText.trim().length);
this.log.info('│ Result preview: %s', collectedText.trim().slice(0, 300));
await this.client.sendMessage({ task_id: task.id, content: collectedText.trim() }, this.config.slug).catch((err) => {
this.log.error({ err, taskId: task.id }, 'Failed to add comment');
});
}
// 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);
}
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> {
// 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) {
@ -160,13 +98,24 @@ export class EventRouter {
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);
const replyCtx = taskId ? { task_id: taskId } : chatId ? { chat_id: chatId } : undefined;
await this.runAndReply(content, replyCtx);
this.log.info('└── MESSAGE handled');
}
/**
* Run agent with prompt and send reply to the appropriate context.
* No side effects agent controls everything via tools.
*/
private async runAndReply(
prompt: string,
replyCtx?: { task_id?: string; chat_id?: string },
): Promise<void> {
let collectedText = '';
for await (const msg of runAgent(content, {
for await (const msg of runAgent(prompt, {
workDir: this.config.workDir,
sessionId: this.config.sessionId,
model: this.config.model,
@ -179,47 +128,21 @@ export class EventRouter {
})) {
if (msg.type === 'text') {
collectedText += msg.content;
} else if (msg.type === 'error') {
this.log.error({ error: msg.content }, 'Agent error');
}
}
// 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)');
if (collectedText.trim() && replyCtx) {
const payload = {
content: collectedText.trim(),
task_id: replyCtx.task_id,
chat_id: replyCtx.chat_id,
};
await this.client.sendMessage(payload, this.config.slug).catch((err) => {
this.log.error({ err, replyCtx }, 'Failed to send 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');
}