@ottocode/server 0.1.264 → 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 (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/ask/service.ts +1 -0
  64. package/src/runtime/provider/custom.ts +73 -0
  65. package/src/runtime/provider/index.ts +6 -85
  66. package/src/runtime/provider/reasoning-builders.ts +280 -0
  67. package/src/runtime/provider/reasoning.ts +68 -264
  68. package/src/runtime/provider/xai.ts +8 -0
  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
@@ -3,26 +3,7 @@ import type { ProviderName } from '@ottocode/sdk';
3
3
  import { catalog } from '@ottocode/sdk';
4
4
  import { readdir } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
- // Embed default agent prompts; only user overrides read from disk.
7
- // eslint-disable-next-line @typescript-eslint/consistent-type-imports
8
- import AGENT_BUILD from '@ottocode/sdk/prompts/agents/build.txt' with {
9
- type: 'text',
10
- };
11
- // eslint-disable-next-line @typescript-eslint/consistent-type-imports
12
- import AGENT_PLAN from '@ottocode/sdk/prompts/agents/plan.txt' with {
13
- type: 'text',
14
- };
15
- // eslint-disable-next-line @typescript-eslint/consistent-type-imports
16
- import AGENT_GENERAL from '@ottocode/sdk/prompts/agents/general.txt' with {
17
- type: 'text',
18
- };
19
- // eslint-disable-next-line @typescript-eslint/consistent-type-imports
20
- import AGENT_INIT from '@ottocode/sdk/prompts/agents/init.txt' with {
21
- type: 'text',
22
- };
23
- import AGENT_RESEARCH from '@ottocode/sdk/prompts/agents/research.txt' with {
24
- type: 'text',
25
- };
6
+ import { resolveAgentPrompt } from './registry-prompts.ts';
26
7
 
27
8
  export type AgentConfig = {
28
9
  name: string;
@@ -291,109 +272,11 @@ export async function resolveAgentConfig(
291
272
  }
292
273
  const agents = await loadAgentsConfig(projectRoot);
293
274
  const entry = agents[name];
294
- let prompt = '';
295
- let promptSource: string = 'none';
296
-
297
- // Override files: project first, then global
298
- const globalAgentsDir = getGlobalAgentsDir();
299
- const localDirTxt = `${projectRoot}/.otto/agents/${name}/agent.txt`.replace(
300
- /\\/g,
301
- '/',
302
- );
303
- const localDirMd = `${projectRoot}/.otto/agents/${name}/agent.md`.replace(
304
- /\\/g,
305
- '/',
306
- );
307
- const localFlatTxt = `${projectRoot}/.otto/agents/${name}.txt`.replace(
308
- /\\/g,
309
- '/',
310
- );
311
- const localFlatMd = `${projectRoot}/.otto/agents/${name}.md`.replace(
312
- /\\/g,
313
- '/',
314
- );
315
- const globalDirTxt = `${globalAgentsDir}/${name}/agent.txt`.replace(
316
- /\\/g,
317
- '/',
318
- );
319
- const globalDirMd = `${globalAgentsDir}/${name}/agent.md`.replace(/\\/g, '/');
320
- const globalFlatTxt = `${globalAgentsDir}/${name}.txt`.replace(/\\/g, '/');
321
- const globalFlatMd = `${globalAgentsDir}/${name}.md`.replace(/\\/g, '/');
322
- const files = [
323
- localDirMd,
324
- localFlatMd,
325
- localDirTxt,
326
- localFlatTxt,
327
- globalDirMd,
328
- globalFlatMd,
329
- globalDirTxt,
330
- globalFlatTxt,
331
- ];
332
- for (const p of files) {
333
- try {
334
- const f = Bun.file(p);
335
- if (await f.exists()) {
336
- const text = await f.text();
337
- if (text.trim()) {
338
- prompt = text;
339
- promptSource = `file:${p}`;
340
- break;
341
- }
342
- }
343
- } catch {}
344
- }
345
-
346
- // If agents.json provides a 'prompt' field, accept inline content or a relative/absolute path
347
- if (entry?.prompt) {
348
- const p = entry.prompt.trim();
349
- if (
350
- /[.](md|txt)$/i.test(p) ||
351
- p.startsWith('.') ||
352
- p.startsWith('/') ||
353
- p.startsWith('~/')
354
- ) {
355
- const candidates: string[] = [];
356
- if (p.startsWith('~/')) {
357
- const home = process.env.HOME || process.env.USERPROFILE || '';
358
- candidates.push(`${home}/${p.slice(2)}`);
359
- } else if (p.startsWith('/')) candidates.push(p);
360
- else candidates.push(`${projectRoot}/${p}`.replace(/\\/g, '/'));
361
- for (const candidate of candidates) {
362
- const pf = Bun.file(candidate);
363
- if (await pf.exists()) {
364
- const t = await pf.text();
365
- if (t.trim()) {
366
- prompt = t;
367
- promptSource = `agents.json:file:${candidate}`;
368
- break;
369
- }
370
- }
371
- }
372
- } else {
373
- prompt = p;
374
- promptSource = 'agents.json:inline';
375
- }
376
- }
377
-
378
- // Fallback: use embedded defaults (plan/build/general); else default to build
379
- if (!prompt) {
380
- const byName = (n: string): string | undefined => {
381
- if (n === 'build') return AGENT_BUILD;
382
- if (n === 'plan') return AGENT_PLAN;
383
- if (n === 'general') return AGENT_GENERAL;
384
- if (n === 'init') return AGENT_INIT;
385
- if (n === 'research') return AGENT_RESEARCH;
386
- return undefined;
387
- };
388
- const candidate = byName(name)?.trim();
389
- if (candidate?.length) {
390
- prompt = candidate;
391
- promptSource = `fallback:embedded:${name}.txt`;
392
- } else {
393
- prompt = (AGENT_BUILD || '').trim();
394
- promptSource = 'fallback:embedded:build.txt';
395
- }
396
- }
275
+ const { prompt } = await resolveAgentPrompt({
276
+ projectRoot,
277
+ name,
278
+ entryPrompt: entry?.prompt,
279
+ });
397
280
 
398
281
  // Default tool access per agent if not explicitly configured
399
282
  let tools = Array.isArray(entry?.tools)
@@ -411,7 +294,6 @@ export async function resolveAgentConfig(
411
294
  const deduped = Array.from(new Set([...tools, ...baseToolSet]));
412
295
  const provider = normalizeProvider(entry?.provider);
413
296
  const model = normalizeModel(entry?.model);
414
- void promptSource;
415
297
  return {
416
298
  name,
417
299
  prompt,
@@ -0,0 +1,116 @@
1
+ import type { getDb } from '@ottocode/database';
2
+ import { sessions } from '@ottocode/database/schema';
3
+ import { eq } from 'drizzle-orm';
4
+ import { publish } from '../../events/bus.ts';
5
+ import { toErrorPayload } from '../errors/handling.ts';
6
+ import {
7
+ pruneSession,
8
+ shouldAutoCompactBeforeOverflow,
9
+ } from '../message/compaction.ts';
10
+ import type {
11
+ completeAssistantMessage,
12
+ updateMessageTokensIncremental,
13
+ updateSessionTokensIncremental,
14
+ } from '../session/db-operations.ts';
15
+ import type { RunOpts } from '../session/queue.ts';
16
+
17
+ type CompleteAssistantMessage = typeof completeAssistantMessage;
18
+ type UpdateSessionTokensIncremental = typeof updateSessionTokensIncremental;
19
+ type UpdateMessageTokensIncremental = typeof updateMessageTokensIncremental;
20
+
21
+ export async function shouldPreemptivelyAutoCompact(
22
+ db: Awaited<ReturnType<typeof getDb>>,
23
+ opts: RunOpts,
24
+ threshold: number | null | undefined,
25
+ ): Promise<boolean> {
26
+ const sessionRows = await db
27
+ .select({ currentContextTokens: sessions.currentContextTokens })
28
+ .from(sessions)
29
+ .where(eq(sessions.id, opts.sessionId))
30
+ .limit(1);
31
+
32
+ return shouldAutoCompactBeforeOverflow({
33
+ autoCompactThresholdTokens: threshold,
34
+ currentContextTokens: sessionRows[0]?.currentContextTokens ?? 0,
35
+ estimatedInputTokens: opts.estimatedInputTokens ?? 0,
36
+ isCompactCommand: opts.isCompactCommand,
37
+ compactionRetries: opts.compactionRetries,
38
+ });
39
+ }
40
+
41
+ function isPromptTooLongError(err: unknown): boolean {
42
+ const errorMessage = err instanceof Error ? err.message : String(err);
43
+ const errorCode = (err as { code?: string })?.code ?? '';
44
+ const responseBody = (err as { responseBody?: string })?.responseBody ?? '';
45
+ const apiErrorType = (err as { apiErrorType?: string })?.apiErrorType ?? '';
46
+ const combinedError = `${errorMessage} ${responseBody}`.toLowerCase();
47
+
48
+ return (
49
+ combinedError.includes('prompt is too long') ||
50
+ combinedError.includes('maximum context length') ||
51
+ combinedError.includes('too many tokens') ||
52
+ combinedError.includes('context_length_exceeded') ||
53
+ combinedError.includes('request too large') ||
54
+ combinedError.includes('exceeds the model') ||
55
+ combinedError.includes('input is too long') ||
56
+ errorCode === 'context_length_exceeded' ||
57
+ apiErrorType === 'invalid_request_error'
58
+ );
59
+ }
60
+
61
+ export async function handleRunnerError(args: {
62
+ err: unknown;
63
+ opts: RunOpts;
64
+ db: Awaited<ReturnType<typeof getDb>>;
65
+ completeAssistantMessage: CompleteAssistantMessage;
66
+ updateSessionTokensIncremental: UpdateSessionTokensIncremental;
67
+ updateMessageTokensIncremental: UpdateMessageTokensIncremental;
68
+ }): Promise<'handled' | 'rethrow'> {
69
+ const { err, opts, db } = args;
70
+ const payload = toErrorPayload(err);
71
+
72
+ if (isPromptTooLongError(err) && !opts.isCompactCommand) {
73
+ try {
74
+ const pruneResult = await pruneSession(db, opts.sessionId);
75
+ void pruneResult;
76
+
77
+ publish({
78
+ type: 'error',
79
+ sessionId: opts.sessionId,
80
+ payload: {
81
+ ...payload,
82
+ message: `Context too large. Auto-compacted old tool results. Please retry your message.`,
83
+ name: 'ContextOverflow',
84
+ },
85
+ });
86
+
87
+ try {
88
+ await args.completeAssistantMessage({}, opts, db);
89
+ } catch {}
90
+ return 'handled';
91
+ } catch {}
92
+ }
93
+
94
+ publish({
95
+ type: 'error',
96
+ sessionId: opts.sessionId,
97
+ payload,
98
+ });
99
+
100
+ try {
101
+ await args.updateSessionTokensIncremental(
102
+ { inputTokens: 0, outputTokens: 0 },
103
+ undefined,
104
+ opts,
105
+ db,
106
+ );
107
+ await args.updateMessageTokensIncremental(
108
+ { inputTokens: 0, outputTokens: 0 },
109
+ undefined,
110
+ opts,
111
+ db,
112
+ );
113
+ await args.completeAssistantMessage({}, opts, db);
114
+ } catch {}
115
+ return 'rethrow';
116
+ }
@@ -0,0 +1,45 @@
1
+ export type RunnerMessage = {
2
+ role: string;
3
+ content: string | Array<unknown>;
4
+ };
5
+
6
+ export function appendRunnerReminderMessages(args: {
7
+ messages: RunnerMessage[];
8
+ isFirstMessage: boolean;
9
+ isOpenAIOAuth: boolean;
10
+ continuationCount?: number;
11
+ }): void {
12
+ const { messages, isFirstMessage, isOpenAIOAuth, continuationCount } = args;
13
+
14
+ if (!isFirstMessage) {
15
+ messages.push(
16
+ isOpenAIOAuth
17
+ ? {
18
+ role: 'system',
19
+ content:
20
+ '[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.',
21
+ }
22
+ : {
23
+ role: 'user',
24
+ content:
25
+ '<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>',
26
+ },
27
+ );
28
+ }
29
+
30
+ if ((continuationCount ?? 0) <= 0) return;
31
+
32
+ messages.push(
33
+ isOpenAIOAuth
34
+ ? {
35
+ role: 'system',
36
+ content:
37
+ '[system-reminder] Your previous response stopped mid-task. Resume from where you left off and complete the actual work — not a plan-only update.',
38
+ }
39
+ : {
40
+ role: 'user',
41
+ content:
42
+ '<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>',
43
+ },
44
+ );
45
+ }
@@ -0,0 +1,75 @@
1
+ import { wrapLanguageModel } from 'ai';
2
+ import { devToolsMiddleware } from '@ai-sdk/devtools';
3
+ import type { OttoConfig } from '@ottocode/sdk';
4
+ import { isDevtoolsEnabled } from '../debug/state.ts';
5
+ import { resolveModel } from '../provider/index.ts';
6
+ import { buildReasoningConfig } from '../provider/reasoning.ts';
7
+ import type { RunOpts } from '../session/queue.ts';
8
+ import { mergeProviderOptions } from './runner-setup-tools.ts';
9
+ import { nowMs } from './runner-setup-utils.ts';
10
+
11
+ export async function resolveRunnerModel(args: {
12
+ opts: RunOpts;
13
+ cfg: OttoConfig;
14
+ }): Promise<{
15
+ model:
16
+ | Awaited<ReturnType<typeof resolveModel>>
17
+ | ReturnType<typeof wrapLanguageModel>;
18
+ resolveModelMs: number;
19
+ }> {
20
+ const resolveModelStartedAt = nowMs();
21
+ const model = await resolveModel(
22
+ args.opts.provider,
23
+ args.opts.model,
24
+ args.cfg,
25
+ {
26
+ sessionId: args.opts.sessionId,
27
+ messageId: args.opts.assistantMessageId,
28
+ reasoningText: args.opts.reasoningText,
29
+ },
30
+ );
31
+ const resolveModelMs = nowMs() - resolveModelStartedAt;
32
+ const wrappedModel = isDevtoolsEnabled()
33
+ ? wrapLanguageModel({
34
+ // biome-ignore lint/suspicious/noExplicitAny: OpenRouter provider uses v2 spec
35
+ model: model as any,
36
+ middleware: devToolsMiddleware(),
37
+ })
38
+ : model;
39
+
40
+ return { model: wrappedModel, resolveModelMs };
41
+ }
42
+
43
+ export function buildRunnerProviderOptions(args: {
44
+ cfg: OttoConfig;
45
+ opts: RunOpts;
46
+ adaptedProviderOptions: Record<string, unknown>;
47
+ maxOutputTokens: number | undefined;
48
+ }): {
49
+ providerOptions: Record<string, unknown>;
50
+ effectiveMaxOutputTokens: number | undefined;
51
+ } {
52
+ const providerOptions = { ...args.adaptedProviderOptions };
53
+
54
+ if (args.opts.provider === 'copilot') {
55
+ providerOptions.openai = {
56
+ ...((providerOptions.openai as Record<string, unknown>) || {}),
57
+ store: false,
58
+ };
59
+ }
60
+
61
+ const reasoningConfig = buildReasoningConfig({
62
+ cfg: args.cfg,
63
+ provider: args.opts.provider,
64
+ model: args.opts.model,
65
+ reasoningText: args.opts.reasoningText,
66
+ reasoningLevel: args.opts.reasoningLevel,
67
+ maxOutputTokens: args.maxOutputTokens,
68
+ });
69
+ mergeProviderOptions(providerOptions, reasoningConfig.providerOptions);
70
+
71
+ return {
72
+ providerOptions,
73
+ effectiveMaxOutputTokens: reasoningConfig.effectiveMaxOutputTokens,
74
+ };
75
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ getConfiguredProviderFamily,
3
+ getSessionSystemPromptPath,
4
+ logger,
5
+ type OttoConfig,
6
+ } from '@ottocode/sdk';
7
+ import { mkdir } from 'node:fs/promises';
8
+ import { dirname } from 'node:path';
9
+ import { composeSystemPrompt } from '../prompt/builder.ts';
10
+ import { isDebugEnabled } from '../debug/state.ts';
11
+ import { getMaxOutputTokens } from '../utils/token.ts';
12
+ import { getCompactionSystemPrompt } from '../message/compaction.ts';
13
+ import { adaptRunnerCall, detectOAuth } from '../provider/oauth-adapter.ts';
14
+ import type { RunOpts } from '../session/queue.ts';
15
+ import { nowMs } from './runner-setup-utils.ts';
16
+
17
+ export type RunnerPromptSetup = {
18
+ system: string;
19
+ systemComponents: string[];
20
+ additionalSystemMessages: Array<{ role: 'system' | 'user'; content: string }>;
21
+ maxOutputTokens: number | undefined;
22
+ providerOptions: Record<string, unknown>;
23
+ needsSpoof: boolean;
24
+ isOpenAIOAuth: boolean;
25
+ effectiveSystemPrompt: string;
26
+ composeSystemPromptMs: number;
27
+ };
28
+
29
+ export async function buildRunnerPrompt(args: {
30
+ opts: RunOpts;
31
+ cfg: OttoConfig;
32
+ agentPrompt: string;
33
+ contextSummary?: string;
34
+ historyLength: number;
35
+ isFirstMessage: boolean;
36
+ }): Promise<RunnerPromptSetup> {
37
+ const { opts, cfg, agentPrompt, contextSummary } = args;
38
+ const composeSystemPromptStartedAt = nowMs();
39
+ const { getAuth } = await import('@ottocode/sdk');
40
+ const auth = await getAuth(opts.provider, cfg.projectRoot);
41
+ const oauth = detectOAuth(opts.provider, auth);
42
+
43
+ const composed = await composeSystemPrompt({
44
+ provider: opts.provider,
45
+ model: opts.model,
46
+ promptFamily: getConfiguredProviderFamily(cfg, opts.provider, opts.model),
47
+ skillSettings: cfg.skills,
48
+ projectRoot: cfg.projectRoot,
49
+ agentPrompt,
50
+ oneShot: opts.oneShot,
51
+ guidedMode: cfg.defaults.guidedMode,
52
+ spoofPrompt: undefined,
53
+ includeProjectTree: false,
54
+ userContext: opts.userContext,
55
+ contextSummary,
56
+ isOpenAIOAuth: oauth.isOpenAIOAuth,
57
+ });
58
+
59
+ const rawMaxOutputTokens = getMaxOutputTokens(opts.provider, opts.model);
60
+ const adapted = adaptRunnerCall(oauth, composed, {
61
+ provider: opts.provider,
62
+ rawMaxOutputTokens,
63
+ });
64
+
65
+ const { system } = adapted;
66
+ const { systemComponents, additionalSystemMessages } = adapted;
67
+ const openAIProviderOptions = adapted.providerOptions.openai as
68
+ | Record<string, unknown>
69
+ | undefined;
70
+ const openAIInstructions =
71
+ typeof openAIProviderOptions?.instructions === 'string'
72
+ ? openAIProviderOptions.instructions
73
+ : '';
74
+ const effectiveSystemPrompt = system || openAIInstructions || composed.prompt;
75
+ const promptMode = oauth.isOpenAIOAuth
76
+ ? 'openai-oauth'
77
+ : oauth.needsSpoof
78
+ ? 'spoof'
79
+ : 'standard';
80
+ const composeSystemPromptMs = nowMs() - composeSystemPromptStartedAt;
81
+
82
+ logger.debug('[prompt] system prompt assembled', {
83
+ sessionId: opts.sessionId,
84
+ messageId: opts.assistantMessageId,
85
+ agent: opts.agent,
86
+ provider: opts.provider,
87
+ model: opts.model,
88
+ promptMode,
89
+ components: systemComponents,
90
+ systemLength: effectiveSystemPrompt.length,
91
+ historyMessages: args.historyLength,
92
+ additionalSystemMessages: additionalSystemMessages.length,
93
+ isFirstMessage: args.isFirstMessage,
94
+ isOpenAIOAuth: oauth.isOpenAIOAuth,
95
+ needsSpoof: oauth.needsSpoof,
96
+ });
97
+ logger.debug('[prompt] detailed prompt context', {
98
+ sessionId: opts.sessionId,
99
+ messageId: opts.assistantMessageId,
100
+ debugDetail: true,
101
+ agentPromptLength: agentPrompt.length,
102
+ contextSummaryLength: contextSummary?.length ?? 0,
103
+ userContextLength: opts.userContext?.length ?? 0,
104
+ oneShot: Boolean(opts.oneShot),
105
+ guidedMode: Boolean(cfg.defaults.guidedMode),
106
+ isOpenAIOAuth: oauth.isOpenAIOAuth,
107
+ needsSpoof: oauth.needsSpoof,
108
+ promptMode,
109
+ rawSystemLength: system.length,
110
+ openAIInstructionsLength: openAIInstructions.length,
111
+ effectiveSystemPromptLength: effectiveSystemPrompt.length,
112
+ systemComponents,
113
+ additionalSystemMessageRoles: additionalSystemMessages.map(
114
+ (message) => message.role,
115
+ ),
116
+ });
117
+ await writeDebugSystemPrompt({
118
+ opts,
119
+ effectiveSystemPrompt,
120
+ promptMode,
121
+ });
122
+
123
+ return {
124
+ system,
125
+ systemComponents,
126
+ additionalSystemMessages,
127
+ maxOutputTokens: adapted.maxOutputTokens,
128
+ providerOptions: adapted.providerOptions,
129
+ needsSpoof: oauth.needsSpoof,
130
+ isOpenAIOAuth: oauth.isOpenAIOAuth,
131
+ effectiveSystemPrompt,
132
+ composeSystemPromptMs,
133
+ };
134
+ }
135
+
136
+ async function writeDebugSystemPrompt(args: {
137
+ opts: RunOpts;
138
+ effectiveSystemPrompt: string;
139
+ promptMode: string;
140
+ }): Promise<void> {
141
+ const { opts, effectiveSystemPrompt, promptMode } = args;
142
+ if (!effectiveSystemPrompt || !isDebugEnabled()) return;
143
+
144
+ const systemPromptPath = getSessionSystemPromptPath(opts.sessionId);
145
+ try {
146
+ await mkdir(dirname(systemPromptPath), { recursive: true });
147
+ await Bun.write(systemPromptPath, effectiveSystemPrompt);
148
+ logger.debug('[prompt] wrote system prompt file', {
149
+ sessionId: opts.sessionId,
150
+ messageId: opts.assistantMessageId,
151
+ path: systemPromptPath,
152
+ debugDetail: true,
153
+ promptMode,
154
+ effectiveSystemPromptLength: effectiveSystemPrompt.length,
155
+ });
156
+ } catch (error) {
157
+ logger.warn('[prompt] failed to write system prompt file', {
158
+ sessionId: opts.sessionId,
159
+ messageId: opts.assistantMessageId,
160
+ error: error instanceof Error ? error.message : String(error),
161
+ });
162
+ }
163
+ }
164
+
165
+ export function appendRunnerPromptMessages(args: {
166
+ opts: RunOpts;
167
+ additionalSystemMessages: Array<{ role: 'system' | 'user'; content: string }>;
168
+ }): void {
169
+ const { opts, additionalSystemMessages } = args;
170
+ if (opts.isCompactCommand && opts.compactionContext) {
171
+ const compactPrompt = getCompactionSystemPrompt();
172
+ additionalSystemMessages.push({
173
+ role: 'system',
174
+ content: compactPrompt,
175
+ });
176
+ additionalSystemMessages.push({
177
+ role: 'user',
178
+ content: `Please summarize this conversation:\n\n<conversation-to-summarize>\n${opts.compactionContext}\n</conversation-to-summarize>`,
179
+ });
180
+ }
181
+
182
+ if (opts.additionalPromptMessages?.length) {
183
+ additionalSystemMessages.push(...opts.additionalPromptMessages);
184
+ }
185
+ }
@@ -0,0 +1,103 @@
1
+ import {
2
+ getConfiguredProviderFamily,
3
+ getModelFamily,
4
+ type OttoConfig,
5
+ } from '@ottocode/sdk';
6
+ import type { DiscoveredTool } from '@ottocode/sdk';
7
+ import type { RunOpts } from '../session/queue.ts';
8
+
9
+ const EDITING_TOOL_NAMES = [
10
+ 'edit',
11
+ 'multiedit',
12
+ 'write',
13
+ 'copy_into',
14
+ 'apply_patch',
15
+ ];
16
+ const MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS = new Set([
17
+ 'build',
18
+ 'general',
19
+ 'init',
20
+ ]);
21
+
22
+ function normalizeToolName(toolName: string): string {
23
+ return toolName === 'bash' ? 'shell' : toolName;
24
+ }
25
+
26
+ export function normalizeToolNames(toolNames: string[]): string[] {
27
+ return Array.from(new Set(toolNames.map(normalizeToolName)));
28
+ }
29
+
30
+ export function mergeProviderOptions(
31
+ base: Record<string, unknown>,
32
+ incoming: Record<string, unknown>,
33
+ ): Record<string, unknown> {
34
+ for (const [key, value] of Object.entries(incoming)) {
35
+ const existing = base[key];
36
+ if (
37
+ existing &&
38
+ typeof existing === 'object' &&
39
+ !Array.isArray(existing) &&
40
+ value &&
41
+ typeof value === 'object' &&
42
+ !Array.isArray(value)
43
+ ) {
44
+ base[key] = {
45
+ ...(existing as Record<string, unknown>),
46
+ ...(value as Record<string, unknown>),
47
+ };
48
+ continue;
49
+ }
50
+
51
+ base[key] = value;
52
+ }
53
+
54
+ return base;
55
+ }
56
+
57
+ export function applyModelFamilyEditToolPolicy(
58
+ agent: string,
59
+ tools: string[],
60
+ provider: RunOpts['provider'],
61
+ model: string,
62
+ cfg?: OttoConfig,
63
+ ): string[] {
64
+ tools = normalizeToolNames(tools);
65
+ if (!MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS.has(agent)) return tools;
66
+
67
+ const family = cfg
68
+ ? getConfiguredProviderFamily(cfg, provider, model)
69
+ : getModelFamily(provider, model);
70
+ const next = tools.filter(
71
+ (toolName) => !EDITING_TOOL_NAMES.includes(toolName),
72
+ );
73
+ const preferredEditingTools =
74
+ family === 'anthropic' || family === 'openai'
75
+ ? ['write', 'copy_into', 'apply_patch']
76
+ : ['write', 'edit', 'multiedit', 'copy_into'];
77
+
78
+ return Array.from(new Set([...next, ...preferredEditingTools]));
79
+ }
80
+
81
+ export function buildAllowedTools(args: {
82
+ agentName: string;
83
+ agentTools: string[];
84
+ provider: RunOpts['provider'];
85
+ model: string;
86
+ cfg: OttoConfig;
87
+ allTools: DiscoveredTool[];
88
+ }): DiscoveredTool[] {
89
+ const allowedToolNames = applyModelFamilyEditToolPolicy(
90
+ args.agentName,
91
+ args.agentTools,
92
+ args.provider,
93
+ args.model,
94
+ args.cfg,
95
+ );
96
+ const allowedNames = new Set([
97
+ ...normalizeToolNames(allowedToolNames),
98
+ 'finish',
99
+ ]);
100
+ return args.allTools.filter(
101
+ (tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
102
+ );
103
+ }
@@ -0,0 +1,21 @@
1
+ export type TimedResult<T> = {
2
+ value: T;
3
+ durationMs: number;
4
+ };
5
+
6
+ export function nowMs(): number {
7
+ const perf = globalThis.performance;
8
+ if (perf && typeof perf.now === 'function') return perf.now();
9
+ return Date.now();
10
+ }
11
+
12
+ export async function timePromise<T>(
13
+ promise: Promise<T>,
14
+ ): Promise<TimedResult<T>> {
15
+ const startedAt = nowMs();
16
+ const value = await promise;
17
+ return {
18
+ value,
19
+ durationMs: nowMs() - startedAt,
20
+ };
21
+ }