@ottocode/server 0.1.245 → 0.1.247

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 (36) hide show
  1. package/package.json +4 -3
  2. package/src/index.ts +5 -0
  3. package/src/openapi/paths/config.ts +118 -2
  4. package/src/openapi/paths/skills.ts +122 -0
  5. package/src/openapi/schemas.ts +35 -3
  6. package/src/presets.ts +1 -1
  7. package/src/routes/auth.ts +24 -30
  8. package/src/routes/branch.ts +3 -2
  9. package/src/routes/config/defaults.ts +10 -3
  10. package/src/routes/config/main.ts +3 -0
  11. package/src/routes/config/models.ts +84 -14
  12. package/src/routes/config/providers.ts +137 -4
  13. package/src/routes/config/utils.ts +72 -2
  14. package/src/routes/doctor.ts +15 -27
  15. package/src/routes/git/commit.ts +16 -5
  16. package/src/routes/research.ts +3 -3
  17. package/src/routes/session-messages.ts +14 -8
  18. package/src/routes/sessions.ts +12 -18
  19. package/src/routes/skills.ts +140 -59
  20. package/src/runtime/agent/registry.ts +8 -5
  21. package/src/runtime/agent/runner-setup.ts +136 -39
  22. package/src/runtime/agent/runner.ts +140 -4
  23. package/src/runtime/ask/service.ts +13 -10
  24. package/src/runtime/message/history-builder.ts +22 -6
  25. package/src/runtime/message/service.ts +7 -1
  26. package/src/runtime/prompt/builder.ts +12 -0
  27. package/src/runtime/prompt/capabilities.ts +200 -0
  28. package/src/runtime/provider/index.ts +98 -0
  29. package/src/runtime/provider/reasoning.ts +73 -17
  30. package/src/runtime/provider/selection.ts +16 -14
  31. package/src/runtime/session/manager.ts +1 -1
  32. package/src/runtime/session/queue.ts +7 -2
  33. package/src/runtime/tools/approval.ts +1 -0
  34. package/src/runtime/tools/guards.ts +4 -3
  35. package/src/runtime/tools/mapping.ts +4 -2
  36. package/src/tools/adapter.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,17 +115,18 @@ 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',
125
126
  'write',
126
127
  'ls',
127
128
  'tree',
128
- 'bash',
129
+ 'shell',
129
130
  'update_todos',
130
131
  'glob',
131
132
  'ripgrep',
@@ -134,28 +135,30 @@ 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',
142
144
  'write',
143
145
  'ls',
144
146
  'tree',
145
- 'bash',
147
+ 'shell',
146
148
  'ripgrep',
147
149
  'glob',
148
150
  'websearch',
149
151
  'update_todos',
150
152
  ],
151
153
  init: [
154
+ 'skill',
152
155
  'read',
153
156
  'edit',
154
157
  'multiedit',
155
158
  'write',
156
159
  'ls',
157
160
  'tree',
158
- 'bash',
161
+ 'shell',
159
162
  'update_todos',
160
163
  'glob',
161
164
  'ripgrep',
@@ -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(
@@ -86,15 +122,27 @@ const MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS = new Set([
86
122
  'init',
87
123
  ]);
88
124
 
125
+ function normalizeToolName(toolName: string): string {
126
+ return toolName === 'bash' ? 'shell' : toolName;
127
+ }
128
+
129
+ function normalizeToolNames(toolNames: string[]): string[] {
130
+ return Array.from(new Set(toolNames.map(normalizeToolName)));
131
+ }
132
+
89
133
  export function applyModelFamilyEditToolPolicy(
90
134
  agent: string,
91
135
  tools: string[],
92
136
  provider: RunOpts['provider'],
93
137
  model: string,
138
+ cfg?: OttoConfig,
94
139
  ): string[] {
140
+ tools = normalizeToolNames(tools);
95
141
  if (!MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS.has(agent)) return tools;
96
142
 
97
- const family = getModelFamily(provider, model);
143
+ const family = cfg
144
+ ? getConfiguredProviderFamily(cfg, provider, model)
145
+ : getModelFamily(provider, model);
98
146
  const next = tools.filter(
99
147
  (toolName) => !EDITING_TOOL_NAMES.includes(toolName),
100
148
  );
@@ -107,40 +155,67 @@ export function applyModelFamilyEditToolPolicy(
107
155
  }
108
156
 
109
157
  export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
158
+ const setupStartedAt = nowMs();
110
159
  const cfgTimer = time('runner:loadConfig+db');
160
+ const loadConfigAndDbStartedAt = nowMs();
111
161
  const cfg = await loadConfig(opts.projectRoot);
112
162
  const db = await getDb(cfg.projectRoot);
163
+ const loadConfigAndDbMs = nowMs() - loadConfigAndDbStartedAt;
113
164
  cfgTimer.end();
114
165
 
115
166
  const agentTimer = time('runner:resolveAgentConfig');
116
- const agentCfg = await resolveAgentConfig(cfg.projectRoot, opts.agent);
167
+ const agentCfgPromise = timePromise(
168
+ resolveAgentConfig(cfg.projectRoot, opts.agent),
169
+ );
170
+ const historyPromise =
171
+ opts.omitHistory || (opts.isCompactCommand && opts.compactionContext)
172
+ ? Promise.resolve({ value: [], durationMs: 0 })
173
+ : timePromise(
174
+ buildHistoryMessages(db, opts.sessionId, opts.assistantMessageId),
175
+ );
176
+ const sessionRowsPromise = timePromise(
177
+ db.select().from(sessions).where(eq(sessions.id, opts.sessionId)).limit(1),
178
+ );
179
+ const discoveredToolsPromise = timePromise(
180
+ discoverProjectTools(cfg.projectRoot, undefined, cfg.skills),
181
+ );
182
+ const { value: agentCfg, durationMs: resolveAgentConfigMs } =
183
+ await agentCfgPromise;
117
184
  agentTimer.end({ agent: opts.agent });
118
185
 
119
186
  const agentPrompt = agentCfg.prompt || '';
120
187
 
121
188
  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
- }
189
+ const { value: history, durationMs: buildHistoryMs } = await historyPromise;
132
190
  historyTimer.end({ messages: history.length });
133
191
 
134
- const sessionRows = await db
135
- .select()
136
- .from(sessions)
137
- .where(eq(sessions.id, opts.sessionId))
138
- .limit(1);
192
+ const { value: sessionRows, durationMs: loadSessionMs } =
193
+ await sessionRowsPromise;
139
194
  const contextSummary = sessionRows[0]?.contextSummary ?? undefined;
195
+ const toolsTimer = time('runner:discoverTools');
196
+ const { value: discovered, durationMs: discoverToolsMs } =
197
+ await discoveredToolsPromise;
198
+ const allTools = discovered.tools;
199
+ const { mcpToolsRecord } = discovered;
200
+
201
+ if (opts.agent === 'research') {
202
+ const currentSession = sessionRows[0];
203
+ const parentSessionId = currentSession?.parentSessionId ?? null;
204
+
205
+ const dbTools = buildDatabaseTools(cfg.projectRoot, parentSessionId);
206
+ for (const dt of dbTools) {
207
+ discovered.tools.push(dt);
208
+ }
209
+ }
210
+
211
+ toolsTimer.end({
212
+ count: allTools.length + Object.keys(mcpToolsRecord).length,
213
+ });
140
214
 
141
215
  const isFirstMessage = !history.some((m) => m.role === 'assistant');
142
216
 
143
217
  const systemTimer = time('runner:composeSystemPrompt');
218
+ const composeSystemPromptStartedAt = nowMs();
144
219
  const { getAuth } = await import('@ottocode/sdk');
145
220
  const auth = await getAuth(opts.provider, cfg.projectRoot);
146
221
  const oauth = detectOAuth(opts.provider, auth);
@@ -148,6 +223,8 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
148
223
  const composed = await composeSystemPrompt({
149
224
  provider: opts.provider,
150
225
  model: opts.model,
226
+ promptFamily: getConfiguredProviderFamily(cfg, opts.provider, opts.model),
227
+ skillSettings: cfg.skills,
151
228
  projectRoot: cfg.projectRoot,
152
229
  agentPrompt,
153
230
  oneShot: opts.oneShot,
@@ -180,6 +257,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
180
257
  : oauth.needsSpoof
181
258
  ? 'spoof'
182
259
  : 'standard';
260
+ const composeSystemPromptMs = nowMs() - composeSystemPromptStartedAt;
183
261
  systemTimer.end();
184
262
  logger.debug('[prompt] system prompt assembled', {
185
263
  sessionId: opts.sessionId,
@@ -216,7 +294,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
216
294
  (message) => message.role,
217
295
  ),
218
296
  });
219
- if (effectiveSystemPrompt) {
297
+ if (effectiveSystemPrompt && isDebugEnabled()) {
220
298
  const systemPromptPath = getSessionSystemPromptPath(opts.sessionId);
221
299
  try {
222
300
  await mkdir(dirname(systemPromptPath), { recursive: true });
@@ -253,40 +331,27 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
253
331
  if (opts.additionalPromptMessages?.length) {
254
332
  additionalSystemMessages.push(...opts.additionalPromptMessages);
255
333
  }
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
334
  const allowedToolNames = applyModelFamilyEditToolPolicy(
276
335
  agentCfg.name,
277
336
  agentCfg.tools || [],
278
337
  opts.provider,
279
338
  opts.model,
280
339
  );
281
- const allowedNames = new Set([...allowedToolNames, 'finish']);
340
+ const allowedNames = new Set([
341
+ ...normalizeToolNames(allowedToolNames),
342
+ 'finish',
343
+ ]);
282
344
  const gated = allTools.filter(
283
345
  (tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
284
346
  );
285
347
 
348
+ const resolveModelStartedAt = nowMs();
286
349
  const model = await resolveModel(opts.provider, opts.model, cfg, {
287
350
  sessionId: opts.sessionId,
288
351
  messageId: opts.assistantMessageId,
352
+ reasoningText: opts.reasoningText,
289
353
  });
354
+ const resolveModelMs = nowMs() - resolveModelStartedAt;
290
355
  const wrappedModel = isDevtoolsEnabled()
291
356
  ? wrapLanguageModel({
292
357
  // biome-ignore lint/suspicious/noExplicitAny: OpenRouter provider uses v2 spec
@@ -297,14 +362,18 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
297
362
 
298
363
  const maxOutputTokens = adapted.maxOutputTokens;
299
364
 
365
+ const setupToolContextStartedAt = nowMs();
300
366
  const { sharedCtx, firstToolTimer, firstToolSeen } = await setupToolContext(
301
367
  opts,
302
368
  db,
303
369
  );
370
+ const setupToolContextMs = nowMs() - setupToolContextStartedAt;
304
371
 
372
+ const buildToolsetStartedAt = nowMs();
305
373
  const providerAuth = await getAuth(opts.provider, opts.projectRoot);
306
374
  const authType = providerAuth?.type;
307
375
  const toolset = adaptTools(gated, sharedCtx, opts.provider, authType);
376
+ const buildToolsetMs = nowMs() - buildToolsetStartedAt;
308
377
 
309
378
  const providerOptions = { ...adapted.providerOptions };
310
379
  let effectiveMaxOutputTokens = maxOutputTokens;
@@ -317,6 +386,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
317
386
  }
318
387
 
319
388
  const reasoningConfig = buildReasoningConfig({
389
+ cfg,
320
390
  provider: opts.provider,
321
391
  model: opts.model,
322
392
  reasoningText: opts.reasoningText,
@@ -326,6 +396,32 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
326
396
  mergeProviderOptions(providerOptions, reasoningConfig.providerOptions);
327
397
  effectiveMaxOutputTokens = reasoningConfig.effectiveMaxOutputTokens;
328
398
 
399
+ const timings: RunnerSetupTimings = {
400
+ loadConfigAndDbMs,
401
+ resolveAgentConfigMs,
402
+ buildHistoryMs,
403
+ loadSessionMs,
404
+ composeSystemPromptMs,
405
+ discoverToolsMs,
406
+ resolveModelMs,
407
+ setupToolContextMs,
408
+ buildToolsetMs,
409
+ totalMs: nowMs() - setupStartedAt,
410
+ };
411
+
412
+ logger.info('[latency] runner setup', {
413
+ sessionId: opts.sessionId,
414
+ messageId: opts.assistantMessageId,
415
+ agent: opts.agent,
416
+ provider: opts.provider,
417
+ model: opts.model,
418
+ historyMessages: history.length,
419
+ systemPromptChars: effectiveSystemPrompt.length,
420
+ additionalPromptMessages: additionalSystemMessages.length,
421
+ allowedToolCount: gated.length,
422
+ timings,
423
+ });
424
+
329
425
  return {
330
426
  cfg,
331
427
  db,
@@ -345,5 +441,6 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
345
441
  needsSpoof: oauth.needsSpoof,
346
442
  isOpenAIOAuth: oauth.isOpenAIOAuth,
347
443
  mcpToolsRecord,
444
+ timings,
348
445
  };
349
446
  }