diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index a951ac2..d10a8b9 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -33,6 +33,9 @@ interface ContainerInput { interface ContainerOutput { status: 'success' | 'error'; phase?: 'progress' | 'final' | 'tool-activity' | 'intermediate'; + agentId?: string; + agentLabel?: string; + agentDone?: boolean; result: string | null; newSessionId?: string; error?: string; @@ -561,10 +564,21 @@ async function runQuery( if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { const tn = message as { task_id: string; status: string; summary: string }; log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); + if (tn.status === 'completed' || tn.status === 'error' || tn.status === 'cancelled') { + writeOutput({ + status: 'success', + phase: 'progress', + agentId: tn.task_id, + agentDone: true, + result: tn.summary || null, + newSessionId, + }); + } } if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_progress') { const tp = message as Record; + const taskId = typeof tp.task_id === 'string' ? tp.task_id : undefined; const summary = typeof tp.summary === 'string' ? tp.summary : ''; const description = typeof tp.description === 'string' ? tp.description : ''; if (description) { @@ -572,6 +586,7 @@ async function runQuery( status: 'success', phase: 'tool-activity', result: description, + agentId: taskId, newSessionId, }); } @@ -586,6 +601,8 @@ async function runQuery( status: 'success', phase: 'progress', result: `πŸ”„ ${desc}`, + agentId: ts.task_id, + agentLabel: desc, newSessionId, }); } diff --git a/src/agent-runner.ts b/src/agent-runner.ts index b3a7b55..74842cf 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -42,6 +42,9 @@ export interface AgentOutput { status: 'success' | 'error'; result: string | null; phase?: 'progress' | 'final' | 'tool-activity' | 'intermediate'; + agentId?: string; + agentLabel?: string; + agentDone?: boolean; newSessionId?: string; error?: string; } diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 348f21a..32c0b4d 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -7,6 +7,11 @@ import { type Channel, type RegisteredGroup } from './types.js'; export type VisiblePhase = 'silent' | 'progress' | 'final'; +interface SubagentTrack { + label: string; + activities: string[]; +} + interface MessageTurnControllerOptions { chatJid: string; group: RegisteredGroup; @@ -36,6 +41,7 @@ export class MessageTurnController { private progressTicker: ReturnType | null = null; private progressEditFailCount = 0; private latestProgressTextForFinal: string | null = null; + private subagents = new Map(); private poisonedSessionDetected = false; private closeRequested = false; @@ -117,20 +123,29 @@ export class MessageTurnController { } if (result.phase === 'tool-activity') { - // Ensure a progress message exists for tool activity sub-lines - if (!this.progressMessageId && !this.progressCreating) { - this.progressCreating = true; - const heading = this.pendingProgressText || 'μž‘μ—… 쀑...'; - this.sendProgressMessage(heading).then(() => { - this.progressCreating = false; - this.ensureProgressTicker(); - // Replay any queued tool activities now that the message exists - if (this.toolActivities.length > 0 && this.progressMessageId) { - void this.syncTrackedProgressMessage(); + if (result.agentId) { + // Subagent tool activity + let track = this.subagents.get(result.agentId); + if (!track) { + track = { label: 'μž‘μ—… 쀑...', activities: [] }; + this.subagents.set(result.agentId, track); + } + if (text) { + const MAX = 2; + track.activities.push(text); + if (track.activities.length > MAX) { + track.activities = track.activities.slice(-MAX); } - }); - this.pendingProgressText = null; + } + this.ensureProgressMessageExists(); + this.ensureProgressTicker(); + if (!this.poisonedSessionDetected) { + this.resetIdleTimer(); + } + return; } + // Main agent tool activity + this.ensureProgressMessageExists(); if (text) { this.addToolActivity(text); } @@ -141,6 +156,39 @@ export class MessageTurnController { } if (result.phase === 'progress') { + if (result.agentId) { + if (result.agentDone) { + this.subagents.delete(result.agentId); + } else { + const label = + text || + (result.agentLabel ? `πŸ”„ ${result.agentLabel}` : 'μž‘μ—… 쀑...'); + const existing = this.subagents.get(result.agentId); + if (existing) { + existing.label = label; + existing.activities = []; + } else { + this.subagents.set(result.agentId, { label, activities: [] }); + } + } + if (!this.latestProgressText) { + this.latestProgressText = 'μž‘μ—… 쀑...'; + this.latestProgressTextForFinal = 'μž‘μ—… 쀑...'; + } + this.ensureProgressMessageExists(); + this.ensureProgressTicker(); + if (this.progressMessageId) { + void this.syncTrackedProgressMessage(); + } + if (!this.poisonedSessionDetected) { + this.resetIdleTimer(); + } + if (result.status === 'error') { + this.hadError = true; + } + return; + } + // Main agent progress if (text) { if (this.progressMessageId) { // Progress message already visible β€” update heading directly @@ -262,27 +310,48 @@ export class MessageTurnController { if (minutes > 0) elapsedParts.push(`${minutes}λΆ„`); elapsedParts.push(`${seconds}초`); - const activityLines = - this.toolActivities.length > 0 - ? '\n' + - this.toolActivities - .map((a, i) => { - const isLast = i === this.toolActivities.length - 1; - const connector = isLast ? 'β””' : 'β”œ'; - const isSummary = a.startsWith('πŸ“‹'); - return isSummary ? `${connector} ${a}` : `${connector} ${a}`; - }) - .join('\n') - : ''; const suffix = `\n\n${elapsedParts.join(' ')}`; - const maxText = - 2000 - - TASK_STATUS_MESSAGE_PREFIX.length - - activityLines.length - - suffix.length; + let body: string; + + if (this.subagents.size > 1) { + // Compact: one line per subagent with latest activity + const lines: string[] = []; + for (const [, track] of this.subagents) { + const latest = track.activities[track.activities.length - 1]; + lines.push(latest ? `${track.label} Β· ${latest}` : track.label); + } + body = lines.join('\n'); + } else if (this.subagents.size === 1) { + // Single subagent: detailed view with activity sub-lines + const [, track] = this.subagents.entries().next().value!; + const lines: string[] = [track.label]; + for (let i = 0; i < track.activities.length; i++) { + const isLast = i === track.activities.length - 1; + lines.push(`${isLast ? 'β””' : 'β”œ'} ${track.activities[i]}`); + } + body = lines.join('\n'); + } else { + // Single agent rendering + const activityLines = + this.toolActivities.length > 0 + ? '\n' + + this.toolActivities + .map((a, i) => { + const isLast = i === this.toolActivities.length - 1; + const connector = isLast ? 'β””' : 'β”œ'; + const isSummary = a.startsWith('πŸ“‹'); + return isSummary ? `${connector} ${a}` : `${connector} ${a}`; + }) + .join('\n') + : ''; + body = text + activityLines; + } + + const maxBody = + 2000 - TASK_STATUS_MESSAGE_PREFIX.length - suffix.length; const truncated = - text.length > maxText ? text.slice(0, maxText - 1) + '…' : text; - return `${TASK_STATUS_MESSAGE_PREFIX}${truncated}${activityLines}${suffix}`; + body.length > maxBody ? body.slice(0, maxBody - 1) + '…' : body; + return `${TASK_STATUS_MESSAGE_PREFIX}${truncated}${suffix}`; } private clearProgressTicker(): void { @@ -296,6 +365,7 @@ export class MessageTurnController { this.pendingProgressText = null; this.progressCreating = false; this.toolActivities = []; + this.subagents.clear(); this.latestProgressText = null; this.previousProgressText = null; this.latestProgressRendered = null; @@ -304,6 +374,32 @@ export class MessageTurnController { this.progressEditFailCount = 0; } + /** + * Ensure a progress message exists in Discord. + * Creates one if needed, using pending or default text. + */ + private ensureProgressMessageExists(): void { + if (this.progressMessageId || this.progressCreating) return; + this.progressCreating = true; + const heading = + this.pendingProgressText || this.latestProgressText || 'μž‘μ—… 쀑...'; + if (!this.latestProgressText) { + this.latestProgressText = heading; + this.latestProgressTextForFinal = heading; + } + void this.sendProgressMessage(heading).then(() => { + this.progressCreating = false; + this.ensureProgressTicker(); + if ( + (this.toolActivities.length > 0 || this.subagents.size > 0) && + this.progressMessageId + ) { + void this.syncTrackedProgressMessage(); + } + }); + this.pendingProgressText = null; + } + /** * Buffer a progress update. The previous pending text gets flushed * immediately, and the new text waits until the next event arrives.