praxis-agent 0.23.1 → 0.24.1

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.
@@ -161,6 +161,7 @@ export interface RewindPoint {
161
161
  fileRestoreAvailable: boolean;
162
162
  }
163
163
  export declare function workflowTokenTarget(prompt: string): number | null;
164
+ export type HookSessionEndReason = 'clear' | 'resume' | 'other';
164
165
  export declare class ClaudeSessionService {
165
166
  private readonly options;
166
167
  private readonly schema;
@@ -182,6 +183,7 @@ export declare class ClaudeSessionService {
182
183
  private activeCostSessionId;
183
184
  private closeCostSavePromise;
184
185
  private readonly sessionMemoryControllers;
186
+ private readonly hookLifecycle;
185
187
  private runtimeCwd;
186
188
  constructor(options: ClaudeSessionServiceOptions);
187
189
  nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
@@ -198,6 +200,7 @@ export declare class ClaudeSessionService {
198
200
  stopTask(sessionId: string, taskId: string): Promise<void>;
199
201
  private applyPermissionUpdates;
200
202
  close(): Promise<void>;
203
+ transitionHookSession(sessionId: string, reason: Exclude<HookSessionEndReason, 'other'>): Promise<void>;
201
204
  createHostedToolRegistry(sessionId: string): ToolRegistry;
202
205
  run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[]): Promise<SessionRunResult>;
203
206
  runShell(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
@@ -319,6 +319,116 @@ function validSessionName(value) {
319
319
  ? name.toLocaleLowerCase()
320
320
  : null;
321
321
  }
322
+ const SESSION_END_HOOK_TIMEOUT_MS = 15_000;
323
+ class HookLifecycle {
324
+ hooks;
325
+ eventSink;
326
+ sessions = new Map();
327
+ pendingSource;
328
+ constructor(hooks, eventSink) {
329
+ this.hooks = hooks;
330
+ this.eventSink = eventSink;
331
+ }
332
+ async start(sessionId, input, fallbackSource, signal) {
333
+ let state = this.sessions.get(sessionId);
334
+ if (state?.started)
335
+ return undefined;
336
+ if (state?.starting)
337
+ return state.starting;
338
+ state = {
339
+ input,
340
+ started: false,
341
+ starting: undefined,
342
+ ending: undefined,
343
+ };
344
+ this.sessions.set(sessionId, state);
345
+ const source = this.pendingSource ?? fallbackSource;
346
+ this.pendingSource = undefined;
347
+ state.starting = this.hooks?.run({ ...input, hook_event_name: 'SessionStart', source }, source, signal);
348
+ try {
349
+ const outcome = await state.starting;
350
+ state.started = true;
351
+ return outcome;
352
+ }
353
+ catch (error) {
354
+ this.sessions.delete(sessionId);
355
+ throw error;
356
+ }
357
+ finally {
358
+ state.starting = undefined;
359
+ }
360
+ }
361
+ async refresh(sessionId, input, signal) {
362
+ const state = this.sessions.get(sessionId);
363
+ if (state)
364
+ state.input = input;
365
+ return this.hooks?.run({ ...input, hook_event_name: 'SessionStart', source: 'compact' }, 'compact', signal);
366
+ }
367
+ async transition(sessionId, reason) {
368
+ await this.end(sessionId, reason);
369
+ this.pendingSource = reason;
370
+ }
371
+ async end(sessionId, reason) {
372
+ const state = this.sessions.get(sessionId);
373
+ if (!state)
374
+ return;
375
+ if (state.starting)
376
+ await state.starting;
377
+ if (!state.started)
378
+ return;
379
+ if (state.ending)
380
+ return state.ending;
381
+ state.ending = this.runEnd(state.input, reason).finally(() => {
382
+ this.sessions.delete(sessionId);
383
+ });
384
+ await state.ending;
385
+ }
386
+ async close() {
387
+ await Promise.all([...this.sessions.keys()].map((sessionId) => this.end(sessionId, 'other')));
388
+ }
389
+ async runEnd(input, reason) {
390
+ if (!this.hooks)
391
+ return;
392
+ const controller = new AbortController();
393
+ let timer;
394
+ try {
395
+ const timeout = new Promise((_resolve, reject) => {
396
+ timer = setTimeout(() => {
397
+ controller.abort();
398
+ reject(new Error(`timed out after ${SESSION_END_HOOK_TIMEOUT_MS}ms`));
399
+ }, SESSION_END_HOOK_TIMEOUT_MS);
400
+ });
401
+ const outcome = await Promise.race([
402
+ this.hooks.run({ ...input, hook_event_name: 'SessionEnd', reason }, reason, controller.signal),
403
+ timeout,
404
+ ]);
405
+ for (const execution of outcome.executions) {
406
+ if (execution.exitCode === 0)
407
+ continue;
408
+ const detail = execution.stderr.trim() ||
409
+ execution.stdout.trim() ||
410
+ `exit code ${execution.exitCode}`;
411
+ this.warn(detail);
412
+ }
413
+ if (outcome.blockedReason && outcome.executions.at(-1)?.exitCode === 0) {
414
+ this.warn(outcome.blockedReason);
415
+ }
416
+ }
417
+ catch (error) {
418
+ this.warn(error instanceof Error ? error.message : String(error));
419
+ }
420
+ finally {
421
+ if (timer !== undefined)
422
+ clearTimeout(timer);
423
+ }
424
+ }
425
+ warn(detail) {
426
+ this.eventSink?.({
427
+ type: 'warning',
428
+ message: `SessionEnd hook failed: ${detail}`,
429
+ });
430
+ }
431
+ }
322
432
  export class ClaudeSessionService {
323
433
  options;
324
434
  schema;
@@ -340,9 +450,11 @@ export class ClaudeSessionService {
340
450
  activeCostSessionId;
341
451
  closeCostSavePromise;
342
452
  sessionMemoryControllers = new Map();
453
+ hookLifecycle;
343
454
  runtimeCwd;
344
455
  constructor(options) {
345
456
  this.options = options;
457
+ this.hookLifecycle = new HookLifecycle(options.hooks, options.eventSink);
346
458
  this.runtimeCwd = options.workspace?.cwd() ?? options.cwd;
347
459
  this.schema = selectClaudeSchemaAdapter(options.claudeVersion);
348
460
  this.scheduledPrompts =
@@ -481,6 +593,7 @@ export class ClaudeSessionService {
481
593
  }
482
594
  }
483
595
  async close() {
596
+ await this.hookLifecycle.close();
484
597
  this.scheduledPrompts?.close();
485
598
  await Promise.all([...this.hostedSubagents].map((executor) => executor.close()));
486
599
  await Promise.resolve();
@@ -494,6 +607,9 @@ export class ClaudeSessionService {
494
607
  this.mcpClosePromise ??= this.options.mcp?.close?.() ?? Promise.resolve();
495
608
  await this.mcpClosePromise;
496
609
  }
610
+ async transitionHookSession(sessionId, reason) {
611
+ await this.hookLifecycle.transition(sessionId, reason);
612
+ }
497
613
  createHostedToolRegistry(sessionId) {
498
614
  const baseTools = this.options.tools;
499
615
  if (!baseTools)
@@ -2243,17 +2359,11 @@ export class ClaudeSessionService {
2243
2359
  }
2244
2360
  },
2245
2361
  };
2362
+ let turnCompleted = false;
2246
2363
  try {
2247
- if (this.options.hooks) {
2248
- const outcome = await this.options.hooks.run({
2249
- ...hookSession,
2250
- hook_event_name: 'SessionStart',
2251
- source: requireExisting ? 'resume' : 'startup',
2252
- }, requireExisting ? 'resume' : 'startup', signal);
2364
+ const outcome = await this.hookLifecycle.start(sessionId, hookSession, requireExisting ? 'resume' : 'startup', signal);
2365
+ if (outcome) {
2253
2366
  await recordHookOutcome(outcome, pendingRecoveryToolCallIds.size > 0);
2254
- if (outcome.blockedReason) {
2255
- throw new Error(`SessionStart hook error: ${outcome.blockedReason}`);
2256
- }
2257
2367
  }
2258
2368
  const approveRecovery = this.options.approveRecovery;
2259
2369
  const recoveryRequest = {
@@ -2620,15 +2730,9 @@ export class ClaudeSessionService {
2620
2730
  // runtime-only context so the next request retains current
2621
2731
  // instructions, plan state, session memory, and hook context.
2622
2732
  if (this.options.hooks) {
2623
- const outcome = await this.options.hooks.run({
2624
- ...hookSession,
2625
- hook_event_name: 'SessionStart',
2626
- source: 'compact',
2627
- }, 'compact', signal);
2628
- await recordHookOutcome(outcome);
2629
- if (outcome.blockedReason) {
2630
- throw new Error(`SessionStart hook error: ${outcome.blockedReason}`);
2631
- }
2733
+ const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
2734
+ if (outcome)
2735
+ await recordHookOutcome(outcome);
2632
2736
  }
2633
2737
  await refreshRuntimeContext();
2634
2738
  this.options.eventSink?.({
@@ -3057,6 +3161,7 @@ export class ClaudeSessionService {
3057
3161
  });
3058
3162
  }
3059
3163
  }
3164
+ turnCompleted = true;
3060
3165
  return {
3061
3166
  sessionId,
3062
3167
  text: structuredCapture && structuredCapture.calls === 1
@@ -3074,35 +3179,8 @@ export class ClaudeSessionService {
3074
3179
  };
3075
3180
  }
3076
3181
  finally {
3077
- try {
3078
- const outcome = await this.options.hooks?.run({
3079
- ...hookSession,
3080
- hook_event_name: 'SessionEnd',
3081
- reason: 'other',
3082
- }, 'other');
3083
- const failedExecutions = outcome?.executions.filter((execution) => execution.exitCode !== 0) ?? [];
3084
- for (const execution of failedExecutions) {
3085
- const detail = execution.stderr.trim() ||
3086
- execution.stdout.trim() ||
3087
- `exit code ${execution.exitCode}`;
3088
- this.options.eventSink?.({
3089
- type: 'warning',
3090
- message: `SessionEnd hook failed: ${detail}`,
3091
- });
3092
- }
3093
- if (outcome?.blockedReason &&
3094
- outcome.executions.at(-1)?.exitCode === 0) {
3095
- this.options.eventSink?.({
3096
- type: 'warning',
3097
- message: `SessionEnd hook failed: ${outcome.blockedReason}`,
3098
- });
3099
- }
3100
- }
3101
- catch (error) {
3102
- this.options.eventSink?.({
3103
- type: 'warning',
3104
- message: `SessionEnd hook failed: ${error instanceof Error ? error.message : String(error)}`,
3105
- });
3182
+ if (!turnCompleted) {
3183
+ await this.hookLifecycle.end(sessionId, 'other');
3106
3184
  }
3107
3185
  }
3108
3186
  });
@@ -72,6 +72,7 @@ interface InteractiveSessionCommands {
72
72
  id: string;
73
73
  prompt: string;
74
74
  } | null>;
75
+ transitionHookSession?(sessionId: string, reason: 'clear' | 'resume'): Promise<void>;
75
76
  close?(): Promise<void>;
76
77
  }
77
78
  export interface InteractiveServiceFactory {
@@ -1294,6 +1294,8 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1294
1294
  text: `API retry ${event.attempt}/${event.maxRetries} · ${event.error}`,
1295
1295
  });
1296
1296
  break;
1297
+ case 'terminal':
1298
+ break;
1297
1299
  case 'elicitation-complete':
1298
1300
  append({
1299
1301
  kind: 'notice',
@@ -3433,9 +3435,16 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3433
3435
  if (selectingSession) {
3434
3436
  if (key.escape || value === '\u001B') {
3435
3437
  if (pickerIncludesNewSessionRef.current && allowNewSession) {
3436
- setSessionId(null);
3437
- setPendingFork(false);
3438
3438
  setSelectingSession(false);
3439
+ const previousSessionId = sessionIdRef.current;
3440
+ void (async () => {
3441
+ if (previousSessionId) {
3442
+ const commands = await service();
3443
+ await commands.transitionHookSession?.(previousSessionId, 'clear');
3444
+ }
3445
+ openSession(null);
3446
+ setPendingFork(false);
3447
+ })().catch(warn);
3439
3448
  }
3440
3449
  else if (allowNewSession) {
3441
3450
  setSelectingSession(false);
@@ -3460,10 +3469,18 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3460
3469
  const selected = filterSessionChoices(currentPickerChoices, sessionSearchRef.current)[selectedIndexRef.current];
3461
3470
  if (selected === undefined)
3462
3471
  return;
3463
- openSession(selected?.sessionId ?? null);
3464
- if (!selected)
3465
- setPendingFork(false);
3466
3472
  setSelectingSession(false);
3473
+ const nextSessionId = selected?.sessionId ?? null;
3474
+ const previousSessionId = sessionIdRef.current;
3475
+ void (async () => {
3476
+ if (previousSessionId && previousSessionId !== nextSessionId) {
3477
+ const commands = await service();
3478
+ await commands.transitionHookSession?.(previousSessionId, nextSessionId === null ? 'clear' : 'resume');
3479
+ }
3480
+ openSession(nextSessionId);
3481
+ if (!selected)
3482
+ setPendingFork(false);
3483
+ })().catch(warn);
3467
3484
  }
3468
3485
  else if (key.backspace || key.delete) {
3469
3486
  sessionSearchRef.current = sessionSearchRef.current.slice(0, -1);
@@ -5218,28 +5235,40 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
5218
5235
  updateMenu({ kind: 'help', tabIndex: 0, selectedIndex: 0 });
5219
5236
  }
5220
5237
  else if (prompt === '/new') {
5221
- statusLineSessionId.current = randomUUID();
5222
- setSessionId(null);
5223
- setSessionColor(undefined);
5224
- setPendingFork(false);
5225
- append({ kind: 'notice', text: 'Started a new session.' });
5238
+ const previousSessionId = sessionIdRef.current;
5239
+ void (async () => {
5240
+ if (previousSessionId) {
5241
+ const commands = await service();
5242
+ await commands.transitionHookSession?.(previousSessionId, 'clear');
5243
+ }
5244
+ statusLineSessionId.current = randomUUID();
5245
+ openSession(null);
5246
+ setPendingFork(false);
5247
+ append({ kind: 'notice', text: 'Started a new session.' });
5248
+ })().catch(warn);
5226
5249
  }
5227
5250
  else if (prompt === '/clear') {
5228
- statusLineSessionId.current = randomUUID();
5229
- setSessionId(null);
5230
- setSessionColor(undefined);
5231
- setPendingFork(false);
5232
- setHistory([]);
5233
- setUsage(undefined);
5234
- setCostUsd(undefined);
5235
- streamingFrameRef.current?.resetText();
5236
- streamingFrameRef.current?.resetThinking();
5237
- streamingFrameRef.current?.flush();
5238
- setThinkingExpanded(false);
5239
- setStatus('ready');
5240
- inputHistoryRef.current = [];
5241
- inputHistoryIndexRef.current = null;
5242
- inputHistoryDraftRef.current = '';
5251
+ const previousSessionId = sessionIdRef.current;
5252
+ void (async () => {
5253
+ if (previousSessionId) {
5254
+ const commands = await service();
5255
+ await commands.transitionHookSession?.(previousSessionId, 'clear');
5256
+ }
5257
+ statusLineSessionId.current = randomUUID();
5258
+ openSession(null);
5259
+ setPendingFork(false);
5260
+ setHistory([]);
5261
+ setUsage(undefined);
5262
+ setCostUsd(undefined);
5263
+ streamingFrameRef.current?.resetText();
5264
+ streamingFrameRef.current?.resetThinking();
5265
+ streamingFrameRef.current?.flush();
5266
+ setThinkingExpanded(false);
5267
+ setStatus('ready');
5268
+ inputHistoryRef.current = [];
5269
+ inputHistoryIndexRef.current = null;
5270
+ inputHistoryDraftRef.current = '';
5271
+ })().catch(warn);
5243
5272
  }
5244
5273
  else if (prompt === '/model') {
5245
5274
  updateMenu({ kind: 'model', selectedIndex: 0 });
@@ -247,6 +247,7 @@ export declare class StreamJsonOutput {
247
247
  private turnThinking;
248
248
  private turnCalls;
249
249
  private turnUsage;
250
+ private turnTerminalReason;
250
251
  private turnActive;
251
252
  private assistantFlushed;
252
253
  private contentStarted;
@@ -1666,6 +1666,7 @@ export class StreamJsonOutput {
1666
1666
  turnThinking = [];
1667
1667
  turnCalls = [];
1668
1668
  turnUsage = emptyUsage();
1669
+ turnTerminalReason;
1669
1670
  turnActive = false;
1670
1671
  assistantFlushed = true;
1671
1672
  contentStarted = false;
@@ -1884,6 +1885,11 @@ export class StreamJsonOutput {
1884
1885
  this.turnUsage = event.usage;
1885
1886
  return;
1886
1887
  }
1888
+ if (event.type === 'terminal') {
1889
+ this.ensureTurn();
1890
+ this.turnTerminalReason = event.reason;
1891
+ return;
1892
+ }
1887
1893
  if (event.type === 'permission-decision') {
1888
1894
  this.flushAssistant();
1889
1895
  return;
@@ -2111,6 +2117,7 @@ export class StreamJsonOutput {
2111
2117
  this.turnThinking = [];
2112
2118
  this.turnCalls = [];
2113
2119
  this.turnUsage = emptyUsage();
2120
+ this.turnTerminalReason = undefined;
2114
2121
  this.turnActive = true;
2115
2122
  this.assistantFlushed = false;
2116
2123
  this.contentStarted = false;
@@ -2224,7 +2231,10 @@ export class StreamJsonOutput {
2224
2231
  event: {
2225
2232
  type: 'message_delta',
2226
2233
  delta: {
2227
- stop_reason: this.turnCalls.length > 0 ? 'tool_use' : 'end_turn',
2234
+ stop_reason: this.turnTerminalReason === 'prompt_too_long'
2235
+ ? 'model_context_window_exceeded'
2236
+ : (this.turnTerminalReason ??
2237
+ (this.turnCalls.length > 0 ? 'tool_use' : 'end_turn')),
2228
2238
  stop_sequence: null,
2229
2239
  },
2230
2240
  usage: { output_tokens: this.turnUsage.outputTokens },
@@ -1577,6 +1577,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1577
1577
  rename: (sessionId, name) => service.rename(sessionId, name),
1578
1578
  sessionNameSuggestion: (sessionId, signal) => service.sessionNameSuggestion(sessionId, signal),
1579
1579
  nextScheduledPrompt: (signal) => service.nextScheduledPrompt(signal),
1580
+ transitionHookSession: (sessionId, reason) => service.transitionHookSession(sessionId, reason),
1580
1581
  close: async () => {
1581
1582
  let failure;
1582
1583
  try {
@@ -78,6 +78,7 @@ export interface ModelWebSearch {
78
78
  blockedDomains?: readonly string[];
79
79
  maxUses: number;
80
80
  }
81
+ export type ModelTerminalReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'prompt_too_long';
81
82
  export type ModelStreamEvent = {
82
83
  type: 'text-delta';
83
84
  delta: string;
@@ -102,6 +103,9 @@ export type ModelStreamEvent = {
102
103
  } | {
103
104
  type: 'usage';
104
105
  usage: ModelUsage;
106
+ } | {
107
+ type: 'terminal';
108
+ reason: ModelTerminalReason;
105
109
  } | {
106
110
  type: 'api-retry';
107
111
  attempt: number;
@@ -135,6 +139,8 @@ export interface ModelProviderCapabilities {
135
139
  };
136
140
  contextWindowTokens?: number;
137
141
  maxOutputTokens?: number;
142
+ /** The provider emits exactly one terminal event as its final stream event. */
143
+ terminalReasons?: boolean;
138
144
  }
139
145
  export interface ModelProvider {
140
146
  readonly capabilities: ModelProviderCapabilities;
@@ -170,6 +176,9 @@ export type RuntimeEvent = {
170
176
  } | {
171
177
  type: 'usage';
172
178
  usage: ModelUsage;
179
+ } | {
180
+ type: 'terminal';
181
+ reason: ModelTerminalReason;
173
182
  } | {
174
183
  type: 'tool-call';
175
184
  call: ModelToolCall;
@@ -480,8 +489,10 @@ export declare class ModelProviderError extends Error {
480
489
  readonly retryable: boolean;
481
490
  readonly status?: number;
482
491
  readonly retryDelayMs?: number;
492
+ readonly kind?: ProviderErrorKind;
483
493
  constructor(message: string, options: {
484
494
  retryable: boolean;
495
+ kind?: ProviderErrorKind;
485
496
  status?: number;
486
497
  retryDelayMs?: number;
487
498
  cause?: unknown;
@@ -492,7 +503,7 @@ export declare class AgentRunCancelledError extends Error {
492
503
  constructor();
493
504
  }
494
505
  export type RuntimeEventSink = (event: RuntimeEvent) => void;
495
- export type ProviderErrorKind = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'unknown' | 'max_output_tokens';
506
+ export type ProviderErrorKind = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'timeout' | 'overloaded' | 'api_error' | 'prompt_too_long' | 'transport_error' | 'cancelled' | 'unknown' | 'max_output_tokens';
496
507
  export declare function modelProviderErrorKind(error: ModelProviderError): ProviderErrorKind;
497
508
  export declare class AgentRuntime {
498
509
  private readonly provider;
@@ -20,9 +20,12 @@ export class ModelProviderError extends Error {
20
20
  retryable;
21
21
  status;
22
22
  retryDelayMs;
23
+ kind;
23
24
  constructor(message, options) {
24
25
  super(message, options.cause === undefined ? undefined : { cause: options.cause });
25
26
  this.retryable = options.retryable;
27
+ if (options.kind !== undefined)
28
+ this.kind = options.kind;
26
29
  if (options.status !== undefined)
27
30
  this.status = options.status;
28
31
  if (options.retryDelayMs !== undefined)
@@ -37,6 +40,8 @@ export class AgentRunCancelledError extends Error {
37
40
  }
38
41
  const emptyUsage = () => ({ inputTokens: 0, outputTokens: 0 });
39
42
  export function modelProviderErrorKind(error) {
43
+ if (error.kind !== undefined)
44
+ return error.kind;
40
45
  if (error.status === 401 || error.status === 403)
41
46
  return 'authentication_failed';
42
47
  if (error.status === 402)
@@ -341,6 +346,7 @@ export class AgentRuntime {
341
346
  let turnUsage = emptyUsage();
342
347
  let streaming = false;
343
348
  const toolCalls = [];
349
+ let terminalReason;
344
350
  const apiStartedAt = request.collectMetrics ? performance.now() : 0;
345
351
  let turnApiDurationMs = 0;
346
352
  let turnApiDurationWithoutRetriesMs;
@@ -349,6 +355,9 @@ export class AgentRuntime {
349
355
  for await (const event of this.provider.complete(providerRequest)) {
350
356
  if (request.signal?.aborted)
351
357
  return this.cancel();
358
+ if (terminalReason !== undefined) {
359
+ throw new ModelProviderError(`Provider emitted ${event.type} after terminal reason ${terminalReason}`, { retryable: false });
360
+ }
352
361
  if (event.type === 'api-retry') {
353
362
  this.emit(event);
354
363
  continue;
@@ -414,6 +423,10 @@ export class AgentRuntime {
414
423
  toolCalls.push(event.call);
415
424
  this.emit(event);
416
425
  }
426
+ else if (event.type === 'terminal') {
427
+ terminalReason = event.reason;
428
+ this.emit(event);
429
+ }
417
430
  else {
418
431
  turnUsage = event.usage;
419
432
  this.emit(event);
@@ -427,6 +440,18 @@ export class AgentRuntime {
427
440
  unrecordedDurationApiMs = addApiDurationMetric(turnApiDurationMs, unrecordedDurationApiMs, 'durationApiMs');
428
441
  }
429
442
  }
443
+ if (this.provider.capabilities.terminalReasons === true &&
444
+ terminalReason === undefined) {
445
+ throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
446
+ }
447
+ if (terminalReason === 'tool_use' && toolCalls.length === 0) {
448
+ throw new ModelProviderError('Provider reported tool_use without a completed tool call', { retryable: false });
449
+ }
450
+ if (terminalReason !== undefined &&
451
+ terminalReason !== 'tool_use' &&
452
+ toolCalls.length > 0) {
453
+ throw new ModelProviderError(`Provider reported ${terminalReason} with completed tool calls`, { retryable: false });
454
+ }
430
455
  if (request.collectMetrics) {
431
456
  const turnApiDurationWithoutRetriesMsResolved = turnApiDurationWithoutRetriesMs ?? turnApiDurationMs;
432
457
  durationApiWithoutRetriesMs = addApiDurationMetric(turnApiDurationWithoutRetriesMsResolved, durationApiWithoutRetriesMs, 'durationApiWithoutRetriesMs');
@@ -1,4 +1,5 @@
1
1
  import { ModelProviderError, } from '../core/runtime.js';
2
+ import { transportFailureKind } from './provider-errors.js';
2
3
  function isRecord(value) {
3
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
4
5
  }
@@ -61,6 +62,52 @@ function readErrorMessage(value, status) {
61
62
  }
62
63
  return `Provider request failed with HTTP ${status}`;
63
64
  }
65
+ function isPromptTooLongError(type, message) {
66
+ return (['prompt_too_long', 'context_length_exceeded'].includes(type) ||
67
+ /prompt\s+(?:is\s+)?too long|context.{0,80}(?:exceed|too long)|maximum context length/iu.test(message));
68
+ }
69
+ function anthropicErrorKind(value, status) {
70
+ const error = isRecord(value) && isRecord(value.error) ? value.error : value;
71
+ const type = isRecord(error) && typeof error.type === 'string' ? error.type : '';
72
+ const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
73
+ if (isPromptTooLongError(type, message))
74
+ return 'prompt_too_long';
75
+ if (type === 'authentication_error' || status === 401 || status === 403)
76
+ return 'authentication_failed';
77
+ if (type === 'billing_error' || status === 402)
78
+ return 'billing_error';
79
+ if (type === 'rate_limit_error' || status === 429)
80
+ return 'rate_limit';
81
+ if (type === 'overloaded_error' || status === 529)
82
+ return 'overloaded';
83
+ if (type === 'api_error')
84
+ return 'api_error';
85
+ if (status === 408)
86
+ return 'timeout';
87
+ if (type === 'invalid_request_error')
88
+ return 'invalid_request';
89
+ if (status !== undefined && status >= 400 && status < 500)
90
+ return 'invalid_request';
91
+ if (status !== undefined && status >= 500)
92
+ return 'server_error';
93
+ return 'unknown';
94
+ }
95
+ function anthropicStopReason(value) {
96
+ if (value === 'end_turn' || value === 'stop_sequence' || value === 'refusal')
97
+ return 'end_turn';
98
+ if (value === 'tool_use')
99
+ return 'tool_use';
100
+ if (value === 'max_tokens')
101
+ return 'max_tokens';
102
+ if (value === 'model_context_window_exceeded')
103
+ return 'prompt_too_long';
104
+ if (typeof value !== 'string' || value.length === 0) {
105
+ throw new ModelProviderError('Provider message delta is missing stop reason', {
106
+ retryable: false,
107
+ });
108
+ }
109
+ throw new ModelProviderError(`Provider returned unsupported stop reason ${value}`, { retryable: false });
110
+ }
64
111
  function completedToolCall(state, index) {
65
112
  const pending = state.tools.get(index);
66
113
  if (!pending)
@@ -105,12 +152,14 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
105
152
  const message = typeof error.message === 'string'
106
153
  ? error.message
107
154
  : 'Provider stream returned an error';
155
+ const kind = anthropicErrorKind(error);
108
156
  const retryable = [
109
157
  'api_error',
110
- 'overloaded_error',
111
- 'rate_limit_error',
112
- ].includes(String(error.type));
113
- throw new ModelProviderError(message, { retryable });
158
+ 'overloaded',
159
+ 'rate_limit',
160
+ 'timeout',
161
+ ].includes(kind);
162
+ throw new ModelProviderError(message, { kind, retryable });
114
163
  }
115
164
  if (value.type === 'message_start') {
116
165
  if (state.messageStarted || !isRecord(value.message)) {
@@ -344,6 +393,17 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
344
393
  throw new ModelProviderError('Provider returned an invalid message delta', { retryable: false });
345
394
  }
346
395
  state.messageDeltaSeen = true;
396
+ if (value.delta !== undefined && !isRecord(value.delta)) {
397
+ throw new ModelProviderError('Provider returned an invalid message delta', {
398
+ retryable: false,
399
+ });
400
+ }
401
+ if (isRecord(value.delta) && value.delta.stop_reason !== undefined) {
402
+ if (state.terminalReason !== undefined) {
403
+ throw new ModelProviderError('Provider returned multiple terminal stop reasons', { retryable: false });
404
+ }
405
+ state.terminalReason = anthropicStopReason(value.delta.stop_reason);
406
+ }
347
407
  if (typeof value.usage.output_tokens === 'number') {
348
408
  state.outputTokens = value.usage.output_tokens;
349
409
  state.usageSeen = true;
@@ -360,6 +420,11 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
360
420
  if (!state.messageDeltaSeen) {
361
421
  throw new ModelProviderError('Provider stopped before the terminal message delta', { retryable: false });
362
422
  }
423
+ if (state.terminalReason === undefined) {
424
+ throw new ModelProviderError('Provider message delta is missing stop reason', {
425
+ retryable: false,
426
+ });
427
+ }
363
428
  state.terminal = true;
364
429
  const events = [];
365
430
  if (state.usageSeen) {
@@ -381,6 +446,7 @@ function parseSseEvent(data, state, maxToolArgumentsBytes, maxToolCallsPerRespon
381
446
  });
382
447
  state.usageSeen = false;
383
448
  }
449
+ events.push({ type: 'terminal', reason: state.terminalReason });
384
450
  return events;
385
451
  }
386
452
  return [];
@@ -532,6 +598,7 @@ export class AnthropicCompatibleProvider {
532
598
  ? {}
533
599
  : { contextWindowTokens: options.contextWindowTokens }),
534
600
  maxOutputTokens: this.maxOutputTokens,
601
+ terminalReasons: true,
535
602
  };
536
603
  this.thinking = validateThinking(options.thinking);
537
604
  this.anthropicVersion = options.anthropicVersion ?? '2023-06-01';
@@ -618,10 +685,14 @@ export class AnthropicCompatibleProvider {
618
685
  response = await this.fetchImplementation(this.endpoint, requestInit);
619
686
  }
620
687
  catch (error) {
621
- if (request.signal?.aborted)
622
- throw error;
623
- throw new ModelProviderError('Provider transport failed', {
624
- retryable: true,
688
+ const kind = transportFailureKind(error, request.signal);
689
+ throw new ModelProviderError(kind === 'cancelled'
690
+ ? 'Provider request cancelled'
691
+ : kind === 'timeout'
692
+ ? 'Provider request timed out'
693
+ : 'Provider transport failed', {
694
+ kind,
695
+ retryable: kind === 'timeout' || kind === 'transport_error',
625
696
  cause: error,
626
697
  });
627
698
  }
@@ -669,12 +740,14 @@ export class AnthropicCompatibleProvider {
669
740
  payload = null;
670
741
  }
671
742
  throw new ModelProviderError(readErrorMessage(payload, response.status), {
743
+ kind: anthropicErrorKind(payload, response.status),
672
744
  retryable: isRetryableStatus(response.status),
673
745
  status: response.status,
674
746
  });
675
747
  }
676
748
  if (!response.body) {
677
749
  throw new ModelProviderError('Provider response has no body', {
750
+ kind: 'transport_error',
678
751
  retryable: true,
679
752
  });
680
753
  }
@@ -734,11 +807,12 @@ export class AnthropicCompatibleProvider {
734
807
  }
735
808
  }
736
809
  catch (error) {
737
- if (error instanceof ModelProviderError || request.signal?.aborted) {
810
+ if (error instanceof ModelProviderError)
738
811
  throw error;
739
- }
812
+ const kind = transportFailureKind(error, request.signal);
740
813
  throw new ModelProviderError('Provider stream failed', {
741
- retryable: true,
814
+ kind,
815
+ retryable: kind === 'timeout' || kind === 'transport_error',
742
816
  cause: error,
743
817
  });
744
818
  }
@@ -35,7 +35,11 @@ export class FallbackModelProvider {
35
35
  // cannot duplicate text/tool events when retried.
36
36
  const events = [];
37
37
  let attemptDurationMs;
38
+ let terminalSeen = false;
38
39
  for await (const event of provider.complete(request)) {
40
+ if (terminalSeen) {
41
+ throw new ModelProviderError(`Provider emitted ${event.type} after its terminal event`, { retryable: false });
42
+ }
39
43
  if (event.type === 'api-attempt-duration') {
40
44
  // Consume the underlying attempt timing rather than replaying it
41
45
  // so nested wrappers report a single retry-free duration.
@@ -51,8 +55,13 @@ export class FallbackModelProvider {
51
55
  attemptDurationMs = durationMs;
52
56
  continue;
53
57
  }
58
+ if (event.type === 'terminal')
59
+ terminalSeen = true;
54
60
  events.push(event);
55
61
  }
62
+ if (provider.capabilities.terminalReasons === true && !terminalSeen) {
63
+ throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
64
+ }
56
65
  yield {
57
66
  type: 'api-attempt-duration',
58
67
  durationMs: attemptDurationMs ??
@@ -1,4 +1,5 @@
1
1
  import { ModelProviderError, } from '../core/runtime.js';
2
+ import { transportFailureKind } from './provider-errors.js';
2
3
  function isRecord(value) {
3
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
4
5
  }
@@ -13,6 +14,48 @@ function readErrorMessage(value, status) {
13
14
  }
14
15
  return `Provider request failed with HTTP ${status}`;
15
16
  }
17
+ function openAiFinishReason(value) {
18
+ if (value === 'stop' || value === 'content_filter')
19
+ return 'end_turn';
20
+ if (value === 'tool_calls' || value === 'function_call')
21
+ return 'tool_use';
22
+ if (value === 'length')
23
+ return 'max_tokens';
24
+ if (typeof value !== 'string' || value.length === 0) {
25
+ throw new ModelProviderError('Provider stream is missing finish reason', {
26
+ retryable: false,
27
+ });
28
+ }
29
+ throw new ModelProviderError(`Provider returned unsupported finish reason ${value}`, { retryable: false });
30
+ }
31
+ function openAiErrorKind(value, status) {
32
+ const error = isRecord(value) && isRecord(value.error) ? value.error : value;
33
+ const type = isRecord(error) && typeof error.type === 'string' ? error.type : '';
34
+ const code = isRecord(error) && typeof error.code === 'string' ? error.code : '';
35
+ const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
36
+ if (['context_length_exceeded', 'prompt_too_long'].includes(type) ||
37
+ ['context_length_exceeded', 'prompt_too_long'].includes(code) ||
38
+ /prompt\s+(?:is\s+)?too long|context.{0,80}(?:exceed|too long)|maximum context length/iu.test(message)) {
39
+ return 'prompt_too_long';
40
+ }
41
+ if (status === 401 || status === 403)
42
+ return 'authentication_failed';
43
+ if (status === 402)
44
+ return 'billing_error';
45
+ if (status === 408)
46
+ return 'timeout';
47
+ if (type === 'rate_limit_error' || status === 429)
48
+ return 'rate_limit';
49
+ if (type === 'overloaded_error' || status === 529)
50
+ return 'overloaded';
51
+ if (type === 'api_error' || type === 'server_error')
52
+ return 'api_error';
53
+ if (status !== undefined && status >= 400 && status < 500)
54
+ return 'invalid_request';
55
+ if (status !== undefined && status >= 500)
56
+ return 'server_error';
57
+ return 'unknown';
58
+ }
16
59
  function completedToolCallEvents(pending) {
17
60
  const events = [...pending.calls.entries()]
18
61
  .sort(([left], [right]) => left - right)
@@ -42,9 +85,18 @@ function completedToolCallEvents(pending) {
42
85
  }
43
86
  function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPerResponse, maxToolMetadataBytes) {
44
87
  if (data === '[DONE]') {
88
+ if (pendingTools.terminalReason === undefined) {
89
+ throw new ModelProviderError('Provider stream is missing finish reason', {
90
+ retryable: false,
91
+ });
92
+ }
45
93
  pendingTools.done = true;
46
94
  pendingTools.terminal = true;
47
- return completedToolCallEvents(pendingTools);
95
+ pendingTools.terminalEmitted = true;
96
+ return [
97
+ ...completedToolCallEvents(pendingTools),
98
+ { type: 'terminal', reason: pendingTools.terminalReason },
99
+ ];
48
100
  }
49
101
  let value;
50
102
  try {
@@ -58,6 +110,16 @@ function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPe
58
110
  }
59
111
  if (!isRecord(value))
60
112
  return [];
113
+ if (isRecord(value.error)) {
114
+ const kind = openAiErrorKind(value);
115
+ const message = typeof value.error.message === 'string'
116
+ ? value.error.message
117
+ : 'Provider stream returned an error';
118
+ throw new ModelProviderError(message, {
119
+ kind,
120
+ retryable: ['api_error', 'overloaded', 'rate_limit', 'timeout'].includes(kind),
121
+ });
122
+ }
61
123
  const events = [];
62
124
  const choices = value.choices;
63
125
  if (Array.isArray(choices)) {
@@ -106,7 +168,11 @@ function parseSseEvent(data, pendingTools, maxToolArgumentsBytes, maxToolCallsPe
106
168
  if (isRecord(first) &&
107
169
  first.finish_reason !== null &&
108
170
  first.finish_reason !== undefined) {
171
+ if (pendingTools.terminalReason !== undefined) {
172
+ throw new ModelProviderError('Provider returned multiple finish reasons', { retryable: false });
173
+ }
109
174
  pendingTools.terminal = true;
175
+ pendingTools.terminalReason = openAiFinishReason(first.finish_reason);
110
176
  if (pendingTools.calls.size > 0) {
111
177
  events.push(...completedToolCallEvents(pendingTools));
112
178
  }
@@ -219,6 +285,7 @@ export class OpenAICompatibleProvider {
219
285
  tools: true,
220
286
  images: true,
221
287
  thinking: { modes: ['disabled'], maxTokens: false },
288
+ terminalReasons: true,
222
289
  ...(options.contextWindowTokens === undefined
223
290
  ? {}
224
291
  : { contextWindowTokens: options.contextWindowTokens }),
@@ -279,10 +346,14 @@ export class OpenAICompatibleProvider {
279
346
  response = await this.fetchImplementation(this.endpoint, requestInit);
280
347
  }
281
348
  catch (error) {
282
- if (request.signal?.aborted)
283
- throw error;
284
- throw new ModelProviderError('Provider transport failed', {
285
- retryable: true,
349
+ const kind = transportFailureKind(error, request.signal);
350
+ throw new ModelProviderError(kind === 'cancelled'
351
+ ? 'Provider request cancelled'
352
+ : kind === 'timeout'
353
+ ? 'Provider request timed out'
354
+ : 'Provider transport failed', {
355
+ kind,
356
+ retryable: kind === 'timeout' || kind === 'transport_error',
286
357
  cause: error,
287
358
  });
288
359
  }
@@ -330,12 +401,14 @@ export class OpenAICompatibleProvider {
330
401
  payload = null;
331
402
  }
332
403
  throw new ModelProviderError(readErrorMessage(payload, response.status), {
404
+ kind: openAiErrorKind(payload, response.status),
333
405
  retryable: isRetryableStatus(response.status),
334
406
  status: response.status,
335
407
  });
336
408
  }
337
409
  if (!response.body) {
338
410
  throw new ModelProviderError('Provider response has no body', {
411
+ kind: 'transport_error',
339
412
  retryable: true,
340
413
  });
341
414
  }
@@ -347,6 +420,7 @@ export class OpenAICompatibleProvider {
347
420
  metadataBytes: 0,
348
421
  done: false,
349
422
  terminal: false,
423
+ terminalEmitted: false,
350
424
  };
351
425
  let streamEnded = false;
352
426
  try {
@@ -383,13 +457,23 @@ export class OpenAICompatibleProvider {
383
457
  if (!pendingTools.terminal) {
384
458
  throw new ModelProviderError('Provider stream ended before a terminal event', { retryable: true });
385
459
  }
460
+ if (!pendingTools.terminalEmitted) {
461
+ if (pendingTools.terminalReason === undefined) {
462
+ throw new ModelProviderError('Provider stream is missing finish reason', {
463
+ retryable: false,
464
+ });
465
+ }
466
+ pendingTools.terminalEmitted = true;
467
+ yield { type: 'terminal', reason: pendingTools.terminalReason };
468
+ }
386
469
  }
387
470
  catch (error) {
388
- if (error instanceof ModelProviderError || request.signal?.aborted) {
471
+ if (error instanceof ModelProviderError)
389
472
  throw error;
390
- }
473
+ const kind = transportFailureKind(error, request.signal);
391
474
  throw new ModelProviderError('Provider stream failed', {
392
- retryable: true,
475
+ kind,
476
+ retryable: kind === 'timeout' || kind === 'transport_error',
393
477
  cause: error,
394
478
  });
395
479
  }
@@ -0,0 +1,3 @@
1
+ export type TransportFailureKind = 'cancelled' | 'timeout' | 'transport_error';
2
+ export declare function transportFailureKind(error: unknown, signal?: AbortSignal): TransportFailureKind;
3
+ //# sourceMappingURL=provider-errors.d.ts.map
@@ -0,0 +1,9 @@
1
+ export function transportFailureKind(error, signal) {
2
+ const reason = signal?.reason;
3
+ if ((error instanceof Error && error.name === 'TimeoutError') ||
4
+ (reason instanceof Error && reason.name === 'TimeoutError')) {
5
+ return 'timeout';
6
+ }
7
+ return signal?.aborted ? 'cancelled' : 'transport_error';
8
+ }
9
+ //# sourceMappingURL=provider-errors.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.23.1",
3
+ "version": "0.24.1",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",