@f5-sales-demo/xcsh 22.7.2 → 22.8.0

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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Generate session titles using a smol, fast model.
3
3
  */
4
+
4
5
  import * as path from "node:path";
5
- import type { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
6
+ import type { AgentMessage, ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
6
7
  import type { Api, Model } from "@f5-sales-demo/pi-ai";
7
8
  import { completeSimple } from "@f5-sales-demo/pi-ai";
8
9
  import { logger, prompt } from "@f5-sales-demo/pi-utils";
@@ -11,6 +12,7 @@ import type { ModelRegistry } from "../config/model-registry";
11
12
  import { resolveRoleSelection } from "../config/model-resolver";
12
13
  import type { Settings } from "../config/settings";
13
14
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
15
+ import type { AutomaticTitleState, SessionEntry } from "../session/session-manager";
14
16
  import { toReasoningEffort } from "../thinking";
15
17
 
16
18
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
@@ -42,7 +44,9 @@ export function sanitizeGeneratedSessionTitle(value: string): string | null {
42
44
  }
43
45
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
44
46
  const record = parsed as Record<string, unknown>;
45
- if (Object.keys(record).length !== 1 || typeof record.title !== "string") return null;
47
+ if (Object.keys(record).some(key => key !== "title" && key !== "provisional") || typeof record.title !== "string")
48
+ return null;
49
+ if ("provisional" in record && typeof record.provisional !== "boolean") return null;
46
50
  const sanitized = record.title.replace(TERMINAL_TITLE_CONTROL_CHARS, "").replace(/\s+/gu, " ").trim();
47
51
  if (!sanitized) return null;
48
52
  return [...sanitized].slice(0, MAX_TITLE_CHARACTERS).join("").trim() || null;
@@ -73,26 +77,26 @@ const TITLE_TOOL = {
73
77
  description: "Return the generated session title as strict JSON.",
74
78
  strict: true,
75
79
  parameters: Type.Object(
76
- { title: Type.String({ maxLength: MAX_TITLE_CHARACTERS }) },
80
+ { title: Type.String({ maxLength: MAX_TITLE_CHARACTERS }), provisional: Type.Boolean() },
77
81
  { additionalProperties: false },
78
82
  ),
79
83
  } as const;
80
84
 
81
85
  /**
82
- * Generate a title for a session based on the first user message.
86
+ * Generate a session name and model-judged provisional status from completed conversation data.
83
87
  *
84
- * @param firstMessage The first user message
88
+ * @param firstMessage Serialized completed exchanges
85
89
  * @param registry Model registry
86
90
  * @param settings Settings used to resolve the smol role, including per-role thinking
87
91
  * @param sessionId Optional session id for sticky API key selection
88
92
  */
89
- export async function generateSessionTitle(
93
+ async function generateSessionName(
90
94
  firstMessage: string,
91
95
  registry: ModelRegistry,
92
96
  settings: Settings,
93
97
  sessionId?: string,
94
98
  currentModel?: Model<Api>,
95
- ): Promise<string | null> {
99
+ ): Promise<GeneratedSessionName | null> {
96
100
  const candidates = getTitleModels(registry, settings, currentModel);
97
101
  if (candidates.length === 0) {
98
102
  logger.debug("title-generator: no title model found");
@@ -137,7 +141,7 @@ ${truncatedMessage}
137
141
  },
138
142
  );
139
143
 
140
- if (response.stopReason === "error") {
144
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
141
145
  logger.debug("title-generator: response error", {
142
146
  model,
143
147
  attempt: attempt + 1,
@@ -147,16 +151,18 @@ ${truncatedMessage}
147
151
  }
148
152
 
149
153
  const toolCalls = response.content.filter(content => content.type === "toolCall");
150
- const title =
154
+ const payload =
151
155
  toolCalls.length === 1 && toolCalls[0]!.name === TITLE_TOOL_NAME
152
- ? sanitizeGeneratedSessionTitle(JSON.stringify(toolCalls[0]!.arguments) ?? "")
153
- : sanitizeGeneratedSessionTitle(
154
- response.content
156
+ ? (JSON.stringify(toolCalls[0]!.arguments) ?? "")
157
+ : toolCalls.length === 0
158
+ ? response.content
155
159
  .filter(content => content.type === "text")
156
160
  .map(content => content.text)
157
161
  .join("")
158
- .trim(),
159
- );
162
+ .trim()
163
+ : "";
164
+ const title = sanitizeGeneratedSessionTitle(payload);
165
+ const provisional = title ? (JSON.parse(payload) as { provisional?: boolean }).provisional : undefined;
160
166
 
161
167
  logger.debug("title-generator: response", {
162
168
  model,
@@ -166,7 +172,7 @@ ${truncatedMessage}
166
172
  titleCharacters: title ? [...title].length : 0,
167
173
  stopReason: response.stopReason,
168
174
  });
169
- if (title) return title;
175
+ if (title && typeof provisional === "boolean") return { title, provisional };
170
176
  } catch (err) {
171
177
  logger.debug("title-generator: error", {
172
178
  model,
@@ -178,31 +184,126 @@ ${truncatedMessage}
178
184
  return null;
179
185
  }
180
186
 
187
+ export interface GeneratedSessionName {
188
+ title: string;
189
+ provisional: boolean;
190
+ }
191
+
192
+ /** Compatibility helper for consumers needing only the generated string. */
193
+ export async function generateSessionTitle(...args: Parameters<typeof generateSessionName>): Promise<string | null> {
194
+ return (await generateSessionName(...args))?.title ?? null;
195
+ }
196
+
181
197
  interface SessionTitleManager {
182
198
  getSessionName?(): string | undefined;
199
+ getBranch?(): SessionEntry[];
200
+ getAutomaticTitleState?(): AutomaticTitleState | undefined;
201
+ readonly titleSource?: "auto" | "user";
202
+ readonly titleRevision?: number;
203
+ setAutomaticSessionName?(
204
+ name: string,
205
+ state: AutomaticTitleState,
206
+ sessionId: string,
207
+ revision: number,
208
+ ): Promise<boolean>;
183
209
  }
184
-
185
210
  interface SessionTitleTarget {
186
211
  sessionId: string;
187
212
  sessionName?: string;
213
+ messages?: AgentMessage[];
188
214
  model?: Model<Api>;
189
215
  modelRegistry: ModelRegistry;
190
216
  settings: Settings;
191
217
  sessionManager: SessionTitleManager;
192
218
  setSessionName(name: string, source: "auto" | "user"): Promise<boolean>;
193
219
  }
220
+ export interface CompletedTitleExchange {
221
+ id: string;
222
+ user: string;
223
+ assistant: string;
224
+ }
225
+
226
+ function messageText(message: { content?: unknown }): string {
227
+ if (typeof message.content === "string") return message.content;
228
+ if (!Array.isArray(message.content)) return "";
229
+ return message.content
230
+ .filter(part => part?.type === "text")
231
+ .map(part => part.text)
232
+ .join("\n");
233
+ }
194
234
 
195
- type TitleGenerator = typeof generateSessionTitle;
235
+ /** Read finalized conversation records, never transport deltas, closure tails, or delegation wrappers. */
236
+ export function completedTitleExchanges(entries: SessionEntry[]): CompletedTitleExchange[] {
237
+ const exchanges: CompletedTitleExchange[] = [];
238
+ let user: { id: string; text: string } | undefined;
239
+ let delegated = false;
240
+ for (const entry of entries) {
241
+ let role: string | undefined;
242
+ let text = "";
243
+ if (entry.type === "message") {
244
+ const message = entry.message;
245
+ role = message.role;
246
+ if (message.role !== "user" && message.role !== "assistant") continue;
247
+ if (message.role === "user" && message.attribution === "agent") continue;
248
+ text = messageText(message);
249
+ if (role === "user") delegated = text.trim().startsWith("<realtime_delegation>");
250
+ if (delegated) continue;
251
+ if (
252
+ message.role === "assistant" &&
253
+ (message.stopReason === "error" ||
254
+ message.stopReason === "aborted" ||
255
+ message.content.some(part => part.type === "toolCall"))
256
+ ) {
257
+ if (
258
+ (message.stopReason === "error" || message.stopReason === "aborted") &&
259
+ exchanges.at(-1)?.id === user?.id
260
+ )
261
+ exchanges.pop();
262
+ continue;
263
+ }
264
+ } else if (entry.type === "custom" && entry.customType === "remote-realtime") {
265
+ const record = entry.data as Record<string, unknown> | undefined;
266
+ if (record?.kind !== "transcript" || typeof record.text !== "string") continue;
267
+ delegated = false;
268
+ role = String(record.role);
269
+ text = record.text;
270
+ }
271
+ if (!text.trim()) continue;
272
+ if (role === "user") user = { id: entry.id, text };
273
+ else if (role === "assistant" && user) {
274
+ const exchange = { id: user.id, user: user.text, assistant: text };
275
+ if (exchanges.at(-1)?.id === user.id) exchanges[exchanges.length - 1] = exchange;
276
+ else exchanges.push(exchange);
277
+ }
278
+ }
279
+ return exchanges;
280
+ }
281
+
282
+ function titleSource(exchanges: CompletedTitleExchange[], currentName?: string): string {
283
+ // Bound the serialized payload, including escapes, while retaining both sides of the latest exchange.
284
+ const recent = exchanges.slice(-3);
285
+ for (let budget = 120; budget >= 0; budget -= 10) {
286
+ const source = JSON.stringify({
287
+ currentTitle: currentName,
288
+ exchanges: recent.map(exchange => ({
289
+ user: truncateTitleSource(exchange.user, budget),
290
+ assistant: truncateTitleSource(exchange.assistant, budget),
291
+ })),
292
+ });
293
+ if (Buffer.byteLength(source) <= MAX_TITLE_INPUT_BYTES) return source;
294
+ }
295
+ return JSON.stringify({ currentTitle: currentName });
296
+ }
297
+
298
+ type TitleGenerator = typeof generateSessionName;
196
299
  interface TitleFlight {
197
300
  promise: Promise<string | null>;
198
- callbacks: Set<(title: string) => void>;
199
301
  }
200
302
  interface TitleCoordinator {
201
303
  flights: Map<string, TitleFlight>;
202
304
  listeners: Set<(title: string) => void>;
203
305
  }
204
306
  const titleCoordinators = new WeakMap<object, TitleCoordinator>();
205
-
206
307
  function titleCoordinator(manager: object): TitleCoordinator {
207
308
  let state = titleCoordinators.get(manager);
208
309
  if (!state) {
@@ -211,7 +312,6 @@ function titleCoordinator(manager: object): TitleCoordinator {
211
312
  }
212
313
  return state;
213
314
  }
214
-
215
315
  export function subscribeSessionTitle(manager: object, listener: (title: string) => void): () => void {
216
316
  const state = titleCoordinator(manager);
217
317
  state.listeners.add(listener);
@@ -220,37 +320,79 @@ export function subscribeSessionTitle(manager: object, listener: (title: string)
220
320
 
221
321
  export function coordinateSessionTitle(
222
322
  target: SessionTitleTarget,
223
- firstMessage: string,
224
- onApplied?: (title: string) => void,
225
- generate: TitleGenerator = generateSessionTitle,
323
+ generate: TitleGenerator = generateSessionName,
226
324
  ): Promise<string | null> {
227
325
  const manager = target.sessionManager;
228
326
  const state = titleCoordinator(manager as object);
229
- const existing = state.flights.get(target.sessionId);
230
- if (existing) {
231
- if (onApplied) existing.callbacks.add(onApplied);
232
- return existing.promise;
233
- }
327
+ const sessionId = target.sessionId;
328
+ const existing = state.flights.get(sessionId);
329
+ if (existing) return existing.promise;
234
330
  const currentName = manager.getSessionName?.() ?? target.sessionName;
235
- if (process.env.PI_NO_TITLE || currentName) return Promise.resolve(null);
236
- const callbacks = new Set<(title: string) => void>();
237
- if (onApplied) callbacks.add(onApplied);
238
- const pending = generate(firstMessage, target.modelRegistry, target.settings, target.sessionId, target.model)
239
- .then(async title => {
240
- if (!title || !(await target.setSessionName(title, "auto"))) return null;
241
- for (const callback of [...callbacks, ...state.listeners])
331
+ const automatic = manager.getAutomaticTitleState?.();
332
+ if (
333
+ process.env.PI_NO_TITLE ||
334
+ manager.titleSource === "user" ||
335
+ (currentName && automatic?.status !== "provisional")
336
+ )
337
+ return Promise.resolve(null);
338
+ const history = () =>
339
+ manager.getBranch?.() ??
340
+ (target.messages ?? []).map((message, index) => ({
341
+ type: "message" as const,
342
+ id: `${index}:${message.timestamp}`,
343
+ parentId: null,
344
+ timestamp: new Date(message.timestamp).toISOString(),
345
+ message,
346
+ }));
347
+ const exchanges = completedTitleExchanges(history());
348
+ const latest = exchanges.at(-1);
349
+ if (!latest || automatic?.exchangeId === latest.id || !manager.setAutomaticSessionName) return Promise.resolve(null);
350
+ const revision = manager.titleRevision ?? 0;
351
+ const source = titleSource(exchanges, currentName);
352
+ const pending = generate(source, target.modelRegistry, target.settings, sessionId, target.model)
353
+ .then(async result => {
354
+ if (!result || target.sessionId !== sessionId || manager.titleRevision !== revision) return null;
355
+ // A still-generic refinement candidate must not replace the opening name or consume its one refinement.
356
+ if (currentName && result.provisional) {
357
+ await manager.setAutomaticSessionName!(
358
+ currentName,
359
+ { status: "provisional", exchangeId: latest.id },
360
+ sessionId,
361
+ revision,
362
+ );
363
+ return null;
364
+ }
365
+ const title = sanitizeGeneratedSessionTitle(JSON.stringify({ title: result.title }));
366
+ if (
367
+ !title ||
368
+ !(await manager.setAutomaticSessionName!(
369
+ title,
370
+ {
371
+ status: result.provisional ? "provisional" : "refined",
372
+ exchangeId: latest.id,
373
+ },
374
+ sessionId,
375
+ revision,
376
+ ))
377
+ )
378
+ return null;
379
+ if (target.sessionId !== sessionId || manager.titleRevision !== revision + 1) return null;
380
+ for (const listener of state.listeners)
242
381
  try {
243
- callback(title);
382
+ listener(title);
244
383
  } catch {
245
- // Title generation is presentation-only and must not fail the user's turn.
384
+ /* Presentation-only. */
246
385
  }
247
386
  return title;
248
387
  })
249
388
  .catch(() => null)
250
389
  .finally(() => {
251
- if (state.flights.get(target.sessionId)?.promise === pending) state.flights.delete(target.sessionId);
390
+ if (state.flights.get(sessionId)?.promise === pending) state.flights.delete(sessionId);
391
+ // Another exchange may finish during inference. Retry only for new history, never for a failed request.
392
+ if (target.sessionId === sessionId && completedTitleExchanges(history()).at(-1)?.id !== latest.id)
393
+ void coordinateSessionTitle(target, generate);
252
394
  });
253
- state.flights.set(target.sessionId, { promise: pending, callbacks });
395
+ state.flights.set(sessionId, { promise: pending });
254
396
  return pending;
255
397
  }
256
398
 
@@ -1,13 +0,0 @@
1
- <system-reminder>
2
- Before doing substantive work on the upcoming user request, create a comprehensive phased todo first.
3
-
4
- You **MUST** call `todo_write` first in this turn.
5
- You **MUST** initialize the todo list with a single `replace` op.
6
- You **MUST** cover the entire request from investigation through implementation and verification — not just the next immediate step.
7
- You **MUST** make task descriptions specific enough that a future turn can execute them without re-planning.
8
- You **MUST** keep task `content` to a short label (5-10 words). Put file paths, implementation steps, and specifics in `details`.
9
- You **MUST** keep exactly one task `in_progress` and all later tasks `pending`.
10
-
11
- After the initial `todo_write` call succeeds, continue with the user's request in the same turn.
12
- Do not emit another `todo_write` call unless task state materially changed.
13
- </system-reminder>