@ottocode/server 0.1.244 → 0.1.246

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 (39) hide show
  1. package/package.json +4 -3
  2. package/src/events/types.ts +9 -9
  3. package/src/index.ts +9 -4
  4. package/src/openapi/paths/auth.ts +11 -11
  5. package/src/openapi/paths/config.ts +118 -2
  6. package/src/openapi/paths/{setu.ts → ottorouter.ts} +31 -31
  7. package/src/openapi/paths/skills.ts +122 -0
  8. package/src/openapi/schemas.ts +35 -3
  9. package/src/openapi/spec.ts +3 -3
  10. package/src/routes/auth.ts +40 -46
  11. package/src/routes/branch.ts +3 -2
  12. package/src/routes/config/defaults.ts +10 -3
  13. package/src/routes/config/main.ts +3 -0
  14. package/src/routes/config/models.ts +84 -14
  15. package/src/routes/config/providers.ts +137 -4
  16. package/src/routes/config/utils.ts +72 -2
  17. package/src/routes/doctor.ts +15 -27
  18. package/src/routes/git/commit.ts +16 -5
  19. package/src/routes/{setu.ts → ottorouter.ts} +52 -49
  20. package/src/routes/research.ts +3 -3
  21. package/src/routes/session-messages.ts +14 -8
  22. package/src/routes/sessions.ts +12 -18
  23. package/src/routes/skills.ts +140 -59
  24. package/src/runtime/agent/registry.ts +5 -2
  25. package/src/runtime/agent/runner-setup.ts +123 -38
  26. package/src/runtime/agent/runner.ts +140 -4
  27. package/src/runtime/ask/service.ts +14 -11
  28. package/src/runtime/message/history-builder.ts +22 -6
  29. package/src/runtime/message/service.ts +7 -1
  30. package/src/runtime/prompt/builder.ts +12 -0
  31. package/src/runtime/prompt/capabilities.ts +200 -0
  32. package/src/runtime/provider/index.ts +106 -5
  33. package/src/runtime/provider/{setu.ts → ottorouter.ts} +22 -22
  34. package/src/runtime/provider/reasoning.ts +73 -17
  35. package/src/runtime/provider/selection.ts +17 -15
  36. package/src/runtime/session/db-operations.ts +1 -1
  37. package/src/runtime/session/manager.ts +1 -1
  38. package/src/runtime/session/queue.ts +7 -2
  39. package/src/runtime/stream/error-handler.ts +3 -3
@@ -1,6 +1,7 @@
1
1
  import type { Hono } from 'hono';
2
2
  import {
3
3
  discoverSkills,
4
+ filterDiscoveredSkills,
4
5
  loadSkill,
5
6
  loadSkillFile,
6
7
  discoverSkillFiles,
@@ -8,32 +9,56 @@ import {
8
9
  validateSkillName,
9
10
  parseSkillFile,
10
11
  logger,
12
+ loadConfig,
13
+ writeSkillSettings,
11
14
  } from '@ottocode/sdk';
12
15
  import { serializeError } from '../runtime/errors/api-error.ts';
13
16
 
17
+ function dedupeSkillsByName<T extends { name: string }>(skills: T[]): T[] {
18
+ const seen = new Set<string>();
19
+ return skills.filter((skill) => {
20
+ const key = skill.name.trim();
21
+ if (!key || seen.has(key)) return false;
22
+ seen.add(key);
23
+ return true;
24
+ });
25
+ }
26
+
27
+ function sortSkillsByName<T extends { name: string }>(skills: T[]): T[] {
28
+ return [...skills].sort((a, b) => a.name.localeCompare(b.name));
29
+ }
30
+
31
+ function mapSkillsWithEnabled(
32
+ discovered: Array<{
33
+ name: string;
34
+ description: string;
35
+ scope: string;
36
+ path: string;
37
+ }>,
38
+ cfg: Awaited<ReturnType<typeof loadConfig>>,
39
+ ) {
40
+ return discovered.map((skill) => ({
41
+ name: skill.name,
42
+ description: skill.description,
43
+ scope: skill.scope,
44
+ path: skill.path,
45
+ enabled: cfg.skills?.items?.[skill.name]?.enabled !== false,
46
+ }));
47
+ }
48
+
14
49
  export function registerSkillsRoutes(app: Hono) {
15
50
  app.get('/v1/skills', async (c) => {
16
51
  try {
17
52
  const projectRoot = c.req.query('project') || process.cwd();
53
+ const cfg = await loadConfig(projectRoot);
18
54
  const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
19
- const skills = await discoverSkills(projectRoot, repoRoot);
20
- // Dedupe by name (same skill may exist in multiple source dirs like
21
- // ~/.claude/skills and ~/.codex/skills). `discoverSkills` already
22
- // dedupes via its internal Map, but be defensive here for UI consistency.
23
- const seen = new Set<string>();
24
- const unique = skills.filter((s) => {
25
- const key = s.name.trim();
26
- if (!key || seen.has(key)) return false;
27
- seen.add(key);
28
- return true;
29
- });
55
+ const discovered = sortSkillsByName(
56
+ await discoverSkills(projectRoot, repoRoot),
57
+ );
58
+ const filtered = filterDiscoveredSkills(discovered, cfg.skills);
59
+ const unique = sortSkillsByName(dedupeSkillsByName(filtered));
30
60
  return c.json({
31
- skills: unique.map((s) => ({
32
- name: s.name,
33
- description: s.description,
34
- scope: s.scope,
35
- path: s.path,
36
- })),
61
+ skills: mapSkillsWithEnabled(unique, cfg),
37
62
  });
38
63
  } catch (error) {
39
64
  logger.error('Failed to list skills', error);
@@ -42,6 +67,68 @@ export function registerSkillsRoutes(app: Hono) {
42
67
  }
43
68
  });
44
69
 
70
+ app.get('/v1/config/skills', async (c) => {
71
+ try {
72
+ const projectRoot = c.req.query('project') || process.cwd();
73
+ const cfg = await loadConfig(projectRoot);
74
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
75
+ const discovered = sortSkillsByName(
76
+ dedupeSkillsByName(await discoverSkills(projectRoot, repoRoot)),
77
+ );
78
+ const filtered = sortSkillsByName(
79
+ filterDiscoveredSkills(discovered, cfg.skills),
80
+ );
81
+ return c.json({
82
+ enabled: cfg.skills?.enabled !== false,
83
+ totalCount: discovered.length,
84
+ enabledCount: filtered.length,
85
+ items: mapSkillsWithEnabled(discovered, cfg),
86
+ });
87
+ } catch (error) {
88
+ logger.error('Failed to get skills config', error);
89
+ const errorResponse = serializeError(error);
90
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
91
+ }
92
+ });
93
+
94
+ app.put('/v1/config/skills', async (c) => {
95
+ try {
96
+ const projectRoot = c.req.query('project') || process.cwd();
97
+ const body = await c.req.json<{
98
+ enabled?: boolean;
99
+ items?: Record<string, { enabled?: boolean }>;
100
+ scope?: 'global' | 'local';
101
+ }>();
102
+ await writeSkillSettings(
103
+ body.scope || 'local',
104
+ {
105
+ ...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
106
+ ...(body.items ? { items: body.items } : {}),
107
+ },
108
+ projectRoot,
109
+ );
110
+ const cfg = await loadConfig(projectRoot);
111
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
112
+ const discovered = sortSkillsByName(
113
+ dedupeSkillsByName(await discoverSkills(projectRoot, repoRoot)),
114
+ );
115
+ const filtered = sortSkillsByName(
116
+ filterDiscoveredSkills(discovered, cfg.skills),
117
+ );
118
+ return c.json({
119
+ success: true,
120
+ enabled: cfg.skills?.enabled !== false,
121
+ totalCount: discovered.length,
122
+ enabledCount: filtered.length,
123
+ items: mapSkillsWithEnabled(discovered, cfg),
124
+ });
125
+ } catch (error) {
126
+ logger.error('Failed to update skills config', error);
127
+ const errorResponse = serializeError(error);
128
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
129
+ }
130
+ });
131
+
45
132
  app.get('/v1/skills/:name', async (c) => {
46
133
  try {
47
134
  const name = c.req.param('name');
@@ -72,52 +159,46 @@ export function registerSkillsRoutes(app: Hono) {
72
159
  }
73
160
  });
74
161
 
75
- app.post('/v1/skills/validate', async (c) => {
76
- app.get('/v1/skills/:name/files', async (c) => {
77
- try {
78
- const name = c.req.param('name');
79
- const projectRoot = c.req.query('project') || process.cwd();
80
- const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
81
- await discoverSkills(projectRoot, repoRoot);
82
-
83
- const files = await discoverSkillFiles(name);
84
- return c.json({ files });
85
- } catch (error) {
86
- logger.error('Failed to list skill files', error);
87
- const errorResponse = serializeError(error);
88
- return c.json(
89
- errorResponse,
90
- (errorResponse.error.status || 500) as 500,
91
- );
92
- }
93
- });
94
-
95
- app.get('/v1/skills/:name/files/*', async (c) => {
96
- try {
97
- const name = c.req.param('name');
98
- const filePath = c.req.path.replace(`/v1/skills/${name}/files/`, '');
99
- const projectRoot = c.req.query('project') || process.cwd();
100
- const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
101
- await discoverSkills(projectRoot, repoRoot);
102
-
103
- const result = await loadSkillFile(name, filePath);
104
- if (!result) {
105
- return c.json(
106
- { error: `File '${filePath}' not found in skill '${name}'` },
107
- 404,
108
- );
109
- }
110
- return c.json({ content: result.content, path: result.resolvedPath });
111
- } catch (error) {
112
- logger.error('Failed to load skill file', error);
113
- const errorResponse = serializeError(error);
162
+ app.get('/v1/skills/:name/files', async (c) => {
163
+ try {
164
+ const name = c.req.param('name');
165
+ const projectRoot = c.req.query('project') || process.cwd();
166
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
167
+ await discoverSkills(projectRoot, repoRoot);
168
+
169
+ const files = await discoverSkillFiles(name);
170
+ return c.json({ files });
171
+ } catch (error) {
172
+ logger.error('Failed to list skill files', error);
173
+ const errorResponse = serializeError(error);
174
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
175
+ }
176
+ });
177
+
178
+ app.get('/v1/skills/:name/files/*', async (c) => {
179
+ try {
180
+ const name = c.req.param('name');
181
+ const filePath = c.req.path.replace(`/v1/skills/${name}/files/`, '');
182
+ const projectRoot = c.req.query('project') || process.cwd();
183
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
184
+ await discoverSkills(projectRoot, repoRoot);
185
+
186
+ const result = await loadSkillFile(name, filePath);
187
+ if (!result) {
114
188
  return c.json(
115
- errorResponse,
116
- (errorResponse.error.status || 500) as 500,
189
+ { error: `File '${filePath}' not found in skill '${name}'` },
190
+ 404,
117
191
  );
118
192
  }
119
- });
193
+ return c.json({ content: result.content, path: result.resolvedPath });
194
+ } catch (error) {
195
+ logger.error('Failed to load skill file', error);
196
+ const errorResponse = serializeError(error);
197
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
198
+ }
199
+ });
120
200
 
201
+ app.post('/v1/skills/validate', async (c) => {
121
202
  try {
122
203
  const body = await c.req.json<{ content: string; path?: string }>();
123
204
  if (!body.content) {
@@ -115,10 +115,11 @@ function mergeAgentEntries(
115
115
  return merged;
116
116
  }
117
117
 
118
- const baseToolSet = ['progress_update', 'finish', 'skill'] as const;
118
+ const baseToolSet = ['progress_update', 'finish'] as const;
119
119
 
120
120
  const defaultToolExtras: Record<string, string[]> = {
121
121
  build: [
122
+ 'skill',
122
123
  'read',
123
124
  'edit',
124
125
  'multiedit',
@@ -134,8 +135,9 @@ const defaultToolExtras: Record<string, string[]> = {
134
135
  'apply_patch',
135
136
  'websearch',
136
137
  ],
137
- plan: ['read', 'ls', 'tree', 'ripgrep', 'update_todos', 'websearch'],
138
+ plan: ['skill', 'read', 'ls', 'tree', 'ripgrep', 'update_todos', 'websearch'],
138
139
  general: [
140
+ 'skill',
139
141
  'read',
140
142
  'edit',
141
143
  'multiedit',
@@ -149,6 +151,7 @@ const defaultToolExtras: Record<string, string[]> = {
149
151
  'update_todos',
150
152
  ],
151
153
  init: [
154
+ 'skill',
152
155
  'read',
153
156
  'edit',
154
157
  'multiedit',
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  loadConfig,
3
3
  logger,
4
+ getConfiguredProviderFamily,
4
5
  getSessionSystemPromptPath,
5
6
  getModelFamily,
7
+ type OttoConfig,
6
8
  } from '@ottocode/sdk';
7
9
  import { wrapLanguageModel } from 'ai';
8
10
  import { devToolsMiddleware } from '@ai-sdk/devtools';
@@ -19,7 +21,7 @@ import type { Tool } from 'ai';
19
21
  import { adaptTools } from '../../tools/adapter.ts';
20
22
  import { buildDatabaseTools } from '../../tools/database/index.ts';
21
23
  import { time } from '../debug/index.ts';
22
- import { isDevtoolsEnabled } from '../debug/state.ts';
24
+ import { isDebugEnabled, isDevtoolsEnabled } from '../debug/state.ts';
23
25
  import { buildHistoryMessages } from '../message/history-builder.ts';
24
26
  import { getMaxOutputTokens } from '../utils/token.ts';
25
27
  import { setupToolContext } from '../tools/setup.ts';
@@ -29,6 +31,39 @@ import { buildReasoningConfig } from '../provider/reasoning.ts';
29
31
  import type { RunOpts } from '../session/queue.ts';
30
32
  import type { ToolAdapterContext } from '../../tools/adapter.ts';
31
33
 
34
+ type RunnerSetupTimings = {
35
+ loadConfigAndDbMs: number;
36
+ resolveAgentConfigMs: number;
37
+ buildHistoryMs: number;
38
+ loadSessionMs: number;
39
+ composeSystemPromptMs: number;
40
+ discoverToolsMs: number;
41
+ resolveModelMs: number;
42
+ setupToolContextMs: number;
43
+ buildToolsetMs: number;
44
+ totalMs: number;
45
+ };
46
+
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
+
32
67
  export interface SetupResult {
33
68
  cfg: Awaited<ReturnType<typeof loadConfig>>;
34
69
  db: Awaited<ReturnType<typeof getDb>>;
@@ -50,6 +85,7 @@ export interface SetupResult {
50
85
  needsSpoof: boolean;
51
86
  isOpenAIOAuth: boolean;
52
87
  mcpToolsRecord: Record<string, Tool>;
88
+ timings: RunnerSetupTimings;
53
89
  }
54
90
 
55
91
  export function mergeProviderOptions(
@@ -91,10 +127,13 @@ export function applyModelFamilyEditToolPolicy(
91
127
  tools: string[],
92
128
  provider: RunOpts['provider'],
93
129
  model: string,
130
+ cfg?: OttoConfig,
94
131
  ): string[] {
95
132
  if (!MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS.has(agent)) return tools;
96
133
 
97
- const family = getModelFamily(provider, model);
134
+ const family = cfg
135
+ ? getConfiguredProviderFamily(cfg, provider, model)
136
+ : getModelFamily(provider, model);
98
137
  const next = tools.filter(
99
138
  (toolName) => !EDITING_TOOL_NAMES.includes(toolName),
100
139
  );
@@ -107,40 +146,67 @@ export function applyModelFamilyEditToolPolicy(
107
146
  }
108
147
 
109
148
  export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
149
+ const setupStartedAt = nowMs();
110
150
  const cfgTimer = time('runner:loadConfig+db');
151
+ const loadConfigAndDbStartedAt = nowMs();
111
152
  const cfg = await loadConfig(opts.projectRoot);
112
153
  const db = await getDb(cfg.projectRoot);
154
+ const loadConfigAndDbMs = nowMs() - loadConfigAndDbStartedAt;
113
155
  cfgTimer.end();
114
156
 
115
157
  const agentTimer = time('runner:resolveAgentConfig');
116
- const agentCfg = await resolveAgentConfig(cfg.projectRoot, opts.agent);
158
+ const agentCfgPromise = timePromise(
159
+ resolveAgentConfig(cfg.projectRoot, opts.agent),
160
+ );
161
+ const historyPromise =
162
+ opts.omitHistory || (opts.isCompactCommand && opts.compactionContext)
163
+ ? Promise.resolve({ value: [], durationMs: 0 })
164
+ : timePromise(
165
+ buildHistoryMessages(db, opts.sessionId, opts.assistantMessageId),
166
+ );
167
+ const sessionRowsPromise = timePromise(
168
+ db.select().from(sessions).where(eq(sessions.id, opts.sessionId)).limit(1),
169
+ );
170
+ const discoveredToolsPromise = timePromise(
171
+ discoverProjectTools(cfg.projectRoot, undefined, cfg.skills),
172
+ );
173
+ const { value: agentCfg, durationMs: resolveAgentConfigMs } =
174
+ await agentCfgPromise;
117
175
  agentTimer.end({ agent: opts.agent });
118
176
 
119
177
  const agentPrompt = agentCfg.prompt || '';
120
178
 
121
179
  const historyTimer = time('runner:buildHistory');
122
- let history: Awaited<ReturnType<typeof buildHistoryMessages>>;
123
- if (opts.omitHistory || (opts.isCompactCommand && opts.compactionContext)) {
124
- history = [];
125
- } else {
126
- history = await buildHistoryMessages(
127
- db,
128
- opts.sessionId,
129
- opts.assistantMessageId,
130
- );
131
- }
180
+ const { value: history, durationMs: buildHistoryMs } = await historyPromise;
132
181
  historyTimer.end({ messages: history.length });
133
182
 
134
- const sessionRows = await db
135
- .select()
136
- .from(sessions)
137
- .where(eq(sessions.id, opts.sessionId))
138
- .limit(1);
183
+ const { value: sessionRows, durationMs: loadSessionMs } =
184
+ await sessionRowsPromise;
139
185
  const contextSummary = sessionRows[0]?.contextSummary ?? undefined;
186
+ const toolsTimer = time('runner:discoverTools');
187
+ const { value: discovered, durationMs: discoverToolsMs } =
188
+ await discoveredToolsPromise;
189
+ const allTools = discovered.tools;
190
+ const { mcpToolsRecord } = discovered;
191
+
192
+ if (opts.agent === 'research') {
193
+ const currentSession = sessionRows[0];
194
+ const parentSessionId = currentSession?.parentSessionId ?? null;
195
+
196
+ const dbTools = buildDatabaseTools(cfg.projectRoot, parentSessionId);
197
+ for (const dt of dbTools) {
198
+ discovered.tools.push(dt);
199
+ }
200
+ }
201
+
202
+ toolsTimer.end({
203
+ count: allTools.length + Object.keys(mcpToolsRecord).length,
204
+ });
140
205
 
141
206
  const isFirstMessage = !history.some((m) => m.role === 'assistant');
142
207
 
143
208
  const systemTimer = time('runner:composeSystemPrompt');
209
+ const composeSystemPromptStartedAt = nowMs();
144
210
  const { getAuth } = await import('@ottocode/sdk');
145
211
  const auth = await getAuth(opts.provider, cfg.projectRoot);
146
212
  const oauth = detectOAuth(opts.provider, auth);
@@ -148,6 +214,8 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
148
214
  const composed = await composeSystemPrompt({
149
215
  provider: opts.provider,
150
216
  model: opts.model,
217
+ promptFamily: getConfiguredProviderFamily(cfg, opts.provider, opts.model),
218
+ skillSettings: cfg.skills,
151
219
  projectRoot: cfg.projectRoot,
152
220
  agentPrompt,
153
221
  oneShot: opts.oneShot,
@@ -180,6 +248,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
180
248
  : oauth.needsSpoof
181
249
  ? 'spoof'
182
250
  : 'standard';
251
+ const composeSystemPromptMs = nowMs() - composeSystemPromptStartedAt;
183
252
  systemTimer.end();
184
253
  logger.debug('[prompt] system prompt assembled', {
185
254
  sessionId: opts.sessionId,
@@ -216,7 +285,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
216
285
  (message) => message.role,
217
286
  ),
218
287
  });
219
- if (effectiveSystemPrompt) {
288
+ if (effectiveSystemPrompt && isDebugEnabled()) {
220
289
  const systemPromptPath = getSessionSystemPromptPath(opts.sessionId);
221
290
  try {
222
291
  await mkdir(dirname(systemPromptPath), { recursive: true });
@@ -253,25 +322,6 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
253
322
  if (opts.additionalPromptMessages?.length) {
254
323
  additionalSystemMessages.push(...opts.additionalPromptMessages);
255
324
  }
256
-
257
- const toolsTimer = time('runner:discoverTools');
258
- const discovered = await discoverProjectTools(cfg.projectRoot);
259
- const allTools = discovered.tools;
260
- const { mcpToolsRecord } = discovered;
261
-
262
- if (opts.agent === 'research') {
263
- const currentSession = sessionRows[0];
264
- const parentSessionId = currentSession?.parentSessionId ?? null;
265
-
266
- const dbTools = buildDatabaseTools(cfg.projectRoot, parentSessionId);
267
- for (const dt of dbTools) {
268
- discovered.tools.push(dt);
269
- }
270
- }
271
-
272
- toolsTimer.end({
273
- count: allTools.length + Object.keys(mcpToolsRecord).length,
274
- });
275
325
  const allowedToolNames = applyModelFamilyEditToolPolicy(
276
326
  agentCfg.name,
277
327
  agentCfg.tools || [],
@@ -283,10 +333,13 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
283
333
  (tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
284
334
  );
285
335
 
336
+ const resolveModelStartedAt = nowMs();
286
337
  const model = await resolveModel(opts.provider, opts.model, cfg, {
287
338
  sessionId: opts.sessionId,
288
339
  messageId: opts.assistantMessageId,
340
+ reasoningText: opts.reasoningText,
289
341
  });
342
+ const resolveModelMs = nowMs() - resolveModelStartedAt;
290
343
  const wrappedModel = isDevtoolsEnabled()
291
344
  ? wrapLanguageModel({
292
345
  // biome-ignore lint/suspicious/noExplicitAny: OpenRouter provider uses v2 spec
@@ -297,14 +350,18 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
297
350
 
298
351
  const maxOutputTokens = adapted.maxOutputTokens;
299
352
 
353
+ const setupToolContextStartedAt = nowMs();
300
354
  const { sharedCtx, firstToolTimer, firstToolSeen } = await setupToolContext(
301
355
  opts,
302
356
  db,
303
357
  );
358
+ const setupToolContextMs = nowMs() - setupToolContextStartedAt;
304
359
 
360
+ const buildToolsetStartedAt = nowMs();
305
361
  const providerAuth = await getAuth(opts.provider, opts.projectRoot);
306
362
  const authType = providerAuth?.type;
307
363
  const toolset = adaptTools(gated, sharedCtx, opts.provider, authType);
364
+ const buildToolsetMs = nowMs() - buildToolsetStartedAt;
308
365
 
309
366
  const providerOptions = { ...adapted.providerOptions };
310
367
  let effectiveMaxOutputTokens = maxOutputTokens;
@@ -317,6 +374,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
317
374
  }
318
375
 
319
376
  const reasoningConfig = buildReasoningConfig({
377
+ cfg,
320
378
  provider: opts.provider,
321
379
  model: opts.model,
322
380
  reasoningText: opts.reasoningText,
@@ -326,6 +384,32 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
326
384
  mergeProviderOptions(providerOptions, reasoningConfig.providerOptions);
327
385
  effectiveMaxOutputTokens = reasoningConfig.effectiveMaxOutputTokens;
328
386
 
387
+ const timings: RunnerSetupTimings = {
388
+ loadConfigAndDbMs,
389
+ resolveAgentConfigMs,
390
+ buildHistoryMs,
391
+ loadSessionMs,
392
+ composeSystemPromptMs,
393
+ discoverToolsMs,
394
+ resolveModelMs,
395
+ setupToolContextMs,
396
+ buildToolsetMs,
397
+ totalMs: nowMs() - setupStartedAt,
398
+ };
399
+
400
+ logger.info('[latency] runner setup', {
401
+ sessionId: opts.sessionId,
402
+ messageId: opts.assistantMessageId,
403
+ agent: opts.agent,
404
+ provider: opts.provider,
405
+ model: opts.model,
406
+ historyMessages: history.length,
407
+ systemPromptChars: effectiveSystemPrompt.length,
408
+ additionalPromptMessages: additionalSystemMessages.length,
409
+ allowedToolCount: gated.length,
410
+ timings,
411
+ });
412
+
329
413
  return {
330
414
  cfg,
331
415
  db,
@@ -345,5 +429,6 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
345
429
  needsSpoof: oauth.needsSpoof,
346
430
  isOpenAIOAuth: oauth.isOpenAIOAuth,
347
431
  mcpToolsRecord,
432
+ timings,
348
433
  };
349
434
  }