@ottocode/server 0.1.265 → 0.1.267

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 (74) 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/prompt/builder.ts +5 -1
  64. package/src/runtime/prompt/capabilities.ts +13 -8
  65. package/src/runtime/provider/custom.ts +73 -0
  66. package/src/runtime/provider/index.ts +2 -85
  67. package/src/runtime/provider/reasoning-builders.ts +280 -0
  68. package/src/runtime/provider/reasoning.ts +67 -264
  69. package/src/tools/adapter/events.ts +116 -0
  70. package/src/tools/adapter/execution.ts +160 -0
  71. package/src/tools/adapter/pending.ts +37 -0
  72. package/src/tools/adapter/persistence.ts +166 -0
  73. package/src/tools/adapter/results.ts +97 -0
  74. package/src/tools/adapter.ts +124 -451
@@ -1,35 +1,32 @@
1
- import {
2
- loadConfig,
3
- logger,
4
- getConfiguredProviderFamily,
5
- getSessionSystemPromptPath,
6
- getModelFamily,
7
- type OttoConfig,
8
- } from '@ottocode/sdk';
9
- import { wrapLanguageModel } from 'ai';
10
- import { devToolsMiddleware } from '@ai-sdk/devtools';
1
+ import { discoverProjectTools, loadConfig, logger } from '@ottocode/sdk';
11
2
  import { getDb } from '@ottocode/database';
12
3
  import { sessions } from '@ottocode/database/schema';
13
4
  import { eq } from 'drizzle-orm';
14
- import { mkdir } from 'node:fs/promises';
15
- import { dirname } from 'node:path';
16
- import { resolveModel } from '../provider/index.ts';
17
- import { resolveAgentConfig } from './registry.ts';
18
- import { composeSystemPrompt } from '../prompt/builder.ts';
19
- import { discoverProjectTools } from '@ottocode/sdk';
20
5
  import type { Tool } from 'ai';
21
6
  import { adaptTools } from '../../tools/adapter.ts';
7
+ import type { ToolAdapterContext } from '../../tools/adapter.ts';
22
8
  import { buildDatabaseTools } from '../../tools/database/index.ts';
23
9
  import { time } from '../debug/index.ts';
24
- import { isDebugEnabled, isDevtoolsEnabled } from '../debug/state.ts';
25
10
  import { buildHistoryMessages } from '../message/history-builder.ts';
26
- import { getMaxOutputTokens } from '../utils/token.ts';
27
11
  import { setupToolContext } from '../tools/setup.ts';
28
- import { getCompactionSystemPrompt } from '../message/compaction.ts';
29
- import { detectOAuth, adaptRunnerCall } from '../provider/oauth-adapter.ts';
30
- import { buildReasoningConfig } from '../provider/reasoning.ts';
31
12
  import type { RunOpts } from '../session/queue.ts';
32
- import type { ToolAdapterContext } from '../../tools/adapter.ts';
13
+ import { resolveAgentConfig } from './registry.ts';
14
+ import {
15
+ appendRunnerPromptMessages,
16
+ buildRunnerPrompt,
17
+ } from './runner-setup-prompt.ts';
18
+ import {
19
+ buildAllowedTools,
20
+ applyModelFamilyEditToolPolicy,
21
+ mergeProviderOptions,
22
+ } from './runner-setup-tools.ts';
23
+ import {
24
+ buildRunnerProviderOptions,
25
+ resolveRunnerModel,
26
+ } from './runner-setup-model.ts';
27
+ import { nowMs, timePromise } from './runner-setup-utils.ts';
28
+
29
+ export { applyModelFamilyEditToolPolicy, mergeProviderOptions };
33
30
 
34
31
  type RunnerSetupTimings = {
35
32
  loadConfigAndDbMs: number;
@@ -44,26 +41,6 @@ type RunnerSetupTimings = {
44
41
  totalMs: number;
45
42
  };
46
43
 
47
- type TimedResult<T> = {
48
- value: T;
49
- durationMs: number;
50
- };
51
-
52
- function nowMs(): number {
53
- const perf = globalThis.performance;
54
- if (perf && typeof perf.now === 'function') return perf.now();
55
- return Date.now();
56
- }
57
-
58
- async function timePromise<T>(promise: Promise<T>): Promise<TimedResult<T>> {
59
- const startedAt = nowMs();
60
- const value = await promise;
61
- return {
62
- value,
63
- durationMs: nowMs() - startedAt,
64
- };
65
- }
66
-
67
44
  export interface SetupResult {
68
45
  cfg: Awaited<ReturnType<typeof loadConfig>>;
69
46
  db: Awaited<ReturnType<typeof getDb>>;
@@ -72,9 +49,7 @@ export interface SetupResult {
72
49
  system: string;
73
50
  systemComponents: string[];
74
51
  additionalSystemMessages: Array<{ role: 'system' | 'user'; content: string }>;
75
- model:
76
- | Awaited<ReturnType<typeof resolveModel>>
77
- | ReturnType<typeof wrapLanguageModel>;
52
+ model: Awaited<ReturnType<typeof resolveRunnerModel>>['model'];
78
53
  maxOutputTokens: number | undefined;
79
54
  effectiveMaxOutputTokens: number | undefined;
80
55
  toolset: ReturnType<typeof adaptTools>;
@@ -88,78 +63,6 @@ export interface SetupResult {
88
63
  timings: RunnerSetupTimings;
89
64
  }
90
65
 
91
- export function mergeProviderOptions(
92
- base: Record<string, unknown>,
93
- incoming: Record<string, unknown>,
94
- ): Record<string, unknown> {
95
- for (const [key, value] of Object.entries(incoming)) {
96
- const existing = base[key];
97
- if (
98
- existing &&
99
- typeof existing === 'object' &&
100
- !Array.isArray(existing) &&
101
- value &&
102
- typeof value === 'object' &&
103
- !Array.isArray(value)
104
- ) {
105
- base[key] = {
106
- ...(existing as Record<string, unknown>),
107
- ...(value as Record<string, unknown>),
108
- };
109
- continue;
110
- }
111
-
112
- base[key] = value;
113
- }
114
-
115
- return base;
116
- }
117
-
118
- const EDITING_TOOL_NAMES = [
119
- 'edit',
120
- 'multiedit',
121
- 'write',
122
- 'copy_into',
123
- 'apply_patch',
124
- ];
125
- const MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS = new Set([
126
- 'build',
127
- 'general',
128
- 'init',
129
- ]);
130
-
131
- function normalizeToolName(toolName: string): string {
132
- return toolName === 'bash' ? 'shell' : toolName;
133
- }
134
-
135
- function normalizeToolNames(toolNames: string[]): string[] {
136
- return Array.from(new Set(toolNames.map(normalizeToolName)));
137
- }
138
-
139
- export function applyModelFamilyEditToolPolicy(
140
- agent: string,
141
- tools: string[],
142
- provider: RunOpts['provider'],
143
- model: string,
144
- cfg?: OttoConfig,
145
- ): string[] {
146
- tools = normalizeToolNames(tools);
147
- if (!MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS.has(agent)) return tools;
148
-
149
- const family = cfg
150
- ? getConfiguredProviderFamily(cfg, provider, model)
151
- : getModelFamily(provider, model);
152
- const next = tools.filter(
153
- (toolName) => !EDITING_TOOL_NAMES.includes(toolName),
154
- );
155
- const preferredEditingTools =
156
- family === 'anthropic' || family === 'openai'
157
- ? ['write', 'copy_into', 'apply_patch']
158
- : ['write', 'edit', 'multiedit', 'copy_into'];
159
-
160
- return Array.from(new Set([...next, ...preferredEditingTools]));
161
- }
162
-
163
66
  export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
164
67
  const setupStartedAt = nowMs();
165
68
  const cfgTimer = time('runner:loadConfig+db');
@@ -189,8 +92,6 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
189
92
  await agentCfgPromise;
190
93
  agentTimer.end({ agent: opts.agent });
191
94
 
192
- const agentPrompt = agentCfg.prompt || '';
193
-
194
95
  const historyTimer = time('runner:buildHistory');
195
96
  const { value: history, durationMs: buildHistoryMs } = await historyPromise;
196
97
  historyTimer.end({ messages: history.length });
@@ -207,7 +108,6 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
207
108
  if (opts.agent === 'research') {
208
109
  const currentSession = sessionRows[0];
209
110
  const parentSessionId = currentSession?.parentSessionId ?? null;
210
-
211
111
  const dbTools = buildDatabaseTools(cfg.projectRoot, parentSessionId);
212
112
  for (const dt of dbTools) {
213
113
  discovered.tools.push(dt);
@@ -221,152 +121,30 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
221
121
  const isFirstMessage = !history.some((m) => m.role === 'assistant');
222
122
 
223
123
  const systemTimer = time('runner:composeSystemPrompt');
224
- const composeSystemPromptStartedAt = nowMs();
225
- const { getAuth } = await import('@ottocode/sdk');
226
- const auth = await getAuth(opts.provider, cfg.projectRoot);
227
- const oauth = detectOAuth(opts.provider, auth);
228
-
229
- const composed = await composeSystemPrompt({
230
- provider: opts.provider,
231
- model: opts.model,
232
- promptFamily: getConfiguredProviderFamily(cfg, opts.provider, opts.model),
233
- skillSettings: cfg.skills,
234
- projectRoot: cfg.projectRoot,
235
- agentPrompt,
236
- oneShot: opts.oneShot,
237
- guidedMode: cfg.defaults.guidedMode,
238
- spoofPrompt: undefined,
239
- includeProjectTree: false,
240
- userContext: opts.userContext,
124
+ const prompt = await buildRunnerPrompt({
125
+ opts,
126
+ cfg,
127
+ agentPrompt: agentCfg.prompt || '',
241
128
  contextSummary,
242
- isOpenAIOAuth: oauth.isOpenAIOAuth,
129
+ historyLength: history.length,
130
+ isFirstMessage,
243
131
  });
244
-
245
- const rawMaxOutputTokens = getMaxOutputTokens(opts.provider, opts.model);
246
- const adapted = adaptRunnerCall(oauth, composed, {
247
- provider: opts.provider,
248
- rawMaxOutputTokens,
132
+ systemTimer.end();
133
+ appendRunnerPromptMessages({
134
+ opts,
135
+ additionalSystemMessages: prompt.additionalSystemMessages,
249
136
  });
250
137
 
251
- const { system } = adapted;
252
- const { systemComponents, additionalSystemMessages } = adapted;
253
- const openAIProviderOptions = adapted.providerOptions.openai as
254
- | Record<string, unknown>
255
- | undefined;
256
- const openAIInstructions =
257
- typeof openAIProviderOptions?.instructions === 'string'
258
- ? openAIProviderOptions.instructions
259
- : '';
260
- const effectiveSystemPrompt = system || openAIInstructions || composed.prompt;
261
- const promptMode = oauth.isOpenAIOAuth
262
- ? 'openai-oauth'
263
- : oauth.needsSpoof
264
- ? 'spoof'
265
- : 'standard';
266
- const composeSystemPromptMs = nowMs() - composeSystemPromptStartedAt;
267
- systemTimer.end();
268
- logger.debug('[prompt] system prompt assembled', {
269
- sessionId: opts.sessionId,
270
- messageId: opts.assistantMessageId,
271
- agent: opts.agent,
138
+ const gated = buildAllowedTools({
139
+ agentName: agentCfg.name,
140
+ agentTools: agentCfg.tools || [],
272
141
  provider: opts.provider,
273
142
  model: opts.model,
274
- promptMode,
275
- components: systemComponents,
276
- systemLength: effectiveSystemPrompt.length,
277
- historyMessages: history.length,
278
- additionalSystemMessages: additionalSystemMessages.length,
279
- isFirstMessage,
280
- isOpenAIOAuth: oauth.isOpenAIOAuth,
281
- needsSpoof: oauth.needsSpoof,
282
- });
283
- logger.debug('[prompt] detailed prompt context', {
284
- sessionId: opts.sessionId,
285
- messageId: opts.assistantMessageId,
286
- debugDetail: true,
287
- agentPromptLength: agentPrompt.length,
288
- contextSummaryLength: contextSummary?.length ?? 0,
289
- userContextLength: opts.userContext?.length ?? 0,
290
- oneShot: Boolean(opts.oneShot),
291
- guidedMode: Boolean(cfg.defaults.guidedMode),
292
- isOpenAIOAuth: oauth.isOpenAIOAuth,
293
- needsSpoof: oauth.needsSpoof,
294
- promptMode,
295
- rawSystemLength: system.length,
296
- openAIInstructionsLength: openAIInstructions.length,
297
- effectiveSystemPromptLength: effectiveSystemPrompt.length,
298
- systemComponents,
299
- additionalSystemMessageRoles: additionalSystemMessages.map(
300
- (message) => message.role,
301
- ),
302
- });
303
- if (effectiveSystemPrompt && isDebugEnabled()) {
304
- const systemPromptPath = getSessionSystemPromptPath(opts.sessionId);
305
- try {
306
- await mkdir(dirname(systemPromptPath), { recursive: true });
307
- await Bun.write(systemPromptPath, effectiveSystemPrompt);
308
- logger.debug('[prompt] wrote system prompt file', {
309
- sessionId: opts.sessionId,
310
- messageId: opts.assistantMessageId,
311
- path: systemPromptPath,
312
- debugDetail: true,
313
- promptMode,
314
- effectiveSystemPromptLength: effectiveSystemPrompt.length,
315
- });
316
- } catch (error) {
317
- logger.warn('[prompt] failed to write system prompt file', {
318
- sessionId: opts.sessionId,
319
- messageId: opts.assistantMessageId,
320
- error: error instanceof Error ? error.message : String(error),
321
- });
322
- }
323
- }
324
-
325
- if (opts.isCompactCommand && opts.compactionContext) {
326
- const compactPrompt = getCompactionSystemPrompt();
327
- additionalSystemMessages.push({
328
- role: 'system',
329
- content: compactPrompt,
330
- });
331
- additionalSystemMessages.push({
332
- role: 'user',
333
- content: `Please summarize this conversation:\n\n<conversation-to-summarize>\n${opts.compactionContext}\n</conversation-to-summarize>`,
334
- });
335
- }
336
-
337
- if (opts.additionalPromptMessages?.length) {
338
- additionalSystemMessages.push(...opts.additionalPromptMessages);
339
- }
340
- const allowedToolNames = applyModelFamilyEditToolPolicy(
341
- agentCfg.name,
342
- agentCfg.tools || [],
343
- opts.provider,
344
- opts.model,
345
- );
346
- const allowedNames = new Set([
347
- ...normalizeToolNames(allowedToolNames),
348
- 'finish',
349
- ]);
350
- const gated = allTools.filter(
351
- (tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
352
- );
353
-
354
- const resolveModelStartedAt = nowMs();
355
- const model = await resolveModel(opts.provider, opts.model, cfg, {
356
- sessionId: opts.sessionId,
357
- messageId: opts.assistantMessageId,
358
- reasoningText: opts.reasoningText,
143
+ cfg,
144
+ allTools,
359
145
  });
360
- const resolveModelMs = nowMs() - resolveModelStartedAt;
361
- const wrappedModel = isDevtoolsEnabled()
362
- ? wrapLanguageModel({
363
- // biome-ignore lint/suspicious/noExplicitAny: OpenRouter provider uses v2 spec
364
- model: model as any,
365
- middleware: devToolsMiddleware(),
366
- })
367
- : model;
368
146
 
369
- const maxOutputTokens = adapted.maxOutputTokens;
147
+ const { model, resolveModelMs } = await resolveRunnerModel({ opts, cfg });
370
148
 
371
149
  const setupToolContextStartedAt = nowMs();
372
150
  const { sharedCtx, firstToolTimer, firstToolSeen } = await setupToolContext(
@@ -376,38 +154,26 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
376
154
  const setupToolContextMs = nowMs() - setupToolContextStartedAt;
377
155
 
378
156
  const buildToolsetStartedAt = nowMs();
157
+ const { getAuth } = await import('@ottocode/sdk');
379
158
  const providerAuth = await getAuth(opts.provider, opts.projectRoot);
380
159
  const authType = providerAuth?.type;
381
160
  const toolset = adaptTools(gated, sharedCtx, opts.provider, authType);
382
161
  const buildToolsetMs = nowMs() - buildToolsetStartedAt;
383
162
 
384
- const providerOptions = { ...adapted.providerOptions };
385
- let effectiveMaxOutputTokens = maxOutputTokens;
386
-
387
- if (opts.provider === 'copilot') {
388
- providerOptions.openai = {
389
- ...((providerOptions.openai as Record<string, unknown>) || {}),
390
- store: false,
391
- };
392
- }
393
-
394
- const reasoningConfig = buildReasoningConfig({
395
- cfg,
396
- provider: opts.provider,
397
- model: opts.model,
398
- reasoningText: opts.reasoningText,
399
- reasoningLevel: opts.reasoningLevel,
400
- maxOutputTokens,
401
- });
402
- mergeProviderOptions(providerOptions, reasoningConfig.providerOptions);
403
- effectiveMaxOutputTokens = reasoningConfig.effectiveMaxOutputTokens;
163
+ const { providerOptions, effectiveMaxOutputTokens } =
164
+ buildRunnerProviderOptions({
165
+ cfg,
166
+ opts,
167
+ adaptedProviderOptions: prompt.providerOptions,
168
+ maxOutputTokens: prompt.maxOutputTokens,
169
+ });
404
170
 
405
171
  const timings: RunnerSetupTimings = {
406
172
  loadConfigAndDbMs,
407
173
  resolveAgentConfigMs,
408
174
  buildHistoryMs,
409
175
  loadSessionMs,
410
- composeSystemPromptMs,
176
+ composeSystemPromptMs: prompt.composeSystemPromptMs,
411
177
  discoverToolsMs,
412
178
  resolveModelMs,
413
179
  setupToolContextMs,
@@ -422,8 +188,8 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
422
188
  provider: opts.provider,
423
189
  model: opts.model,
424
190
  historyMessages: history.length,
425
- systemPromptChars: effectiveSystemPrompt.length,
426
- additionalPromptMessages: additionalSystemMessages.length,
191
+ systemPromptChars: prompt.effectiveSystemPrompt.length,
192
+ additionalPromptMessages: prompt.additionalSystemMessages.length,
427
193
  allowedToolCount: gated.length,
428
194
  timings,
429
195
  });
@@ -433,19 +199,19 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
433
199
  db,
434
200
  agentCfg,
435
201
  history,
436
- system,
437
- systemComponents,
438
- additionalSystemMessages,
439
- model: wrappedModel,
440
- maxOutputTokens,
202
+ system: prompt.system,
203
+ systemComponents: prompt.systemComponents,
204
+ additionalSystemMessages: prompt.additionalSystemMessages,
205
+ model,
206
+ maxOutputTokens: prompt.maxOutputTokens,
441
207
  effectiveMaxOutputTokens,
442
208
  toolset,
443
209
  sharedCtx,
444
210
  firstToolTimer,
445
211
  firstToolSeen,
446
212
  providerOptions,
447
- needsSpoof: oauth.needsSpoof,
448
- isOpenAIOAuth: oauth.isOpenAIOAuth,
213
+ needsSpoof: prompt.needsSpoof,
214
+ isOpenAIOAuth: prompt.isOpenAIOAuth,
449
215
  mcpToolsRecord,
450
216
  timings,
451
217
  };
@@ -0,0 +1,112 @@
1
+ import { logger } from '@ottocode/sdk';
2
+ import { time } from '../debug/index.ts';
3
+ import type { RunOpts } from '../session/queue.ts';
4
+ import type { RunnerMessage } from './runner-reminders.ts';
5
+ import type { SetupResult } from './runner-setup.ts';
6
+
7
+ export function nowMs(): number {
8
+ const perf = globalThis.performance;
9
+ if (perf && typeof perf.now === 'function') return perf.now();
10
+ return Date.now();
11
+ }
12
+
13
+ export function approximateMessageChars(messages: RunnerMessage[]): number {
14
+ let total = 0;
15
+ for (const message of messages) {
16
+ total += message.role.length;
17
+ if (typeof message.content === 'string') {
18
+ total += message.content.length;
19
+ continue;
20
+ }
21
+ try {
22
+ total += JSON.stringify(message.content).length;
23
+ } catch {}
24
+ }
25
+ return total;
26
+ }
27
+
28
+ export function summarizeToolShape(tools: Record<string, unknown>) {
29
+ const names = Object.keys(tools);
30
+ const entries = names.map((name) => {
31
+ const toolValue = tools[name];
32
+ let approxChars = 0;
33
+ try {
34
+ approxChars = JSON.stringify(toolValue).length;
35
+ } catch {}
36
+ return { name, approxChars };
37
+ });
38
+ entries.sort((a, b) => b.approxChars - a.approxChars);
39
+ return {
40
+ toolNames: names,
41
+ toolSchemaCharsApprox: entries.reduce(
42
+ (total, entry) => total + entry.approxChars,
43
+ 0,
44
+ ),
45
+ largestTools: entries.slice(0, 8),
46
+ };
47
+ }
48
+
49
+ export function createFirstOutputLatencyLogger(args: {
50
+ opts: RunOpts;
51
+ runStartedAt: number;
52
+ queueWaitMs: number;
53
+ timings: SetupResult['timings'];
54
+ }) {
55
+ const streamStartTimer = time('runner:first-delta');
56
+ let firstDeltaSeen = false;
57
+ return (kind: 'text' | 'reasoning') => {
58
+ if (firstDeltaSeen) return;
59
+ firstDeltaSeen = true;
60
+ const firstOutputMs = nowMs() - args.runStartedAt;
61
+ streamStartTimer.end({
62
+ kind,
63
+ queueWaitMs: args.queueWaitMs,
64
+ setupMs: args.timings.totalMs,
65
+ });
66
+ logger.info('[latency] first output', {
67
+ sessionId: args.opts.sessionId,
68
+ messageId: args.opts.assistantMessageId,
69
+ agent: args.opts.agent,
70
+ provider: args.opts.provider,
71
+ model: args.opts.model,
72
+ kind,
73
+ queueWaitMs: args.queueWaitMs,
74
+ firstOutputMs,
75
+ setupMs: args.timings.totalMs,
76
+ totalSinceEnqueueMs: args.queueWaitMs + firstOutputMs,
77
+ timings: args.timings,
78
+ });
79
+ };
80
+ }
81
+
82
+ export function logStreamRequestReady(args: {
83
+ opts: RunOpts;
84
+ setup: SetupResult;
85
+ queueWaitMs: number;
86
+ messages: RunnerMessage[];
87
+ toolset: Record<string, unknown>;
88
+ hasPrepareStep: boolean;
89
+ }): void {
90
+ const { opts, setup, queueWaitMs, messages, toolset, hasPrepareStep } = args;
91
+ const toolShape = summarizeToolShape(toolset);
92
+ logger.info('[latency] stream request ready', {
93
+ sessionId: opts.sessionId,
94
+ messageId: opts.assistantMessageId,
95
+ agent: opts.agent,
96
+ provider: opts.provider,
97
+ model: opts.model,
98
+ queueWaitMs,
99
+ setupMs: setup.timings.totalMs,
100
+ messageCount: messages.length,
101
+ toolCount: Object.keys(toolset).length,
102
+ toolNames: toolShape.toolNames,
103
+ toolSchemaCharsApprox: toolShape.toolSchemaCharsApprox,
104
+ largestTools: toolShape.largestTools,
105
+ hasPrepareStep,
106
+ providerOptionsKeys: Object.keys(setup.providerOptions),
107
+ systemPromptChars: setup.system.length,
108
+ messageCharsApprox: approximateMessageChars(messages),
109
+ additionalSystemMessages: setup.additionalSystemMessages.length,
110
+ historyMessages: setup.history.length,
111
+ });
112
+ }
@@ -0,0 +1,108 @@
1
+ import { messageParts } from '@ottocode/database/schema';
2
+ import { eq } from 'drizzle-orm';
3
+ import { logger } from '@ottocode/sdk';
4
+ import { publish } from '../../events/bus.ts';
5
+ import type { getDb } from '@ottocode/database';
6
+ import type { RunOpts } from '../session/queue.ts';
7
+ import type { ToolAdapterContext } from '../../tools/adapter.ts';
8
+ import type { createTurnDumpCollector } from '../debug/turn-dump.ts';
9
+ import type { RunnerToolObserverState } from './runner-tool-observer.ts';
10
+ import { nowMs } from './runner-telemetry.ts';
11
+
12
+ type TurnDumpCollector = NonNullable<
13
+ ReturnType<typeof createTurnDumpCollector>
14
+ >;
15
+
16
+ export type RunnerTextState = {
17
+ currentPartId: string | null;
18
+ accumulated: string;
19
+ latestAssistantText: string;
20
+ lastTextDeltaStepIndex: number | null;
21
+ firstPublishedDeltaSeen: boolean;
22
+ };
23
+
24
+ export async function handleRunnerTextDelta(args: {
25
+ delta: string;
26
+ state: RunnerTextState;
27
+ toolObserver: RunnerToolObserverState;
28
+ opts: RunOpts;
29
+ db: Awaited<ReturnType<typeof getDb>>;
30
+ sharedCtx: ToolAdapterContext;
31
+ stepIndex: number;
32
+ dump: TurnDumpCollector | null;
33
+ firstToolSeen: () => boolean;
34
+ logFirstOutputLatency: (kind: 'text' | 'reasoning') => void;
35
+ runStartedAt: number;
36
+ queueWaitMs: number;
37
+ setupMs: number;
38
+ }): Promise<boolean> {
39
+ const { delta, state, opts, db, sharedCtx, stepIndex, dump } = args;
40
+ state.accumulated += delta;
41
+ if (state.accumulated.trim()) {
42
+ state.latestAssistantText = state.accumulated;
43
+ }
44
+ if (state.accumulated.length > 0) {
45
+ state.lastTextDeltaStepIndex = stepIndex;
46
+ }
47
+ dump?.recordTextDelta(stepIndex, state.accumulated);
48
+ if (
49
+ (delta.trim().length > 0 && args.toolObserver.toolActivityObserved) ||
50
+ (delta.trim().length > 0 && args.firstToolSeen())
51
+ ) {
52
+ args.toolObserver.trailingAssistantTextAfterTool = true;
53
+ args.toolObserver.endedWithToolActivity = false;
54
+ }
55
+
56
+ if (!state.currentPartId && !state.accumulated.trim()) {
57
+ return false;
58
+ }
59
+
60
+ args.logFirstOutputLatency('text');
61
+
62
+ if (!state.currentPartId) {
63
+ state.currentPartId = crypto.randomUUID();
64
+ sharedCtx.assistantPartId = state.currentPartId;
65
+ await db.insert(messageParts).values({
66
+ id: state.currentPartId,
67
+ messageId: opts.assistantMessageId,
68
+ index: await sharedCtx.nextIndex(),
69
+ stepIndex: null,
70
+ type: 'text',
71
+ content: JSON.stringify({ text: state.accumulated }),
72
+ agent: opts.agent,
73
+ provider: opts.provider,
74
+ model: opts.model,
75
+ startedAt: Date.now(),
76
+ });
77
+ }
78
+
79
+ publish({
80
+ type: 'message.part.delta',
81
+ sessionId: opts.sessionId,
82
+ payload: {
83
+ messageId: opts.assistantMessageId,
84
+ partId: state.currentPartId,
85
+ stepIndex,
86
+ delta,
87
+ },
88
+ });
89
+ if (!state.firstPublishedDeltaSeen) {
90
+ state.firstPublishedDeltaSeen = true;
91
+ logger.info('[latency] first published delta', {
92
+ sessionId: opts.sessionId,
93
+ messageId: opts.assistantMessageId,
94
+ agent: opts.agent,
95
+ provider: opts.provider,
96
+ model: opts.model,
97
+ sinceRunStartMs: nowMs() - args.runStartedAt,
98
+ queueWaitMs: args.queueWaitMs,
99
+ setupMs: args.setupMs,
100
+ deltaPreview: delta.length > 80 ? `${delta.slice(0, 80)}…` : delta,
101
+ });
102
+ }
103
+ await db
104
+ .update(messageParts)
105
+ .set({ content: JSON.stringify({ text: state.accumulated }) })
106
+ .where(eq(messageParts.id, state.currentPartId));
107
+ return true;
108
+ }