talon-agent 3.34.0 → 3.34.1

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.
@@ -115,261 +115,302 @@ export interface RemoteModelPresentation {
115
115
  ): string;
116
116
  }
117
117
 
118
- export function createRemoteModelPresentation(
119
- options: RemotePresentationOptions,
120
- ): RemoteModelPresentation {
121
- const {
122
- label,
123
- getCatalog,
124
- maxCallbackIdLength,
125
- allowCallbackSeparators,
126
- quickPickLimit,
127
- groupThreshold = 60,
128
- } = options;
118
+ /** `RemotePresentationOptions` with defaults applied — what every renderer takes. */
119
+ type Knobs = Required<RemotePresentationOptions>;
129
120
 
130
- function isCallbackSafeModelID(modelID: string): boolean {
131
- if (modelID.length > maxCallbackIdLength) return false;
132
- if (allowCallbackSeparators) return true;
133
- return !modelID.includes(":") && !modelID.includes("/");
134
- }
121
+ const LIST_PREVIEW = 24;
122
+ const FREE_PREVIEW = 8;
135
123
 
136
- function getQuickPickModels(
137
- catalog: RemoteModelCatalog,
138
- currentModelID?: string,
139
- ): Array<RemoteModelCatalogEntry> {
140
- const picks: Array<RemoteModelCatalogEntry> = [];
141
- const seen = new Set<string>();
124
+ function isCallbackSafeModelID(knobs: Knobs, modelID: string): boolean {
125
+ if (modelID.length > knobs.maxCallbackIdLength) return false;
126
+ if (knobs.allowCallbackSeparators) return true;
127
+ return !modelID.includes(":") && !modelID.includes("/");
128
+ }
142
129
 
143
- const tryAdd = (model: RemoteModelCatalogEntry | undefined) => {
144
- if (!model || seen.has(model.id) || !isCallbackSafeModelID(model.id))
145
- return;
146
- picks.push(model);
147
- seen.add(model.id);
148
- };
130
+ /** `• <selection> — <name> (<provider> · <price> · <ctx> · <availability>)` */
131
+ function describeModel(
132
+ model: RemoteModelCatalogEntry,
133
+ catalog: RemoteModelCatalog,
134
+ ): string {
135
+ const tags = [
136
+ model.providerName,
137
+ model.free ? "free" : `$${model.costInput}/${model.costOutput}`,
138
+ `${formatCtxWindow(model.contextWindow)} ctx`,
139
+ getAvailabilityLabel(model),
140
+ ];
141
+ return `• ${getRemoteModelSelectionValue(model, catalog)} — ${model.name} (${tags.join(" · ")})`;
142
+ }
149
143
 
150
- if (currentModelID) {
151
- const currentModel = resolveRemoteModelInput(currentModelID, catalog);
152
- if (currentModel.kind === "exact") {
153
- tryAdd(currentModel.model);
154
- } else if (currentModel.kind === "ambiguous") {
155
- tryAdd(currentModel.matches[0]);
156
- }
157
- }
144
+ function describeProvider(
145
+ provider: RemoteModelCatalog["providers"][number],
146
+ ): string {
147
+ const detail = provider.connected
148
+ ? "connected"
149
+ : provider.loginRequired
150
+ ? `login: ${provider.authMethods.join(", ")}`
151
+ : provider.envRequired
152
+ ? `env: ${provider.envKeys.join(", ")}`
153
+ : provider.source;
154
+ return `• ${provider.name} (${provider.id}) — ${detail} · ${provider.modelCount} models`;
155
+ }
158
156
 
159
- for (const model of catalog.connectedFreeModels) {
160
- if (picks.length >= quickPickLimit) break;
161
- tryAdd(model);
162
- }
157
+ function getQuickPickModels(
158
+ knobs: Knobs,
159
+ catalog: RemoteModelCatalog,
160
+ currentModelID?: string,
161
+ ): Array<RemoteModelCatalogEntry> {
162
+ const picks: Array<RemoteModelCatalogEntry> = [];
163
+ const seen = new Set<string>();
164
+
165
+ const tryAdd = (model: RemoteModelCatalogEntry | undefined) => {
166
+ if (!model || seen.has(model.id) || !isCallbackSafeModelID(knobs, model.id))
167
+ return;
168
+ picks.push(model);
169
+ seen.add(model.id);
170
+ };
163
171
 
164
- if (picks.length < quickPickLimit) {
165
- for (const model of catalog.connectedModels) {
166
- if (picks.length >= quickPickLimit) break;
167
- tryAdd(model);
168
- }
172
+ if (currentModelID) {
173
+ const currentModel = resolveRemoteModelInput(currentModelID, catalog);
174
+ if (currentModel.kind === "exact") {
175
+ tryAdd(currentModel.model);
176
+ } else if (currentModel.kind === "ambiguous") {
177
+ tryAdd(currentModel.matches[0]);
169
178
  }
179
+ }
170
180
 
171
- return picks;
181
+ for (const model of catalog.connectedFreeModels) {
182
+ if (picks.length >= knobs.quickPickLimit) break;
183
+ tryAdd(model);
172
184
  }
173
185
 
174
- async function getSettingsPresentation(
175
- activeModel: string,
176
- options: RemotePickerOptions = {},
177
- ): Promise<RemotePickerResult> {
178
- const callbackPrefix = options.callbackPrefix ?? "settings:model:";
179
- const navPrefix = options.navCallbackPrefix ?? "settings:models";
180
- const catalog = await getCatalog();
181
- const current = getRemoteModelInfo(catalog, activeModel);
186
+ if (picks.length < knobs.quickPickLimit) {
187
+ for (const model of catalog.connectedModels) {
188
+ if (picks.length >= knobs.quickPickLimit) break;
189
+ tryAdd(model);
190
+ }
191
+ }
182
192
 
183
- const filter = options.filter === "free" ? "free" : "all";
184
- const selectable = catalog.connectedModels.filter((m) =>
185
- isCallbackSafeModelID(m.id),
186
- );
187
- const freeCount = selectable.filter((m) => m.free).length;
188
- const scoped = selectable.filter(
189
- (m) =>
190
- (filter === "all" || m.free) &&
191
- (!options.provider || m.providerID === options.provider),
192
- );
193
+ return picks;
194
+ }
193
195
 
194
- // A remote catalog can run to hundreds of models, and only the first
195
- // page's worth ever fit on screen — so offer the provider list as the
196
- // way in when nothing narrower was asked for and the flat list would be
197
- // unreadable anyway.
198
- const providers = catalog.connectedProviders;
199
- const asGroups =
200
- !options.provider &&
201
- providers.length > 1 &&
202
- scoped.length > groupThreshold;
196
+ /** One page of the picker: provider chips when the flat list is unreadable, models otherwise. */
197
+ type PickerPage = {
198
+ asGroups: boolean;
199
+ page: number;
200
+ totalPages: number;
201
+ providers: RemoteModelCatalog["connectedProviders"];
202
+ models: Array<RemoteModelCatalogEntry>;
203
+ };
203
204
 
204
- const pageSize = Math.max(1, options.pageSize ?? quickPickLimit);
205
- const source = asGroups ? providers : scoped;
206
- const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
207
- const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
208
- const slice = source.slice((page - 1) * pageSize, page * pageSize);
205
+ function pagePicker(
206
+ knobs: Knobs,
207
+ catalog: RemoteModelCatalog,
208
+ scoped: Array<RemoteModelCatalogEntry>,
209
+ options: RemotePickerOptions,
210
+ ): PickerPage {
211
+ // A remote catalog can run to hundreds of models, and only the first
212
+ // page's worth ever fit on screen — so offer the provider list as the
213
+ // way in when nothing narrower was asked for and the flat list would be
214
+ // unreadable anyway.
215
+ const providers = catalog.connectedProviders;
216
+ const asGroups =
217
+ !options.provider &&
218
+ providers.length > 1 &&
219
+ scoped.length > knobs.groupThreshold;
220
+ const pageSize = Math.max(1, options.pageSize ?? knobs.quickPickLimit);
221
+ const source = asGroups ? providers : scoped;
222
+ const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
223
+ const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
224
+ const from = (page - 1) * pageSize;
225
+ return {
226
+ asGroups,
227
+ page,
228
+ totalPages,
229
+ providers: asGroups ? providers.slice(from, from + pageSize) : [],
230
+ models: asGroups ? [] : scoped.slice(from, from + pageSize),
231
+ };
232
+ }
209
233
 
210
- const modelButtons: Array<ModelButton> = asGroups
211
- ? (slice as typeof providers).map((p) => ({
212
- text: `${p.name} (${p.modelCount})`,
213
- callback_data: `${navPrefix}:provider:${p.id}`,
214
- }))
215
- : (slice as typeof scoped).map((m) => {
216
- const btnLabel =
217
- m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
218
- const txt = m.free ? `${btnLabel} ★` : btnLabel;
219
- const sel =
220
- current &&
221
- m.id === current.id &&
222
- m.providerID === current.providerID;
223
- return {
224
- text: sel ? `✓ ${txt}` : txt,
225
- callback_data: `${callbackPrefix}${m.id}`,
226
- };
227
- });
228
- if (!asGroups) {
229
- modelButtons.push({
230
- text: "Reset",
231
- callback_data: `${callbackPrefix}reset`,
232
- });
233
- }
234
+ function pickerButtons(
235
+ paged: PickerPage,
236
+ current: RemoteModelCatalogEntry | undefined,
237
+ callbackPrefix: string,
238
+ navPrefix: string,
239
+ ): Array<ModelButton> {
240
+ if (paged.asGroups) {
241
+ return paged.providers.map((p) => ({
242
+ text: `${p.name} (${p.modelCount})`,
243
+ callback_data: `${navPrefix}:provider:${p.id}`,
244
+ }));
245
+ }
246
+ const buttons = paged.models.map((m) => {
247
+ const btnLabel =
248
+ m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
249
+ const txt = m.free ? `${btnLabel} ★` : btnLabel;
250
+ const sel =
251
+ current && m.id === current.id && m.providerID === current.providerID;
252
+ return {
253
+ text: sel ? `✓ ${txt}` : txt,
254
+ callback_data: `${callbackPrefix}${m.id}`,
255
+ };
256
+ });
257
+ buttons.push({ text: "Reset", callback_data: `${callbackPrefix}reset` });
258
+ return buttons;
259
+ }
234
260
 
235
- const details: Array<string> = [];
236
- if (current) {
237
- details.push(
238
- `Provider: ${current.providerName} · ${getAvailabilityLabel(current)}`,
239
- );
240
- details.push(
241
- `Context: ${formatCtxWindow(current.contextWindow)} · reasoning ${current.reasoning ? "yes" : "no"} · tools ${current.toolcall ? "yes" : "no"}`,
242
- );
243
- }
244
- const np = catalog.connectedProviders.length;
245
- const nm = catalog.connectedModels.length;
261
+ function pickerDetails(
262
+ knobs: Knobs,
263
+ catalog: RemoteModelCatalog,
264
+ current: RemoteModelCatalogEntry | undefined,
265
+ ): Array<string> {
266
+ const details: Array<string> = [];
267
+ if (current) {
246
268
  details.push(
247
- `${label}: ${np} provider${np === 1 ? "" : "s"} connected · ${nm} model${nm === 1 ? "" : "s"} usable`,
269
+ `Provider: ${current.providerName} · ${getAvailabilityLabel(current)}`,
270
+ );
271
+ details.push(
272
+ `Context: ${formatCtxWindow(current.contextWindow)} · reasoning ${current.reasoning ? "yes" : "no"} · tools ${current.toolcall ? "yes" : "no"}`,
248
273
  );
249
- if (catalog.loginProviders.length > 0) {
250
- const preview = catalog.loginProviders
251
- .slice(0, 4)
252
- .map((p) => p.name)
253
- .join(", ");
254
- details.push(
255
- `Login available: ${preview}${catalog.loginProviders.length > 4 ? "…" : ""}`,
256
- );
257
- }
258
- details.push("Hint: use /model <name> to switch.");
259
- return {
260
- modelButtons,
261
- modelDetails: details,
262
- view: asGroups ? "groups" : "models",
263
- page,
264
- totalPages,
265
- filter,
266
- freeCount,
267
- totalCount: selectable.length,
268
- ...(options.provider ? { provider: options.provider } : {}),
269
- };
270
274
  }
275
+ const np = catalog.connectedProviders.length;
276
+ const nm = catalog.connectedModels.length;
277
+ details.push(
278
+ `${knobs.label}: ${np} provider${np === 1 ? "" : "s"} connected · ${nm} model${nm === 1 ? "" : "s"} usable`,
279
+ );
280
+ if (catalog.loginProviders.length > 0) {
281
+ const preview = catalog.loginProviders
282
+ .slice(0, 4)
283
+ .map((p) => p.name)
284
+ .join(", ");
285
+ details.push(
286
+ `Login available: ${preview}${catalog.loginProviders.length > 4 ? "…" : ""}`,
287
+ );
288
+ }
289
+ details.push("Hint: use /model <name> to switch.");
290
+ return details;
291
+ }
271
292
 
272
- async function renderModelSummary(
273
- activeModel: string,
274
- defaultModel: string,
275
- ): Promise<{ text: string; quickButtons: Array<ModelButton> }> {
276
- const { modelButtons, modelDetails } =
277
- await getSettingsPresentation(activeModel);
278
- const catalog = await getCatalog();
279
- const current = getRemoteModelInfo(catalog, activeModel);
280
- const currentLabel = current
281
- ? getRemoteModelSelectionValue(current, catalog)
282
- : activeModel;
283
- const freePreview = catalog.connectedFreeModels.slice(0, 8);
293
+ async function getSettingsPresentation(
294
+ knobs: Knobs,
295
+ activeModel: string,
296
+ options: RemotePickerOptions = {},
297
+ ): Promise<RemotePickerResult> {
298
+ const callbackPrefix = options.callbackPrefix ?? "settings:model:";
299
+ const navPrefix = options.navCallbackPrefix ?? "settings:models";
300
+ const catalog = await knobs.getCatalog();
301
+ const current = getRemoteModelInfo(catalog, activeModel);
284
302
 
285
- const lines = [
286
- `Model: ${currentLabel}${activeModel === defaultModel ? " (default)" : ""}`,
287
- ...modelDetails,
288
- ];
289
- if (freePreview.length > 0) {
290
- lines.push("", "Free now");
291
- for (const m of freePreview) {
292
- const tags = [
293
- m.providerName,
294
- m.free ? "free" : `$${m.costInput}/${m.costOutput}`,
295
- `${formatCtxWindow(m.contextWindow)} ctx`,
296
- getAvailabilityLabel(m),
297
- ];
298
- lines.push(
299
- `• ${getRemoteModelSelectionValue(m, catalog)} — ${m.name} (${tags.join(" · ")})`,
300
- );
301
- }
302
- }
303
- return { text: lines.join("\n"), quickButtons: modelButtons };
303
+ const filter = options.filter === "free" ? "free" : "all";
304
+ const selectable = catalog.connectedModels.filter((m) =>
305
+ isCallbackSafeModelID(knobs, m.id),
306
+ );
307
+ const freeCount = selectable.filter((m) => m.free).length;
308
+ const scoped = selectable.filter(
309
+ (m) =>
310
+ (filter === "all" || m.free) &&
311
+ (!options.provider || m.providerID === options.provider),
312
+ );
313
+ const paged = pagePicker(knobs, catalog, scoped, options);
314
+ return {
315
+ modelButtons: pickerButtons(paged, current, callbackPrefix, navPrefix),
316
+ modelDetails: pickerDetails(knobs, catalog, current),
317
+ view: paged.asGroups ? "groups" : "models",
318
+ page: paged.page,
319
+ totalPages: paged.totalPages,
320
+ filter,
321
+ freeCount,
322
+ totalCount: selectable.length,
323
+ ...(options.provider ? { provider: options.provider } : {}),
324
+ };
325
+ }
326
+
327
+ async function renderModelSummary(
328
+ knobs: Knobs,
329
+ activeModel: string,
330
+ defaultModel: string,
331
+ ): Promise<{ text: string; quickButtons: Array<ModelButton> }> {
332
+ const { modelButtons, modelDetails } = await getSettingsPresentation(
333
+ knobs,
334
+ activeModel,
335
+ );
336
+ const catalog = await knobs.getCatalog();
337
+ const current = getRemoteModelInfo(catalog, activeModel);
338
+ const currentLabel = current
339
+ ? getRemoteModelSelectionValue(current, catalog)
340
+ : activeModel;
341
+ const freePreview = catalog.connectedFreeModels.slice(0, FREE_PREVIEW);
342
+
343
+ const lines = [
344
+ `Model: ${currentLabel}${activeModel === defaultModel ? " (default)" : ""}`,
345
+ ...modelDetails,
346
+ ];
347
+ if (freePreview.length > 0) {
348
+ lines.push("", "Free now");
349
+ for (const m of freePreview) lines.push(describeModel(m, catalog));
304
350
  }
351
+ return { text: lines.join("\n"), quickButtons: modelButtons };
352
+ }
305
353
 
306
- async function renderModelList(
307
- mode: "free" | "all" | "providers",
308
- ): Promise<string> {
309
- const catalog = await getCatalog();
310
- if (mode === "providers") {
311
- const lines = [`${label} Providers`];
312
- for (const p of catalog.providers.slice(0, 24)) {
313
- const detail = p.connected
314
- ? "connected"
315
- : p.loginRequired
316
- ? `login: ${p.authMethods.join(", ")}`
317
- : p.envRequired
318
- ? `env: ${p.envKeys.join(", ")}`
319
- : p.source;
320
- lines.push(
321
- `• ${p.name} (${p.id}) — ${detail} · ${p.modelCount} models`,
322
- );
323
- }
324
- if (catalog.providers.length > 24)
325
- lines.push(`…and ${catalog.providers.length - 24} more`);
326
- return lines.join("\n");
327
- }
328
- const source =
329
- mode === "free" ? catalog.connectedFreeModels : catalog.connectedModels;
330
- const title =
331
- mode === "free" ? "Connected Free Models" : "Connected Models";
332
- const lines = [title];
333
- for (const m of source.slice(0, 24)) {
334
- const tags = [
335
- m.providerName,
336
- m.free ? "free" : `$${m.costInput}/${m.costOutput}`,
337
- `${formatCtxWindow(m.contextWindow)} ctx`,
338
- getAvailabilityLabel(m),
339
- ];
340
- lines.push(
341
- `• ${getRemoteModelSelectionValue(m, catalog)} — ${m.name} (${tags.join(" · ")})`,
342
- );
354
+ async function renderModelList(
355
+ knobs: Knobs,
356
+ mode: "free" | "all" | "providers",
357
+ ): Promise<string> {
358
+ const catalog = await knobs.getCatalog();
359
+ if (mode === "providers") {
360
+ const lines = [`${knobs.label} Providers`];
361
+ for (const p of catalog.providers.slice(0, LIST_PREVIEW)) {
362
+ lines.push(describeProvider(p));
343
363
  }
344
- if (source.length > 24) lines.push(`…and ${source.length - 24} more`);
364
+ if (catalog.providers.length > LIST_PREVIEW)
365
+ lines.push(`…and ${catalog.providers.length - LIST_PREVIEW} more`);
345
366
  return lines.join("\n");
346
367
  }
347
-
348
- function formatSelectionError(
349
- input: string,
350
- resolution: Exclude<RemoteModelResolution, { kind: "exact" }>,
351
- catalog: RemoteModelCatalog,
352
- ): string {
353
- if (resolution.kind === "missing")
354
- return `No ${label} model matched "${input}".`;
355
- const preview = resolution.matches
356
- .slice(0, 6)
357
- .map((m) => {
358
- const provider =
359
- m.providerName === m.providerID
360
- ? m.providerName
361
- : `${m.providerName} / ${m.providerID}`;
362
- return `${getRemoteModelSelectionValue(m, catalog)} — ${provider} (${getAvailabilityLabel(m)})`;
363
- })
364
- .join(", ");
365
- return `Model query "${input}" is ambiguous. Try one of: ${preview}`;
368
+ const source =
369
+ mode === "free" ? catalog.connectedFreeModels : catalog.connectedModels;
370
+ const title = mode === "free" ? "Connected Free Models" : "Connected Models";
371
+ const lines = [title];
372
+ for (const m of source.slice(0, LIST_PREVIEW)) {
373
+ lines.push(describeModel(m, catalog));
366
374
  }
375
+ if (source.length > LIST_PREVIEW)
376
+ lines.push(`…and ${source.length - LIST_PREVIEW} more`);
377
+ return lines.join("\n");
378
+ }
379
+
380
+ function formatSelectionError(
381
+ knobs: Knobs,
382
+ input: string,
383
+ resolution: Exclude<RemoteModelResolution, { kind: "exact" }>,
384
+ catalog: RemoteModelCatalog,
385
+ ): string {
386
+ if (resolution.kind === "missing")
387
+ return `No ${knobs.label} model matched "${input}".`;
388
+ const preview = resolution.matches
389
+ .slice(0, 6)
390
+ .map((m) => {
391
+ const provider =
392
+ m.providerName === m.providerID
393
+ ? m.providerName
394
+ : `${m.providerName} / ${m.providerID}`;
395
+ return `${getRemoteModelSelectionValue(m, catalog)} — ${provider} (${getAvailabilityLabel(m)})`;
396
+ })
397
+ .join(", ");
398
+ return `Model query "${input}" is ambiguous. Try one of: ${preview}`;
399
+ }
367
400
 
401
+ export function createRemoteModelPresentation(
402
+ options: RemotePresentationOptions,
403
+ ): RemoteModelPresentation {
404
+ const knobs: Knobs = { groupThreshold: 60, ...options };
368
405
  return {
369
- getQuickPickModels,
370
- getSettingsPresentation,
371
- renderModelSummary,
372
- renderModelList,
373
- formatSelectionError,
406
+ getQuickPickModels: (catalog, currentModelID) =>
407
+ getQuickPickModels(knobs, catalog, currentModelID),
408
+ getSettingsPresentation: (activeModel, pickerOptions) =>
409
+ getSettingsPresentation(knobs, activeModel, pickerOptions),
410
+ renderModelSummary: (activeModel, defaultModel) =>
411
+ renderModelSummary(knobs, activeModel, defaultModel),
412
+ renderModelList: (mode) => renderModelList(knobs, mode),
413
+ formatSelectionError: (input, resolution, catalog) =>
414
+ formatSelectionError(knobs, input, resolution, catalog),
374
415
  };
375
416
  }
@@ -21,7 +21,7 @@ import {
21
21
  TALON_MCP_SERVER_NAME,
22
22
  TALON_PLUGIN_MCP_SERVER_NAME,
23
23
  getChatMcpServerName,
24
- safeMcpNamePart,
24
+ getPluginMcpServerPrefix,
25
25
  } from "./mcp.js";
26
26
 
27
27
  /**
@@ -52,7 +52,7 @@ import {
52
52
  */
53
53
  export function buildPermissionRuleset(chatId: string): RemotePermissionRule[] {
54
54
  const ourServerName = getChatMcpServerName(chatId);
55
- const ourPluginPrefix = `${TALON_PLUGIN_MCP_SERVER_NAME}-${safeMcpNamePart(chatId, "chat")}-`;
55
+ const ourPluginPrefix = getPluginMcpServerPrefix(chatId);
56
56
  return [
57
57
  { permission: "tool", pattern: `${ourServerName}_*`, action: "allow" },
58
58
  {
@@ -249,10 +249,23 @@ function estimateTokens(text: string): number {
249
249
  return Math.ceil(text.length / 4);
250
250
  }
251
251
 
252
+ /**
253
+ * (label, model) pairs already warned about this process. A recurring
254
+ * caller (heartbeat, dream) builds the same prompt on every run, so the
255
+ * first warning says everything the rest would.
256
+ */
257
+ const warnedCacheMinimums = new Set<string>();
258
+
259
+ /** Test seam. */
260
+ export function resetCacheMinimumWarnings(): void {
261
+ warnedCacheMinimums.clear();
262
+ }
263
+
252
264
  /**
253
265
  * Warn when a prompt is too small to be cacheable on its model. No-op when
254
266
  * the model's floor is unknown or the prompt clears it. `label` names the
255
- * caller (e.g. `"dream"`) so the warning points somewhere.
267
+ * caller (e.g. `"dream"`) so the warning points somewhere. Warns once per
268
+ * (label, model) per process.
256
269
  */
257
270
  export function warnIfBelowCacheMinimum(
258
271
  label: string,
@@ -263,6 +276,9 @@ export function warnIfBelowCacheMinimum(
263
276
  if (min === undefined) return;
264
277
  const estimated = estimateTokens(prompt);
265
278
  if (estimated >= min) return;
279
+ const key = `${label}\0${model}`;
280
+ if (warnedCacheMinimums.has(key)) return;
281
+ warnedCacheMinimums.add(key);
266
282
  logWarn(
267
283
  "agent",
268
284
  `[${label}] prompt ~${estimated} tokens is below ${model}'s ${min}-token ` +
@@ -23,6 +23,7 @@ import {
23
23
  import { classify } from "../../core/errors.js";
24
24
  import type { ChatRunParams } from "../../core/agent-runtime/capabilities.js";
25
25
  import type { QueryParams, QueryResult } from "./handler-types.js";
26
+ import { buildResultEvents } from "./result-events.js";
26
27
 
27
28
  const SENTINEL = Symbol("handler-to-events:sentinel");
28
29
 
@@ -205,21 +206,15 @@ export async function* handlerToEvents(
205
206
  return;
206
207
  }
207
208
 
208
- const usage = {
209
- inputTokens: result.inputTokens,
210
- outputTokens: result.outputTokens,
211
- cacheRead: result.cacheRead,
212
- cacheWrite: result.cacheWrite,
213
- modelId: params.model.id,
214
- };
215
- yield { type: "usage", usage };
216
- yield {
217
- type: "completed",
218
- result: {
219
- text: result.text,
220
- durationMs: result.durationMs,
221
- usage,
222
- modelId: params.model.id,
209
+ yield* buildResultEvents({
210
+ text: result.text,
211
+ durationMs: result.durationMs,
212
+ usage: {
213
+ inputTokens: result.inputTokens,
214
+ outputTokens: result.outputTokens,
215
+ cacheRead: result.cacheRead,
216
+ cacheWrite: result.cacheWrite,
223
217
  },
224
- };
218
+ modelId: params.model.id,
219
+ });
225
220
  }