@agi-cli/server 0.1.93 → 0.1.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agi-cli/server",
3
- "version": "0.1.93",
3
+ "version": "0.1.94",
4
4
  "description": "HTTP API server for AGI CLI",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@agi-cli/sdk": "0.1.93",
33
- "@agi-cli/database": "0.1.93",
32
+ "@agi-cli/sdk": "0.1.94",
33
+ "@agi-cli/database": "0.1.94",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
package/src/index.ts CHANGED
@@ -251,4 +251,4 @@ export {
251
251
  setTraceEnabled,
252
252
  isTraceEnabled,
253
253
  } from './runtime/debug-state.ts';
254
- export { logger } from './runtime/logger.ts';
254
+ export { logger } from '@agi-cli/sdk';
package/src/routes/ask.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  import { handleAskRequest } from '../runtime/ask-service.ts';
8
8
  import type { EmbeddedAppConfig } from '../index.ts';
9
9
  import { serializeError } from '../runtime/api-error.ts';
10
- import { logger } from '../runtime/logger.ts';
10
+ import { logger } from '@agi-cli/sdk';
11
11
 
12
12
  export function registerAskRoutes(app: Hono) {
13
13
  app.post('/v1/ask', async (c) => {
@@ -1,7 +1,7 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { loadConfig } from '@agi-cli/sdk';
3
3
  import type { EmbeddedAppConfig } from '../../index.ts';
4
- import { logger } from '../../runtime/logger.ts';
4
+ import { logger } from '@agi-cli/sdk';
5
5
  import { serializeError } from '../../runtime/api-error.ts';
6
6
  import { discoverAllAgents, getDefault } from './utils.ts';
7
7
 
@@ -1,6 +1,6 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { basename } from 'node:path';
3
- import { logger } from '../../runtime/logger.ts';
3
+ import { logger } from '@agi-cli/sdk';
4
4
  import { serializeError } from '../../runtime/api-error.ts';
5
5
 
6
6
  export function registerCwdRoute(app: Hono) {
@@ -1,7 +1,7 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { loadConfig } from '@agi-cli/sdk';
3
3
  import type { EmbeddedAppConfig } from '../../index.ts';
4
- import { logger } from '../../runtime/logger.ts';
4
+ import { logger } from '@agi-cli/sdk';
5
5
  import { serializeError } from '../../runtime/api-error.ts';
6
6
  import {
7
7
  discoverAllAgents,
@@ -1,7 +1,7 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { loadConfig, catalog, type ProviderId } from '@agi-cli/sdk';
3
3
  import type { EmbeddedAppConfig } from '../../index.ts';
4
- import { logger } from '../../runtime/logger.ts';
4
+ import { logger } from '@agi-cli/sdk';
5
5
  import { serializeError } from '../../runtime/api-error.ts';
6
6
  import {
7
7
  isProviderAuthorizedHybrid,
@@ -2,7 +2,7 @@ import type { Hono } from 'hono';
2
2
  import { loadConfig } from '@agi-cli/sdk';
3
3
  import type { ProviderId } from '@agi-cli/sdk';
4
4
  import type { EmbeddedAppConfig } from '../../index.ts';
5
- import { logger } from '../../runtime/logger.ts';
5
+ import { logger } from '@agi-cli/sdk';
6
6
  import { serializeError } from '../../runtime/api-error.ts';
7
7
  import { getAuthorizedProviders, getDefault } from './utils.ts';
8
8
 
@@ -8,7 +8,7 @@ import { readdir } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import type { EmbeddedAppConfig } from '../../index.ts';
10
10
  import type { AGIConfig } from '@agi-cli/sdk';
11
- import { logger } from '../../runtime/logger.ts';
11
+ import { logger } from '@agi-cli/sdk';
12
12
  import { loadAgentsConfig } from '../../runtime/agent-registry.ts';
13
13
 
14
14
  export async function isProviderAuthorizedHybrid(
@@ -4,7 +4,7 @@ import { join, relative } from 'node:path';
4
4
  import { exec } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { serializeError } from '../runtime/api-error.ts';
7
- import { logger } from '../runtime/logger.ts';
7
+ import { logger } from '@agi-cli/sdk';
8
8
 
9
9
  const execAsync = promisify(exec);
10
10
 
@@ -9,7 +9,7 @@ import {
9
9
  ensureProviderEnv,
10
10
  } from '@agi-cli/sdk';
11
11
  import { dispatchAssistantMessage } from '../runtime/message-service.ts';
12
- import { logger } from '../runtime/logger.ts';
12
+ import { logger } from '@agi-cli/sdk';
13
13
  import { serializeError } from '../runtime/api-error.ts';
14
14
 
15
15
  type MessagePartRow = typeof messageParts.$inferSelect;
@@ -8,7 +8,7 @@ import { isProviderId, catalog } from '@agi-cli/sdk';
8
8
  import { resolveAgentConfig } from '../runtime/agent-registry.ts';
9
9
  import { createSession as createSessionRow } from '../runtime/session-manager.ts';
10
10
  import { serializeError } from '../runtime/api-error.ts';
11
- import { logger } from '../runtime/logger.ts';
11
+ import { logger } from '@agi-cli/sdk';
12
12
 
13
13
  export function registerSessionsRoutes(app: Hono) {
14
14
  // List sessions
@@ -1,6 +1,7 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { streamSSE } from 'hono/streaming';
3
3
  import type { TerminalManager } from '@agi-cli/sdk';
4
+ import { logger } from '@agi-cli/sdk';
4
5
 
5
6
  export function registerTerminalsRoutes(
6
7
  app: Hono,
@@ -16,9 +17,9 @@ export function registerTerminalsRoutes(
16
17
 
17
18
  app.post('/v1/terminals', async (c) => {
18
19
  try {
19
- console.log('[API] POST /v1/terminals called');
20
+ logger.debug('POST /v1/terminals called');
20
21
  const body = await c.req.json();
21
- console.log('[API] Request body:', body);
22
+ logger.debug('Creating terminal request received', body);
22
23
  const { command, args, purpose, cwd, title } = body;
23
24
 
24
25
  if (!command || !purpose) {
@@ -31,7 +32,7 @@ export function registerTerminalsRoutes(
31
32
  }
32
33
  const resolvedCwd = cwd || process.cwd();
33
34
 
34
- console.log('[API] Creating terminal with:', {
35
+ logger.debug('Creating terminal', {
35
36
  command: resolvedCommand,
36
37
  args,
37
38
  purpose,
@@ -47,7 +48,7 @@ export function registerTerminalsRoutes(
47
48
  title,
48
49
  });
49
50
 
50
- console.log('[API] Terminal created successfully:', terminal.id);
51
+ logger.debug('Terminal created successfully', { id: terminal.id });
51
52
 
52
53
  return c.json({
53
54
  terminalId: terminal.id,
@@ -56,11 +57,7 @@ export function registerTerminalsRoutes(
56
57
  command: terminal.command,
57
58
  });
58
59
  } catch (error) {
59
- console.error('[API] Error creating terminal:', error);
60
- console.error(
61
- '[API] Error stack:',
62
- error instanceof Error ? error.stack : 'No stack',
63
- );
60
+ logger.error('Error creating terminal', error);
64
61
  const message = error instanceof Error ? error.message : String(error);
65
62
  return c.json({ error: message }, 500);
66
63
  }
@@ -79,21 +76,24 @@ export function registerTerminalsRoutes(
79
76
 
80
77
  app.get('/v1/terminals/:id/output', async (c) => {
81
78
  const id = c.req.param('id');
82
- console.log('[SSE] Client connecting to terminal:', id);
79
+ logger.debug('SSE client connecting to terminal', { id });
83
80
  const terminal = terminalManager.get(id);
84
81
 
85
82
  if (!terminal) {
86
- console.error('[SSE] Terminal not found:', id);
83
+ logger.debug('SSE terminal not found', { id });
87
84
  return c.json({ error: 'Terminal not found' }, 404);
88
85
  }
89
86
 
90
87
  return streamSSE(c, async (stream) => {
91
- console.log('[SSE] Stream started for terminal:', id);
88
+ logger.debug('SSE stream started for terminal', { id });
92
89
  // Send historical buffer first (unless skipHistory is set)
93
90
  const skipHistory = c.req.query('skipHistory') === 'true';
94
91
  if (!skipHistory) {
95
92
  const history = terminal.read();
96
- console.log('[SSE] Sending history, lines:', history.length);
93
+ logger.debug('SSE sending terminal history', {
94
+ id,
95
+ lines: history.length,
96
+ });
97
97
  for (const line of history) {
98
98
  await stream.write(
99
99
  `data: ${JSON.stringify({ type: 'data', line })}\n\n`,
@@ -105,7 +105,7 @@ export function registerTerminalsRoutes(
105
105
  try {
106
106
  await stream.write(`data: ${JSON.stringify(payload)}\n\n`);
107
107
  } catch (error) {
108
- console.error('[SSE] Error writing event:', error);
108
+ logger.error('SSE error writing event', error, { id });
109
109
  }
110
110
  };
111
111
 
@@ -141,7 +141,9 @@ export function registerTerminalsRoutes(
141
141
  }
142
142
 
143
143
  function onAbort() {
144
- console.log('[SSE] Client disconnected:', terminal.id);
144
+ logger.debug('SSE client disconnected from terminal', {
145
+ id: terminal.id,
146
+ });
145
147
  stream.close();
146
148
  finish();
147
149
  }
@@ -23,6 +23,18 @@ const state: DebugState = {
23
23
  runtimeTraceOverride: null,
24
24
  };
25
25
 
26
+ type GlobalDebugFlags = {
27
+ __AGI_DEBUG_ENABLED__?: boolean;
28
+ __AGI_TRACE_ENABLED__?: boolean;
29
+ };
30
+
31
+ const globalFlags = globalThis as GlobalDebugFlags;
32
+
33
+ function syncGlobalFlags() {
34
+ globalFlags.__AGI_DEBUG_ENABLED__ = state.enabled;
35
+ globalFlags.__AGI_TRACE_ENABLED__ = state.traceEnabled;
36
+ }
37
+
26
38
  /**
27
39
  * Check if environment variables indicate debug mode
28
40
  */
@@ -63,6 +75,7 @@ function initialize() {
63
75
  if (state.runtimeTraceOverride === null) {
64
76
  state.traceEnabled = checkEnvTrace();
65
77
  }
78
+ syncGlobalFlags();
66
79
  }
67
80
 
68
81
  /**
@@ -92,6 +105,7 @@ export function isTraceEnabled(): boolean {
92
105
  export function setDebugEnabled(enabled: boolean): void {
93
106
  state.enabled = enabled;
94
107
  state.runtimeOverride = enabled;
108
+ syncGlobalFlags();
95
109
  }
96
110
 
97
111
  /**
@@ -103,6 +117,7 @@ export function setDebugEnabled(enabled: boolean): void {
103
117
  export function setTraceEnabled(enabled: boolean): void {
104
118
  state.traceEnabled = enabled;
105
119
  state.runtimeTraceOverride = enabled;
120
+ syncGlobalFlags();
106
121
  }
107
122
 
108
123
  /**
@@ -113,6 +128,7 @@ export function resetDebugState(): void {
113
128
  state.runtimeTraceOverride = null;
114
129
  state.enabled = checkEnvDebug();
115
130
  state.traceEnabled = checkEnvTrace();
131
+ syncGlobalFlags();
116
132
  }
117
133
 
118
134
  /**
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { isDebugEnabled as isDebugEnabledNew } from './debug-state';
9
- import { time as timeNew, debug as debugNew } from './logger';
9
+ import { time as timeNew, debug as debugNew } from '@agi-cli/sdk';
10
10
 
11
11
  const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
12
12
 
@@ -31,6 +31,7 @@ export async function setupToolContext(
31
31
  provider: opts.provider,
32
32
  model: opts.model,
33
33
  projectRoot: opts.projectRoot,
34
+ stepExecution: { states: new Map() },
34
35
  onFirstToolCall: () => {
35
36
  if (firstToolSeen) return;
36
37
  firstToolSeen = true;
@@ -3,6 +3,11 @@ import type { DB } from '@agi-cli/database';
3
3
  import { messageParts } from '@agi-cli/database/schema';
4
4
  import { publish } from '../events/bus.ts';
5
5
 
6
+ export type StepExecutionState = {
7
+ chain: Promise<void>;
8
+ failed: boolean;
9
+ };
10
+
6
11
  export type ToolAdapterContext = {
7
12
  sessionId: string;
8
13
  messageId: string;
@@ -15,6 +20,9 @@ export type ToolAdapterContext = {
15
20
  nextIndex: () => number | Promise<number>;
16
21
  stepIndex?: number;
17
22
  onFirstToolCall?: () => void;
23
+ stepExecution?: {
24
+ states: Map<number, StepExecutionState>;
25
+ };
18
26
  };
19
27
 
20
28
  export function extractFinishText(input: unknown): string | undefined {
@@ -4,7 +4,10 @@ import { eq } from 'drizzle-orm';
4
4
  import { publish } from '../events/bus.ts';
5
5
  import type { DiscoveredTool } from '@agi-cli/sdk';
6
6
  import { getCwd, setCwd, joinRelative } from '../runtime/cwd.ts';
7
- import type { ToolAdapterContext } from '../runtime/tool-context.ts';
7
+ import type {
8
+ ToolAdapterContext,
9
+ StepExecutionState,
10
+ } from '../runtime/tool-context.ts';
8
11
  import { isToolError } from '@agi-cli/sdk/tools/error';
9
12
 
10
13
  export type { ToolAdapterContext } from '../runtime/tool-context.ts';
@@ -49,6 +52,11 @@ export function adaptTools(
49
52
  const pendingCalls = new Map<string, PendingCallMeta[]>();
50
53
  let firstToolCallReported = false;
51
54
 
55
+ if (!ctx.stepExecution) {
56
+ ctx.stepExecution = { states: new Map<number, StepExecutionState>() };
57
+ }
58
+ const stepStates = ctx.stepExecution.states;
59
+
52
60
  // Anthropic allows max 4 cache_control blocks
53
61
  // Cache only the most frequently used tools: read, write, bash
54
62
  const cacheableTools = new Set(['read', 'write', 'bash', 'edit']);
@@ -57,6 +65,68 @@ export function adaptTools(
57
65
  for (const { name, tool } of tools) {
58
66
  const base = tool;
59
67
 
68
+ const processedToolErrors = new WeakSet<object>();
69
+
70
+ const persistToolErrorResult = async (
71
+ errorResult: unknown,
72
+ {
73
+ callId,
74
+ startTs,
75
+ stepIndexForEvent,
76
+ args,
77
+ }: {
78
+ callId?: string;
79
+ startTs?: number;
80
+ stepIndexForEvent: number;
81
+ args?: unknown;
82
+ },
83
+ ) => {
84
+ const resultPartId = crypto.randomUUID();
85
+ const endTs = Date.now();
86
+ const dur =
87
+ typeof startTs === 'number' ? Math.max(0, endTs - startTs) : null;
88
+
89
+ const contentObj: {
90
+ name: string;
91
+ result: unknown;
92
+ callId?: string;
93
+ args?: unknown;
94
+ } = {
95
+ name,
96
+ result: errorResult,
97
+ callId,
98
+ };
99
+
100
+ if (args !== undefined) {
101
+ contentObj.args = args;
102
+ }
103
+
104
+ const index = await ctx.nextIndex();
105
+
106
+ await ctx.db.insert(messageParts).values({
107
+ id: resultPartId,
108
+ messageId: ctx.messageId,
109
+ index,
110
+ stepIndex: stepIndexForEvent,
111
+ type: 'tool_result',
112
+ content: JSON.stringify(contentObj),
113
+ agent: ctx.agent,
114
+ provider: ctx.provider,
115
+ model: ctx.model,
116
+ startedAt: startTs,
117
+ completedAt: endTs,
118
+ toolName: name,
119
+ toolCallId: callId,
120
+ toolDurationMs: dur ?? undefined,
121
+ });
122
+
123
+ publish({
124
+ type: 'tool.result',
125
+ sessionId: ctx.sessionId,
126
+ payload: { ...contentObj, stepIndex: stepIndexForEvent },
127
+ });
128
+ };
129
+
60
130
  // Add cache control for Anthropic to cache tool definitions (max 2 tools)
61
131
  const shouldCache =
62
132
  provider === 'anthropic' &&
@@ -212,263 +282,282 @@ export function adaptTools(
212
282
  const startTsFromQueue = meta?.startTs;
213
283
  const stepIndexForEvent = meta?.stepIndex ?? ctx.stepIndex;
214
284
 
215
- try {
216
- // Handle session-relative paths and cwd tools
217
- let res: ToolExecuteReturn | { cwd: string } | null | undefined;
218
- const cwd = getCwd(ctx.sessionId);
219
- if (name === 'pwd') {
220
- res = { cwd };
221
- } else if (name === 'cd') {
222
- const next = joinRelative(
223
- cwd,
224
- String((input as Record<string, unknown>)?.path ?? '.'),
225
- );
226
- setCwd(ctx.sessionId, next);
227
- res = { cwd: next };
228
- } else if (
229
- ['read', 'write', 'ls', 'tree'].includes(name) &&
230
- typeof (input as Record<string, unknown>)?.path === 'string'
231
- ) {
232
- const rel = joinRelative(
233
- cwd,
234
- String((input as Record<string, unknown>).path),
235
- );
236
- const nextInput = {
237
- ...(input as Record<string, unknown>),
238
- path: rel,
239
- } as ToolExecuteInput;
240
- // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
241
- res = base.execute?.(nextInput, options as any);
242
- } else if (name === 'bash') {
243
- const needsCwd =
244
- !input ||
245
- typeof (input as Record<string, unknown>).cwd !== 'string';
246
- const nextInput = needsCwd
247
- ? ({
248
- ...(input as Record<string, unknown>),
249
- cwd,
250
- } as ToolExecuteInput)
251
- : input;
252
- // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
253
- res = base.execute?.(nextInput, options as any);
254
- } else {
255
- // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
256
- res = base.execute?.(input, options as any);
257
- }
258
- let result: unknown = res;
259
- // If tool returns an async iterable, stream deltas while accumulating
260
- if (res && typeof res === 'object' && Symbol.asyncIterator in res) {
261
- const chunks: unknown[] = [];
262
- for await (const chunk of res as AsyncIterable<unknown>) {
263
- chunks.push(chunk);
285
+ const stepKey =
286
+ typeof stepIndexForEvent === 'number' &&
287
+ Number.isFinite(stepIndexForEvent)
288
+ ? stepIndexForEvent
289
+ : 0;
290
+ let stepState = stepStates.get(stepKey);
291
+ if (!stepState) {
292
+ stepState = { chain: Promise.resolve(), failed: false };
293
+ stepStates.set(stepKey, stepState);
294
+ }
295
+
296
+ const executeWithGuards = async (): Promise<ToolExecuteReturn> => {
297
+ try {
298
+ if (stepState.failed) {
299
+ const skipError = {
300
+ ok: false,
301
+ error: `Cannot execute "${name}" because a previous tool call in this step failed. Retry the failing tool before continuing with "${name}".`,
302
+ details: { skippedTool: name },
303
+ };
304
+ throw skipError;
305
+ }
306
+ // Handle session-relative paths and cwd tools
307
+ let res: ToolExecuteReturn | { cwd: string } | null | undefined;
308
+ const cwd = getCwd(ctx.sessionId);
309
+ if (name === 'pwd') {
310
+ res = { cwd };
311
+ } else if (name === 'cd') {
312
+ const next = joinRelative(
313
+ cwd,
314
+ String((input as Record<string, unknown>)?.path ?? '.'),
315
+ );
316
+ setCwd(ctx.sessionId, next);
317
+ res = { cwd: next };
318
+ } else if (
319
+ ['read', 'write', 'ls', 'tree'].includes(name) &&
320
+ typeof (input as Record<string, unknown>)?.path === 'string'
321
+ ) {
322
+ const rel = joinRelative(
323
+ cwd,
324
+ String((input as Record<string, unknown>).path),
325
+ );
326
+ const nextInput = {
327
+ ...(input as Record<string, unknown>),
328
+ path: rel,
329
+ } as ToolExecuteInput;
330
+ // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
331
+ res = base.execute?.(nextInput, options as any);
332
+ } else if (name === 'bash') {
333
+ const needsCwd =
334
+ !input ||
335
+ typeof (input as Record<string, unknown>).cwd !== 'string';
336
+ const nextInput = needsCwd
337
+ ? ({
338
+ ...(input as Record<string, unknown>),
339
+ cwd,
340
+ } as ToolExecuteInput)
341
+ : input;
342
+ // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
343
+ res = base.execute?.(nextInput, options as any);
344
+ } else {
345
+ // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
346
+ res = base.execute?.(input, options as any);
347
+ }
348
+ let result: unknown = res;
349
+ // If tool returns an async iterable, stream deltas while accumulating
350
+ if (res && typeof res === 'object' && Symbol.asyncIterator in res) {
351
+ const chunks: unknown[] = [];
352
+ for await (const chunk of res as AsyncIterable<unknown>) {
353
+ chunks.push(chunk);
354
+ publish({
355
+ type: 'tool.delta',
356
+ sessionId: ctx.sessionId,
357
+ payload: {
358
+ name,
359
+ channel: 'output',
360
+ delta: chunk,
361
+ stepIndex: stepIndexForEvent,
362
+ callId: callIdFromQueue,
363
+ },
364
+ });
365
+ }
366
+ // Prefer the last chunk as the result if present, otherwise the entire array
367
+ result = chunks.length > 0 ? chunks[chunks.length - 1] : null;
368
+ } else {
369
+ // Await promise or passthrough value
370
+ result = await Promise.resolve(res as ToolExecuteReturn);
371
+ }
372
+
373
+ if (isToolError(result)) {
374
+ stepState.failed = true;
375
+ await persistToolErrorResult(result, {
376
+ callId: callIdFromQueue,
377
+ startTs: startTsFromQueue,
378
+ stepIndexForEvent,
379
+ args: meta?.args,
380
+ });
381
+ processedToolErrors.add(result as object);
382
+ throw result;
383
+ }
384
+
385
+ const resultPartId = crypto.randomUUID();
386
+ const callId = callIdFromQueue;
387
+ const startTs = startTsFromQueue;
388
+ const contentObj: {
389
+ name: string;
390
+ result: unknown;
391
+ callId?: string;
392
+ artifact?: unknown;
393
+ args?: unknown;
394
+ } = {
395
+ name,
396
+ result,
397
+ callId,
398
+ };
399
+ if (meta?.args !== undefined) {
400
+ contentObj.args = meta.args;
401
+ }
402
+ if (result && typeof result === 'object' && 'artifact' in result) {
403
+ try {
404
+ const maybeArtifact = (result as { artifact?: unknown })
405
+ .artifact;
406
+ if (maybeArtifact !== undefined)
407
+ contentObj.artifact = maybeArtifact;
408
+ } catch {}
409
+ }
410
+
411
+ const index = await ctx.nextIndex();
412
+ const endTs = Date.now();
413
+ const dur =
414
+ typeof startTs === 'number' ? Math.max(0, endTs - startTs) : null;
415
+
416
+ // Special-case: keep progress_update result lightweight; publish first, persist best-effort
417
+ if (name === 'progress_update') {
264
418
  publish({
265
- type: 'tool.delta',
419
+ type: 'tool.result',
266
420
  sessionId: ctx.sessionId,
267
- payload: {
268
- name,
269
- channel: 'output',
270
- delta: chunk,
271
- stepIndex: stepIndexForEvent,
272
- callId: callIdFromQueue,
273
- },
421
+ payload: { ...contentObj, stepIndex: stepIndexForEvent },
274
422
  });
423
+ // Persist without blocking the event loop
424
+ (async () => {
425
+ try {
426
+ await ctx.db.insert(messageParts).values({
427
+ id: resultPartId,
428
+ messageId: ctx.messageId,
429
+ index,
430
+ stepIndex: stepIndexForEvent,
431
+ type: 'tool_result',
432
+ content: JSON.stringify(contentObj),
433
+ agent: ctx.agent,
434
+ provider: ctx.provider,
435
+ model: ctx.model,
436
+ startedAt: startTs,
437
+ completedAt: endTs,
438
+ toolName: name,
439
+ toolCallId: callId,
440
+ toolDurationMs: dur ?? undefined,
441
+ });
442
+ } catch {}
443
+ })();
444
+ return result as ToolExecuteReturn;
275
445
  }
276
- // Prefer the last chunk as the result if present, otherwise the entire array
277
- result = chunks.length > 0 ? chunks[chunks.length - 1] : null;
278
- } else {
279
- // Await promise or passthrough value
280
- result = await Promise.resolve(res as ToolExecuteReturn);
281
- }
282
- const resultPartId = crypto.randomUUID();
283
- const callId = callIdFromQueue;
284
- const startTs = startTsFromQueue;
285
- const contentObj: {
286
- name: string;
287
- result: unknown;
288
- callId?: string;
289
- artifact?: unknown;
290
- args?: unknown;
291
- } = {
292
- name,
293
- result,
294
- callId,
295
- };
296
- if (meta?.args !== undefined) {
297
- contentObj.args = meta.args;
298
- }
299
- if (result && typeof result === 'object' && 'artifact' in result) {
446
+
447
+ await ctx.db.insert(messageParts).values({
448
+ id: resultPartId,
449
+ messageId: ctx.messageId,
450
+ index,
451
+ stepIndex: stepIndexForEvent,
452
+ type: 'tool_result',
453
+ content: JSON.stringify(contentObj),
454
+ agent: ctx.agent,
455
+ provider: ctx.provider,
456
+ model: ctx.model,
457
+ startedAt: startTs,
458
+ completedAt: endTs,
459
+ toolName: name,
460
+ toolCallId: callId,
461
+ toolDurationMs: dur ?? undefined,
462
+ });
463
+ // Update session aggregates: total tool time and counts per tool
300
464
  try {
301
- const maybeArtifact = (result as { artifact?: unknown }).artifact;
302
- if (maybeArtifact !== undefined)
303
- contentObj.artifact = maybeArtifact;
465
+ const sessRows = await ctx.db
466
+ .select()
467
+ .from(sessions)
468
+ .where(eq(sessions.id, ctx.sessionId));
469
+ if (sessRows.length) {
470
+ const row = sessRows[0] as typeof sessions.$inferSelect;
471
+ const totalToolTimeMs =
472
+ Number(row.totalToolTimeMs || 0) + (dur ?? 0);
473
+ let counts: Record<string, number> = {};
474
+ try {
475
+ counts = row.toolCountsJson
476
+ ? JSON.parse(row.toolCountsJson)
477
+ : {};
478
+ } catch {}
479
+ counts[name] = (counts[name] || 0) + 1;
480
+ await ctx.db
481
+ .update(sessions)
482
+ .set({
483
+ totalToolTimeMs,
484
+ toolCountsJson: JSON.stringify(counts),
485
+ lastActiveAt: endTs,
486
+ })
487
+ .where(eq(sessions.id, ctx.sessionId));
488
+ }
304
489
  } catch {}
305
- }
306
-
307
- const index = await ctx.nextIndex();
308
- const endTs = Date.now();
309
- const dur =
310
- typeof startTs === 'number' ? Math.max(0, endTs - startTs) : null;
311
-
312
- // Special-case: keep progress_update result lightweight; publish first, persist best-effort
313
- if (name === 'progress_update') {
314
490
  publish({
315
491
  type: 'tool.result',
316
492
  sessionId: ctx.sessionId,
317
493
  payload: { ...contentObj, stepIndex: stepIndexForEvent },
318
494
  });
319
- // Persist without blocking the event loop
320
- (async () => {
495
+ if (name === 'update_plan') {
321
496
  try {
322
- await ctx.db.insert(messageParts).values({
323
- id: resultPartId,
324
- messageId: ctx.messageId,
325
- index,
326
- stepIndex: stepIndexForEvent,
327
- type: 'tool_result',
328
- content: JSON.stringify(contentObj),
329
- agent: ctx.agent,
330
- provider: ctx.provider,
331
- model: ctx.model,
332
- startedAt: startTs,
333
- completedAt: endTs,
334
- toolName: name,
335
- toolCallId: callId,
336
- toolDurationMs: dur ?? undefined,
337
- });
497
+ const resultValue = (contentObj as { result?: unknown })
498
+ .result as { items?: unknown; note?: unknown } | undefined;
499
+ if (resultValue && Array.isArray(resultValue.items)) {
500
+ publish({
501
+ type: 'plan.updated',
502
+ sessionId: ctx.sessionId,
503
+ payload: {
504
+ items: resultValue.items,
505
+ note: resultValue.note,
506
+ },
507
+ });
508
+ }
338
509
  } catch {}
339
- })();
510
+ }
340
511
  return result as ToolExecuteReturn;
341
- }
512
+ } catch (error) {
513
+ stepState.failed = true;
342
514
 
343
- await ctx.db.insert(messageParts).values({
344
- id: resultPartId,
345
- messageId: ctx.messageId,
346
- index,
347
- stepIndex: stepIndexForEvent,
348
- type: 'tool_result',
349
- content: JSON.stringify(contentObj),
350
- agent: ctx.agent,
351
- provider: ctx.provider,
352
- model: ctx.model,
353
- startedAt: startTs,
354
- completedAt: endTs,
355
- toolName: name,
356
- toolCallId: callId,
357
- toolDurationMs: dur ?? undefined,
358
- });
359
- // Update session aggregates: total tool time and counts per tool
360
- try {
361
- const sessRows = await ctx.db
362
- .select()
363
- .from(sessions)
364
- .where(eq(sessions.id, ctx.sessionId));
365
- if (sessRows.length) {
366
- const row = sessRows[0] as typeof sessions.$inferSelect;
367
- const totalToolTimeMs =
368
- Number(row.totalToolTimeMs || 0) + (dur ?? 0);
369
- let counts: Record<string, number> = {};
370
- try {
371
- counts = row.toolCountsJson
372
- ? JSON.parse(row.toolCountsJson)
373
- : {};
374
- } catch {}
375
- counts[name] = (counts[name] || 0) + 1;
376
- await ctx.db
377
- .update(sessions)
378
- .set({
379
- totalToolTimeMs,
380
- toolCountsJson: JSON.stringify(counts),
381
- lastActiveAt: endTs,
382
- })
383
- .where(eq(sessions.id, ctx.sessionId));
515
+ // Tool execution failed
516
+ if (
517
+ isToolError(error) &&
518
+ processedToolErrors.has(error as object)
519
+ ) {
520
+ throw error;
384
521
  }
385
- } catch {}
386
- publish({
387
- type: 'tool.result',
388
- sessionId: ctx.sessionId,
389
- payload: { ...contentObj, stepIndex: stepIndexForEvent },
390
- });
391
- if (name === 'update_plan') {
392
- try {
393
- const result = (contentObj as { result?: unknown }).result as
394
- | { items?: unknown; note?: unknown }
395
- | undefined;
396
- if (result && Array.isArray(result.items)) {
397
- publish({
398
- type: 'plan.updated',
399
- sessionId: ctx.sessionId,
400
- payload: { items: result.items, note: result.note },
401
- });
402
- }
403
- } catch {}
404
- }
405
- return result;
406
- } catch (error) {
407
- // Tool execution failed
408
- // Check if the error is already a structured tool error response
409
- const resultPartId = crypto.randomUUID();
410
- const callId = callIdFromQueue;
411
- const startTs = startTsFromQueue;
412
- const endTs = Date.now();
413
- const dur =
414
- typeof startTs === 'number' ? Math.max(0, endTs - startTs) : null;
415
-
416
- // If the tool returned a structured error response, use it directly
417
- // Otherwise, wrap the thrown error in our standard format
418
- let errorResult: unknown;
419
- if (isToolError(error)) {
420
- errorResult = error;
421
- } else {
422
- const errorMessage =
423
- error instanceof Error ? error.message : String(error);
424
- const errorStack = error instanceof Error ? error.stack : undefined;
425
- errorResult = {
426
- ok: false,
427
- error: errorMessage,
428
- stack: errorStack,
429
- };
430
- }
431
522
 
432
- const contentObj = {
433
- name,
434
- result: errorResult,
435
- callId,
436
- };
523
+ const errorResult = isToolError(error)
524
+ ? error
525
+ : (() => {
526
+ const errorMessage =
527
+ error instanceof Error ? error.message : String(error);
528
+ const errorStack =
529
+ error instanceof Error ? error.stack : undefined;
530
+ return {
531
+ ok: false,
532
+ error: errorMessage,
533
+ stack: errorStack,
534
+ };
535
+ })();
437
536
 
438
- if (meta?.args !== undefined) {
439
- contentObj.args = meta.args;
440
- }
537
+ await persistToolErrorResult(errorResult, {
538
+ callId: callIdFromQueue,
539
+ startTs: startTsFromQueue,
540
+ stepIndexForEvent,
541
+ args: meta?.args,
542
+ });
441
543
 
442
- const index = await ctx.nextIndex();
443
-
444
- // Save error result to database
445
- await ctx.db.insert(messageParts).values({
446
- id: resultPartId,
447
- messageId: ctx.messageId,
448
- index,
449
- stepIndex: stepIndexForEvent,
450
- type: 'tool_result',
451
- content: JSON.stringify(contentObj),
452
- agent: ctx.agent,
453
- provider: ctx.provider,
454
- model: ctx.model,
455
- startedAt: startTs,
456
- completedAt: endTs,
457
- toolName: name,
458
- toolCallId: callId,
459
- toolDurationMs: dur ?? undefined,
460
- });
544
+ if (isToolError(error)) {
545
+ processedToolErrors.add(error as object);
546
+ }
461
547
 
462
- // Publish error result
463
- publish({
464
- type: 'tool.result',
465
- sessionId: ctx.sessionId,
466
- payload: { ...contentObj, stepIndex: stepIndexForEvent },
467
- });
548
+ // Re-throw so AI SDK can handle it
549
+ throw error;
550
+ }
551
+ };
468
552
 
469
- // Re-throw so AI SDK can handle it
470
- throw error;
471
- }
553
+ const queued = stepState.chain
554
+ .catch(() => undefined)
555
+ .then(() => executeWithGuards());
556
+ stepState.chain = queued.then(
557
+ () => undefined,
558
+ () => undefined,
559
+ );
560
+ return queued;
472
561
  },
473
562
  } as Tool;
474
563
  }
@@ -1,206 +0,0 @@
1
- /**
2
- * Centralized logging utility
3
- *
4
- * Provides structured logging with debug mode awareness.
5
- * Replaces scattered console.log calls throughout the codebase.
6
- */
7
-
8
- import { isDebugEnabled, isTraceEnabled } from './debug-state';
9
-
10
- export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
11
-
12
- /**
13
- * Format a log message with optional metadata
14
- */
15
- function _formatMessage(
16
- level: LogLevel,
17
- message: string,
18
- meta?: Record<string, unknown>,
19
- ): string {
20
- const timestamp = new Date().toISOString();
21
- const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
22
-
23
- if (meta && Object.keys(meta).length > 0) {
24
- return `${prefix} ${message} ${JSON.stringify(meta)}`;
25
- }
26
-
27
- return `${prefix} ${message}`;
28
- }
29
-
30
- /**
31
- * Log at debug level (only when debug mode is enabled)
32
- */
33
- export function debug(message: string, meta?: Record<string, unknown>): void {
34
- if (!isDebugEnabled()) return;
35
-
36
- try {
37
- if (meta && Object.keys(meta).length > 0) {
38
- console.log(`[debug] ${message}`, meta);
39
- } else {
40
- console.log(`[debug] ${message}`);
41
- }
42
- } catch {
43
- // Silently fail
44
- }
45
- }
46
-
47
- /**
48
- * Log informational messages (only when debug or trace mode is enabled)
49
- */
50
- export function info(message: string, meta?: Record<string, unknown>): void {
51
- if (!isDebugEnabled() && !isTraceEnabled()) return;
52
-
53
- try {
54
- if (meta && Object.keys(meta).length > 0) {
55
- console.log(`[info] ${message}`, meta);
56
- } else {
57
- console.log(`[info] ${message}`);
58
- }
59
- } catch {
60
- // Silently fail
61
- }
62
- }
63
-
64
- /**
65
- * Log warning messages
66
- */
67
- export function warn(message: string, meta?: Record<string, unknown>): void {
68
- try {
69
- if (meta && Object.keys(meta).length > 0) {
70
- console.warn(`[warn] ${message}`, meta);
71
- } else {
72
- console.warn(`[warn] ${message}`);
73
- }
74
- } catch {
75
- // Silently fail
76
- }
77
- }
78
-
79
- /**
80
- * Log error messages (only in debug mode, stack trace only with --trace)
81
- */
82
- export function error(
83
- message: string,
84
- err?: unknown,
85
- meta?: Record<string, unknown>,
86
- ): void {
87
- // Only log errors when debug mode is enabled
88
- if (!isDebugEnabled()) return;
89
-
90
- try {
91
- const logMeta: Record<string, unknown> = { ...meta };
92
-
93
- if (err) {
94
- if (err instanceof Error) {
95
- // Always show error name and message in debug mode
96
- logMeta.error = {
97
- name: err.name,
98
- message: err.message,
99
- };
100
-
101
- // Show full stack trace only with --trace flag
102
- if (isTraceEnabled() && err.stack) {
103
- logMeta.error.stack = err.stack;
104
- }
105
- } else if (typeof err === 'string') {
106
- logMeta.error = err;
107
- } else if (err && typeof err === 'object') {
108
- // For other error objects, try to extract useful info
109
- const errObj = err as Record<string, unknown>;
110
- logMeta.error = {
111
- ...(typeof errObj.name === 'string' ? { name: errObj.name } : {}),
112
- ...(typeof errObj.message === 'string'
113
- ? { message: errObj.message }
114
- : {}),
115
- ...(typeof errObj.code === 'string' ? { code: errObj.code } : {}),
116
- ...(typeof errObj.status === 'number'
117
- ? { status: errObj.status }
118
- : {}),
119
- ...(typeof errObj.statusCode === 'number'
120
- ? { statusCode: errObj.statusCode }
121
- : {}),
122
- };
123
-
124
- // Include stack in trace mode
125
- if (isTraceEnabled() && typeof errObj.stack === 'string') {
126
- logMeta.error.stack = errObj.stack;
127
- }
128
- } else {
129
- // Fallback for primitive types
130
- logMeta.error = String(err);
131
- }
132
- }
133
-
134
- if (Object.keys(logMeta).length > 0) {
135
- console.error(`[error] ${message}`, logMeta);
136
- } else {
137
- console.error(`[error] ${message}`);
138
- }
139
- } catch (logErr) {
140
- // Last resort: at least try to log something
141
- try {
142
- console.error(`[error] ${message} (logging failed:`, logErr, ')');
143
- } catch {
144
- // Give up silently
145
- }
146
- }
147
- }
148
-
149
- /**
150
- * Logger object with all methods
151
- */
152
- export const logger = {
153
- debug,
154
- info,
155
- warn,
156
- error,
157
- };
158
-
159
- /**
160
- * Timing utilities (integrates with existing debug.ts timing)
161
- */
162
- function nowMs(): number {
163
- const perf = (globalThis as { performance?: { now?: () => number } })
164
- .performance;
165
- if (perf && typeof perf.now === 'function') return perf.now();
166
- return Date.now();
167
- }
168
-
169
- type Timer = {
170
- end(meta?: Record<string, unknown>): void;
171
- };
172
-
173
- /**
174
- * Create a timer for performance measurement
175
- * Only active when debug mode is enabled
176
- */
177
- export function time(label: string): Timer {
178
- if (!isDebugEnabled()) {
179
- return { end() {} };
180
- }
181
-
182
- const start = nowMs();
183
- let finished = false;
184
-
185
- return {
186
- end(meta?: Record<string, unknown>) {
187
- if (finished) return;
188
- finished = true;
189
- const duration = nowMs() - start;
190
-
191
- try {
192
- const line = `[timing] ${label} ${duration.toFixed(1)}ms`;
193
- if (meta && Object.keys(meta).length) {
194
- console.log(line, meta);
195
- } else {
196
- console.log(line);
197
- }
198
- } catch {
199
- // Silently fail
200
- }
201
- },
202
- };
203
- }
204
-
205
- // Export legacy compatibility
206
- export { isDebugEnabled, isTraceEnabled };