diff --git a/package.json b/package.json index 83bb614f626..9b32924bb5e 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "format": "prettier --write \"src/**/*.ts\"", "format:fix": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", - "prepare": "husky", + "prepare": "bash -c 'npm run build && (husky || true)'", "setup": "tsx setup/index.ts", "auth": "tsx src/whatsapp-auth.ts", "test:e2e:tasks": "tsx scripts/test-task-sdk-e2e.ts", diff --git a/src/agent/actions-http.test.ts b/src/agent/actions-http.test.ts index ee3d0a1f69f..61143845f51 100644 --- a/src/agent/actions-http.test.ts +++ b/src/agent/actions-http.test.ts @@ -1,7 +1,12 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { z } from 'zod'; import { ActionsHttp } from './actions-http.js'; +import type { AgentStatus } from './status-writer.js'; import type { ActionContext, RegisteredAction } from '../api/action.js'; // ─── Test helpers ────────────────────────────────────────────────── @@ -50,10 +55,24 @@ describe('ActionsHttp', () => { let server: ActionsHttp; let baseUrl: string; let token: string; + let dataDir: string; + + function readStatus(): AgentStatus { + return JSON.parse( + fs.readFileSync(path.join(dataDir, 'ipc', 'status.json'), 'utf8'), + ) as AgentStatus; + } beforeEach(async () => { actions = new Map(); - server = new ActionsHttp(() => actions); + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentlite-actions-http-')); + server = new ActionsHttp(() => actions, { + agentId: 'agent-1', + agentName: 'Observer', + dataDir, + sessionId: 'session-1', + sessionStartedAt: '2026-04-19T10:00:00.000Z', + }); const info = await server.start(); if (!info) throw new Error('No LAN IP available for tests'); baseUrl = info.url; @@ -64,6 +83,7 @@ describe('ActionsHttp', () => { afterEach(async () => { await server.stop(); + fs.rmSync(dataDir, { force: true, recursive: true }); }); describe('auth', () => { @@ -98,6 +118,139 @@ describe('ActionsHttp', () => { }); }); + describe('status instrumentation', () => { + it('writes an idle status file when the server starts', () => { + expect(readStatus()).toEqual({ + schemaVersion: 1, + updatedAt: expect.any(String), + agentId: 'agent-1', + agentName: 'Observer', + status: 'idle', + phase: 'idle', + currentTool: null, + toolArgsSummary: null, + lastToolResultSummary: null, + lastToolDurationMs: null, + lastToolResult: null, + turnCount: 0, + currentTaskId: null, + workItemId: null, + workItemTitle: null, + sessionId: 'session-1', + sessionStartedAt: '2026-04-19T10:00:00.000Z', + }); + }); + + it('writes tool start and completion status around a successful action call', async () => { + let statusSeenInsideHandler: AgentStatus | null = null; + actions.set( + 'workflow_items_move', + makeAction({ + inputSchema: { itemId: z.string() }, + handler: async () => { + statusSeenInsideHandler = readStatus(); + return { ok: true }; + }, + }), + ); + + const res = await post( + baseUrl, + '/call', + { name: 'workflow_items_move', payload: { itemId: 'item-42' } }, + token, + ); + + expect(res.status).toBe(200); + expect(statusSeenInsideHandler).toEqual({ + schemaVersion: 1, + updatedAt: expect.any(String), + agentId: 'agent-1', + agentName: 'Observer', + status: 'tool-calling', + phase: 'tool_call_start', + currentTool: 'workflow_items_move', + toolArgsSummary: 'workflow_items_move', + lastToolResultSummary: null, + lastToolDurationMs: null, + lastToolResult: null, + turnCount: 1, + currentTaskId: null, + workItemId: null, + workItemTitle: null, + sessionId: 'session-1', + sessionStartedAt: '2026-04-19T10:00:00.000Z', + }); + + expect(readStatus()).toEqual({ + schemaVersion: 1, + updatedAt: expect.any(String), + agentId: 'agent-1', + agentName: 'Observer', + status: 'idle', + phase: 'tool_call_done', + currentTool: null, + toolArgsSummary: 'workflow_items_move', + lastToolResultSummary: '{"ok":true}', + lastToolDurationMs: expect.any(Number), + lastToolResult: { + toolName: 'workflow_items_move', + preview: '{"ok":true}', + }, + turnCount: 1, + currentTaskId: null, + workItemId: null, + workItemTitle: null, + sessionId: 'session-1', + sessionStartedAt: '2026-04-19T10:00:00.000Z', + }); + }); + + it('writes an error status when an action handler throws', async () => { + actions.set( + 'workflow_tasks_update', + makeAction({ + inputSchema: { itemId: z.string() }, + handler: async () => { + throw new Error('boom'); + }, + }), + ); + + const res = await post( + baseUrl, + '/call', + { name: 'workflow_tasks_update', payload: { itemId: 'item-43' } }, + token, + ); + + expect(res.status).toBe(500); + expect(readStatus()).toEqual({ + schemaVersion: 1, + updatedAt: expect.any(String), + agentId: 'agent-1', + agentName: 'Observer', + status: 'error', + phase: 'error', + currentTool: null, + toolArgsSummary: 'workflow_tasks_update', + lastToolResultSummary: null, + lastToolDurationMs: expect.any(Number), + lastToolResult: { + toolName: 'workflow_tasks_update', + preview: 'boom', + isError: true, + }, + turnCount: 1, + currentTaskId: null, + workItemId: null, + workItemTitle: null, + sessionId: 'session-1', + sessionStartedAt: '2026-04-19T10:00:00.000Z', + }); + }); + }); + describe('/search query grammar', () => { beforeEach(() => { actions.set( diff --git a/src/agent/actions-http.ts b/src/agent/actions-http.ts index 1685398565f..7d071efc59a 100644 --- a/src/agent/actions-http.ts +++ b/src/agent/actions-http.ts @@ -29,24 +29,76 @@ import type { RegisteredAction, } from '../api/action.js'; import { logger } from '../logger.js'; +import { + summarizeArgs, + type AgentToolResult, + type AgentStatus, + writeStatusFile, +} from './status-writer.js'; interface TokenBinding { groupFolder: string; isMain: boolean; } +interface StatusConfig { + agentId: string; + agentName: string; + dataDir: string; + sessionId: string; + sessionStartedAt: string; +} + export interface ActionsHttpInfo { url: string; host: string; port: number; } +function summarizeToolResult( + toolName: string | null, + result: unknown, + isError = false, +): AgentToolResult | null { + if (result == null) { + return null; + } + + let preview: string; + try { + preview = typeof result === 'string' ? result : JSON.stringify(result); + } catch { + preview = String(result); + } + + return { + toolName, + preview: preview.slice(0, 200), + ...(isError ? { isError: true } : {}), + }; +} + +function summarizeResult(result: unknown): string | null { + try { + return JSON.stringify(result ?? null).slice(0, 200); + } catch { + return String(result).slice(0, 200); + } +} + export class ActionsHttp { private server: http.Server | null = null; private info: ActionsHttpInfo | null = null; private tokens = new Map(); + private turnCount = 0; + private lastWrittenStatus: AgentStatus | null = null; + private currentTaskId: string | null = null; + private readonly toolNamesByUseId = new Map(); - constructor(private getActions: () => Map) {} + constructor( + private getActions: () => Map, + private readonly statusConfig?: StatusConfig, + ) {} getInfo(): ActionsHttpInfo | null { return this.info; @@ -90,6 +142,7 @@ export class ActionsHttp { { url: this.info.url }, 'Action HTTP server started (LAN bound)', ); + this.writeIdleStatus(); return this.info; } @@ -128,6 +181,143 @@ export class ActionsHttp { return this.tokens.get(token) ?? null; } + writeTerminalStatus(status: 'done' | 'error'): void { + const previous = + this.lastWrittenStatus ?? + this.buildStatus({ + currentTool: null, + lastToolDurationMs: null, + lastToolResult: null, + phase: 'idle', + status: 'idle', + toolArgsSummary: null, + }); + + this.writeStatus({ + ...previous, + currentTool: null, + lastToolDurationMs: previous.lastToolDurationMs, + lastToolResult: previous.lastToolResult, + phase: status, + status, + toolArgsSummary: previous.toolArgsSummary, + updatedAt: new Date().toISOString(), + }); + } + + writeThinkingStatus(toolArgsSummary: string | null = null): void { + const previous = this.lastWrittenStatus; + + this.writeStatus( + this.buildStatus({ + currentTool: null, + lastToolDurationMs: previous?.lastToolDurationMs ?? null, + lastToolResult: previous?.lastToolResult ?? null, + phase: 'thinking', + status: 'thinking', + toolArgsSummary, + }), + ); + } + + writeWaitingStatus(toolArgsSummary: string | null = null): void { + const previous = this.lastWrittenStatus; + + this.writeStatus( + this.buildStatus({ + currentTool: previous?.currentTool ?? null, + lastToolDurationMs: previous?.lastToolDurationMs ?? null, + lastToolResult: previous?.lastToolResult ?? null, + phase: 'waiting', + status: 'waiting', + toolArgsSummary: toolArgsSummary ?? previous?.toolArgsSummary ?? null, + }), + ); + } + + writeToolCallingStatus( + currentTool: string, + toolArgsSummary: string | null = null, + toolUseId: string | null = null, + ): void { + if (toolUseId) { + this.toolNamesByUseId.set(toolUseId, currentTool); + } + + this.writeStatus( + this.buildStatus({ + currentTool, + lastToolDurationMs: null, + lastToolResult: null, + phase: 'tool_call_start', + status: 'tool-calling', + toolArgsSummary: toolArgsSummary ?? currentTool, + }), + ); + } + + writeToolResultStatus( + result: unknown, + toolUseId: string | null = null, + isError = false, + ): void { + const previous = this.lastWrittenStatus; + const toolName = toolUseId + ? (this.toolNamesByUseId.get(toolUseId) ?? previous?.currentTool ?? null) + : (previous?.currentTool ?? null); + + if (toolUseId) { + this.toolNamesByUseId.delete(toolUseId); + } + + this.writeStatus( + this.buildStatus({ + currentTool: null, + lastToolDurationMs: previous?.lastToolDurationMs ?? null, + lastToolResult: summarizeToolResult(toolName, result, isError), + phase: isError ? 'error' : 'tool_call_done', + status: isError ? 'error' : 'idle', + toolArgsSummary: previous?.toolArgsSummary ?? null, + }), + ); + } + + writeTaskStartedStatus(taskId: string): void { + this.currentTaskId = taskId; + this.writeWaitingStatus('scheduled task started'); + } + + writeTaskFinishedStatus( + taskId: string, + status: 'done' | 'error' | 'idle', + result: unknown = null, + ): void { + if (this.currentTaskId === taskId) { + this.currentTaskId = null; + } + + const previous = this.lastWrittenStatus; + this.writeStatus( + this.buildStatus({ + currentTool: null, + lastToolDurationMs: previous?.lastToolDurationMs ?? null, + lastToolResult: + summarizeToolResult('scheduled task', result, status === 'error') ?? + previous?.lastToolResult ?? + null, + phase: + status === 'error' ? 'error' : status === 'done' ? 'done' : 'idle', + status, + toolArgsSummary: + status === 'error' + ? 'scheduled task failed' + : status === 'done' + ? 'scheduled task completed' + : 'scheduled task skipped', + }), + ); + } + private async handle( req: http.IncomingMessage, res: http.ServerResponse, @@ -206,11 +396,57 @@ export class ActionsHttp { isMain: binding.isMain, log, }; + this.turnCount += 1; + const callStart = Date.now(); + this.writeStatus( + this.buildStatus({ + currentTool: name, + lastToolDurationMs: null, + lastToolResult: null, + phase: 'tool_call_start', + status: 'tool-calling', + toolArgsSummary: summarizeArgs(name, actionPayload), + }), + ); try { const result = await entry.handler(actionPayload, ctx); + const previous = this.lastWrittenStatus; + const resultSummary = summarizeToolResult(name, result); + this.writeStatus( + this.buildStatus({ + currentTool: null, + lastToolResultSummary: summarizeResult(result), + lastToolDurationMs: Date.now() - callStart, + lastToolResult: resultSummary, + phase: 'tool_call_done', + status: 'idle', + toolArgsSummary: + previous?.toolArgsSummary ?? summarizeArgs(name, actionPayload), + }), + ); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ result: result ?? null })); } catch (err) { + const previous = this.lastWrittenStatus; + this.writeStatus( + this.buildStatus({ + currentTool: null, + lastToolResultSummary: null, + lastToolDurationMs: Date.now() - callStart, + lastToolResult: + summarizeToolResult( + name, + err instanceof Error ? err.message : String(err), + true, + ) ?? + previous?.lastToolResult ?? + null, + phase: 'error', + status: 'error', + toolArgsSummary: + previous?.toolArgsSummary ?? summarizeArgs(name, actionPayload), + }), + ); logger.warn({ action: name, err }, 'Custom action handler threw'); res.writeHead(500, { 'content-type': 'application/json' }); res.end( @@ -225,6 +461,87 @@ export class ActionsHttp { res.writeHead(404); res.end(); } + + private buildStatus( + overrides: Pick< + AgentStatus, + | 'currentTool' + | 'lastToolDurationMs' + | 'lastToolResult' + | 'phase' + | 'status' + | 'toolArgsSummary' + > & + Partial>, + ): AgentStatus { + const previous = this.lastWrittenStatus; + const hasLastToolResultSummary = Object.prototype.hasOwnProperty.call( + overrides, + 'lastToolResultSummary', + ); + + return { + schemaVersion: 1, + updatedAt: new Date().toISOString(), + agentId: + this.statusConfig?.agentId ?? previous?.agentId ?? 'unknown-agent', + agentName: + this.statusConfig?.agentName ?? previous?.agentName ?? 'Unknown Agent', + status: overrides.status, + phase: overrides.phase, + currentTool: overrides.currentTool, + toolArgsSummary: overrides.toolArgsSummary, + lastToolResultSummary: hasLastToolResultSummary + ? (overrides.lastToolResultSummary ?? null) + : overrides.phase === 'tool_call_start' + ? null + : (overrides.lastToolResult?.preview ?? + previous?.lastToolResultSummary ?? + null), + lastToolDurationMs: overrides.lastToolDurationMs, + lastToolResult: overrides.lastToolResult, + turnCount: this.turnCount, + currentTaskId: this.currentTaskId, + workItemId: null, + workItemTitle: previous?.workItemTitle ?? null, + sessionId: + this.statusConfig?.sessionId ?? + previous?.sessionId ?? + 'unknown-session', + sessionStartedAt: + this.statusConfig?.sessionStartedAt ?? + previous?.sessionStartedAt ?? + new Date().toISOString(), + }; + } + + writeIdleStatus(): void { + const previous = this.lastWrittenStatus; + + this.writeStatus( + this.buildStatus({ + currentTool: null, + lastToolDurationMs: null, + lastToolResult: previous?.lastToolResult ?? null, + phase: 'idle', + status: 'idle', + toolArgsSummary: null, + }), + ); + } + + private writeStatus(status: AgentStatus): void { + if (!this.statusConfig) { + return; + } + + try { + writeStatusFile(this.statusConfig.dataDir, status); + this.lastWrittenStatus = status; + } catch (err) { + logger.warn({ err }, 'Failed to write agent activity status file'); + } + } } // ─── Module-private helpers ─────────────────────────────────────── diff --git a/src/agent/agent-impl.ts b/src/agent/agent-impl.ts index 00c40ae67f2..32bf95794d2 100644 --- a/src/agent/agent-impl.ts +++ b/src/agent/agent-impl.ts @@ -5,6 +5,7 @@ * Multiple Agents share a BoxLite runtime via the parent AgentLite. */ +import crypto from 'crypto'; import fs from 'fs'; import { EventEmitter } from 'events'; @@ -66,6 +67,33 @@ export { type Agent }; // ─── Implementation ───────────────────────────────────────────────── +const signalAwareAgents = new Set<{ + handleProcessSignal: (signal: NodeJS.Signals) => void; +}>(); +let processSignalHandlersInstalled = false; + +function notifyAgentsOfProcessSignal(signal: NodeJS.Signals): void { + for (const agent of signalAwareAgents) { + agent.handleProcessSignal(signal); + } +} + +function ensureProcessSignalHandlers(): void { + if (processSignalHandlersInstalled) { + return; + } + + process.on('SIGTERM', () => { + notifyAgentsOfProcessSignal('SIGTERM'); + process.exit(1); + }); + process.on('SIGINT', () => { + notifyAgentsOfProcessSignal('SIGINT'); + process.exit(1); + }); + processSignalHandlersInstalled = true; +} + export class AgentImpl extends (EventEmitter as { new (): TypedEmitter }) implements Agent, AgentContext @@ -101,7 +129,10 @@ export class AgentImpl private ipcHandle: { stop(): void } | null = null; private schedulerHandle: { stop(): void } | null = null; private actions = new Map(); - readonly actionsHttp = new ActionsHttp(() => this.actions); + private readonly sessionId: string; + private readonly sessionStartedAt: string; + private fatalSignal: NodeJS.Signals | null = null; + readonly actionsHttp: ActionsHttp; /** Outbound ACP (Zed Agent Client Protocol) client; null unless opts.acp.peers is set. */ acpClient: AcpOutboundClient | null = null; @@ -123,10 +154,19 @@ export class AgentImpl this._options = options; this._registry = registry ?? null; this.credentialResolver = options?.credentials ?? null; + this.sessionId = crypto.randomBytes(16).toString('hex'); + this.sessionStartedAt = new Date().toISOString(); this.queue = new GroupQueue({ dataDir: this.config.dataDir, maxConcurrent: runtimeConfig.maxConcurrentContainers, }); + this.actionsHttp = new ActionsHttp(() => this.actions, { + agentId: this.config.agentId, + agentName: this.config.agentName, + dataDir: this.config.dataDir, + sessionId: this.sessionId, + sessionStartedAt: this.sessionStartedAt, + }); // Create managers with this as the shared context this.channelMgr = new ChannelManager(this); @@ -138,6 +178,7 @@ export class AgentImpl this.groupMgr, this.taskMgr, ); + this.registerStatusWriterEventHooks(); } // ─── Identity ─────────────────────────────────────────────────── @@ -423,11 +464,15 @@ export class AgentImpl if (this._started) throw new Error(`Agent "${this.name}" already running`); this._started = true; this._stopping = false; + this.fatalSignal = null; + ensureProcessSignalHandlers(); + signalAwareAgents.add(this); // Ensure directories exist fs.mkdirSync(this.config.storeDir, { recursive: true }); fs.mkdirSync(this.config.groupsDir, { recursive: true }); fs.mkdirSync(this.config.dataDir, { recursive: true }); + fs.mkdirSync(path.join(this.config.dataDir, 'ipc'), { recursive: true }); this.groupMgr.copyGroupTemplates(); this.groupMgr.syncAgentCustomizations(); @@ -468,9 +513,13 @@ export class AgentImpl } async stop(): Promise { + signalAwareAgents.delete(this); this._stopping = true; this.ipcHandle?.stop(); this.schedulerHandle?.stop(); + if (!this.fatalSignal) { + this.actionsHttp.writeTerminalStatus('done'); + } await this.actionsHttp.stop(); await this.messageMgr.waitForStop(); if (this.acpClient) { @@ -484,6 +533,96 @@ export class AgentImpl this.emit('stopped'); } + handleProcessSignal(signal: NodeJS.Signals): void { + if (this.fatalSignal) { + return; + } + + this.fatalSignal = signal; + logger.warn( + { agent: this.name, signal }, + 'Agent process received a shutdown signal', + ); + this.actionsHttp.writeTerminalStatus('error'); + } + + private registerStatusWriterEventHooks(): void { + this.on('run.tool', (event) => { + this.actionsHttp.writeToolCallingStatus( + event.toolName, + event.input ? String(event.input).slice(0, 200) : event.toolName, + event.toolUseId, + ); + }); + + this.on('run.sdk_message', (event) => { + const msg = event.message; + + if ( + event.sdkType === 'assistant' && + Array.isArray(msg?.message?.content) + ) { + const text = (msg.message.content as Array>) + .filter( + (block) => block.type === 'text' && typeof block.text === 'string', + ) + .map((block) => String(block.text).trim()) + .filter(Boolean) + .join('\n') + .slice(0, 200); + + if (text) { + this.actionsHttp.writeThinkingStatus(text); + } + } + + if (event.sdkType === 'user' && Array.isArray(msg?.message?.content)) { + for (const block of msg.message.content as Array< + Record + >) { + if (block.type !== 'tool_result') { + continue; + } + + this.actionsHttp.writeToolResultStatus( + block.content ?? null, + typeof block.tool_use_id === 'string' ? block.tool_use_id : null, + Boolean(block.is_error), + ); + break; + } + } + }); + + this.on('task.run.queued', () => { + this.actionsHttp.writeWaitingStatus('scheduled task queued'); + }); + this.on('task.run.started', (event) => { + this.actionsHttp.writeTaskStartedStatus(event.taskId); + }); + this.on('task.run.succeeded', (event) => { + this.actionsHttp.writeTaskFinishedStatus( + event.taskId, + 'done', + event.result, + ); + }); + this.on('task.run.failed', (event) => { + this.actionsHttp.writeTaskFinishedStatus( + event.taskId, + 'error', + event.error, + ); + }); + this.on('task.run.skipped', (event) => { + this.actionsHttp.writeTaskFinishedStatus( + event.taskId, + 'idle', + event.reason, + ); + }); + } + // ─── Subsystem wiring ─────────────────────────────────────────── private startSubsystems(): void { diff --git a/src/agent/message-processor.ts b/src/agent/message-processor.ts index bf9ceb8ca04..7027e04080a 100644 --- a/src/agent/message-processor.ts +++ b/src/agent/message-processor.ts @@ -368,6 +368,7 @@ export class MessageProcessor { group.folder, isMain, ); + this.ctx.actionsHttp.writeThinkingStatus(); const output = await runContainerAgent( group, { @@ -410,6 +411,8 @@ export class MessageProcessor { } catch (err) { logger.error({ group: group.name, err }, 'Agent error'); return 'error'; + } finally { + this.ctx.actionsHttp.writeIdleStatus(); } } diff --git a/src/agent/status-writer.test.ts b/src/agent/status-writer.test.ts new file mode 100644 index 00000000000..37ed4ff0180 --- /dev/null +++ b/src/agent/status-writer.test.ts @@ -0,0 +1,84 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + summarizeArgs, + writeStatusFile, + type AgentStatus, +} from './status-writer.js'; + +const tempDirs: string[] = []; + +function makeTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentlite-status-')); + tempDirs.push(dir); + return dir; +} + +function createStatus(overrides: Partial = {}): AgentStatus { + return { + schemaVersion: 1, + updatedAt: '2026-04-19T10:00:00.000Z', + agentId: 'agent-1', + agentName: 'Observer', + status: 'tool-calling', + phase: 'tool_call_start', + currentTool: 'Read', + toolArgsSummary: 'file: index.ts', + lastToolResultSummary: null, + lastToolDurationMs: null, + lastToolResult: null, + turnCount: 2, + currentTaskId: null, + workItemId: 'item-1', + workItemTitle: 'Instrument agent activity', + sessionId: 'session-1', + sessionStartedAt: '2026-04-19T09:55:00.000Z', + ...overrides, + }; +} + +afterEach(() => { + while (tempDirs.length > 0) { + fs.rmSync(tempDirs.pop()!, { force: true, recursive: true }); + } +}); + +describe('status-writer', () => { + it('writes the status file under data/ipc with an atomic rename', () => { + const dataDir = makeTempDir(); + const status = createStatus(); + + writeStatusFile(dataDir, status); + + const statusPath = path.join(dataDir, 'ipc', 'status.json'); + const tmpPath = path.join(dataDir, 'ipc', 'status.json.tmp'); + + expect(fs.existsSync(statusPath)).toBe(true); + expect(fs.existsSync(tmpPath)).toBe(false); + expect( + JSON.parse(fs.readFileSync(statusPath, 'utf8')) as AgentStatus, + ).toEqual(status); + }); + + it.each([ + ['Read', { file_path: '/tmp/project/src/index.ts' }, 'file: index.ts'], + ['Write', { file_path: '/tmp/project/src/index.ts' }, 'file: index.ts'], + [ + 'Bash', + { command: 'printf "hello"\n'.repeat(10) }, + `$ ${'printf "hello"\n'.repeat(10).slice(0, 80)}`, + ], + [ + 'call_action', + { name: 'workflow_items_move' }, + 'action: workflow_items_move', + ], + ['UnknownTool', { anything: true }, 'UnknownTool'], + ])('summarizes %s arguments', (toolName, payload, expected) => { + expect(summarizeArgs(toolName, payload)).toBe(expected); + }); +}); diff --git a/src/agent/status-writer.ts b/src/agent/status-writer.ts new file mode 100644 index 00000000000..fa15e239f0b --- /dev/null +++ b/src/agent/status-writer.ts @@ -0,0 +1,71 @@ +import { mkdirSync, renameSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; + +export interface AgentToolResult { + toolName: string | null; + preview: string; + isError?: boolean; +} + +export interface AgentStatus { + schemaVersion: 1; + updatedAt: string; + agentId: string; + agentName: string; + status: 'idle' | 'thinking' | 'tool-calling' | 'waiting' | 'done' | 'error'; + phase: + | 'tool_call_start' + | 'tool_call_done' + | 'thinking' + | 'waiting' + | 'idle' + | 'done' + | 'error'; + currentTool: string | null; + toolArgsSummary: string | null; + lastToolResultSummary: string | null; + lastToolDurationMs: number | null; + lastToolResult: AgentToolResult | null; + turnCount: number; + currentTaskId: string | null; + workItemId: string | null; + workItemTitle: string | null; + sessionId: string; + sessionStartedAt: string; +} + +export function writeStatusFile(dataDir: string, status: AgentStatus): void { + const ipcDir = join(dataDir, 'ipc'); + const statusPath = join(ipcDir, 'status.json'); + const tmpPath = `${statusPath}.tmp`; + + mkdirSync(dirname(statusPath), { recursive: true }); + writeFileSync(tmpPath, JSON.stringify(status, null, 2), 'utf8'); + renameSync(tmpPath, statusPath); +} + +export function summarizeArgs(toolName: string, payload: unknown): string { + const record = + payload && typeof payload === 'object' + ? (payload as Record) + : {}; + + switch (toolName) { + case 'Read': + case 'Write': + case 'Edit': + return `file: ${String(record.file_path ?? '?') + .split('/') + .pop()}`; + case 'Bash': + return `$ ${String(record.command ?? '').slice(0, 80)}`; + case 'call_action': + return `action: ${String(record.name ?? '?')}`; + case 'Grep': + return `pattern: ${String(record.pattern ?? '?').slice(0, 60)}`; + case 'Glob': + return `pattern: ${String(record.pattern ?? '?')}`; + default: + return toolName; + } +} diff --git a/src/cli.ts b/src/cli.ts index 340aa38b34b..60e0a3e163f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,9 +39,20 @@ async function main(): Promise { const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); await agentlite.stop(); + process.exit(0); }; - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => { + void shutdown('SIGTERM').catch((err) => { + logger.error({ err }, 'Failed to stop AgentLite after SIGTERM'); + process.exit(1); + }); + }); + process.on('SIGINT', () => { + void shutdown('SIGINT').catch((err) => { + logger.error({ err }, 'Failed to stop AgentLite after SIGINT'); + process.exit(1); + }); + }); await agent.start(); } diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 5a9e7aae6ef..b113976df8f 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -236,6 +236,7 @@ async function runTask( ); try { + deps.actionsHttp.writeThinkingStatus(); const output = await runContainerAgent( group, { @@ -312,6 +313,8 @@ async function runTask( if (closeTimer) clearTimeout(closeTimer); error = err instanceof Error ? err.message : String(err); logger.error({ taskId: task.id, error }, 'Task failed'); + } finally { + deps.actionsHttp.writeIdleStatus(); } const durationMs = Date.now() - startTime; @@ -410,6 +413,7 @@ export function startSchedulerLoop(deps: SchedulerDependencies): { timestamp: new Date().toISOString(), }); + deps.actionsHttp.writeWaitingStatus(); deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => runTask(currentTask, deps), );