@ottocode/server 0.1.265 → 0.1.266

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.
Files changed (72) hide show
  1. package/package.json +3 -3
  2. package/src/routes/auth/copilot.ts +699 -0
  3. package/src/routes/auth/oauth.ts +578 -0
  4. package/src/routes/auth/onboarding.ts +45 -0
  5. package/src/routes/auth/providers.ts +189 -0
  6. package/src/routes/auth/service.ts +167 -0
  7. package/src/routes/auth/state.ts +23 -0
  8. package/src/routes/auth/status.ts +203 -0
  9. package/src/routes/auth/wallet.ts +229 -0
  10. package/src/routes/auth.ts +12 -2080
  11. package/src/routes/config/models-service.ts +411 -0
  12. package/src/routes/config/models.ts +6 -426
  13. package/src/routes/config/providers-service.ts +237 -0
  14. package/src/routes/config/providers.ts +10 -242
  15. package/src/routes/files/handlers.ts +297 -0
  16. package/src/routes/files/service.ts +313 -0
  17. package/src/routes/files.ts +12 -608
  18. package/src/routes/git/commit-service.ts +207 -0
  19. package/src/routes/git/commit.ts +6 -220
  20. package/src/routes/git/remote-service.ts +116 -0
  21. package/src/routes/git/remote.ts +8 -115
  22. package/src/routes/git/staging-service.ts +111 -0
  23. package/src/routes/git/staging.ts +10 -205
  24. package/src/routes/mcp/auth.ts +338 -0
  25. package/src/routes/mcp/lifecycle.ts +263 -0
  26. package/src/routes/mcp/servers.ts +212 -0
  27. package/src/routes/mcp/service.ts +664 -0
  28. package/src/routes/mcp/state.ts +13 -0
  29. package/src/routes/mcp.ts +6 -1233
  30. package/src/routes/ottorouter/billing.ts +593 -0
  31. package/src/routes/ottorouter/service.ts +92 -0
  32. package/src/routes/ottorouter/topup.ts +301 -0
  33. package/src/routes/ottorouter/wallet.ts +370 -0
  34. package/src/routes/ottorouter.ts +6 -1319
  35. package/src/routes/research/service.ts +339 -0
  36. package/src/routes/research.ts +12 -390
  37. package/src/routes/sessions/crud.ts +563 -0
  38. package/src/routes/sessions/queue.ts +242 -0
  39. package/src/routes/sessions/retry.ts +121 -0
  40. package/src/routes/sessions/service.ts +768 -0
  41. package/src/routes/sessions/share.ts +434 -0
  42. package/src/routes/sessions.ts +8 -1977
  43. package/src/routes/skills/service.ts +221 -0
  44. package/src/routes/skills/spec.ts +309 -0
  45. package/src/routes/skills.ts +31 -909
  46. package/src/routes/terminals/service.ts +326 -0
  47. package/src/routes/terminals.ts +19 -295
  48. package/src/routes/tunnel/service.ts +217 -0
  49. package/src/routes/tunnel.ts +29 -219
  50. package/src/runtime/agent/registry-prompts.ts +147 -0
  51. package/src/runtime/agent/registry.ts +6 -124
  52. package/src/runtime/agent/runner-errors.ts +116 -0
  53. package/src/runtime/agent/runner-reminders.ts +45 -0
  54. package/src/runtime/agent/runner-setup-model.ts +75 -0
  55. package/src/runtime/agent/runner-setup-prompt.ts +185 -0
  56. package/src/runtime/agent/runner-setup-tools.ts +103 -0
  57. package/src/runtime/agent/runner-setup-utils.ts +21 -0
  58. package/src/runtime/agent/runner-setup.ts +54 -288
  59. package/src/runtime/agent/runner-telemetry.ts +112 -0
  60. package/src/runtime/agent/runner-text.ts +108 -0
  61. package/src/runtime/agent/runner-tool-observer.ts +86 -0
  62. package/src/runtime/agent/runner.ts +79 -378
  63. package/src/runtime/provider/custom.ts +73 -0
  64. package/src/runtime/provider/index.ts +2 -85
  65. package/src/runtime/provider/reasoning-builders.ts +280 -0
  66. package/src/runtime/provider/reasoning.ts +67 -264
  67. package/src/tools/adapter/events.ts +116 -0
  68. package/src/tools/adapter/execution.ts +160 -0
  69. package/src/tools/adapter/pending.ts +37 -0
  70. package/src/tools/adapter/persistence.ts +166 -0
  71. package/src/tools/adapter/results.ts +97 -0
  72. package/src/tools/adapter.ts +124 -451
@@ -0,0 +1,86 @@
1
+ import { subscribe } from '../../events/bus.ts';
2
+ import type { OttoEvent } from '../../events/types.ts';
3
+ import type { createTurnDumpCollector } from '../debug/turn-dump.ts';
4
+
5
+ export type RunnerToolObserverState = {
6
+ finishObserved: boolean;
7
+ toolActivityObserved: boolean;
8
+ trailingAssistantTextAfterTool: boolean;
9
+ endedWithToolActivity: boolean;
10
+ lastToolName?: string;
11
+ };
12
+
13
+ type TurnDumpCollector = NonNullable<
14
+ ReturnType<typeof createTurnDumpCollector>
15
+ >;
16
+
17
+ export function observeRunnerToolEvents(args: {
18
+ sessionId: string;
19
+ dump: TurnDumpCollector | null;
20
+ getStepIndex: () => number;
21
+ onToolCall?: () => void;
22
+ }): { state: RunnerToolObserverState; unsubscribe: () => void } {
23
+ const state: RunnerToolObserverState = {
24
+ finishObserved: false,
25
+ toolActivityObserved: false,
26
+ trailingAssistantTextAfterTool: false,
27
+ endedWithToolActivity: false,
28
+ lastToolName: undefined,
29
+ };
30
+
31
+ const unsubscribe = subscribe(args.sessionId, (evt: OttoEvent) => {
32
+ if (evt.type === 'tool.call' || evt.type === 'tool.result') {
33
+ state.toolActivityObserved = true;
34
+ state.trailingAssistantTextAfterTool = false;
35
+ state.endedWithToolActivity = true;
36
+ try {
37
+ state.lastToolName = (
38
+ evt.payload as { name?: string } | undefined
39
+ )?.name;
40
+ } catch {
41
+ state.lastToolName = undefined;
42
+ }
43
+ }
44
+ if (evt.type === 'tool.call') {
45
+ args.onToolCall?.();
46
+ if (args.dump) {
47
+ try {
48
+ const p = evt.payload as {
49
+ name?: string;
50
+ callId?: string;
51
+ args?: unknown;
52
+ };
53
+ args.dump.recordToolCall(
54
+ args.getStepIndex(),
55
+ p.name ?? '',
56
+ p.callId ?? '',
57
+ p.args,
58
+ );
59
+ } catch {}
60
+ }
61
+ }
62
+ if (evt.type === 'tool.result') {
63
+ if (args.dump) {
64
+ try {
65
+ const p = evt.payload as {
66
+ name?: string;
67
+ callId?: string;
68
+ result?: unknown;
69
+ };
70
+ args.dump.recordToolResult(
71
+ args.getStepIndex(),
72
+ p.name ?? '',
73
+ p.callId ?? '',
74
+ p.result,
75
+ );
76
+ } catch {}
77
+ }
78
+ try {
79
+ const name = (evt.payload as { name?: string } | undefined)?.name;
80
+ if (name === 'finish') state.finishObserved = true;
81
+ } catch {}
82
+ }
83
+ });
84
+
85
+ return { state, unsubscribe };
86
+ }
@@ -1,11 +1,6 @@
1
1
  import { hasToolCall, streamText } from 'ai';
2
2
  import { logger } from '@ottocode/sdk';
3
- import type { getDb } from '@ottocode/database';
4
- import { messageParts, sessions } from '@ottocode/database/schema';
5
- import { eq } from 'drizzle-orm';
6
- import { publish, subscribe } from '../../events/bus.ts';
7
- import { time } from '../debug/index.ts';
8
- import { toErrorPayload } from '../errors/handling.ts';
3
+ import { publish } from '../../events/bus.ts';
9
4
  import {
10
5
  type RunOpts,
11
6
  setRunning,
@@ -24,10 +19,6 @@ import {
24
19
  createAbortHandler,
25
20
  createFinishHandler,
26
21
  } from '../stream/handlers.ts';
27
- import {
28
- pruneSession,
29
- shouldAutoCompactBeforeOverflow,
30
- } from '../message/compaction.ts';
31
22
  import { triggerDeferredTitleGeneration } from '../message/service.ts';
32
23
  import { setupRunner } from './runner-setup.ts';
33
24
  import {
@@ -46,6 +37,21 @@ import {
46
37
  consumeOauthCodexTextDelta,
47
38
  } from '../stream/text-guard.ts';
48
39
  import { createTurnDumpCollector } from '../debug/turn-dump.ts';
40
+ import {
41
+ appendRunnerReminderMessages,
42
+ type RunnerMessage,
43
+ } from './runner-reminders.ts';
44
+ import {
45
+ createFirstOutputLatencyLogger,
46
+ logStreamRequestReady,
47
+ nowMs,
48
+ } from './runner-telemetry.ts';
49
+ import { handleRunnerTextDelta, type RunnerTextState } from './runner-text.ts';
50
+ import { observeRunnerToolEvents } from './runner-tool-observer.ts';
51
+ import {
52
+ handleRunnerError,
53
+ shouldPreemptivelyAutoCompact,
54
+ } from './runner-errors.ts';
49
55
 
50
56
  export {
51
57
  enqueueAssistantRun,
@@ -56,95 +62,6 @@ export {
56
62
  getRunnerState,
57
63
  } from '../session/queue.ts';
58
64
 
59
- const DEFAULT_TRACED_TOOL_INPUTS = new Set([
60
- 'write',
61
- 'edit',
62
- 'multiedit',
63
- 'copy_into',
64
- 'apply_patch',
65
- ]);
66
-
67
- function shouldTraceToolInput(name: string): boolean {
68
- void DEFAULT_TRACED_TOOL_INPUTS;
69
- void name;
70
- return false;
71
- }
72
-
73
- function summarizeTraceValue(value: unknown, max = 160): string {
74
- try {
75
- const json = JSON.stringify(value);
76
- if (typeof json === 'string') {
77
- return json.length > max ? `${json.slice(0, max)}…` : json;
78
- }
79
- } catch {}
80
- const fallback = String(value);
81
- return fallback.length > max ? `${fallback.slice(0, max)}…` : fallback;
82
- }
83
-
84
- function nowMs(): number {
85
- const perf = globalThis.performance;
86
- if (perf && typeof perf.now === 'function') return perf.now();
87
- return Date.now();
88
- }
89
-
90
- function approximateMessageChars(
91
- messages: Array<{ role: string; content: string | Array<unknown> }>,
92
- ): number {
93
- let total = 0;
94
- for (const message of messages) {
95
- total += message.role.length;
96
- if (typeof message.content === 'string') {
97
- total += message.content.length;
98
- continue;
99
- }
100
- try {
101
- total += JSON.stringify(message.content).length;
102
- } catch {}
103
- }
104
- return total;
105
- }
106
-
107
- function summarizeToolShape(tools: Record<string, unknown>) {
108
- const names = Object.keys(tools);
109
- const entries = names.map((name) => {
110
- const toolValue = tools[name];
111
- let approxChars = 0;
112
- try {
113
- approxChars = JSON.stringify(toolValue).length;
114
- } catch {}
115
- return { name, approxChars };
116
- });
117
- entries.sort((a, b) => b.approxChars - a.approxChars);
118
- return {
119
- toolNames: names,
120
- toolSchemaCharsApprox: entries.reduce(
121
- (total, entry) => total + entry.approxChars,
122
- 0,
123
- ),
124
- largestTools: entries.slice(0, 8),
125
- };
126
- }
127
-
128
- async function shouldPreemptivelyAutoCompact(
129
- db: Awaited<ReturnType<typeof getDb>>,
130
- opts: RunOpts,
131
- threshold: number | null | undefined,
132
- ): Promise<boolean> {
133
- const sessionRows = await db
134
- .select({ currentContextTokens: sessions.currentContextTokens })
135
- .from(sessions)
136
- .where(eq(sessions.id, opts.sessionId))
137
- .limit(1);
138
-
139
- return shouldAutoCompactBeforeOverflow({
140
- autoCompactThresholdTokens: threshold,
141
- currentContextTokens: sessionRows[0]?.currentContextTokens ?? 0,
142
- estimatedInputTokens: opts.estimatedInputTokens ?? 0,
143
- isCompactCommand: opts.isCompactCommand,
144
- compactionRetries: opts.compactionRetries,
145
- });
146
- }
147
-
148
65
  export async function runSessionLoop(sessionId: string) {
149
66
  setRunning(sessionId, true);
150
67
 
@@ -225,41 +142,16 @@ async function runAssistant(opts: RunOpts) {
225
142
 
226
143
  const isFirstMessage = !history.some((m) => m.role === 'assistant');
227
144
 
228
- const messagesWithSystemInstructions: Array<{
229
- role: string;
230
- content: string | Array<unknown>;
231
- }> = [...additionalSystemMessages, ...history];
232
-
233
- if (!isFirstMessage) {
234
- if (isOpenAIOAuth) {
235
- messagesWithSystemInstructions.push({
236
- role: 'system',
237
- content:
238
- '[system-reminder] Continuing an existing session. Execute directly, use tools as needed, and call `finish` at the end. For simple questions, your answer IS the response — do not add a "Summary:" recap.',
239
- });
240
- } else {
241
- messagesWithSystemInstructions.push({
242
- role: 'user',
243
- content:
244
- '<system-reminder>Continuing an existing session. Answer or complete the work directly, then call `finish`. For simple questions, your answer IS the response — do NOT add a labeled "Summary:" line or recap trivial replies.</system-reminder>',
245
- });
246
- }
247
- }
248
- if ((opts.continuationCount ?? 0) > 0) {
249
- if (isOpenAIOAuth) {
250
- messagesWithSystemInstructions.push({
251
- role: 'system',
252
- content:
253
- '[system-reminder] Your previous response stopped mid-task. Resume from where you left off and complete the actual work — not a plan-only update.',
254
- });
255
- } else {
256
- messagesWithSystemInstructions.push({
257
- role: 'user',
258
- content:
259
- '<system-reminder>Your previous response stopped before calling `finish`. Resume from where you left off, do the actual work (no plan-only updates), then stream a summary and call `finish`.</system-reminder>',
260
- });
261
- }
262
- }
145
+ const messagesWithSystemInstructions: RunnerMessage[] = [
146
+ ...additionalSystemMessages,
147
+ ...history,
148
+ ];
149
+ appendRunnerReminderMessages({
150
+ messages: messagesWithSystemInstructions,
151
+ isFirstMessage,
152
+ isOpenAIOAuth,
153
+ continuationCount: opts.continuationCount,
154
+ });
263
155
 
264
156
  const dump = createTurnDumpCollector({
265
157
  sessionId: opts.sessionId,
@@ -286,98 +178,34 @@ async function runAssistant(opts: RunOpts) {
286
178
  });
287
179
  }
288
180
 
289
- let _finishObserved = false;
290
- let _toolActivityObserved = false;
291
- let _trailingAssistantTextAfterTool = false;
292
- let _endedWithToolActivity = false;
293
- let _lastToolName: string | undefined;
294
181
  let _abortedByUser = false;
295
182
  let titleGenerationTriggered = false;
296
- const unsubscribeFinish = subscribe(opts.sessionId, (evt) => {
297
- if (evt.type === 'tool.call' || evt.type === 'tool.result') {
298
- _toolActivityObserved = true;
299
- _trailingAssistantTextAfterTool = false;
300
- _endedWithToolActivity = true;
301
- try {
302
- _lastToolName = (evt.payload as { name?: string } | undefined)?.name;
303
- } catch {
304
- _lastToolName = undefined;
305
- }
306
- }
307
- if (evt.type === 'tool.call') {
308
- triggerTitleGenerationWhenReady();
309
- if (dump) {
310
- try {
311
- const p = evt.payload as {
312
- name?: string;
313
- callId?: string;
314
- args?: unknown;
315
- };
316
- dump.recordToolCall(stepIndex, p.name ?? '', p.callId ?? '', p.args);
317
- } catch {}
318
- }
319
- }
320
- if (evt.type === 'tool.result') {
321
- if (dump) {
322
- try {
323
- const p = evt.payload as {
324
- name?: string;
325
- callId?: string;
326
- result?: unknown;
327
- };
328
- dump.recordToolResult(
329
- stepIndex,
330
- p.name ?? '',
331
- p.callId ?? '',
332
- p.result,
333
- );
334
- } catch {}
335
- }
336
- try {
337
- const name = (evt.payload as { name?: string } | undefined)?.name;
338
- if (name === 'finish') _finishObserved = true;
339
- } catch {}
340
- }
183
+ const logFirstOutputLatency = createFirstOutputLatencyLogger({
184
+ opts,
185
+ runStartedAt,
186
+ queueWaitMs,
187
+ timings,
341
188
  });
342
189
 
343
- const streamStartTimer = time('runner:first-delta');
344
- let firstDeltaSeen = false;
345
- const logFirstOutputLatency = (kind: 'text' | 'reasoning') => {
346
- if (firstDeltaSeen) return;
347
- firstDeltaSeen = true;
348
- const firstOutputMs = nowMs() - runStartedAt;
349
- streamStartTimer.end({ kind, queueWaitMs, setupMs: timings.totalMs });
350
- logger.info('[latency] first output', {
351
- sessionId: opts.sessionId,
352
- messageId: opts.assistantMessageId,
353
- agent: opts.agent,
354
- provider: opts.provider,
355
- model: opts.model,
356
- kind,
357
- queueWaitMs,
358
- firstOutputMs,
359
- setupMs: timings.totalMs,
360
- totalSinceEnqueueMs: queueWaitMs + firstOutputMs,
361
- timings,
362
- });
190
+ const textState: RunnerTextState = {
191
+ currentPartId: null,
192
+ accumulated: '',
193
+ latestAssistantText: '',
194
+ lastTextDeltaStepIndex: null,
195
+ firstPublishedDeltaSeen: false,
363
196
  };
364
-
365
- let currentPartId: string | null = null;
366
- let accumulated = '';
367
- let latestAssistantText = '';
368
- let lastTextDeltaStepIndex: number | null = null;
369
197
  let stepIndex = 0;
370
198
  const oauthTextGuard = isOpenAIOAuth
371
199
  ? createOauthCodexTextGuardState()
372
200
  : null;
373
201
 
374
- const getCurrentPartId = () => currentPartId;
202
+ const getCurrentPartId = () => textState.currentPartId;
375
203
  const getStepIndex = () => stepIndex;
376
204
  const updateCurrentPartId = (id: string | null) => {
377
- currentPartId = id;
205
+ textState.currentPartId = id;
378
206
  };
379
207
  const updateAccumulated = (text: string) => {
380
- accumulated = text;
208
+ textState.accumulated = text;
381
209
  };
382
210
  const incrementStepIndex = () => {
383
211
  stepIndex += 1;
@@ -399,6 +227,13 @@ async function runAssistant(opts: RunOpts) {
399
227
  sessionId: opts.sessionId,
400
228
  });
401
229
  };
230
+ const toolObserver = observeRunnerToolEvents({
231
+ sessionId: opts.sessionId,
232
+ dump,
233
+ getStepIndex,
234
+ onToolCall: triggerTitleGenerationWhenReady,
235
+ });
236
+ const unsubscribeFinish = toolObserver.unsubscribe;
402
237
 
403
238
  const reasoningStates = new Map<string, ReasoningState>();
404
239
 
@@ -452,26 +287,13 @@ async function runAssistant(opts: RunOpts) {
452
287
  const stopWhenCondition = isCopilotResponsesApi
453
288
  ? undefined
454
289
  : hasToolCall('finish');
455
- const toolShape = summarizeToolShape(toolset as Record<string, unknown>);
456
- logger.info('[latency] stream request ready', {
457
- sessionId: opts.sessionId,
458
- messageId: opts.assistantMessageId,
459
- agent: opts.agent,
460
- provider: opts.provider,
461
- model: opts.model,
290
+ logStreamRequestReady({
291
+ opts,
292
+ setup,
462
293
  queueWaitMs,
463
- setupMs: timings.totalMs,
464
- messageCount: messagesWithSystemInstructions.length,
465
- toolCount: Object.keys(toolset).length,
466
- toolNames: toolShape.toolNames,
467
- toolSchemaCharsApprox: toolShape.toolSchemaCharsApprox,
468
- largestTools: toolShape.largestTools,
294
+ messages: messagesWithSystemInstructions,
295
+ toolset: toolset as Record<string, unknown>,
469
296
  hasPrepareStep: Boolean(prepareStep),
470
- providerOptionsKeys: Object.keys(providerOptions),
471
- systemPromptChars: system.length,
472
- messageCharsApprox: approximateMessageChars(messagesWithSystemInstructions),
473
- additionalSystemMessages: additionalSystemMessages.length,
474
- historyMessages: history.length,
475
297
  });
476
298
 
477
299
  try {
@@ -516,9 +338,7 @@ async function runAssistant(opts: RunOpts) {
516
338
  model: opts.model,
517
339
  invokeMs: nowMs() - streamInvocationStartedAt,
518
340
  });
519
- const tracedToolInputNamesById = new Map<string, string>();
520
341
  let firstFullStreamPartSeen = false;
521
- let firstPublishedDeltaSeen = false;
522
342
 
523
343
  for await (const part of result.fullStream) {
524
344
  if (!part) continue;
@@ -538,38 +358,22 @@ async function runAssistant(opts: RunOpts) {
538
358
  }
539
359
 
540
360
  if (part.type === 'tool-input-start') {
541
- if (shouldTraceToolInput(part.toolName)) {
542
- tracedToolInputNamesById.set(part.id, part.toolName);
543
- }
544
361
  continue;
545
362
  }
546
363
 
547
364
  if (part.type === 'tool-input-delta') {
548
- const toolName = tracedToolInputNamesById.get(part.id);
549
- if (toolName) void summarizeTraceValue(part.delta);
550
365
  continue;
551
366
  }
552
367
 
553
368
  if (part.type === 'tool-input-end') {
554
- const toolName = tracedToolInputNamesById.get(part.id);
555
- if (toolName) {
556
- tracedToolInputNamesById.delete(part.id);
557
- }
558
369
  continue;
559
370
  }
560
371
 
561
372
  if (part.type === 'tool-call') {
562
- if (shouldTraceToolInput(part.toolName)) {
563
- tracedToolInputNamesById.delete(part.toolCallId);
564
- void summarizeTraceValue(part.input);
565
- }
566
373
  continue;
567
374
  }
568
375
 
569
376
  if (part.type === 'tool-result') {
570
- if (shouldTraceToolInput(part.toolName)) {
571
- void summarizeTraceValue(part.output);
572
- }
573
377
  continue;
574
378
  }
575
379
 
@@ -582,73 +386,21 @@ async function runAssistant(opts: RunOpts) {
582
386
  : rawDelta;
583
387
  if (!delta) continue;
584
388
 
585
- accumulated += delta;
586
- if (accumulated.trim()) {
587
- latestAssistantText = accumulated;
588
- }
589
- if (accumulated.length > 0) {
590
- lastTextDeltaStepIndex = stepIndex;
591
- }
592
- dump?.recordTextDelta(stepIndex, accumulated);
593
- if (
594
- (delta.trim().length > 0 && _toolActivityObserved) ||
595
- (delta.trim().length > 0 && firstToolSeen())
596
- ) {
597
- _trailingAssistantTextAfterTool = true;
598
- _endedWithToolActivity = false;
599
- }
600
-
601
- if (!currentPartId && !accumulated.trim()) {
602
- continue;
603
- }
604
-
605
- logFirstOutputLatency('text');
606
-
607
- if (!currentPartId) {
608
- currentPartId = crypto.randomUUID();
609
- sharedCtx.assistantPartId = currentPartId;
610
- await db.insert(messageParts).values({
611
- id: currentPartId,
612
- messageId: opts.assistantMessageId,
613
- index: await sharedCtx.nextIndex(),
614
- stepIndex: null,
615
- type: 'text',
616
- content: JSON.stringify({ text: accumulated }),
617
- agent: opts.agent,
618
- provider: opts.provider,
619
- model: opts.model,
620
- startedAt: Date.now(),
621
- });
622
- }
623
-
624
- publish({
625
- type: 'message.part.delta',
626
- sessionId: opts.sessionId,
627
- payload: {
628
- messageId: opts.assistantMessageId,
629
- partId: currentPartId,
630
- stepIndex,
631
- delta,
632
- },
389
+ await handleRunnerTextDelta({
390
+ delta,
391
+ state: textState,
392
+ toolObserver: toolObserver.state,
393
+ opts,
394
+ db,
395
+ sharedCtx,
396
+ stepIndex,
397
+ dump,
398
+ firstToolSeen,
399
+ logFirstOutputLatency,
400
+ runStartedAt,
401
+ queueWaitMs,
402
+ setupMs: timings.totalMs,
633
403
  });
634
- if (!firstPublishedDeltaSeen) {
635
- firstPublishedDeltaSeen = true;
636
- logger.info('[latency] first published delta', {
637
- sessionId: opts.sessionId,
638
- messageId: opts.assistantMessageId,
639
- agent: opts.agent,
640
- provider: opts.provider,
641
- model: opts.model,
642
- sinceRunStartMs: nowMs() - runStartedAt,
643
- queueWaitMs,
644
- setupMs: timings.totalMs,
645
- deltaPreview: delta.length > 80 ? `${delta.slice(0, 80)}…` : delta,
646
- });
647
- }
648
- await db
649
- .update(messageParts)
650
- .set({ content: JSON.stringify({ text: accumulated }) })
651
- .where(eq(messageParts.id, currentPartId));
652
404
  continue;
653
405
  }
654
406
 
@@ -690,7 +442,7 @@ async function runAssistant(opts: RunOpts) {
690
442
  }
691
443
 
692
444
  const fs = firstToolSeen();
693
- if (!fs && !_finishObserved) {
445
+ if (!fs && !toolObserver.state.finishObserved) {
694
446
  publish({
695
447
  type: 'finish-step',
696
448
  sessionId: opts.sessionId,
@@ -717,10 +469,11 @@ async function runAssistant(opts: RunOpts) {
717
469
  }
718
470
 
719
471
  if (dump) {
720
- const finalTextSnapshot = latestAssistantText || accumulated;
472
+ const finalTextSnapshot =
473
+ textState.latestAssistantText || textState.accumulated;
721
474
  if (finalTextSnapshot.length > 0) {
722
475
  dump.recordTextDelta(
723
- lastTextDeltaStepIndex ?? stepIndex,
476
+ textState.lastTextDeltaStepIndex ?? stepIndex,
724
477
  finalTextSnapshot,
725
478
  { force: true },
726
479
  );
@@ -728,74 +481,22 @@ async function runAssistant(opts: RunOpts) {
728
481
  dump.recordStreamEnd({
729
482
  finishReason: streamFinishReason,
730
483
  rawFinishReason: streamRawFinishReason,
731
- finishObserved: _finishObserved,
484
+ finishObserved: toolObserver.state.finishObserved,
732
485
  aborted: _abortedByUser,
733
486
  });
734
487
  }
735
488
  } catch (err) {
736
489
  unsubscribeFinish();
737
490
  dump?.recordError(err);
738
- const payload = toErrorPayload(err);
739
-
740
- const errorMessage = err instanceof Error ? err.message : String(err);
741
- const errorCode = (err as { code?: string })?.code ?? '';
742
- const responseBody = (err as { responseBody?: string })?.responseBody ?? '';
743
- const apiErrorType = (err as { apiErrorType?: string })?.apiErrorType ?? '';
744
- const combinedError = `${errorMessage} ${responseBody}`.toLowerCase();
745
-
746
- const isPromptTooLong =
747
- combinedError.includes('prompt is too long') ||
748
- combinedError.includes('maximum context length') ||
749
- combinedError.includes('too many tokens') ||
750
- combinedError.includes('context_length_exceeded') ||
751
- combinedError.includes('request too large') ||
752
- combinedError.includes('exceeds the model') ||
753
- combinedError.includes('input is too long') ||
754
- errorCode === 'context_length_exceeded' ||
755
- apiErrorType === 'invalid_request_error';
756
-
757
- if (isPromptTooLong && !opts.isCompactCommand) {
758
- try {
759
- const pruneResult = await pruneSession(db, opts.sessionId);
760
- void pruneResult;
761
-
762
- publish({
763
- type: 'error',
764
- sessionId: opts.sessionId,
765
- payload: {
766
- ...payload,
767
- message: `Context too large. Auto-compacted old tool results. Please retry your message.`,
768
- name: 'ContextOverflow',
769
- },
770
- });
771
-
772
- try {
773
- await completeAssistantMessage({}, opts, db);
774
- } catch {}
775
- return;
776
- } catch {}
777
- }
778
- publish({
779
- type: 'error',
780
- sessionId: opts.sessionId,
781
- payload,
491
+ const outcome = await handleRunnerError({
492
+ err,
493
+ opts,
494
+ db,
495
+ completeAssistantMessage,
496
+ updateSessionTokensIncremental,
497
+ updateMessageTokensIncremental,
782
498
  });
783
-
784
- try {
785
- await updateSessionTokensIncremental(
786
- { inputTokens: 0, outputTokens: 0 },
787
- undefined,
788
- opts,
789
- db,
790
- );
791
- await updateMessageTokensIncremental(
792
- { inputTokens: 0, outputTokens: 0 },
793
- undefined,
794
- opts,
795
- db,
796
- );
797
- await completeAssistantMessage({}, opts, db);
798
- } catch {}
499
+ if (outcome === 'handled') return;
799
500
  throw err;
800
501
  } finally {
801
502
  if (dump) {