@ottocode/server 0.1.196 → 0.1.197

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/server",
3
- "version": "0.1.196",
3
+ "version": "0.1.197",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -14,6 +14,26 @@
14
14
  "import": "./src/runtime/agent-registry.ts",
15
15
  "types": "./src/runtime/agent-registry.ts"
16
16
  },
17
+ "./runtime/ask/service": {
18
+ "import": "./src/runtime/ask/service.ts",
19
+ "types": "./src/runtime/ask/service.ts"
20
+ },
21
+ "./runtime/agent/runner": {
22
+ "import": "./src/runtime/agent/runner.ts",
23
+ "types": "./src/runtime/agent/runner.ts"
24
+ },
25
+ "./events/bus": {
26
+ "import": "./src/events/bus.ts",
27
+ "types": "./src/events/bus.ts"
28
+ },
29
+ "./events/types": {
30
+ "import": "./src/events/types.ts",
31
+ "types": "./src/events/types.ts"
32
+ },
33
+ "./runtime/tools/approval": {
34
+ "import": "./src/runtime/tools/approval.ts",
35
+ "types": "./src/runtime/tools/approval.ts"
36
+ },
17
37
  "./runtime/ask-service.ts": {
18
38
  "import": "./src/runtime/ask-service.ts",
19
39
  "types": "./src/runtime/ask-service.ts"
@@ -29,8 +49,8 @@
29
49
  "typecheck": "tsc --noEmit"
30
50
  },
31
51
  "dependencies": {
32
- "@ottocode/sdk": "0.1.196",
33
- "@ottocode/database": "0.1.196",
52
+ "@ottocode/sdk": "0.1.197",
53
+ "@ottocode/database": "0.1.197",
34
54
  "drizzle-orm": "^0.44.5",
35
55
  "hono": "^4.9.9",
36
56
  "zod": "^4.1.8"
@@ -0,0 +1,72 @@
1
+ export type OauthCodexContinuationInput = {
2
+ provider: string;
3
+ isOpenAIOAuth: boolean;
4
+ finishObserved: boolean;
5
+ continuationCount: number;
6
+ maxContinuations: number;
7
+ finishReason?: string;
8
+ rawFinishReason?: string;
9
+ firstToolSeen: boolean;
10
+ droppedPseudoToolText: boolean;
11
+ lastAssistantText: string;
12
+ };
13
+
14
+ export type OauthCodexContinuationDecision = {
15
+ shouldContinue: boolean;
16
+ reason?: string;
17
+ };
18
+
19
+ const INTERMEDIATE_PROGRESS_PATTERNS: RegExp[] = [
20
+ /\bnext\s+i(?:['\u2019]ll|\s+will)\b/i,
21
+ /\bnow\s+i(?:['\u2019]ll|\s+will)\b/i,
22
+ /\bi(?:['\u2019]ll|\s+will)\s+(inspect|check|look|read|scan|trace|review|update|fix|implement|run|continue|retry)\b/i,
23
+ /\bi(?:\s+am|\s*'m)\s+going\s+to\b/i,
24
+ /\b(and|then)\s+continue\b/i,
25
+ ];
26
+
27
+ /**
28
+ * Detects whether assistant text looks like an intermediate progress update
29
+ * (e.g. "Next I'll inspect...") rather than a final user-facing completion.
30
+ */
31
+ export function looksLikeIntermediateProgressText(text: string): boolean {
32
+ const trimmed = text.trim();
33
+ if (!trimmed) return false;
34
+ return INTERMEDIATE_PROGRESS_PATTERNS.some((pattern) =>
35
+ pattern.test(trimmed),
36
+ );
37
+ }
38
+
39
+ function isTruncatedResponse(
40
+ finishReason?: string,
41
+ rawFinishReason?: string,
42
+ ): boolean {
43
+ if (finishReason === 'length') return true;
44
+ return rawFinishReason === 'max_output_tokens';
45
+ }
46
+
47
+ /**
48
+ * Decides whether an OpenAI OAuth Codex turn should auto-continue to recover
49
+ * only from hard truncation. Other completion behavior is handled by
50
+ * stream step limits and prompt alignment, not synthetic continuation turns.
51
+ */
52
+ export function decideOauthCodexContinuation(
53
+ input: OauthCodexContinuationInput,
54
+ ): OauthCodexContinuationDecision {
55
+ if (input.provider !== 'openai' || !input.isOpenAIOAuth) {
56
+ return { shouldContinue: false };
57
+ }
58
+
59
+ if (input.finishObserved) {
60
+ return { shouldContinue: false };
61
+ }
62
+
63
+ if (input.continuationCount >= input.maxContinuations) {
64
+ return { shouldContinue: false, reason: 'max-continuations-reached' };
65
+ }
66
+
67
+ if (isTruncatedResponse(input.finishReason, input.rawFinishReason)) {
68
+ return { shouldContinue: true, reason: 'truncated' };
69
+ }
70
+
71
+ return { shouldContinue: false };
72
+ }
@@ -110,6 +110,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
110
110
  includeProjectTree: isFirstMessage,
111
111
  userContext: opts.userContext,
112
112
  contextSummary,
113
+ isOpenAIOAuth: oauth.isOpenAIOAuth,
113
114
  });
114
115
 
115
116
  const rawMaxOutputTokens = getMaxOutputTokens(opts.provider, opts.model);
@@ -1,4 +1,4 @@
1
- import { hasToolCall, streamText } from 'ai';
1
+ import { hasToolCall, stepCountIs, streamText } from 'ai';
2
2
  import { messages, messageParts } from '@ottocode/database/schema';
3
3
  import { eq } from 'drizzle-orm';
4
4
  import { publish, subscribe } from '../../events/bus.ts';
@@ -32,6 +32,11 @@ import {
32
32
  handleReasoningDelta,
33
33
  handleReasoningEnd,
34
34
  } from './runner-reasoning.ts';
35
+ import {
36
+ createOauthCodexTextGuardState,
37
+ consumeOauthCodexTextDelta,
38
+ } from '../stream/text-guard.ts';
39
+ import { decideOauthCodexContinuation } from './oauth-codex-continuation.ts';
35
40
 
36
41
  export {
37
42
  enqueueAssistantRun,
@@ -94,11 +99,34 @@ async function runAssistant(opts: RunOpts) {
94
99
  }> = [...additionalSystemMessages, ...history];
95
100
 
96
101
  if (!isFirstMessage) {
97
- messagesWithSystemInstructions.push({
98
- role: isOpenAIOAuth ? 'system' : 'user',
99
- content:
100
- 'SYSTEM REMINDER: You are continuing an existing session. When you have completed the task, you MUST stream a text summary of what you did to the user, and THEN call the `finish` tool. Do not call `finish` without a summary.',
101
- });
102
+ if (isOpenAIOAuth) {
103
+ messagesWithSystemInstructions.push({
104
+ role: 'system',
105
+ content:
106
+ 'SYSTEM REMINDER: You are continuing an existing session. Continue executing directly, use tools as needed, and provide a concise final summary when complete.',
107
+ });
108
+ } else {
109
+ messagesWithSystemInstructions.push({
110
+ role: 'user',
111
+ content:
112
+ 'SYSTEM REMINDER: You are continuing an existing session. When you have completed the task, you MUST stream a text summary of what you did to the user, and THEN call the `finish` tool. Do not call `finish` without a summary.',
113
+ });
114
+ }
115
+ }
116
+ if ((opts.continuationCount ?? 0) > 0) {
117
+ if (isOpenAIOAuth) {
118
+ messagesWithSystemInstructions.push({
119
+ role: 'system',
120
+ content:
121
+ 'SYSTEM REMINDER: Your previous response stopped mid-task. Continue immediately from where you left off and finish the actual implementation, not just a plan update.',
122
+ });
123
+ } else {
124
+ messagesWithSystemInstructions.push({
125
+ role: 'user',
126
+ content:
127
+ 'SYSTEM REMINDER: Your previous response stopped before calling `finish`. Continue executing immediately from where you left off, avoid plan-only updates, and call `finish` only after streaming the final user summary.',
128
+ });
129
+ }
102
130
  }
103
131
 
104
132
  debugLog(
@@ -123,7 +151,11 @@ async function runAssistant(opts: RunOpts) {
123
151
 
124
152
  let currentPartId: string | null = null;
125
153
  let accumulated = '';
154
+ let latestAssistantText = '';
126
155
  let stepIndex = 0;
156
+ const oauthTextGuard = isOpenAIOAuth
157
+ ? createOauthCodexTextGuardState()
158
+ : null;
127
159
 
128
160
  const getCurrentPartId = () => currentPartId;
129
161
  const getStepIndex = () => stepIndex;
@@ -164,6 +196,9 @@ async function runAssistant(opts: RunOpts) {
164
196
  const onAbort = createAbortHandler(opts, db, getStepIndex, sharedCtx);
165
197
 
166
198
  const onFinish = createFinishHandler(opts, db, completeAssistantMessage);
199
+ const stopWhenCondition = isOpenAIOAuth
200
+ ? stepCountIs(48)
201
+ : hasToolCall('finish');
167
202
 
168
203
  try {
169
204
  const result = streamText({
@@ -177,7 +212,7 @@ async function runAssistant(opts: RunOpts) {
177
212
  : {}),
178
213
  ...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
179
214
  abortSignal: opts.abortSignal,
180
- stopWhen: hasToolCall('finish'),
215
+ stopWhen: stopWhenCondition,
181
216
  // biome-ignore lint/suspicious/noExplicitAny: AI SDK callback types mismatch
182
217
  onStepFinish: onStepFinish as any,
183
218
  // biome-ignore lint/suspicious/noExplicitAny: AI SDK callback types mismatch
@@ -193,10 +228,18 @@ async function runAssistant(opts: RunOpts) {
193
228
  if (!part) continue;
194
229
 
195
230
  if (part.type === 'text-delta') {
196
- const delta = part.text;
231
+ const rawDelta = part.text;
232
+ if (!rawDelta) continue;
233
+
234
+ const delta = oauthTextGuard
235
+ ? consumeOauthCodexTextDelta(oauthTextGuard, rawDelta)
236
+ : rawDelta;
197
237
  if (!delta) continue;
198
238
 
199
239
  accumulated += delta;
240
+ if (accumulated.trim()) {
241
+ latestAssistantText = accumulated;
242
+ }
200
243
 
201
244
  if (!currentPartId && !accumulated.trim()) {
202
245
  continue;
@@ -282,6 +325,11 @@ async function runAssistant(opts: RunOpts) {
282
325
  }
283
326
 
284
327
  const fs = firstToolSeen();
328
+ if (oauthTextGuard?.dropped) {
329
+ debugLog(
330
+ '[RUNNER] Dropped pseudo tool-call text leaked by OpenAI OAuth stream',
331
+ );
332
+ }
285
333
  if (!fs && !_finishObserved) {
286
334
  publish({
287
335
  type: 'finish-step',
@@ -301,66 +349,81 @@ async function runAssistant(opts: RunOpts) {
301
349
  streamFinishReason = undefined;
302
350
  }
303
351
 
352
+ let streamRawFinishReason: string | undefined;
353
+ try {
354
+ streamRawFinishReason = await result.rawFinishReason;
355
+ } catch {
356
+ streamRawFinishReason = undefined;
357
+ }
358
+
304
359
  debugLog(
305
- `[RUNNER] Stream finished. finishSeen=${_finishObserved}, firstToolSeen=${fs}, finishReason=${streamFinishReason}`,
360
+ `[RUNNER] Stream finished. finishSeen=${_finishObserved}, firstToolSeen=${fs}, finishReason=${streamFinishReason}, rawFinishReason=${streamRawFinishReason}`,
306
361
  );
307
362
 
308
- const wasTruncated = streamFinishReason === 'length';
363
+ const MAX_CONTINUATIONS = 6;
364
+ const continuationCount = opts.continuationCount ?? 0;
365
+ const continuationDecision = decideOauthCodexContinuation({
366
+ provider: opts.provider,
367
+ isOpenAIOAuth,
368
+ finishObserved: _finishObserved,
369
+ continuationCount,
370
+ maxContinuations: MAX_CONTINUATIONS,
371
+ finishReason: streamFinishReason,
372
+ rawFinishReason: streamRawFinishReason,
373
+ firstToolSeen: fs,
374
+ droppedPseudoToolText: oauthTextGuard?.dropped ?? false,
375
+ lastAssistantText: latestAssistantText,
376
+ });
309
377
 
310
- const shouldContinue =
311
- opts.provider === 'openai' &&
312
- isOpenAIOAuth &&
313
- !_finishObserved &&
314
- (wasTruncated || fs);
378
+ if (continuationDecision.shouldContinue) {
379
+ debugLog(
380
+ `[RUNNER] WARNING: Stream ended without finish. reason=${continuationDecision.reason ?? 'unknown'}, finishReason=${streamFinishReason}, rawFinishReason=${streamRawFinishReason}, firstToolSeen=${fs}. Auto-continuing.`,
381
+ );
315
382
 
316
- if (shouldContinue) {
317
383
  debugLog(
318
- `[RUNNER] WARNING: Stream ended without finish. finishReason=${streamFinishReason}, firstToolSeen=${fs}. Auto-continuing.`,
384
+ `[RUNNER] Auto-continuing (${continuationCount + 1}/${MAX_CONTINUATIONS})...`,
319
385
  );
320
386
 
321
- const MAX_CONTINUATIONS = 10;
322
- const count = opts.continuationCount ?? 0;
323
- if (count < MAX_CONTINUATIONS) {
387
+ try {
388
+ await completeAssistantMessage({}, opts, db);
389
+ } catch (err) {
324
390
  debugLog(
325
- `[RUNNER] Auto-continuing (${count + 1}/${MAX_CONTINUATIONS})...`,
391
+ `[RUNNER] completeAssistantMessage failed before continuation: ${err instanceof Error ? err.message : String(err)}`,
326
392
  );
393
+ }
327
394
 
328
- try {
329
- await completeAssistantMessage({}, opts, db);
330
- } catch (err) {
331
- debugLog(
332
- `[RUNNER] completeAssistantMessage failed before continuation: ${err instanceof Error ? err.message : String(err)}`,
333
- );
334
- }
335
-
336
- const continuationMessageId = crypto.randomUUID();
337
- await db.insert(messages).values({
338
- id: continuationMessageId,
339
- sessionId: opts.sessionId,
340
- role: 'assistant',
341
- status: 'pending',
342
- agent: opts.agent,
343
- provider: opts.provider,
344
- model: opts.model,
345
- createdAt: Date.now(),
346
- });
395
+ const continuationMessageId = crypto.randomUUID();
396
+ await db.insert(messages).values({
397
+ id: continuationMessageId,
398
+ sessionId: opts.sessionId,
399
+ role: 'assistant',
400
+ status: 'pending',
401
+ agent: opts.agent,
402
+ provider: opts.provider,
403
+ model: opts.model,
404
+ createdAt: Date.now(),
405
+ });
347
406
 
348
- publish({
349
- type: 'message.created',
350
- sessionId: opts.sessionId,
351
- payload: { id: continuationMessageId, role: 'assistant' },
352
- });
407
+ publish({
408
+ type: 'message.created',
409
+ sessionId: opts.sessionId,
410
+ payload: { id: continuationMessageId, role: 'assistant' },
411
+ });
353
412
 
354
- enqueueAssistantRun(
355
- {
356
- ...opts,
357
- assistantMessageId: continuationMessageId,
358
- continuationCount: count + 1,
359
- },
360
- runSessionLoop,
361
- );
362
- return;
363
- }
413
+ enqueueAssistantRun(
414
+ {
415
+ ...opts,
416
+ assistantMessageId: continuationMessageId,
417
+ continuationCount: continuationCount + 1,
418
+ },
419
+ runSessionLoop,
420
+ );
421
+ return;
422
+ }
423
+ if (
424
+ continuationDecision.reason === 'max-continuations-reached' &&
425
+ !_finishObserved
426
+ ) {
364
427
  debugLog(
365
428
  `[RUNNER] Max continuations (${MAX_CONTINUATIONS}) reached, stopping.`,
366
429
  );
@@ -23,7 +23,11 @@ export async function buildHistoryMessages(
23
23
  const toolHistory = new ToolHistoryTracker();
24
24
 
25
25
  for (const m of rows) {
26
- if (m.role === 'assistant' && m.status !== 'complete') {
26
+ if (
27
+ m.role === 'assistant' &&
28
+ m.status !== 'complete' &&
29
+ m.status !== 'completed'
30
+ ) {
27
31
  debugLog(
28
32
  `[buildHistoryMessages] Skipping assistant message ${m.id} with status ${m.status} (current turn still in progress)`,
29
33
  );
@@ -12,6 +12,10 @@ import GUIDED_PROMPT from '@ottocode/sdk/prompts/modes/guided.txt' with {
12
12
  type: 'text',
13
13
  };
14
14
  // eslint-disable-next-line @typescript-eslint/consistent-type-imports
15
+ import OPENAI_OAUTH_PROMPT from '@ottocode/sdk/prompts/providers/openai-oauth.txt' with {
16
+ type: 'text',
17
+ };
18
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
15
19
  import ANTHROPIC_SPOOF_PROMPT from '@ottocode/sdk/prompts/providers/anthropicSpoof.txt' with {
16
20
  type: 'text',
17
21
  };
@@ -35,6 +39,7 @@ export async function composeSystemPrompt(options: {
35
39
  includeProjectTree?: boolean;
36
40
  userContext?: string;
37
41
  contextSummary?: string;
42
+ isOpenAIOAuth?: boolean;
38
43
  }): Promise<ComposedSystemPrompt> {
39
44
  const components: string[] = [];
40
45
  if (options.spoofPrompt) {
@@ -49,27 +54,38 @@ export async function composeSystemPrompt(options: {
49
54
  }
50
55
 
51
56
  const parts: string[] = [];
57
+ if (options.isOpenAIOAuth) {
58
+ const oauthInstructions = (OPENAI_OAUTH_PROMPT || '').trim();
59
+ if (oauthInstructions) {
60
+ parts.push(oauthInstructions);
61
+ components.push('provider:openai-oauth');
62
+ }
63
+ if (options.agentPrompt.trim()) {
64
+ parts.push(options.agentPrompt.trim());
65
+ components.push('agent');
66
+ }
67
+ } else {
68
+ const providerResult = await providerBasePrompt(
69
+ options.provider,
70
+ options.model,
71
+ options.projectRoot,
72
+ );
73
+ const baseInstructions = (BASE_PROMPT || '').trim();
52
74
 
53
- const providerResult = await providerBasePrompt(
54
- options.provider,
55
- options.model,
56
- options.projectRoot,
57
- );
58
- const baseInstructions = (BASE_PROMPT || '').trim();
59
-
60
- parts.push(
61
- providerResult.prompt.trim(),
62
- baseInstructions.trim(),
63
- options.agentPrompt.trim(),
64
- );
65
- if (providerResult.prompt.trim()) {
66
- components.push(`provider:${providerResult.resolvedType}`);
67
- }
68
- if (baseInstructions.trim()) {
69
- components.push('base');
70
- }
71
- if (options.agentPrompt.trim()) {
72
- components.push('agent');
75
+ parts.push(
76
+ providerResult.prompt.trim(),
77
+ baseInstructions.trim(),
78
+ options.agentPrompt.trim(),
79
+ );
80
+ if (providerResult.prompt.trim()) {
81
+ components.push(`provider:${providerResult.resolvedType}`);
82
+ }
83
+ if (baseInstructions.trim()) {
84
+ components.push('base');
85
+ }
86
+ if (options.agentPrompt.trim()) {
87
+ components.push('agent');
88
+ }
73
89
  }
74
90
 
75
91
  if (options.oneShot) {
@@ -188,7 +188,7 @@ export function createErrorHandler(
188
188
  } else {
189
189
  await db
190
190
  .update(messages)
191
- .set({ status: 'completed', completedAt: Date.now() })
191
+ .set({ status: 'complete', completedAt: Date.now() })
192
192
  .where(eq(messages.id, opts.assistantMessageId));
193
193
 
194
194
  publish({
@@ -260,7 +260,7 @@ export function createErrorHandler(
260
260
  await db
261
261
  .update(messages)
262
262
  .set({
263
- status: compactionSucceeded ? 'completed' : 'error',
263
+ status: compactionSucceeded ? 'complete' : 'error',
264
264
  completedAt: Date.now(),
265
265
  })
266
266
  .where(eq(messages.id, compactMessageId));
@@ -0,0 +1,77 @@
1
+ export type OauthCodexTextGuardState = {
2
+ raw: string;
3
+ sanitized: string;
4
+ dropped: boolean;
5
+ };
6
+
7
+ const LEAK_PATTERNS: RegExp[] = [
8
+ /assistant\s+to=/i,
9
+ /assistant\s+to\b/i,
10
+ /\bassistant\b\s*$/i,
11
+ /assistant\s+to=functions\./i,
12
+ /assistant\s+to=functions\b/i,
13
+ /to=functions\.[a-z0-9_]+\s+(commentary|analysis|final)\b/i,
14
+ /call:tool\{/i,
15
+ ];
16
+
17
+ function findFirstLeakIndex(text: string): number {
18
+ let index = -1;
19
+ for (const pattern of LEAK_PATTERNS) {
20
+ const match = pattern.exec(text);
21
+ if (!match || match.index < 0) continue;
22
+ if (index === -1 || match.index < index) {
23
+ index = match.index;
24
+ }
25
+ }
26
+ return index;
27
+ }
28
+
29
+ /**
30
+ * Removes codex pseudo tool-call leakage from text streams.
31
+ *
32
+ * Some OAuth Codex responses leak harness syntax (e.g. "assistant to=functions...")
33
+ * into user-facing text. Once such a marker appears, everything from that marker
34
+ * onward is considered non-user text and dropped.
35
+ */
36
+ export function stripCodexPseudoToolText(raw: string): {
37
+ sanitized: string;
38
+ dropped: boolean;
39
+ } {
40
+ const leakIndex = findFirstLeakIndex(raw);
41
+ if (leakIndex === -1) {
42
+ return { sanitized: raw, dropped: false };
43
+ }
44
+ return {
45
+ sanitized: raw.slice(0, leakIndex).trimEnd(),
46
+ dropped: true,
47
+ };
48
+ }
49
+
50
+ export function createOauthCodexTextGuardState(): OauthCodexTextGuardState {
51
+ return {
52
+ raw: '',
53
+ sanitized: '',
54
+ dropped: false,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Consumes a raw delta and returns only safe delta text.
60
+ */
61
+ export function consumeOauthCodexTextDelta(
62
+ state: OauthCodexTextGuardState,
63
+ rawDelta: string,
64
+ ): string {
65
+ if (!rawDelta) return '';
66
+ state.raw += rawDelta;
67
+ const next = stripCodexPseudoToolText(state.raw);
68
+ if (next.dropped) state.dropped = true;
69
+
70
+ let safeDelta = '';
71
+ if (next.sanitized.startsWith(state.sanitized)) {
72
+ safeDelta = next.sanitized.slice(state.sanitized.length);
73
+ }
74
+
75
+ state.sanitized = next.sanitized;
76
+ return safeDelta;
77
+ }