@agi-cli/server 0.1.74 → 0.1.76

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": "@agi-cli/server",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "description": "HTTP API server for AGI CLI",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@agi-cli/sdk": "0.1.74",
33
- "@agi-cli/database": "0.1.74",
32
+ "@agi-cli/sdk": "0.1.76",
33
+ "@agi-cli/database": "0.1.76",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
@@ -195,6 +195,11 @@ export function getOpenAPISpec() {
195
195
  },
196
196
  provider: { $ref: '#/components/schemas/Provider' },
197
197
  model: { type: 'string' },
198
+ userContext: {
199
+ type: 'string',
200
+ description:
201
+ 'Optional user-provided context to include in the system prompt.',
202
+ },
198
203
  },
199
204
  },
200
205
  },
@@ -85,6 +85,17 @@ export function registerSessionMessagesRoutes(app: Hono) {
85
85
  const db = await getDb(cfg.projectRoot);
86
86
  const sessionId = c.req.param('id');
87
87
  const body = await c.req.json().catch(() => ({}));
88
+
89
+ // DEBUG: Log received body
90
+ logger.info('[API] Received message request', {
91
+ sessionId,
92
+ hasContent: !!body?.content,
93
+ hasUserContext: !!body?.userContext,
94
+ userContext: body?.userContext
95
+ ? `${String(body.userContext).substring(0, 50)}...`
96
+ : 'NONE',
97
+ });
98
+
88
99
  // Load session to inherit its provider/model/agent by default
89
100
  const sessionRows = await db
90
101
  .select()
@@ -101,6 +112,14 @@ export function registerSessionMessagesRoutes(app: Hono) {
101
112
  const content = body?.content ?? '';
102
113
  const userContext = body?.userContext;
103
114
 
115
+ // DEBUG: Log extracted userContext
116
+ logger.info('[API] Extracted userContext', {
117
+ userContext: userContext
118
+ ? `${String(userContext).substring(0, 50)}...`
119
+ : 'NONE',
120
+ typeOf: typeof userContext,
121
+ });
122
+
104
123
  // Validate model capabilities if tools are allowed for this agent
105
124
  const wantsToolCalls = true; // agent toolset may be non-empty
106
125
  try {
@@ -37,6 +37,12 @@ export async function dispatchAssistantMessage(
37
37
  oneShot,
38
38
  userContext,
39
39
  } = options;
40
+
41
+ // DEBUG: Log userContext in dispatch
42
+ debugLog(
43
+ `[MESSAGE_SERVICE] dispatchAssistantMessage called with userContext: ${userContext ? `${userContext.substring(0, 50)}...` : 'NONE'}`,
44
+ );
45
+
40
46
  const sessionId = session.id;
41
47
  const now = Date.now();
42
48
  const userMessageId = crypto.randomUUID();
@@ -100,6 +106,11 @@ export async function dispatchAssistantMessage(
100
106
  payload: { id: assistantMessageId, role: 'assistant' },
101
107
  });
102
108
 
109
+ // DEBUG: Log before enqueue
110
+ debugLog(
111
+ `[MESSAGE_SERVICE] Enqueuing assistant run with userContext: ${userContext ? `${userContext.substring(0, 50)}...` : 'NONE'}`,
112
+ );
113
+
103
114
  enqueueAssistantRun({
104
115
  sessionId,
105
116
  assistantMessageId,
@@ -128,21 +139,44 @@ function scheduleSessionTitle(args: {
128
139
  db: DB;
129
140
  sessionId: string;
130
141
  content: unknown;
131
- }): void {
132
- const { sessionId } = args;
133
- if (titleInFlight.has(sessionId)) return;
134
- titleInFlight.add(sessionId);
135
- void (async () => {
142
+ }) {
143
+ const { cfg, db, sessionId, content } = args;
144
+
145
+ if (titleInFlight.has(sessionId) || titlePending.has(sessionId)) {
146
+ return;
147
+ }
148
+
149
+ const processNext = () => {
150
+ if (titleQueue.length === 0) {
151
+ return;
152
+ }
153
+ if (titleActiveCount >= TITLE_CONCURRENCY_LIMIT) {
154
+ return;
155
+ }
156
+ const next = titleQueue.shift();
157
+ if (!next) return;
158
+ titleActiveCount++;
159
+ next();
160
+ };
161
+
162
+ const task = async () => {
163
+ titleInFlight.add(sessionId);
164
+ titlePending.delete(sessionId);
136
165
  try {
137
- const alreadyTitled = await sessionHasTitle(args.db, sessionId);
138
- if (alreadyTitled) return;
139
- await withTitleSlot(() => updateSessionTitle(args));
140
- } catch {
141
- // Swallow title generation errors; they are non-blocking.
166
+ await generateSessionTitle({ cfg, db, sessionId, content });
167
+ } catch (err) {
168
+ debugLog('[TITLE_GEN] Title generation error:');
169
+ debugLog(err);
142
170
  } finally {
143
171
  titleInFlight.delete(sessionId);
172
+ titleActiveCount--;
173
+ processNext();
144
174
  }
145
- })();
175
+ };
176
+
177
+ titlePending.add(sessionId);
178
+ titleQueue.push(task);
179
+ processNext();
146
180
  }
147
181
 
148
182
  function enqueueSessionTitle(args: {
@@ -151,72 +185,54 @@ function enqueueSessionTitle(args: {
151
185
  sessionId: string;
152
186
  content: unknown;
153
187
  }) {
154
- const { sessionId } = args;
155
- if (titlePending.has(sessionId) || titleInFlight.has(sessionId)) return;
156
- titlePending.add(sessionId);
157
- Promise.resolve()
158
- .then(() => {
159
- titlePending.delete(sessionId);
160
- scheduleSessionTitle(args);
161
- })
162
- .catch(() => {
163
- titlePending.delete(sessionId);
164
- });
188
+ scheduleSessionTitle(args);
165
189
  }
166
190
 
167
- async function updateSessionTitle(args: {
191
+ async function generateSessionTitle(args: {
168
192
  cfg: AGIConfig;
169
193
  db: DB;
170
194
  sessionId: string;
171
195
  content: unknown;
172
- }) {
196
+ }): Promise<void> {
173
197
  const { cfg, db, sessionId, content } = args;
198
+
174
199
  try {
175
- const rows = await db
200
+ const existingSession = await db
176
201
  .select()
177
202
  .from(sessions)
178
203
  .where(eq(sessions.id, sessionId));
179
- if (!rows.length) return;
180
- const current = rows[0];
181
- const alreadyHasTitle =
182
- current.title != null && String(current.title).trim().length > 0;
183
- let heuristic = '';
184
- if (!alreadyHasTitle) {
185
- heuristic = deriveTitle(String(content ?? ''));
186
- if (heuristic) {
187
- await db
188
- .update(sessions)
189
- .set({ title: heuristic })
190
- .where(eq(sessions.id, sessionId));
191
- publish({
192
- type: 'session.updated',
193
- sessionId,
194
- payload: { title: heuristic },
195
- });
196
- }
204
+
205
+ if (!existingSession.length) {
206
+ debugLog('[TITLE_GEN] Session not found, aborting');
207
+ return;
197
208
  }
198
209
 
199
- const providerId =
200
- (current.provider as ProviderId) ?? cfg.defaults.provider;
201
- const modelId = current.model ?? cfg.defaults.model;
210
+ const sess = existingSession[0];
211
+ if (sess.title && sess.title !== 'New Session') {
212
+ debugLog('[TITLE_GEN] Session already has a title, skipping');
213
+ return;
214
+ }
215
+
216
+ const provider = sess.provider ?? cfg.defaults.provider;
217
+ const modelName = sess.model ?? cfg.defaults.model;
218
+
219
+ debugLog('[TITLE_GEN] Generating title for session');
220
+ debugLog(`[TITLE_GEN] Provider: ${provider}, Model: ${modelName}`);
202
221
 
203
- debugLog('[TITLE_GEN] Starting title generation');
204
- debugLog(`[TITLE_GEN] Provider: ${providerId}, Model: ${modelId}`);
222
+ const model = await resolveModel(provider, modelName, cfg);
205
223
 
206
- // Check if we need OAuth spoof prompt (same logic as runner)
207
224
  const { getAuth } = await import('@agi-cli/sdk');
208
225
  const { getProviderSpoofPrompt } = await import('./prompt.ts');
209
- const auth = await getAuth(providerId, cfg.projectRoot);
226
+ const auth = await getAuth(provider, cfg.projectRoot);
210
227
  const needsSpoof = auth?.type === 'oauth';
211
228
  const spoofPrompt = needsSpoof
212
- ? getProviderSpoofPrompt(providerId)
229
+ ? getProviderSpoofPrompt(provider)
213
230
  : undefined;
214
231
 
215
- debugLog(`[TITLE_GEN] Auth type: ${auth?.type ?? 'none'}`);
216
- debugLog(`[TITLE_GEN] Needs spoof: ${needsSpoof}`);
217
- debugLog(`[TITLE_GEN] Spoof prompt length: ${spoofPrompt?.length ?? 0}`);
232
+ debugLog(
233
+ `[TITLE_GEN] needsSpoof: ${needsSpoof}, spoofPrompt: ${spoofPrompt || 'NONE'}`,
234
+ );
218
235
 
219
- const model = await resolveModel(providerId, modelId, cfg);
220
236
  const promptText = String(content ?? '').slice(0, 2000);
221
237
 
222
238
  const titlePrompt = [
@@ -281,123 +297,49 @@ async function updateSessionTitle(args: {
281
297
  const sanitized = sanitizeTitle(modelTitle);
282
298
  debugLog(`[TITLE_GEN] After sanitization: "${sanitized}"`);
283
299
 
284
- if (!sanitized) {
285
- debugLog('[TITLE_GEN] Title sanitized to empty, aborting');
300
+ if (!sanitized || sanitized === 'New Session') {
301
+ debugLog('[TITLE_GEN] Sanitized title is empty or default, aborting');
286
302
  return;
287
303
  }
288
304
 
289
- modelTitle = sanitized;
290
-
291
- const check = await db
292
- .select()
293
- .from(sessions)
294
- .where(eq(sessions.id, sessionId));
295
- if (!check.length) return;
296
- const currentTitle = String(check[0].title ?? '').trim();
297
- if (currentTitle && currentTitle !== heuristic) {
298
- debugLog(
299
- `[TITLE_GEN] Session already has different title: "${currentTitle}", skipping`,
300
- );
301
- return;
302
- }
303
-
304
- debugLog(`[TITLE_GEN] Setting final title: "${modelTitle}"`);
305
305
  await db
306
306
  .update(sessions)
307
- .set({ title: modelTitle })
307
+ .set({ title: sanitized, updatedAt: Date.now() })
308
308
  .where(eq(sessions.id, sessionId));
309
+
310
+ debugLog(`[TITLE_GEN] Setting final title: "${sanitized}"`);
311
+
309
312
  publish({
310
313
  type: 'session.updated',
311
314
  sessionId,
312
- payload: { title: modelTitle },
315
+ payload: { id: sessionId, title: sanitized },
313
316
  });
314
317
  } catch (err) {
315
- debugLog('[TITLE_GEN] Fatal error:');
318
+ debugLog('[TITLE_GEN] Error in generateSessionTitle:');
316
319
  debugLog(err);
317
320
  }
318
321
  }
319
322
 
320
- async function withTitleSlot<T>(fn: () => Promise<T>): Promise<T> {
321
- await acquireTitleSlot();
322
- try {
323
- return await fn();
324
- } finally {
325
- releaseTitleSlot();
326
- }
327
- }
328
-
329
- async function sessionHasTitle(db: DB, sessionId: string): Promise<boolean> {
330
- try {
331
- const rows = await db
332
- .select({ title: sessions.title })
333
- .from(sessions)
334
- .where(eq(sessions.id, sessionId))
335
- .limit(1);
336
- if (!rows.length) return false;
337
- const title = rows[0]?.title;
338
- return typeof title === 'string' && title.trim().length > 0;
339
- } catch {
340
- return false;
341
- }
342
- }
343
-
344
- function acquireTitleSlot(): Promise<void> {
345
- if (titleActiveCount < TITLE_CONCURRENCY_LIMIT) {
346
- titleActiveCount += 1;
347
- return Promise.resolve();
348
- }
349
- return new Promise((resolve) => {
350
- titleQueue.push(() => {
351
- titleActiveCount += 1;
352
- resolve();
353
- });
354
- });
355
- }
356
-
357
- function releaseTitleSlot(): void {
358
- if (titleActiveCount > 0) titleActiveCount -= 1;
359
- const next = titleQueue.shift();
360
- if (next) {
361
- next();
362
- }
323
+ function sanitizeTitle(raw: string): string {
324
+ let s = raw.trim();
325
+ s = s.replace(/^["']|["']$/g, '');
326
+ s = s.replace(/[.!?]+$/, '');
327
+ s = s.replace(/\s+/g, ' ');
328
+ if (s.length > 80) s = s.slice(0, 80).trim();
329
+ return s;
363
330
  }
364
331
 
365
- async function touchSessionLastActive(args: { db: DB; sessionId: string }) {
332
+ async function touchSessionLastActive(args: {
333
+ db: DB;
334
+ sessionId: string;
335
+ }): Promise<void> {
366
336
  const { db, sessionId } = args;
367
337
  try {
368
338
  await db
369
339
  .update(sessions)
370
- .set({ lastActiveAt: Date.now() })
340
+ .set({ updatedAt: Date.now() })
371
341
  .where(eq(sessions.id, sessionId));
372
- } catch {}
373
- }
374
-
375
- function deriveTitle(text: string): string {
376
- const cleaned = String(text || '')
377
- .replace(/```[\s\S]*?```/g, ' ')
378
- .replace(/`[^`]*`/g, ' ')
379
- .replace(/\s+/g, ' ')
380
- .trim();
381
- if (!cleaned) return '';
382
- const endIdx = (() => {
383
- const punct = ['? ', '. ', '! ']
384
- .map((p) => cleaned.indexOf(p))
385
- .filter((i) => i > 0);
386
- const idx = Math.min(...(punct.length ? punct : [cleaned.length]));
387
- return Math.min(idx + 1, cleaned.length);
388
- })();
389
- const first = cleaned.slice(0, endIdx).trim();
390
- const maxLen = 64;
391
- const base = first.length > 8 ? first : cleaned;
392
- const truncated =
393
- base.length > maxLen ? `${base.slice(0, maxLen - 1).trimEnd()}…` : base;
394
- return truncated;
395
- }
396
-
397
- function sanitizeTitle(s: string): string {
398
- let t = s.trim();
399
- t = t.replace(/^['"""''()[\\]]+|['"""''()[\\]]+$/g, '').trim();
400
- t = t.replace(/[\\s\\-_:–—]+$/g, '').trim();
401
- if (t.length > 64) t = `${t.slice(0, 63).trimEnd()}…`;
402
- return t;
342
+ } catch (err) {
343
+ debugLog('[touchSessionLastActive] Error:', err);
344
+ }
403
345
  }
@@ -122,7 +122,18 @@ async function runAssistant(opts: RunOpts) {
122
122
  const history = await buildHistoryMessages(db, opts.sessionId);
123
123
  historyTimer.end({ messages: history.length });
124
124
 
125
- const isFirstMessage = history.length === 0;
125
+ // FIX: For OAuth, we need to check if this is the first ASSISTANT message
126
+ // The user message is already in history by this point, so history.length will be > 0
127
+ // We need to add additionalSystemMessages on the first assistant turn
128
+ const isFirstMessage = !history.some((m) => m.role === 'assistant');
129
+
130
+ debugLog(`[RUNNER] isFirstMessage: ${isFirstMessage}`);
131
+ debugLog(`[RUNNER] userContext provided: ${opts.userContext ? 'YES' : 'NO'}`);
132
+ if (opts.userContext) {
133
+ debugLog(
134
+ `[RUNNER] userContext value: ${opts.userContext.substring(0, 100)}${opts.userContext.length > 100 ? '...' : ''}`,
135
+ );
136
+ }
126
137
 
127
138
  const systemTimer = time('runner:composeSystemPrompt');
128
139
  const { getAuth } = await import('@agi-cli/sdk');
@@ -133,10 +144,14 @@ async function runAssistant(opts: RunOpts) {
133
144
  ? getProviderSpoofPrompt(opts.provider)
134
145
  : undefined;
135
146
 
147
+ debugLog(`[RUNNER] needsSpoof (OAuth): ${needsSpoof}`);
148
+ debugLog(`[RUNNER] spoofPrompt: ${spoofPrompt || 'NONE'}`);
149
+
136
150
  let system: string;
137
151
  let additionalSystemMessages: Array<{ role: 'system'; content: string }> = [];
138
152
 
139
153
  if (spoofPrompt) {
154
+ // OAuth mode: short spoof in system field, full instructions in messages array
140
155
  system = spoofPrompt;
141
156
  const fullPrompt = await composeSystemPrompt({
142
157
  provider: opts.provider,
@@ -148,8 +163,25 @@ async function runAssistant(opts: RunOpts) {
148
163
  includeProjectTree: isFirstMessage,
149
164
  userContext: opts.userContext,
150
165
  });
166
+
167
+ // FIX: Always add the system message for OAuth because:
168
+ // 1. System messages are NOT stored in the database
169
+ // 2. buildHistoryMessages only returns user/assistant messages
170
+ // 3. We need the full instructions on every turn
151
171
  additionalSystemMessages = [{ role: 'system', content: fullPrompt }];
172
+
173
+ debugLog('[RUNNER] OAuth mode: additionalSystemMessages created');
174
+ debugLog(`[RUNNER] fullPrompt length: ${fullPrompt.length}`);
175
+ debugLog(
176
+ `[RUNNER] fullPrompt contains userContext: ${fullPrompt.includes('<user-provided-state-context>') ? 'YES' : 'NO'}`,
177
+ );
178
+ if (opts.userContext && fullPrompt.includes(opts.userContext)) {
179
+ debugLog('[RUNNER] ✅ userContext IS in fullPrompt');
180
+ } else if (opts.userContext) {
181
+ debugLog('[RUNNER] ❌ userContext NOT in fullPrompt!');
182
+ }
152
183
  } else {
184
+ // API key mode: full instructions in system field
153
185
  system = await composeSystemPrompt({
154
186
  provider: opts.provider,
155
187
  model: opts.model,
@@ -174,11 +206,29 @@ async function runAssistant(opts: RunOpts) {
174
206
  'progress_update',
175
207
  ]);
176
208
  const gated = allTools.filter((t) => allowedNames.has(t.name));
209
+
210
+ // FIX: For OAuth, ALWAYS prepend the system message because it's never in history
211
+ // For API key mode, only add on first message (when additionalSystemMessages is empty)
177
212
  const messagesWithSystemInstructions = [
178
- ...(isFirstMessage ? additionalSystemMessages : []),
213
+ ...additionalSystemMessages, // Always add for OAuth, empty for API key mode
179
214
  ...history,
180
215
  ];
181
216
 
217
+ debugLog(
218
+ `[RUNNER] messagesWithSystemInstructions length: ${messagesWithSystemInstructions.length}`,
219
+ );
220
+ debugLog(
221
+ `[RUNNER] additionalSystemMessages length: ${additionalSystemMessages.length}`,
222
+ );
223
+ if (additionalSystemMessages.length > 0) {
224
+ debugLog(
225
+ '[RUNNER] ✅ additionalSystemMessages ADDED to messagesWithSystemInstructions',
226
+ );
227
+ debugLog(
228
+ `[RUNNER] This happens on EVERY turn for OAuth (system messages not stored in DB)`,
229
+ );
230
+ }
231
+
182
232
  const { sharedCtx, firstToolTimer, firstToolSeen } = await setupToolContext(
183
233
  opts,
184
234
  db,
@@ -259,9 +309,24 @@ async function runAssistant(opts: RunOpts) {
259
309
  maxToolResults: 30,
260
310
  });
261
311
 
312
+ debugLog(
313
+ `[RUNNER] After optimizeContext: ${contextOptimized.length} messages`,
314
+ );
315
+
262
316
  // 2. Truncate history
263
317
  const truncatedMessages = truncateHistory(contextOptimized, 20);
264
318
 
319
+ debugLog(
320
+ `[RUNNER] After truncateHistory: ${truncatedMessages.length} messages`,
321
+ );
322
+ if (truncatedMessages.length > 0 && truncatedMessages[0].role === 'system') {
323
+ debugLog('[RUNNER] ✅ First message is system message');
324
+ } else if (truncatedMessages.length > 0) {
325
+ debugLog(
326
+ `[RUNNER] ⚠️ First message is NOT system (it's ${truncatedMessages[0].role})`,
327
+ );
328
+ }
329
+
265
330
  // 3. Add cache control
266
331
  const { system: cachedSystem, messages: optimizedMessages } = addCacheControl(
267
332
  opts.provider,
@@ -269,6 +334,13 @@ async function runAssistant(opts: RunOpts) {
269
334
  truncatedMessages,
270
335
  );
271
336
 
337
+ debugLog(
338
+ `[RUNNER] Final optimizedMessages: ${optimizedMessages.length} messages`,
339
+ );
340
+ debugLog(
341
+ `[RUNNER] cachedSystem (spoof): ${typeof cachedSystem === 'string' ? cachedSystem.substring(0, 100) : JSON.stringify(cachedSystem).substring(0, 100)}`,
342
+ );
343
+
272
344
  try {
273
345
  // @ts-expect-error this is fine 🔥
274
346
  const result = streamText({
@@ -312,29 +384,23 @@ async function runAssistant(opts: RunOpts) {
312
384
  await db
313
385
  .update(messageParts)
314
386
  .set({
315
- content: JSON.stringify({
316
- text: accumulated,
317
- error: errorPayload.message,
318
- }),
387
+ content: JSON.stringify({ error: errorPayload }),
319
388
  })
320
- .where(eq(messageParts.messageId, opts.assistantMessageId));
389
+ .where(eq(messageParts.id, currentPartId));
390
+
321
391
  publish({
322
- type: 'error',
392
+ type: 'message.error',
323
393
  sessionId: opts.sessionId,
324
394
  payload: {
325
395
  messageId: opts.assistantMessageId,
326
- error: errorPayload.message,
327
- details: errorPayload.details,
396
+ partId: currentPartId,
397
+ error: errorPayload,
328
398
  },
329
399
  });
330
400
  throw error;
331
401
  } finally {
332
- if (!firstToolSeen()) firstToolTimer.end({ skipped: true });
333
- try {
334
- unsubscribeFinish();
335
- } catch {}
336
- try {
337
- await cleanupEmptyTextParts(opts, db);
338
- } catch {}
402
+ unsubscribeFinish();
403
+ await cleanupEmptyTextParts(db, opts.assistantMessageId);
404
+ firstToolTimer.end({ seen: firstToolSeen });
339
405
  }
340
406
  }