@ottocode/server 0.1.227 → 0.1.230

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.
@@ -0,0 +1,330 @@
1
+ import { getLocalDataDir } from '@ottocode/sdk';
2
+ import { join } from 'node:path';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { isDebugEnabled } from './state.ts';
5
+
6
+ const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
7
+
8
+ function isDumpEnabled(): boolean {
9
+ const explicit = process.env.OTTO_DEBUG_DUMP;
10
+ if (explicit) return TRUTHY.has(explicit.trim().toLowerCase());
11
+ return isDebugEnabled();
12
+ }
13
+
14
+ export interface TurnDumpData {
15
+ sessionId: string;
16
+ messageId: string;
17
+ timestamp: string;
18
+ provider: string;
19
+ model: string;
20
+ agent: string;
21
+ continuationCount?: number;
22
+ system: {
23
+ prompt: string;
24
+ components: string[];
25
+ length: number;
26
+ };
27
+ additionalSystemMessages: Array<{ role: string; content: string }>;
28
+ history: Array<{
29
+ role: string;
30
+ content: unknown;
31
+ _contentLength?: number;
32
+ }>;
33
+ finalMessages: Array<{
34
+ role: string;
35
+ content: unknown;
36
+ _contentLength?: number;
37
+ }>;
38
+ tools: {
39
+ names: string[];
40
+ count: number;
41
+ };
42
+ modelConfig: {
43
+ maxOutputTokens: number | undefined;
44
+ effectiveMaxOutputTokens: number | undefined;
45
+ providerOptions: Record<string, unknown>;
46
+ isOpenAIOAuth: boolean;
47
+ needsSpoof: boolean;
48
+ };
49
+ stream: {
50
+ toolCalls: Array<{
51
+ stepIndex: number;
52
+ name: string;
53
+ callId: string;
54
+ args: unknown;
55
+ timestamp: string;
56
+ }>;
57
+ toolResults: Array<{
58
+ stepIndex: number;
59
+ name: string;
60
+ callId: string;
61
+ result: unknown;
62
+ _resultLength?: number;
63
+ timestamp: string;
64
+ }>;
65
+ textDeltas: Array<{
66
+ stepIndex: number;
67
+ textSnapshot: string;
68
+ length: number;
69
+ timestamp: string;
70
+ }>;
71
+ steps: Array<{
72
+ stepIndex: number;
73
+ finishReason: string | undefined;
74
+ usage?: {
75
+ inputTokens?: number;
76
+ outputTokens?: number;
77
+ };
78
+ timestamp: string;
79
+ }>;
80
+ finishReason?: string;
81
+ rawFinishReason?: string;
82
+ finishObserved: boolean;
83
+ aborted: boolean;
84
+ };
85
+ error?: {
86
+ message: string;
87
+ name?: string;
88
+ stack?: string;
89
+ };
90
+ duration?: number;
91
+ }
92
+
93
+ export class TurnDumpCollector {
94
+ private data: TurnDumpData;
95
+ private startTime: number;
96
+ private lastTextSnapshot: string = '';
97
+ private textSnapshotInterval = 2000;
98
+ private lastTextSnapshotTime = 0;
99
+
100
+ constructor(opts: {
101
+ sessionId: string;
102
+ messageId: string;
103
+ provider: string;
104
+ model: string;
105
+ agent: string;
106
+ continuationCount?: number;
107
+ }) {
108
+ this.startTime = Date.now();
109
+ this.data = {
110
+ sessionId: opts.sessionId,
111
+ messageId: opts.messageId,
112
+ timestamp: new Date().toISOString(),
113
+ provider: opts.provider,
114
+ model: opts.model,
115
+ agent: opts.agent,
116
+ continuationCount: opts.continuationCount,
117
+ system: { prompt: '', components: [], length: 0 },
118
+ additionalSystemMessages: [],
119
+ history: [],
120
+ finalMessages: [],
121
+ tools: { names: [], count: 0 },
122
+ modelConfig: {
123
+ maxOutputTokens: undefined,
124
+ effectiveMaxOutputTokens: undefined,
125
+ providerOptions: {},
126
+ isOpenAIOAuth: false,
127
+ needsSpoof: false,
128
+ },
129
+ stream: {
130
+ toolCalls: [],
131
+ toolResults: [],
132
+ textDeltas: [],
133
+ steps: [],
134
+ finishObserved: false,
135
+ aborted: false,
136
+ },
137
+ };
138
+ }
139
+
140
+ setSystemPrompt(prompt: string, components: string[]) {
141
+ this.data.system = {
142
+ prompt,
143
+ components,
144
+ length: prompt.length,
145
+ };
146
+ }
147
+
148
+ setAdditionalSystemMessages(msgs: Array<{ role: string; content: string }>) {
149
+ this.data.additionalSystemMessages = msgs;
150
+ }
151
+
152
+ setHistory(history: Array<{ role: string; content: unknown }>) {
153
+ this.data.history = history.map((m) => {
154
+ const contentStr =
155
+ typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
156
+ return {
157
+ role: m.role,
158
+ content: m.content,
159
+ _contentLength: contentStr.length,
160
+ };
161
+ });
162
+ }
163
+
164
+ setFinalMessages(
165
+ msgs: Array<{ role: string; content: string | Array<unknown> }>,
166
+ ) {
167
+ this.data.finalMessages = msgs.map((m) => {
168
+ const contentStr =
169
+ typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
170
+ return {
171
+ role: m.role,
172
+ content: m.content,
173
+ _contentLength: contentStr.length,
174
+ };
175
+ });
176
+ }
177
+
178
+ setTools(toolset: Record<string, unknown>) {
179
+ const names = Object.keys(toolset);
180
+ this.data.tools = { names, count: names.length };
181
+ }
182
+
183
+ setModelConfig(config: {
184
+ maxOutputTokens: number | undefined;
185
+ effectiveMaxOutputTokens: number | undefined;
186
+ providerOptions: Record<string, unknown>;
187
+ isOpenAIOAuth: boolean;
188
+ needsSpoof: boolean;
189
+ }) {
190
+ this.data.modelConfig = config;
191
+ }
192
+
193
+ recordToolCall(
194
+ stepIndex: number,
195
+ name: string,
196
+ callId: string,
197
+ args: unknown,
198
+ ) {
199
+ this.data.stream.toolCalls.push({
200
+ stepIndex,
201
+ name,
202
+ callId,
203
+ args,
204
+ timestamp: new Date().toISOString(),
205
+ });
206
+ }
207
+
208
+ recordToolResult(
209
+ stepIndex: number,
210
+ name: string,
211
+ callId: string,
212
+ result: unknown,
213
+ ) {
214
+ const resultStr =
215
+ typeof result === 'string' ? result : JSON.stringify(result);
216
+ const truncated =
217
+ resultStr.length > 50_000
218
+ ? `${resultStr.slice(0, 50_000)}...[TRUNCATED]`
219
+ : result;
220
+ this.data.stream.toolResults.push({
221
+ stepIndex,
222
+ name,
223
+ callId,
224
+ result: truncated,
225
+ _resultLength: resultStr.length,
226
+ timestamp: new Date().toISOString(),
227
+ });
228
+ }
229
+
230
+ recordTextDelta(
231
+ stepIndex: number,
232
+ accumulated: string,
233
+ opts?: { force?: boolean },
234
+ ) {
235
+ const force = opts?.force === true;
236
+ const now = Date.now();
237
+ if (
238
+ !force &&
239
+ now - this.lastTextSnapshotTime < this.textSnapshotInterval &&
240
+ this.lastTextSnapshot.length > 0
241
+ ) {
242
+ return;
243
+ }
244
+ if (force && accumulated.length === 0 && this.lastTextSnapshot.length > 0) {
245
+ return;
246
+ }
247
+ if (force && accumulated === this.lastTextSnapshot) {
248
+ return;
249
+ }
250
+ this.lastTextSnapshotTime = now;
251
+ this.lastTextSnapshot = accumulated;
252
+ this.data.stream.textDeltas.push({
253
+ stepIndex,
254
+ textSnapshot:
255
+ accumulated.length > 5000
256
+ ? `${accumulated.slice(0, 5000)}...[TRUNCATED at 5000 chars, total: ${accumulated.length}]`
257
+ : accumulated,
258
+ length: accumulated.length,
259
+ timestamp: new Date().toISOString(),
260
+ });
261
+ }
262
+
263
+ recordStepFinish(
264
+ stepIndex: number,
265
+ finishReason: string | undefined,
266
+ usage?: { inputTokens?: number; outputTokens?: number },
267
+ ) {
268
+ this.data.stream.steps.push({
269
+ stepIndex,
270
+ finishReason,
271
+ usage,
272
+ timestamp: new Date().toISOString(),
273
+ });
274
+ }
275
+
276
+ recordStreamEnd(opts: {
277
+ finishReason?: string;
278
+ rawFinishReason?: string;
279
+ finishObserved: boolean;
280
+ aborted: boolean;
281
+ }) {
282
+ this.data.stream.finishReason = opts.finishReason;
283
+ this.data.stream.rawFinishReason = opts.rawFinishReason;
284
+ this.data.stream.finishObserved = opts.finishObserved;
285
+ this.data.stream.aborted = opts.aborted;
286
+ }
287
+
288
+ recordError(err: unknown) {
289
+ this.data.error = {
290
+ message: err instanceof Error ? err.message : String(err),
291
+ name: err instanceof Error ? err.name : undefined,
292
+ stack: err instanceof Error ? err.stack : undefined,
293
+ };
294
+ }
295
+
296
+ async flush(projectRoot: string) {
297
+ this.data.duration = Date.now() - this.startTime;
298
+
299
+ const dumpDir = join(getLocalDataDir(projectRoot), 'debug-dumps');
300
+ await mkdir(dumpDir, { recursive: true });
301
+
302
+ const ts = new Date()
303
+ .toISOString()
304
+ .replace(/[:.]/g, '-')
305
+ .replace('T', '_')
306
+ .replace('Z', '');
307
+ const sessionShort = this.data.sessionId.slice(0, 8);
308
+ const filename = `turn_${ts}_${sessionShort}.json`;
309
+ const filepath = join(dumpDir, filename);
310
+
311
+ await Bun.write(filepath, JSON.stringify(this.data, null, 2));
312
+ return filepath;
313
+ }
314
+ }
315
+
316
+ export function shouldDumpTurn(): boolean {
317
+ return isDumpEnabled();
318
+ }
319
+
320
+ export function createTurnDumpCollector(opts: {
321
+ sessionId: string;
322
+ messageId: string;
323
+ provider: string;
324
+ model: string;
325
+ agent: string;
326
+ continuationCount?: number;
327
+ }): TurnDumpCollector | null {
328
+ if (!shouldDumpTurn()) return null;
329
+ return new TurnDumpCollector(opts);
330
+ }
@@ -1,4 +1,10 @@
1
- import { convertToModelMessages, type ModelMessage, type UIMessage } from 'ai';
1
+ import {
2
+ convertToModelMessages,
3
+ type FilePart,
4
+ type ModelMessage,
5
+ type TextPart,
6
+ type UIMessage,
7
+ } from 'ai';
2
8
  import type { getDb } from '@ottocode/database';
3
9
  import { messages, messageParts } from '@ottocode/database/schema';
4
10
  import { eq, asc } from 'drizzle-orm';
@@ -20,7 +26,7 @@ export async function buildHistoryMessages(
20
26
  .where(eq(messages.sessionId, sessionId))
21
27
  .orderBy(asc(messages.createdAt));
22
28
 
23
- const ui: UIMessage[] = [];
29
+ const history: ModelMessage[] = [];
24
30
  const toolHistory = new ToolHistoryTracker();
25
31
 
26
32
  for (const m of rows) {
@@ -49,13 +55,13 @@ export async function buildHistoryMessages(
49
55
  }
50
56
 
51
57
  if (m.role === 'user') {
52
- const uparts: UIMessage['parts'] = [];
58
+ const userParts: Array<TextPart | FilePart> = [];
53
59
  for (const p of parts) {
54
60
  if (p.type === 'text') {
55
61
  try {
56
62
  const obj = JSON.parse(p.content ?? '{}');
57
63
  const t = String(obj.text ?? '');
58
- if (t) uparts.push({ type: 'text', text: t });
64
+ if (t) userParts.push({ type: 'text', text: t });
59
65
  } catch {}
60
66
  } else if (p.type === 'image') {
61
67
  try {
@@ -64,11 +70,11 @@ export async function buildHistoryMessages(
64
70
  mediaType?: string;
65
71
  };
66
72
  if (obj.data && obj.mediaType) {
67
- uparts.push({
73
+ userParts.push({
68
74
  type: 'file',
75
+ data: obj.data,
69
76
  mediaType: obj.mediaType,
70
- url: `data:${obj.mediaType};base64,${obj.data}`,
71
- } as never);
77
+ });
72
78
  }
73
79
  } catch {}
74
80
  } else if (p.type === 'file') {
@@ -81,41 +87,72 @@ export async function buildHistoryMessages(
81
87
  textContent?: string;
82
88
  };
83
89
  if (obj.type === 'text' && obj.textContent) {
84
- uparts.push({
90
+ userParts.push({
85
91
  type: 'text',
86
92
  text: `<file name="${obj.name || 'file'}">\n${obj.textContent}\n</file>`,
87
93
  });
88
94
  } else if (obj.type === 'pdf' && obj.data && obj.mediaType) {
89
- uparts.push({
95
+ userParts.push({
90
96
  type: 'file',
97
+ data: obj.data,
98
+ filename: obj.name,
91
99
  mediaType: obj.mediaType,
92
- url: `data:${obj.mediaType};base64,${obj.data}`,
93
- } as never);
100
+ });
94
101
  } else if (obj.type === 'image' && obj.data && obj.mediaType) {
95
- uparts.push({
102
+ userParts.push({
96
103
  type: 'file',
104
+ data: obj.data,
105
+ filename: obj.name,
97
106
  mediaType: obj.mediaType,
98
- url: `data:${obj.mediaType};base64,${obj.data}`,
99
- } as never);
107
+ });
100
108
  }
101
109
  } catch {}
102
110
  }
103
111
  }
104
- if (uparts.length) {
105
- ui.push({ id: m.id, role: 'user', parts: uparts });
112
+ if (userParts.length) {
113
+ history.push({ role: 'user', content: userParts });
106
114
  }
107
115
  continue;
108
116
  }
109
117
 
110
118
  if (m.role === 'assistant') {
111
119
  const assistantParts: UIMessage['parts'] = [];
112
- const toolCalls: Array<{ name: string; callId: string; args: unknown }> =
113
- [];
114
- const toolResults: Array<{
115
- name: string;
116
- callId: string;
117
- result: unknown;
118
- }> = [];
120
+ const flushAssistantParts = async () => {
121
+ if (!assistantParts.length) return;
122
+ history.push(
123
+ ...(await convertToModelMessages([
124
+ { role: 'assistant', parts: assistantParts },
125
+ ])),
126
+ );
127
+ assistantParts.length = 0;
128
+ };
129
+ const toolResultsById = new Map<
130
+ string,
131
+ {
132
+ name: string;
133
+ callId: string;
134
+ result: unknown;
135
+ }
136
+ >();
137
+
138
+ for (const p of parts) {
139
+ if (p.type !== 'tool_result' || p.compactedAt) continue;
140
+
141
+ try {
142
+ const obj = JSON.parse(p.content ?? '{}') as {
143
+ name?: string;
144
+ callId?: string;
145
+ result?: unknown;
146
+ };
147
+ if (obj.callId) {
148
+ toolResultsById.set(obj.callId, {
149
+ name: obj.name ?? 'tool',
150
+ callId: obj.callId,
151
+ result: obj.result,
152
+ });
153
+ }
154
+ } catch {}
155
+ }
119
156
 
120
157
  for (const p of parts) {
121
158
  if (p.type === 'reasoning') continue;
@@ -135,89 +172,60 @@ export async function buildHistoryMessages(
135
172
  callId?: string;
136
173
  args?: unknown;
137
174
  };
138
- if (obj.callId && obj.name) {
139
- toolCalls.push({
175
+ if (!obj.callId || !obj.name) continue;
176
+ if (obj.name === 'finish') continue;
177
+
178
+ const toolType = `tool-${obj.name}` as `tool-${string}`;
179
+ let result = toolResultsById.get(obj.callId);
180
+
181
+ if (!result) {
182
+ debugLog(
183
+ `[buildHistoryMessages] Synthesizing error result for incomplete tool call ${obj.name}#${obj.callId}`,
184
+ );
185
+ result = {
140
186
  name: obj.name,
141
187
  callId: obj.callId,
142
- args: obj.args,
143
- });
188
+ result:
189
+ 'Error: The tool execution was interrupted or failed to return a result. You may need to retry this operation.',
190
+ };
144
191
  }
145
- } catch {}
146
- } else if (p.type === 'tool_result') {
147
- if (p.compactedAt) continue;
148
192
 
149
- try {
150
- const obj = JSON.parse(p.content ?? '{}') as {
151
- name?: string;
152
- callId?: string;
153
- result?: unknown;
193
+ const part = {
194
+ type: toolType,
195
+ state: 'output-available',
196
+ toolCallId: obj.callId,
197
+ input: obj.args,
198
+ output: (() => {
199
+ const r = result.result;
200
+ if (typeof r === 'string') return r;
201
+ try {
202
+ return JSON.stringify(r);
203
+ } catch {
204
+ return String(r);
205
+ }
206
+ })(),
154
207
  };
155
- if (obj.callId) {
156
- toolResults.push({
157
- name: obj.name ?? 'tool',
158
- callId: obj.callId,
159
- result: obj.result,
160
- });
161
- }
162
- } catch {}
163
- }
164
- }
165
-
166
- const toolResultsById = new Map(
167
- toolResults.map((result) => [result.callId, result]),
168
- );
169
-
170
- for (const call of toolCalls) {
171
- if (call.name === 'finish') continue;
172
208
 
173
- const toolType = `tool-${call.name}` as `tool-${string}`;
174
- let result = toolResultsById.get(call.callId);
209
+ toolHistory.register(part, {
210
+ toolName: obj.name,
211
+ callId: obj.callId,
212
+ args: obj.args,
213
+ result: result.result,
214
+ });
175
215
 
176
- if (!result) {
177
- debugLog(
178
- `[buildHistoryMessages] Synthesizing error result for incomplete tool call ${call.name}#${call.callId}`,
179
- );
180
- result = {
181
- name: call.name,
182
- callId: call.callId,
183
- result:
184
- 'Error: The tool execution was interrupted or failed to return a result. You may need to retry this operation.',
185
- };
216
+ assistantParts.push(part as never);
217
+ await flushAssistantParts();
218
+ } catch {}
186
219
  }
187
-
188
- const part = {
189
- type: toolType,
190
- state: 'output-available',
191
- toolCallId: call.callId,
192
- input: call.args,
193
- output: (() => {
194
- const r = result.result;
195
- if (typeof r === 'string') return r;
196
- try {
197
- return JSON.stringify(r);
198
- } catch {
199
- return String(r);
200
- }
201
- })(),
202
- };
203
-
204
- toolHistory.register(part, {
205
- toolName: call.name,
206
- callId: call.callId,
207
- args: call.args,
208
- result: result.result,
209
- });
210
-
211
- assistantParts.push(part as never);
212
220
  }
213
221
 
214
222
  if (assistantParts.length) {
215
- ui.push({ id: m.id, role: 'assistant', parts: assistantParts });
223
+ await flushAssistantParts();
216
224
  }
217
225
  }
218
226
  }
219
227
 
220
- return await convertToModelMessages(ui);
228
+ return history;
221
229
  }
222
230
 
223
231
  async function _logPendingToolParts(
@@ -7,7 +7,11 @@ import { publish } from '../../events/bus.ts';
7
7
  import { enqueueAssistantRun } from '../session/queue.ts';
8
8
  import { runSessionLoop } from '../agent/runner.ts';
9
9
  import { resolveModel } from '../provider/index.ts';
10
- import { getFastModelForAuth, type ProviderId } from '@ottocode/sdk';
10
+ import {
11
+ getFastModelForAuth,
12
+ type ProviderId,
13
+ type ReasoningLevel,
14
+ } from '@ottocode/sdk';
11
15
  import { debugLog } from '../debug/index.ts';
12
16
  import { isCompactCommand, buildCompactionContext } from './compaction.ts';
13
17
  import { detectOAuth, adaptSimpleCall } from '../provider/oauth-adapter.ts';
@@ -25,6 +29,7 @@ type DispatchOptions = {
25
29
  oneShot?: boolean;
26
30
  userContext?: string;
27
31
  reasoningText?: boolean;
32
+ reasoningLevel?: ReasoningLevel;
28
33
  images?: Array<{ data: string; mediaType: string }>;
29
34
  files?: Array<{
30
35
  type: 'image' | 'pdf' | 'text';
@@ -49,6 +54,7 @@ export async function dispatchAssistantMessage(
49
54
  oneShot,
50
55
  userContext,
51
56
  reasoningText,
57
+ reasoningLevel,
52
58
  images,
53
59
  files,
54
60
  } = options;
@@ -187,6 +193,7 @@ export async function dispatchAssistantMessage(
187
193
  oneShot: Boolean(oneShot),
188
194
  userContext,
189
195
  reasoningText,
196
+ reasoningLevel,
190
197
  isCompactCommand: isCompact,
191
198
  compactionContext,
192
199
  toolApprovalMode,
@@ -71,21 +71,23 @@ export async function composeSystemPrompt(options: {
71
71
  options.projectRoot,
72
72
  );
73
73
  const baseInstructions = (BASE_PROMPT || '').trim();
74
+ const agentInstructions = options.agentPrompt.trim();
75
+ const providerInstructions = providerResult.prompt.trim();
74
76
 
75
77
  parts.push(
76
- providerResult.prompt.trim(),
77
78
  baseInstructions.trim(),
78
- options.agentPrompt.trim(),
79
+ agentInstructions,
80
+ providerInstructions,
79
81
  );
80
- if (providerResult.prompt.trim()) {
81
- components.push(`provider:${providerResult.resolvedType}`);
82
- }
83
82
  if (baseInstructions.trim()) {
84
83
  components.push('base');
85
84
  }
86
- if (options.agentPrompt.trim()) {
85
+ if (agentInstructions) {
87
86
  components.push('agent');
88
87
  }
88
+ if (providerInstructions) {
89
+ components.push(`provider:${providerResult.resolvedType}`);
90
+ }
89
91
  }
90
92
 
91
93
  if (options.oneShot) {