@cubicecho/agent-core 4.3.0 → 4.5.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.
package/README.md CHANGED
@@ -268,6 +268,24 @@ const page: SideTaskInput = [
268
268
  const text = await ask(config, visionModel, "You are an OCR engine.", page);
269
269
  ```
270
270
 
271
+ ### Telling a finished reply from a cut-off one
272
+
273
+ `ask` returns the text alone, so a reply that ran into `maxTokens` reads like one that finished.
274
+ Where the reply is the product, `askReply` takes the same arguments and also returns the choice's
275
+ `finish_reason` as the server sent it, or `undefined` where it sent none. A model looping on a page
276
+ writes until the ceiling stops it, and a transcript saved from that is worse than none:
277
+
278
+ ```ts
279
+ const { text, finishReason } = await askReply(config, visionModel, "You are an OCR engine.", page);
280
+ if (finishReason === FinishReason.Length) {
281
+ // cut off: retry with a higher ceiling, or keep the OCR text already there
282
+ }
283
+ ```
284
+
285
+ Nothing is thrown for a cut-off reply, since whether one is still worth keeping is the caller's
286
+ call. `askJson` needs no such variant: a reply cut off before its JSON closed does not parse, so it
287
+ comes back `undefined`, as a reply with no JSON in it does.
288
+
271
289
  ## Sizing a request before sending it
272
290
 
273
291
  `runTurn` will refuse a request that cannot fit rather than spending a round trip finding out:
@@ -797,9 +815,15 @@ once `used` (the last turn's prompt tokens, or the estimate) is past three quart
797
815
  The kept tail fills at most 35% of it and starts on a user message, since a transcript resuming
798
816
  mid-exchange is one servers refuse; leading system prompts are never folded, and an earlier
799
817
  summary is continued rather than summarised. `compactTranscript` writes the summary and tells
800
- `beforeCompact` hooks what is going while it does. Because the cut lands on
801
- a user message, one long tool run under a single question has nothing to fold — pruning is what
802
- keeps that one going.
818
+ `beforeCompact` hooks what is going while it does.
819
+
820
+ A run that is one question and a long tool loop has no user message to cut at, so there the plan
821
+ cuts inside the loop instead: the question is kept whole (the plan's `question`), the calls and
822
+ results after it are folded up to an assistant message, and the request becomes the summary, the
823
+ question, then the rest of the loop — every tool result still after its call. A transcript with a
824
+ user message to cut at is planned as before. `runAgentLoop({ compact: true })` folds such a run on
825
+ its own; a host that numbers messages across steps passes `hooks.offset`, the stored index of the
826
+ run's first message, so the ids `beforeCompact` is told match its own.
803
827
 
804
828
  ```ts
805
829
  beforeStep: async (messages, step) => {
@@ -932,8 +956,8 @@ leading `system` messages, and reads an earlier summary back out of a `SUMMARY_L
932
956
  them. A host whose system prompt is a separate argument and whose summary is a column has neither
933
957
  in the array, and knows both exactly. Given them, nothing is scanned.
934
958
 
935
- `applyCompaction(messages, fold)` is the way back — the summary as a `system` message, then
936
- everything from `through` — and it writes the same `SUMMARY_LEAD` `planCompaction` looks for, so
959
+ `applyCompaction(messages, fold)` is the way back — the summary as a `system` message, then the
960
+ fold's `question` if it was made inside one, then everything from `through` — and it writes the same `SUMMARY_LEAD` `planCompaction` looks for, so
937
961
  the next fold continues those notes rather than summarising them a second time. No fold yet hands
938
962
  back the messages themselves.
939
963
 
@@ -125,9 +125,17 @@ export interface CompactionOptions {
125
125
  export interface CompactionPlan {
126
126
  /** The first message folded away. Everything before it is a system prompt and stays. */
127
127
  from: number;
128
- /** The first message kept whole, always a user message. */
128
+ /**
129
+ * The first message kept whole: a user message, or an assistant message on a fold made inside
130
+ * one question's tool loop (see `question`).
131
+ */
129
132
  cut: number;
130
- /** The messages from `from` to `cut`, the ones the summary replaces. */
133
+ /**
134
+ * The user message kept whole between the summary and `cut`, on a fold made inside its tool
135
+ * loop. Absent on a fold that cuts at a user message.
136
+ */
137
+ question?: number;
138
+ /** The messages from `from` to `cut`, `question` left out: the ones the summary replaces. */
131
139
  toSummarise: Message[];
132
140
  /** The summary an earlier compaction left, which this one continues. */
133
141
  previous?: string;
@@ -155,6 +163,13 @@ export interface CompactionPlan {
155
163
  * than summarised as if it were conversation. No plan comes back when the window is not full
156
164
  * enough, or when the only legal cut folds too little to pay for the summary.
157
165
  *
166
+ * A transcript that is one question and a long tool loop has no user message to cut at, so where
167
+ * no user cut folds anything the plan cuts inside the last question's loop instead: the question
168
+ * is kept whole as `question`, the assistant and tool messages after it are folded up to an
169
+ * assistant message, and the tail resumes there — every tool result in it still follows its call.
170
+ * `applyCompaction` sends the question after the summary. A transcript with a user cut is planned
171
+ * exactly as it was.
172
+ *
158
173
  * Both of those are recovered by reading the transcript, which is what a host whose array holds
159
174
  * everything it sends has to do. A host that keeps its fold as a record beside an append-only
160
175
  * transcript — its system prompt a separate argument, no summary message in the array at all —
@@ -206,6 +221,11 @@ export interface CompactionRecord {
206
221
  summary: string;
207
222
  /** Index into the transcript the plan was made for: the first message still sent whole. */
208
223
  through: number;
224
+ /**
225
+ * The user message sent whole after the summary and ahead of `through`, on a fold made inside
226
+ * that question's tool loop. Absent on a fold that cut at a user message.
227
+ */
228
+ question?: number;
209
229
  /** ISO 8601, so the chat can show where history was folded and how stale the notes are. */
210
230
  at: string;
211
231
  }
@@ -213,7 +233,8 @@ export interface CompactionRecord {
213
233
  export interface CompactionRunOptions {
214
234
  /**
215
235
  * Hooks to tell. `context` is extended with `compacting` and `range`, whose indexes are the
216
- * plan's — and so the host's own, for a plan made over a stored transcript. `honourVeto` waits
236
+ * plan's — and so the host's own, for a plan made over a stored transcript. `compacting` leaves
237
+ * out a `question` the fold keeps. `honourVeto` waits
217
238
  * for the hooks and lets one stop the compaction; without it they add no latency.
218
239
  */
219
240
  hooks?: {
@@ -221,6 +242,14 @@ export interface CompactionRunOptions {
221
242
  context: HookContext;
222
243
  onNote?: OnNote;
223
244
  honourVeto?: boolean;
245
+ /**
246
+ * What the transcript's first message is numbered as in the `compacting` uuids: the host's
247
+ * stored index of `messages[0]`, for a host that numbers messages across steps or requests.
248
+ * See `turnMessages`.
249
+ *
250
+ * @defaultValue `0`
251
+ */
252
+ offset?: number;
224
253
  };
225
254
  /**
226
255
  * The window is already exceeded — the caller caught a `ContextOverflow`, or is compacting to
@@ -256,7 +285,8 @@ export declare function runCompaction(messages: Message[], plan: CompactionPlan,
256
285
  * Absent, the leading `system` messages are found by scanning, and zero of them is the ordinary
257
286
  * case for a host whose system prompt is a separate argument.
258
287
  * @returns `messages` itself when there is no record or its summary is blank, otherwise a new
259
- * array: the kept head, one summary message, then everything from the record's `through` on.
288
+ * array: the kept head, one summary message, the record's `question` when it has one and the head
289
+ * does not already hold it, then everything from the record's `through` on.
260
290
  *
261
291
  * @remarks
262
292
  * The inverse of storing a `CompactionRecord`, and the shape `planCompaction` expects to meet
@@ -264,7 +294,7 @@ export declare function runCompaction(messages: Message[], plan: CompactionPlan,
264
294
  * continues these notes rather than summarising them a second time. Any earlier summary message in
265
295
  * the kept head is dropped, since the record's already contains it.
266
296
  */
267
- export declare function applyCompaction(messages: Message[], record?: Pick<CompactionRecord, 'summary' | 'through'>, { from }?: {
297
+ export declare function applyCompaction(messages: Message[], record?: Pick<CompactionRecord, 'summary' | 'through' | 'question'>, { from }?: {
268
298
  from?: number;
269
299
  }): Message[];
270
300
  /**
@@ -278,7 +308,8 @@ export declare function applyCompaction(messages: Message[], record?: Pick<Compa
278
308
  * prompts, when the host keeps them in the array. Zero is the stored-fold case,
279
309
  * where the summary is the request's first message.
280
310
  * @returns The index in the request. One before the record's `through` gives `head`, where the
281
- * summary sits — an index among the kept head messages themselves included.
311
+ * summary sits — an index among the kept head messages themselves included — except the record's
312
+ * `question`, which is sent whole right after the summary.
282
313
  *
283
314
  * @remarks
284
315
  * A transcript that stays append-only and a request that does not are two numberings of the same
@@ -286,7 +317,7 @@ export declare function applyCompaction(messages: Message[], record?: Pick<Compa
286
317
  * hook — has to say which it is in. An index inside the folded stretch answers with the summary
287
318
  * message that now stands for it.
288
319
  */
289
- export declare const requestIndex: (index: number, record?: Pick<CompactionRecord, 'through'>, head?: number) => number;
320
+ export declare const requestIndex: (index: number, record?: Pick<CompactionRecord, 'through' | 'question'>, head?: number) => number;
290
321
  /**
291
322
  * The transcript with the plan's stretch replaced by one system message holding its summary.
292
323
  *
@@ -142,6 +142,56 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
142
142
  * @returns `index` where it lies from zero to `most`, otherwise the nearer of the two.
143
143
  */
144
144
  const clamp = (index, most) => Math.min(Math.max(index, 0), most);
145
+ /**
146
+ * Where to cut, by `target` when there is one and by `keepRatio` otherwise, among the indexes
147
+ * `isCut` allows.
148
+ *
149
+ * @param messages - The transcript. Not written to.
150
+ * @param search - The weights, the bounds and which indexes are legal cuts. See `CutSearch`.
151
+ * @returns `cut`, the length of `messages` when no legal cut was found, and on a plan made to a
152
+ * `target` the cost it leaves as `after`.
153
+ */
154
+ const findCut = (messages, { from, cost, limit, keepRatio, target, summaryTokens, estimate, isCut, kept }) => {
155
+ let cut = messages.length;
156
+ let after;
157
+ if (target !== undefined && target > 0) {
158
+ // Forward from the head, so the first cut that reaches the target is the one that folds least.
159
+ let folded = 0;
160
+ for (let at = from; at < messages.length; at++) {
161
+ if (isCut(at)) {
162
+ cut = at;
163
+ after = cost - folded + summaryTokens;
164
+ if (after <= limit * target) {
165
+ break;
166
+ }
167
+ }
168
+ if (at !== kept) {
169
+ folded += estimate(messages[at]);
170
+ }
171
+ }
172
+ return { cut, after };
173
+ }
174
+ const budget = limit * keepRatio;
175
+ let tail = 0;
176
+ for (let at = messages.length - 1; at > from; at--) {
177
+ tail += estimate(messages[at]);
178
+ if (tail > budget) {
179
+ break;
180
+ }
181
+ cut = at;
182
+ }
183
+ while (cut < messages.length && isCut(cut) === false) {
184
+ cut++;
185
+ }
186
+ return { cut };
187
+ };
188
+ /**
189
+ * The index of the last user message in a transcript.
190
+ *
191
+ * @param messages - The transcript.
192
+ * @returns The index, or `-1` when there is none.
193
+ */
194
+ const lastUser = (messages) => messages.findLastIndex((message) => message.role === Role.User);
145
195
  /**
146
196
  * Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
147
197
  *
@@ -159,6 +209,13 @@ const clamp = (index, most) => Math.min(Math.max(index, 0), most);
159
209
  * than summarised as if it were conversation. No plan comes back when the window is not full
160
210
  * enough, or when the only legal cut folds too little to pay for the summary.
161
211
  *
212
+ * A transcript that is one question and a long tool loop has no user message to cut at, so where
213
+ * no user cut folds anything the plan cuts inside the last question's loop instead: the question
214
+ * is kept whole as `question`, the assistant and tool messages after it are folded up to an
215
+ * assistant message, and the tail resumes there — every tool result in it still follows its call.
216
+ * `applyCompaction` sends the question after the summary. A transcript with a user cut is planned
217
+ * exactly as it was.
218
+ *
162
219
  * Both of those are recovered by reading the transcript, which is what a host whose array holds
163
220
  * everything it sends has to do. A host that keeps its fold as a record beside an append-only
164
221
  * transcript — its system prompt a separate argument, no summary message in the array at all —
@@ -184,47 +241,53 @@ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT,
184
241
  const head = systemHead(messages);
185
242
  const from = clamp(givenFrom ?? head.from, messages.length);
186
243
  const previous = givenPrevious ?? head.previous;
187
- let cut = messages.length;
188
- let after;
189
- if (target !== undefined && target > 0) {
190
- // Forward from the head, so the first cut that reaches the target is the one that folds least.
191
- let folded = 0;
192
- for (let at = from; at < messages.length; at++) {
193
- if (messages[at].role === Role.User) {
194
- cut = at;
195
- after = cost - folded + summaryTokens;
196
- if (after <= limit * target) {
197
- break;
198
- }
199
- }
200
- folded += estimate(messages[at]);
201
- }
244
+ const search = { from, cost, limit, keepRatio, target, summaryTokens, estimate };
245
+ const atUser = findCut(messages, { ...search, isCut: (at) => messages[at].role === Role.User });
246
+ const foldsAtUser = atUser.cut < messages.length && atUser.cut - from >= FEWEST_FOLDED;
247
+ if (foldsAtUser) {
248
+ return planFor(messages, from, atUser.cut, previous, atUser.after);
202
249
  }
203
- else {
204
- const budget = limit * keepRatio;
205
- let kept = 0;
206
- for (let at = messages.length - 1; at > from; at--) {
207
- kept += estimate(messages[at]);
208
- if (kept > budget) {
209
- break;
210
- }
211
- cut = at;
212
- }
213
- while (cut < messages.length && messages[cut].role !== Role.User) {
214
- cut++;
215
- }
250
+ // A cut that folds too little but already reaches the target means there is nothing to fold.
251
+ const reachesTarget = target !== undefined && atUser.after !== undefined && atUser.after <= limit * target;
252
+ if (reachesTarget) {
253
+ return undefined;
216
254
  }
217
- if (cut >= messages.length || cut - from < FEWEST_FOLDED) {
255
+ // No user message to cut at: fold inside the last question's tool loop, keeping the question.
256
+ const question = lastUser(messages);
257
+ if (question < 0) {
218
258
  return undefined;
219
259
  }
220
- return {
221
- from,
222
- cut,
223
- toSummarise: messages.slice(from, cut),
224
- ...(previous ? { previous } : {}),
225
- ...(after === undefined ? {} : { after: Math.round(after) }),
226
- };
260
+ const inLoop = findCut(messages, {
261
+ ...search,
262
+ isCut: (at) => at > question && messages[at].role === Role.Assistant,
263
+ kept: question,
264
+ });
265
+ if (inLoop.cut >= messages.length) {
266
+ return undefined;
267
+ }
268
+ const folded = planFor(messages, from, inLoop.cut, previous, inLoop.after, question);
269
+ return folded.toSummarise.length >= FEWEST_FOLDED ? folded : undefined;
227
270
  }
271
+ /**
272
+ * A plan for a cut `planCompaction` has chosen.
273
+ *
274
+ * @param messages - The transcript. Not written to.
275
+ * @param from - The first message folded.
276
+ * @param cut - The first message kept whole.
277
+ * @param [previous] - The summary this one continues.
278
+ * @param [after] - What the request is expected to cost once folded, on a plan made to a target.
279
+ * @param [question] - The user message kept whole ahead of `cut`, on a fold inside its tool loop.
280
+ * Left out of `toSummarise` where it lies between `from` and `cut`.
281
+ * @returns The plan, `toSummarise` a new array of the same message objects.
282
+ */
283
+ const planFor = (messages, from, cut, previous, after, question) => ({
284
+ from,
285
+ cut,
286
+ ...(question === undefined ? {} : { question }),
287
+ toSummarise: messages.slice(from, cut).filter((_, at) => at + from !== question),
288
+ ...(previous ? { previous } : {}),
289
+ ...(after === undefined ? {} : { after: Math.round(after) }),
290
+ });
228
291
  /**
229
292
  * What the summariser is handed for a plan: the earlier summary if there was one, then each
230
293
  * message as its role and at most 4000 characters of its text.
@@ -256,6 +319,22 @@ export function summaryInput(plan) {
256
319
  * resolves with an empty string where the model wrote nothing.
257
320
  */
258
321
  export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens = SUMMARY_TOKENS, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
322
+ /**
323
+ * What `beforeCompact` hooks are told is going: the plan's stretch as `turnMessages` reads it,
324
+ * less the question the fold keeps.
325
+ *
326
+ * @param sessionId - Prefixes every uuid.
327
+ * @param messages - The transcript the plan was made for.
328
+ * @param plan - What `planCompaction` returned for it.
329
+ * @param [offset] - What `messages[0]` is numbered as in the uuids.
330
+ * @returns The user and assistant text from `plan.from` to `plan.cut`, `plan.question` left out.
331
+ */
332
+ const foldedMessages = (sessionId, messages, plan, offset = 0) => {
333
+ const { from, cut, question } = plan;
334
+ const read = (start, end) => turnMessages(sessionId, messages, start, end, { offset });
335
+ const splitsAtQuestion = question !== undefined && question >= from && question < cut;
336
+ return splitsAtQuestion ? [...read(from, question), ...read(question + 1, cut)] : read(from, cut);
337
+ };
259
338
  /**
260
339
  * The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
261
340
  *
@@ -275,7 +354,7 @@ export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens =
275
354
  export async function runCompaction(messages, plan, summarise, { hooks, forced = false } = {}) {
276
355
  const context = hooks && {
277
356
  ...hooks.context,
278
- compacting: turnMessages(hooks.context.session.id, messages, plan.from, plan.cut),
357
+ compacting: foldedMessages(hooks.context.session.id, messages, plan, hooks.offset),
279
358
  range: { from: plan.from, through: plan.cut },
280
359
  };
281
360
  let summary;
@@ -295,7 +374,12 @@ export async function runCompaction(messages, plan, summarise, { hooks, forced =
295
374
  if (!summary.trim()) {
296
375
  return undefined;
297
376
  }
298
- return { summary: summary.trim(), through: plan.cut, at: new Date().toISOString() };
377
+ return {
378
+ summary: summary.trim(),
379
+ through: plan.cut,
380
+ ...(plan.question === undefined ? {} : { question: plan.question }),
381
+ at: new Date().toISOString(),
382
+ };
299
383
  }
300
384
  /**
301
385
  * The transcript as the server should see it: the folded head replaced by its summary.
@@ -308,7 +392,8 @@ export async function runCompaction(messages, plan, summarise, { hooks, forced =
308
392
  * Absent, the leading `system` messages are found by scanning, and zero of them is the ordinary
309
393
  * case for a host whose system prompt is a separate argument.
310
394
  * @returns `messages` itself when there is no record or its summary is blank, otherwise a new
311
- * array: the kept head, one summary message, then everything from the record's `through` on.
395
+ * array: the kept head, one summary message, the record's `question` when it has one and the head
396
+ * does not already hold it, then everything from the record's `through` on.
312
397
  *
313
398
  * @remarks
314
399
  * The inverse of storing a `CompactionRecord`, and the shape `planCompaction` expects to meet
@@ -321,12 +406,22 @@ export function applyCompaction(messages, record, { from } = {}) {
321
406
  return messages;
322
407
  }
323
408
  const head = clamp(from ?? systemHead(messages).from, record.through);
409
+ const question = keptQuestion(record, head);
324
410
  return [
325
411
  ...messages.slice(0, head).filter((message) => !isSummary(message)),
326
412
  summaryMessage(record.summary),
413
+ ...(question === undefined ? [] : [messages[question]]),
327
414
  ...messages.slice(record.through),
328
415
  ];
329
416
  }
417
+ /**
418
+ * The question a fold sends after its summary, unless the kept head already holds it.
419
+ *
420
+ * @param record - The fold.
421
+ * @param head - How many messages are kept ahead of the summary.
422
+ * @returns `record.question` where it lies from `head` up to `through`, otherwise `undefined`.
423
+ */
424
+ const keptQuestion = ({ question, through }, head) => question !== undefined && question >= head && question < through ? question : undefined;
330
425
  /**
331
426
  * Where a stored index sits in the request `applyCompaction` builds, once a fold has shifted
332
427
  * everything after it.
@@ -338,7 +433,8 @@ export function applyCompaction(messages, record, { from } = {}) {
338
433
  * prompts, when the host keeps them in the array. Zero is the stored-fold case,
339
434
  * where the summary is the request's first message.
340
435
  * @returns The index in the request. One before the record's `through` gives `head`, where the
341
- * summary sits — an index among the kept head messages themselves included.
436
+ * summary sits — an index among the kept head messages themselves included — except the record's
437
+ * `question`, which is sent whole right after the summary.
342
438
  *
343
439
  * @remarks
344
440
  * A transcript that stays append-only and a request that does not are two numberings of the same
@@ -350,7 +446,12 @@ export const requestIndex = (index, record, head = 0) => {
350
446
  if (!record) {
351
447
  return index;
352
448
  }
353
- return index < record.through ? head : index - record.through + head + 1;
449
+ const question = keptQuestion(record, head);
450
+ if (index === question) {
451
+ return head + 1;
452
+ }
453
+ const afterSummary = question === undefined ? 1 : 2;
454
+ return index < record.through ? head : index - record.through + head + afterSummary;
354
455
  };
355
456
  /**
356
457
  * The transcript with the plan's stretch replaced by one system message holding its summary.
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export { type KeywordPreselection, type KeywordPreselectOptions, PRESELECT_SCHEM
31
31
  export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, orderTools, PROXY_TOOLS, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, type ToolOrder, } from './tools/tool-loading.ts';
32
32
  export { type ContinueTurnOptions, continueTurn, } from './turn/continuation.ts';
33
33
  export { type RunTurnOptions, runTurn } from './turn/run-turn.ts';
34
- export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, taskCall, tryAsk, } from './turn/side-task.ts';
34
+ export { type AskJsonOptions, ask, askJson, askReply, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, type SideTaskReply, taskCall, tryAsk, } from './turn/side-task.ts';
35
35
  export { AgentLoopError, type AgentLoopFailure, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from './wire/errors.ts';
36
36
  export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow, isTransient, SMALLEST_LIKELY_WINDOW, } from './wire/retry.ts';
37
37
  export { CacheBreakReason, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ export { PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselect, preselectByKeywords, pre
30
30
  export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, orderTools, PROXY_TOOLS, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from './tools/tool-loading.js';
31
31
  export { continueTurn, } from './turn/continuation.js';
32
32
  export { runTurn } from './turn/run-turn.js';
33
- export { ask, askJson, clean, listLines, parseJson, resetHints, taskCall, tryAsk, } from './turn/side-task.js';
33
+ export { ask, askJson, askReply, clean, listLines, parseJson, resetHints, taskCall, tryAsk, } from './turn/side-task.js';
34
34
  export { AgentLoopError, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from './wire/errors.js';
35
35
  export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow, isTransient, SMALLEST_LIKELY_WINDOW, } from './wire/retry.js';
36
36
  export { CacheBreakReason, streamTurn, } from './wire/stream.js';
@@ -70,6 +70,14 @@ export interface AgentLoopHooks {
70
70
  preface?: string;
71
71
  /** Hears each note, from before the request and from `afterTurn`. */
72
72
  onNote?: OnNote;
73
+ /**
74
+ * What the run's first message is numbered as in the uuids hooks are told — the host's stored
75
+ * index of `messages[0]`, for a host that numbers messages across steps or runs. Read for
76
+ * `beforeCompact`'s `compacting` and `afterTurn`'s `turn`. See `turnMessages`.
77
+ *
78
+ * @defaultValue `0`
79
+ */
80
+ offset?: number;
73
81
  }
74
82
  /** How full the window is as a step is about to be sent, as `beforeStep` is told it. */
75
83
  export interface StepWindow {
@@ -235,10 +235,10 @@ async function foldBeforeStep(transcript, reported, summarise, { options, charsP
235
235
  return transcript;
236
236
  }
237
237
  const folded = await compactTranscript(transcript, plan, summarise, {
238
- ...(hooks ? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote } } : {}),
238
+ ...(hooks ? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote, offset: hooks.offset } } : {}),
239
239
  });
240
240
  if (folded !== transcript) {
241
- notice(`compacted ${plan.cut - plan.from} messages into a summary`);
241
+ notice(`compacted ${plan.toSummarise.length} messages into a summary`);
242
242
  }
243
243
  return folded;
244
244
  }
@@ -260,7 +260,7 @@ const tellAfterTurn = (hooks, messages, at, reply) => notify(hooks.run, HookEven
260
260
  ? {
261
261
  turn: {
262
262
  index: turnIndex(messages, at),
263
- messages: turnMessages(hooks.context.session.id, messages, at),
263
+ messages: turnMessages(hooks.context.session.id, messages, at, undefined, { offset: hooks.offset }),
264
264
  },
265
265
  }
266
266
  : {}),
@@ -122,6 +122,33 @@ export interface SideTask {
122
122
  * `reasoning_content` the server sent beside it, stripped the same way, or an empty string.
123
123
  */
124
124
  export declare function ask(config: Endpoint, model: string, system: string, user: SideTaskInput, options?: SideTaskOptions): Promise<string>;
125
+ /** A side task's reply, with why the model stopped writing it. */
126
+ export interface SideTaskReply {
127
+ /** The reply's content as `ask` returns it: trimmed, thinking stripped. */
128
+ text: string;
129
+ /**
130
+ * The first choice's `finish_reason` as the server sent it, compared with `FinishReason`.
131
+ * `FinishReason.Length` means `text` was cut off at the ceiling. Undefined when none was sent.
132
+ */
133
+ finishReason: string | undefined;
134
+ }
135
+ /**
136
+ * Runs a side task as `ask` does, and says why the reply ended as well as what it said.
137
+ *
138
+ * @param config - Where to send it and how long to wait.
139
+ * @param model - The model to ask.
140
+ * @param system - The instruction.
141
+ * @param user - The input it applies to. Content parts where the model is being shown an image.
142
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, extra fields, cancellation, notices.
143
+ * @returns The text `ask` would return, and the `finish_reason` it came with.
144
+ *
145
+ * @remarks
146
+ * For a caller whose reply is the product, such as a page's transcript: one that ran into the
147
+ * ceiling, often because the model was looping, reads like a finished one in its text alone.
148
+ * Nothing is thrown for it, because whether a cut-off reply is still worth keeping is the
149
+ * caller's call.
150
+ */
151
+ export declare function askReply(config: Endpoint, model: string, system: string, user: SideTaskInput, options?: SideTaskOptions): Promise<SideTaskReply>;
125
152
  /** What `askJson` takes besides a side task's options. */
126
153
  export interface AskJsonOptions extends SideTaskOptions {
127
154
  /**
@@ -150,7 +177,7 @@ export interface AskJsonOptions extends SideTaskOptions {
150
177
  * @param schema - The JSON Schema of the answer. Its root is held to an object, as a tool's is.
151
178
  * @param [options] - A side task's options, plus the schema's `name` and whether it is `strict`.
152
179
  * @returns What the reply parsed to, unchecked against the schema: `T` is the caller's claim.
153
- * `undefined` when `parseJson` found nothing in it.
180
+ * `undefined` when `parseJson` found nothing in it, as in a reply cut off before its JSON closed.
154
181
  *
155
182
  * @remarks
156
183
  * Sends `response_format` with the schema, which a llama.cpp server compiles into a grammar and
@@ -216,7 +243,7 @@ export declare const clean: (line: string) => string;
216
243
  export declare const listLines: (text: string, max: number, maxChars: number) => string[];
217
244
  /**
218
245
  * A side task's settings as the call that honours them: its endpoint, its model, and the options
219
- * `ask`, `askJson`, `summariser` and `preselect` take last.
246
+ * `ask`, `askReply`, `askJson`, `summariser` and `preselect` take last.
220
247
  *
221
248
  * @param task - The task, ordinarily `resolved.tasks.<key>`.
222
249
  * @param [options] - What the host adds — cancellation, notices, `keywords` — and its own defaults
@@ -227,7 +254,7 @@ export declare const listLines: (text: string, max: number, maxChars: number) =>
227
254
  * `endpoint` is the task's own object when no `agent` was given, a copy otherwise.
228
255
  *
229
256
  * @remarks
230
- * A helper rather than a second signature on each of the four, so a setting added to a task later
257
+ * A helper rather than a second signature on each entry point, so a setting added to a task later
231
258
  * arrives through `options` without a host's call changing. What the task states wins over the
232
259
  * same field in `options`: the host's code is written once and the task is what an operator
233
260
  * configured over it, so a host passes its own ceiling as the value for a task that names none.
@@ -72,7 +72,27 @@ export const resetHints = () => noHints().clear();
72
72
  * @returns The reply's content, trimmed. Where that is empty once the thinking is stripped, a
73
73
  * `reasoning_content` the server sent beside it, stripped the same way, or an empty string.
74
74
  */
75
- export function ask(config, model, system, user, options = {}) {
75
+ export async function ask(config, model, system, user, options = {}) {
76
+ const { text } = await complete(config, model, system, user, options);
77
+ return text;
78
+ }
79
+ /**
80
+ * Runs a side task as `ask` does, and says why the reply ended as well as what it said.
81
+ *
82
+ * @param config - Where to send it and how long to wait.
83
+ * @param model - The model to ask.
84
+ * @param system - The instruction.
85
+ * @param user - The input it applies to. Content parts where the model is being shown an image.
86
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, extra fields, cancellation, notices.
87
+ * @returns The text `ask` would return, and the `finish_reason` it came with.
88
+ *
89
+ * @remarks
90
+ * For a caller whose reply is the product, such as a page's transcript: one that ran into the
91
+ * ceiling, often because the model was looping, reads like a finished one in its text alone.
92
+ * Nothing is thrown for it, because whether a cut-off reply is still worth keeping is the
93
+ * caller's call.
94
+ */
95
+ export function askReply(config, model, system, user, options = {}) {
76
96
  return complete(config, model, system, user, options);
77
97
  }
78
98
  /**
@@ -89,8 +109,8 @@ export function ask(config, model, system, user, options = {}) {
89
109
  * An `extraBody` goes out less the fields this model has refused by name.
90
110
  * @param [format] - Builds the extra body fields from the endpoint's capabilities and the model's.
91
111
  * Called on every send, so a re-send is built from what has been latched since. Absent adds none.
92
- * @returns The first choice's message as `answerOf` reads it, empty when there was no answer.
93
- * Throws what the request throws, except that a 400 or 422 `negotiate` did not answer, on a
112
+ * @returns The first choice's message as `answerOf` reads it, empty when there was no answer, and
113
+ * its `finish_reason`. Throws what the request throws, except that a 400 or 422 `negotiate` did not answer, on a
94
114
  * request that carried the hints or an effort, is retried once without either.
95
115
  */
96
116
  async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, extraBody, signal, onNotice }, format) {
@@ -168,7 +188,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
168
188
  modelCapabilitiesFor(supports, model).reasoningEffort = false;
169
189
  }
170
190
  }
171
- return answerOf(response.choices[0]?.message);
191
+ const [choice] = response.choices;
192
+ // Typed as always present, and sent as `null` or not at all by servers that have none to give.
193
+ return { text: answerOf(choice?.message), finishReason: choice?.finish_reason ?? undefined };
172
194
  }
173
195
  /**
174
196
  * A reply's answer, thinking stripped, or its scratchpad where the answer is nothing else.
@@ -204,7 +226,7 @@ function answerOf(message) {
204
226
  * @param schema - The JSON Schema of the answer. Its root is held to an object, as a tool's is.
205
227
  * @param [options] - A side task's options, plus the schema's `name` and whether it is `strict`.
206
228
  * @returns What the reply parsed to, unchecked against the schema: `T` is the caller's claim.
207
- * `undefined` when `parseJson` found nothing in it.
229
+ * `undefined` when `parseJson` found nothing in it, as in a reply cut off before its JSON closed.
208
230
  *
209
231
  * @remarks
210
232
  * Sends `response_format` with the schema, which a llama.cpp server compiles into a grammar and
@@ -217,7 +239,7 @@ function answerOf(message) {
217
239
  export async function askJson(config, model, system, user, schema, { name = 'answer', strict = true, ...options } = {}) {
218
240
  const sanitized = sanitizeSchema(schema);
219
241
  const instruction = `${system}\n\nReply with JSON alone, matching this JSON Schema:\n${JSON.stringify(sanitized)}`;
220
- const reply = await complete(config, model, instruction, user, options, (supports, refused) => refused.structuredOutput
242
+ const { text } = await complete(config, model, instruction, user, options, (supports, refused) => refused.structuredOutput
221
243
  ? {
222
244
  response_format: {
223
245
  type: JSON_SCHEMA_FORMAT,
@@ -229,7 +251,7 @@ export async function askJson(config, model, system, user, schema, { name = 'ans
229
251
  },
230
252
  }
231
253
  : {});
232
- return parseJson(reply);
254
+ return parseJson(text);
233
255
  }
234
256
  /**
235
257
  * Runs a side task and answers `undefined` where it fails, so the work it supports carries on.
@@ -321,7 +343,7 @@ export const listLines = (text, max, maxChars) => text
321
343
  .slice(0, max);
322
344
  /**
323
345
  * A side task's settings as the call that honours them: its endpoint, its model, and the options
324
- * `ask`, `askJson`, `summariser` and `preselect` take last.
346
+ * `ask`, `askReply`, `askJson`, `summariser` and `preselect` take last.
325
347
  *
326
348
  * @param task - The task, ordinarily `resolved.tasks.<key>`.
327
349
  * @param [options] - What the host adds — cancellation, notices, `keywords` — and its own defaults
@@ -332,7 +354,7 @@ export const listLines = (text, max, maxChars) => text
332
354
  * `endpoint` is the task's own object when no `agent` was given, a copy otherwise.
333
355
  *
334
356
  * @remarks
335
- * A helper rather than a second signature on each of the four, so a setting added to a task later
357
+ * A helper rather than a second signature on each entry point, so a setting added to a task later
336
358
  * arrives through `options` without a host's call changing. What the task states wins over the
337
359
  * same field in `options`: the host's code is written once and the task is what an operator
338
360
  * configured over it, so a host passes its own ceiling as the value for a task that names none.
package/llms.txt CHANGED
@@ -305,6 +305,7 @@ One-shot calls that support a run without being one: picking tools, naming a ses
305
305
  - `AskJsonOptions` (type) — What `askJson` takes besides a side task's options.
306
306
  - `ask` — Runs a side task and returns the reply text, thinking stripped.
307
307
  - `askJson` — A side task whose answer is JSON matching a schema, parsed.
308
+ - `askReply` — Runs a side task as `ask` does, and says why the reply ended as well as what it said.
308
309
  - `clean` — Strips the quoting and list punctuation models decorate short answers with.
309
310
  - `listLines` — A list-shaped reply, one item per line, cleaned of the bullets and quotes models decorate them with.
310
311
  - `parseJson` — The first array or object in a model's reply, parsed, whatever prose or fence surrounds it.
@@ -312,7 +313,8 @@ One-shot calls that support a run without being one: picking tools, naming a ses
312
313
  - `SideTask` (type) — One side task's own settings, as a resolved agent spec carries them under `tasks.<key>`.
313
314
  - `SideTaskInput` (type) — The input a side task applies its instruction to: text, or the content parts a vision model reads.
314
315
  - `SideTaskOptions` (type) — What a side task may be given.
315
- - `taskCall` — A side task's settings as the call that honours them: its endpoint, its model, and the options `ask`, `askJson`, `summariser` and `preselect` take last.
316
+ - `SideTaskReply` (type) — A side task's reply, with why the model stopped writing it.
317
+ - `taskCall` — A side task's settings as the call that honours them: its endpoint, its model, and the options `ask`, `askReply`, `askJson`, `summariser` and `preselect` take last.
316
318
  - `tryAsk` — Runs a side task and answers `undefined` where it fails, so the work it supports carries on.
317
319
 
318
320
  ### errors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.3.0",
3
+ "version": "4.5.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",