@alisio/core 0.1.0-alpha.23 → 0.1.0-alpha.24

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.
@@ -13,6 +13,7 @@ import { createArtifactPublisher } from "./artifacts/publisher.js";
13
13
  import { ArtifactStore } from "./artifacts/store.js";
14
14
  import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
15
15
  import { completeText } from "./core/compaction.js";
16
+ import { DEFAULT_MAX_OUTPUT_TOKENS } from "./core/output-limit.js";
16
17
  import { ToolRegistry } from "./core/registry.js";
17
18
  import { AgentRunner } from "./core/runner.js";
18
19
  import { HerdrBridge } from "./integrations/herdr.js";
@@ -638,6 +639,39 @@ export async function createApplication(options = {}) {
638
639
  return {};
639
640
  }
640
641
  };
642
+ /**
643
+ * The maximum output a model's catalog declares (`ModelInfo.maxOutputTokens`), for the budget
644
+ * of each request. Looks in the active provider's catalog first (loading it once, bounded, if
645
+ * the first request comes before it); then in the catalogs of the configured profiles that
646
+ * were already listed, taking the smallest declaration when several providers list the same
647
+ * model id so a derived budget never exceeds any of them. Unknown stays unknown.
648
+ */
649
+ const declaredOutputLimit = async (model) => {
650
+ if (!models.has(model)) {
651
+ const loading = ensureModels();
652
+ if (loading) {
653
+ let timer;
654
+ await Promise.race([
655
+ loading.catch(() => undefined),
656
+ new Promise((resolve) => {
657
+ timer = setTimeout(resolve, 2_000);
658
+ }),
659
+ ]);
660
+ if (timer)
661
+ clearTimeout(timer);
662
+ }
663
+ }
664
+ const own = models.get(model)?.maxOutputTokens;
665
+ if (own !== undefined)
666
+ return own;
667
+ const declared = catalogCache
668
+ ? availableProviderModels(catalogCache.value)
669
+ .filter((entry) => entry.model.id === model)
670
+ .map((entry) => entry.model.maxOutputTokens)
671
+ .filter((value) => typeof value === "number" && value > 0)
672
+ : [];
673
+ return declared.length ? Math.min(...declared) : undefined;
674
+ };
641
675
  const runner = new AgentRunner({
642
676
  provider,
643
677
  providerFor,
@@ -650,6 +684,14 @@ export async function createApplication(options = {}) {
650
684
  compaction: config.compaction,
651
685
  extensions: plugins,
652
686
  contextWindow,
687
+ modelOutputLimit: declaredOutputLimit,
688
+ fallbackMaxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
689
+ effortLevels: (model) => {
690
+ const known = models.get(model)?.effort;
691
+ if (!known)
692
+ void ensureModels();
693
+ return known;
694
+ },
653
695
  ...(options.approve && !options.readOnly ? { approve: options.approve } : {}),
654
696
  pathAccess,
655
697
  ...(analysisEnabled
@@ -985,6 +1027,7 @@ export async function createApplication(options = {}) {
985
1027
  case "limits.maxContextChars":
986
1028
  case "limits.firstTokenTimeoutMs":
987
1029
  case "limits.firstTokenRetries":
1030
+ case "limits.truncationRecoveries":
988
1031
  case "limits.timeoutMs": {
989
1032
  const patch = limitLeaf(key.slice("limits.".length));
990
1033
  config.limits = { ...config.limits, ...patch };
package/dist/config.d.ts CHANGED
@@ -177,8 +177,9 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
177
177
  timeoutMs: z.ZodDefault<z.ZodNumber>;
178
178
  firstTokenTimeoutMs: z.ZodDefault<z.ZodNumber>;
179
179
  firstTokenRetries: z.ZodDefault<z.ZodNumber>;
180
+ truncationRecoveries: z.ZodDefault<z.ZodNumber>;
180
181
  maxContextChars: z.ZodDefault<z.ZodNumber>;
181
- maxOutputTokens: z.ZodDefault<z.ZodNumber>;
182
+ maxOutputTokens: z.ZodOptional<z.ZodNumber>;
182
183
  maxTokens: z.ZodOptional<z.ZodNumber>;
183
184
  }, z.core.$strip>>;
184
185
  analysis: z.ZodDefault<z.ZodObject<{
@@ -274,8 +275,9 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
274
275
  timeoutMs: number;
275
276
  firstTokenTimeoutMs: number;
276
277
  firstTokenRetries: number;
278
+ truncationRecoveries: number;
277
279
  maxContextChars: number;
278
- maxOutputTokens: number;
280
+ maxOutputTokens?: number | undefined;
279
281
  maxTokens?: number | undefined;
280
282
  };
281
283
  analysis: {
@@ -448,8 +450,9 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
448
450
  timeoutMs: number;
449
451
  firstTokenTimeoutMs: number;
450
452
  firstTokenRetries: number;
453
+ truncationRecoveries: number;
451
454
  maxContextChars: number;
452
- maxOutputTokens: number;
455
+ maxOutputTokens?: number | undefined;
453
456
  maxTokens?: number | undefined;
454
457
  };
455
458
  analysis: {
@@ -558,11 +561,12 @@ declare const SETTABLE_KEYS: {
558
561
  readonly "context.claudeMdFallback": z.ZodDefault<z.ZodBoolean>;
559
562
  readonly "context.maxBytes": z.ZodDefault<z.ZodNumber>;
560
563
  readonly "limits.maxTurns": z.ZodDefault<z.ZodNumber>;
561
- readonly "limits.maxOutputTokens": z.ZodDefault<z.ZodNumber>;
564
+ readonly "limits.maxOutputTokens": z.ZodOptional<z.ZodNumber>;
562
565
  readonly "limits.maxContextChars": z.ZodDefault<z.ZodNumber>;
563
566
  readonly "limits.timeoutMs": z.ZodDefault<z.ZodNumber>;
564
567
  readonly "limits.firstTokenTimeoutMs": z.ZodDefault<z.ZodNumber>;
565
568
  readonly "limits.firstTokenRetries": z.ZodDefault<z.ZodNumber>;
569
+ readonly "limits.truncationRecoveries": z.ZodDefault<z.ZodNumber>;
566
570
  readonly "pluginHooks.timeoutMs": z.ZodDefault<z.ZodNumber>;
567
571
  readonly "tui.paddingX": z.ZodDefault<z.ZodNumber>;
568
572
  readonly "tui.contentPaddingX": z.ZodDefault<z.ZodNumber>;
package/dist/config.js CHANGED
@@ -187,12 +187,23 @@ const configObjectSchema = z
187
187
  * disables the retry. Never counts as a turn.
188
188
  */
189
189
  firstTokenRetries: z.number().int().min(0).max(3).default(1),
190
+ /**
191
+ * How many times a turn is requested again after the model's response was cut off by
192
+ * `maxOutputTokens` before it was usable (no text and no tool call, or a tool call with
193
+ * incomplete arguments). The truncated calls are never executed. 0 disables the
194
+ * recovery. Never counts as a turn.
195
+ */
196
+ truncationRecoveries: z.number().int().min(0).max(5).default(2),
190
197
  maxContextChars: z.number().int().positive().default(800000),
191
198
  /**
192
- * Per-call output token budget for one agent turn. 4096 starves reasoning-heavy models,
193
- * which can spend the whole budget on `reasoning_content` before any usable text arrives.
199
+ * Per-call output token budget for one agent turn. Deliberately WITHOUT a schema default:
200
+ * when unset, the budget of each request is the model catalog's declared maximum (capped
201
+ * at 65536) or 16384 (see `core/output-limit.ts`), so "set by the user" stays
202
+ * distinguishable from "defaulted". A value set here always wins. 4096 starves
203
+ * reasoning-heavy models, which can spend the whole budget on `reasoning_content`
204
+ * before any usable text arrives.
194
205
  */
195
- maxOutputTokens: z.number().int().positive().default(16384),
206
+ maxOutputTokens: z.number().int().positive().optional(),
196
207
  /** Cumulative tokens per run; default is proportional to the context window. */
197
208
  maxTokens: z.number().int().positive().optional(),
198
209
  })
@@ -201,8 +212,8 @@ const configObjectSchema = z
201
212
  timeoutMs: 600000,
202
213
  firstTokenTimeoutMs: 90000,
203
214
  firstTokenRetries: 1,
215
+ truncationRecoveries: 2,
204
216
  maxContextChars: 800000,
205
- maxOutputTokens: 16384,
206
217
  })),
207
218
  /**
208
219
  * Python analysis and downloadable artifacts. `enabled: false` registers none of the
@@ -703,6 +714,7 @@ const SETTABLE_KEYS = {
703
714
  "limits.timeoutMs": SETTABLE_SECTIONS.limits.shape.timeoutMs,
704
715
  "limits.firstTokenTimeoutMs": SETTABLE_SECTIONS.limits.shape.firstTokenTimeoutMs,
705
716
  "limits.firstTokenRetries": SETTABLE_SECTIONS.limits.shape.firstTokenRetries,
717
+ "limits.truncationRecoveries": SETTABLE_SECTIONS.limits.shape.truncationRecoveries,
706
718
  "pluginHooks.timeoutMs": SETTABLE_SECTIONS.pluginHooks.shape.timeoutMs,
707
719
  "tui.paddingX": SETTABLE_SECTIONS.tui.shape.paddingX,
708
720
  "tui.contentPaddingX": SETTABLE_SECTIONS.tui.shape.contentPaddingX,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The output-token budget of one model request.
3
+ *
4
+ * Priority: a value the user set explicitly (`limits.maxOutputTokens` in any config layer,
5
+ * `/settings`, a per-run option) always wins, even above the model's declared maximum (they asked
6
+ * for it, and a provider that rejects it says so); otherwise the maximum the model's catalog
7
+ * declares (`ModelInfo.maxOutputTokens`), capped at `MAX_AUTO_OUTPUT_TOKENS`; otherwise
8
+ * `DEFAULT_MAX_OUTPUT_TOKENS`. The value is resolved for the model of each request, so switching
9
+ * model within a session changes it.
10
+ */
11
+ /** Output budget used when neither the user nor the model catalog says anything. */
12
+ export declare const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
13
+ /**
14
+ * Upper bound of a budget taken from a model's declared maximum. Catalogs declare the model's
15
+ * hard limit (hundreds of thousands of tokens for some reasoning models), which is far more than
16
+ * a single agent turn should be allowed to stream; an explicit user value is never capped by it.
17
+ */
18
+ export declare const MAX_AUTO_OUTPUT_TOKENS = 65536;
19
+ /** Where the effective budget came from. */
20
+ export type OutputLimitSource = "user" | "model" | "default";
21
+ export interface OutputLimit {
22
+ value: number;
23
+ source: OutputLimitSource;
24
+ }
25
+ export declare function resolveMaxOutputTokens(input: {
26
+ /** Set by the user (config, settings or a per-run option); `undefined` when only defaulted. */
27
+ explicit?: number | undefined;
28
+ /** `ModelInfo.maxOutputTokens` of the model of the request, when its catalog declares one. */
29
+ declared?: number | undefined;
30
+ /** Used when nothing else applies; `DEFAULT_MAX_OUTPUT_TOKENS` unless the host knows better. */
31
+ fallback?: number | undefined;
32
+ }): OutputLimit;
33
+ /** A short English phrase for messages and logs. */
34
+ export declare function describeOutputLimitSource(source: OutputLimitSource | undefined): string;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The output-token budget of one model request.
3
+ *
4
+ * Priority: a value the user set explicitly (`limits.maxOutputTokens` in any config layer,
5
+ * `/settings`, a per-run option) always wins, even above the model's declared maximum (they asked
6
+ * for it, and a provider that rejects it says so); otherwise the maximum the model's catalog
7
+ * declares (`ModelInfo.maxOutputTokens`), capped at `MAX_AUTO_OUTPUT_TOKENS`; otherwise
8
+ * `DEFAULT_MAX_OUTPUT_TOKENS`. The value is resolved for the model of each request, so switching
9
+ * model within a session changes it.
10
+ */
11
+ /** Output budget used when neither the user nor the model catalog says anything. */
12
+ export const DEFAULT_MAX_OUTPUT_TOKENS = 16_384;
13
+ /**
14
+ * Upper bound of a budget taken from a model's declared maximum. Catalogs declare the model's
15
+ * hard limit (hundreds of thousands of tokens for some reasoning models), which is far more than
16
+ * a single agent turn should be allowed to stream; an explicit user value is never capped by it.
17
+ */
18
+ export const MAX_AUTO_OUTPUT_TOKENS = 65_536;
19
+ const positive = (value) => typeof value === "number" && Number.isFinite(value) && value >= 1;
20
+ export function resolveMaxOutputTokens(input) {
21
+ if (positive(input.explicit))
22
+ return { value: Math.floor(input.explicit), source: "user" };
23
+ if (positive(input.declared))
24
+ return { value: Math.min(Math.floor(input.declared), MAX_AUTO_OUTPUT_TOKENS), source: "model" };
25
+ return {
26
+ value: positive(input.fallback) ? Math.floor(input.fallback) : DEFAULT_MAX_OUTPUT_TOKENS,
27
+ source: "default",
28
+ };
29
+ }
30
+ /** A short English phrase for messages and logs. */
31
+ export function describeOutputLimitSource(source) {
32
+ if (source === "user")
33
+ return "set by you";
34
+ if (source === "model")
35
+ return "from the model catalog";
36
+ if (source === "default")
37
+ return "default";
38
+ return "current limit";
39
+ }
@@ -28,8 +28,31 @@ export interface RunnerOptions {
28
28
  firstTokenTimeoutMs?: number;
29
29
  /** See `RunnerSettingsPatch.firstTokenRetries`. */
30
30
  firstTokenRetries?: number;
31
+ /** See `RunnerSettingsPatch.truncationRecoveries`. */
32
+ truncationRecoveries?: number;
33
+ /**
34
+ * Reasoning effort levels a model advertises (`ModelInfo.effort`), when known. Lets a recovery
35
+ * from an exhausted reasoning budget request one level lower; without it effort is unchanged.
36
+ */
37
+ effortLevels?: (model: string) => {
38
+ supportedLevels: string[];
39
+ defaultLevel?: string;
40
+ } | undefined;
31
41
  maxContextChars?: number;
42
+ /**
43
+ * Output-token budget the USER set (`limits.maxOutputTokens`). Left unset, the budget of each
44
+ * request is the model's declared maximum (`modelOutputLimit`, capped), else
45
+ * `fallbackMaxOutputTokens`. See `resolveMaxOutputTokens`.
46
+ */
32
47
  maxOutputTokens?: number;
48
+ /**
49
+ * The maximum output a model's catalog declares (`ModelInfo.maxOutputTokens`), when known.
50
+ * May be async (a catalog still loading); a failure counts as unknown. Called for the model
51
+ * of every request, so a model switch changes the budget.
52
+ */
53
+ modelOutputLimit?: (model: string) => number | undefined | Promise<number | undefined>;
54
+ /** Budget when neither the user nor the catalog says anything (4096 for bare embedders). */
55
+ fallbackMaxOutputTokens?: number;
33
56
  maxTokens?: number;
34
57
  onEvent?: (event: RunEvent) => void;
35
58
  readConcurrency?: number;
@@ -85,6 +108,11 @@ export interface RunnerSettingsPatch {
85
108
  * received for the request.
86
109
  */
87
110
  firstTokenRetries?: number;
111
+ /**
112
+ * How many times a turn is requested again after the response was cut off by the output-token
113
+ * limit before it was usable. `0` or unset disables it. A recovery is not a turn.
114
+ */
115
+ truncationRecoveries?: number;
88
116
  }
89
117
  /**
90
118
  * Per-run overrides used by embedders and child sessions. Callers must only NARROW: `policy`
@@ -116,6 +144,8 @@ export interface RunOptions {
116
144
  firstTokenTimeoutMs?: number;
117
145
  /** Silent-request retries for this run; beats the runner-level `firstTokenRetries`. */
118
146
  firstTokenRetries?: number;
147
+ /** Output-truncation recoveries for this run; beats the runner-level `truncationRecoveries`. */
148
+ truncationRecoveries?: number;
119
149
  /** Reasoning effort level for this run; beats the runner-level default when set. */
120
150
  reasoningEffort?: string;
121
151
  /** Run id to use for this run's events (e.g. preassigned by an embedder); a UUID otherwise. */
@@ -202,6 +232,8 @@ export declare class AgentRunner {
202
232
  contextWindow?: number;
203
233
  reasoningEffort?: string;
204
234
  }>;
235
+ /** The output-token budget for `model`: user value, else catalog maximum, else fallback. */
236
+ private outputLimit;
205
237
  /** Change the model used by subsequent turns of a session; recorded in the store. */
206
238
  setModel(sessionId: string, model: string): void;
207
239
  /** Manually compact a session outside a run. */
@@ -1,8 +1,12 @@
1
1
  import { isEphemeralRunEventType, textProjection, textResult, } from "@alisio/sdk";
2
2
  import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
+ import { resolveMaxOutputTokens } from "./output-limit.js";
3
4
  import { isTimeoutReason, providerLabel, RunTimeoutError } from "./timeout.js";
5
+ import { incompleteCalls, isOutputTruncationError, lowerEffort, RunTruncationError, TRUNCATION_NOTICE, } from "./truncation.js";
4
6
  /** Pause before a silent request is sent again; short, and cut off by a stop or the run limit. */
5
7
  const FIRST_TOKEN_RETRY_PAUSE_MS = 250;
8
+ /** Pause before a cut-off turn is requested again; short, and cut off by a stop or the run limit. */
9
+ const TRUNCATION_RETRY_PAUSE_MS = 100;
6
10
  function abortableDelay(ms, signal) {
7
11
  return new Promise((resolve) => {
8
12
  if (signal.aborted)
@@ -211,11 +215,21 @@ export class AgentRunner {
211
215
  instructions: await o.context.instructions(sessionId),
212
216
  messages: o.store.messages(sessionId),
213
217
  maxContextChars: o.maxContextChars ?? 800_000,
214
- maxOutputTokens: o.maxOutputTokens ?? 4096,
218
+ maxOutputTokens: (await this.outputLimit(session.model)).value,
215
219
  timeoutMs: o.timeoutMs ?? 300_000,
216
220
  ...(o.reasoningEffort ? { reasoningEffort: o.reasoningEffort } : {}),
217
221
  };
218
222
  }
223
+ /** The output-token budget for `model`: user value, else catalog maximum, else fallback. */
224
+ async outputLimit(model, perRun) {
225
+ const o = this.options;
226
+ const declared = await Promise.resolve(o.modelOutputLimit?.(model)).catch(() => undefined);
227
+ return resolveMaxOutputTokens({
228
+ explicit: perRun ?? o.maxOutputTokens,
229
+ declared,
230
+ fallback: o.fallbackMaxOutputTokens ?? 4096,
231
+ });
232
+ }
219
233
  /** Change the model used by subsequent turns of a session; recorded in the store. */
220
234
  setModel(sessionId, model) {
221
235
  const id = model.trim();
@@ -715,6 +729,12 @@ export class AgentRunner {
715
729
  const runTimeoutMs = options.timeoutMs ?? o.timeoutMs ?? 300_000;
716
730
  const firstTokenMs = options.firstTokenTimeoutMs ?? o.firstTokenTimeoutMs ?? 0;
717
731
  const firstTokenRetries = firstTokenMs > 0 ? Math.max(0, options.firstTokenRetries ?? o.firstTokenRetries ?? 0) : 0;
732
+ const truncationRecoveries = Math.max(0, options.truncationRecoveries ?? o.truncationRecoveries ?? 0);
733
+ /** Responses of this run cut off by the output limit (the first plus every recovery). */
734
+ let truncations = 0;
735
+ /** Continuation notice and lowered effort for the request that follows a cut-off response. */
736
+ let recoveryNotice;
737
+ let recoveryEffort;
718
738
  const runStartedAt = Date.now();
719
739
  const timeout = AbortSignal.timeout(runTimeoutMs);
720
740
  const combined = AbortSignal.any([controller.signal, timeout, ...(signal ? [signal] : [])]);
@@ -865,6 +885,18 @@ export class AgentRunner {
865
885
  // display-only extension of the persisted transcript and never reach a model prompt
866
886
  // (raw image bytes included). The store keeps the rich parts for TUI replay.
867
887
  const providerMessages = messages.map((m) => m.role === "tool" ? { ...m, result: textProjection(m.result) } : m);
888
+ // After a cut-off response the request carries a transient continuation notice (never
889
+ // persisted: the stored transcript only ever holds what the model really answered).
890
+ if (recoveryNotice)
891
+ providerMessages.push({ role: "user", text: recoveryNotice });
892
+ const requestEffort = recoveryEffort ?? options.reasoningEffort ?? o.reasoningEffort;
893
+ recoveryNotice = undefined;
894
+ recoveryEffort = undefined;
895
+ // Resolved for the model of THIS request (a catalog may still be loading on the first).
896
+ const outputLimit = await this.outputLimit(model, options.maxOutputTokens);
897
+ combined.throwIfAborted();
898
+ /** The provider threw the "cut off by the output limit" signal instead of completing. */
899
+ let cutByError = false;
868
900
  let requested = Date.now();
869
901
  let firstDelta;
870
902
  stage = "waiting_model";
@@ -896,14 +928,12 @@ export class AgentRunner {
896
928
  instructions,
897
929
  messages: providerMessages,
898
930
  tools,
899
- maxOutputTokens: options.maxOutputTokens ?? o.maxOutputTokens ?? 4096,
931
+ maxOutputTokens: outputLimit.value,
900
932
  signal: firstTokenMs > 0 ? AbortSignal.any([combined, silent.signal]) : combined,
901
933
  model,
902
934
  sessionId,
903
935
  ...(o.nativeTools?.length ? { nativeTools: o.nativeTools } : {}),
904
- ...((options.reasoningEffort ?? o.reasoningEffort)
905
- ? { reasoningEffort: options.reasoningEffort ?? o.reasoningEffort }
906
- : {}),
936
+ ...(requestEffort ? { reasoningEffort: requestEffort } : {}),
907
937
  })) {
908
938
  combined.throwIfAborted();
909
939
  stopSilentTimer();
@@ -939,8 +969,14 @@ export class AgentRunner {
939
969
  !combined.aborted &&
940
970
  silentAttempts <= firstTokenRetries;
941
971
  if (!silentFailure) {
942
- if (failure !== undefined)
972
+ if (failure !== undefined) {
973
+ // A response cut off before anything usable is recovered like a truncated one.
974
+ if (!completion && !combined.aborted && isOutputTruncationError(failure)) {
975
+ cutByError = true;
976
+ break;
977
+ }
943
978
  throw failure;
979
+ }
944
980
  break;
945
981
  }
946
982
  // Do not start an attempt the whole-run limit could not let finish.
@@ -963,6 +999,57 @@ export class AgentRunner {
963
999
  stage = "waiting_model";
964
1000
  }
965
1001
  stage = "other";
1002
+ // A response cut off by the output limit that cannot be used as it is: no visible text
1003
+ // and no tool call, or a tool call whose arguments are incomplete. Complete calls of a
1004
+ // cut response, and a cut response with usable text, keep their normal handling below.
1005
+ let cut;
1006
+ if (cutByError)
1007
+ cut = "empty_response";
1008
+ else if (!completion)
1009
+ throw new Error("Provider stream ended without a completed response");
1010
+ else if (truncated) {
1011
+ if (incompleteCalls(completion.calls))
1012
+ cut = "tool_call_cut";
1013
+ else if (!completion.text.trim() && !completion.calls.length)
1014
+ cut = "empty_response";
1015
+ }
1016
+ if (cut) {
1017
+ const maxOutputTokens = outputLimit.value;
1018
+ truncations++;
1019
+ // Visible text is kept as a normal assistant message; the cut calls are discarded:
1020
+ // never executed, never persisted (they would have no result), no provider data.
1021
+ const keep = completion?.text ?? "";
1022
+ if (keep.trim()) {
1023
+ o.store.append(sessionId, { role: "assistant", text: keep, calls: [] });
1024
+ lastText = keep;
1025
+ }
1026
+ if (truncations > truncationRecoveries)
1027
+ throw new RunTruncationError({
1028
+ attempts: truncations,
1029
+ maxOutputTokens,
1030
+ source: outputLimit.source,
1031
+ ...(modelId ? { model: modelId } : {}),
1032
+ });
1033
+ // Reasoning exhausted the budget (nothing at all came out): ask for less of it once.
1034
+ const levels = o.effortLevels?.(model);
1035
+ const lowered = truncations === 1 && cut === "empty_response"
1036
+ ? lowerEffort(requestEffort ?? levels?.defaultLevel, levels?.supportedLevels)
1037
+ : undefined;
1038
+ emit("truncation_recovery", {
1039
+ attempt: truncations,
1040
+ of: truncationRecoveries,
1041
+ reason: cut,
1042
+ maxOutputTokens,
1043
+ ...(lowered ? { effort: lowered } : {}),
1044
+ });
1045
+ recoveryNotice = TRUNCATION_NOTICE;
1046
+ recoveryEffort = lowered;
1047
+ await abortableDelay(TRUNCATION_RETRY_PAUSE_MS, combined);
1048
+ combined.throwIfAborted();
1049
+ // The recovery is not a turn.
1050
+ turn--;
1051
+ continue;
1052
+ }
966
1053
  if (!completion)
967
1054
  throw new Error("Provider stream ended without a completed response");
968
1055
  const durationMs = Date.now() - requested;
@@ -999,7 +1086,8 @@ export class AgentRunner {
999
1086
  // The adapter kept a usable answer, but the token budget cut it off.
1000
1087
  emit("response_truncated", {
1001
1088
  turn: turn + 1,
1002
- maxOutputTokens: options.maxOutputTokens ?? o.maxOutputTokens ?? 4096,
1089
+ maxOutputTokens: outputLimit.value,
1090
+ source: outputLimit.source,
1003
1091
  });
1004
1092
  final.truncated = true;
1005
1093
  }
@@ -1116,6 +1204,15 @@ export class AgentRunner {
1116
1204
  finish("failed", failure.message);
1117
1205
  throw failure;
1118
1206
  }
1207
+ if (error instanceof RunTruncationError) {
1208
+ emit("run_failed", {
1209
+ error: error.message,
1210
+ code: "output_truncated",
1211
+ truncation: error.info,
1212
+ });
1213
+ finish("failed", error.message);
1214
+ throw error;
1215
+ }
1119
1216
  // On cancellation report the abort reason, not the transport's secondary error.
1120
1217
  const cause = combined.aborted ? (combined.reason ?? error) : error;
1121
1218
  const message = cause instanceof Error ? cause.message : String(cause);
@@ -0,0 +1,30 @@
1
+ import { type RunTruncationInfo, type ToolCall } from "@alisio/sdk";
2
+ /**
3
+ * Recovery from a model response that the output-token limit (`limits.maxOutputTokens`) cut
4
+ * off before it was usable. Provider-agnostic: the runner decides from the completed message
5
+ * (`truncated: true`) or from the typed `OutputTruncatedError` signal, never from SDK types.
6
+ */
7
+ /** The run failed because every response was cut off; carries the numbers for the UIs. */
8
+ export declare class RunTruncationError extends Error {
9
+ readonly code = "output_truncated";
10
+ readonly info: RunTruncationInfo;
11
+ constructor(info: RunTruncationInfo);
12
+ }
13
+ /** Plain-text failure message (TUI, headless, logs). English only; the web localizes it. */
14
+ export declare function describeTruncation(info: RunTruncationInfo): string;
15
+ /** Whether a provider failure means "the response was cut off by the output-token limit". */
16
+ export declare function isOutputTruncationError(error: unknown): boolean;
17
+ /**
18
+ * Whether a tool call of a truncated response cannot be trusted: missing id or name, or
19
+ * arguments that are not a complete JSON object. Empty arguments only count as incomplete for
20
+ * the LAST call (the one being streamed when the limit hit); an earlier call with no arguments
21
+ * is how a provider reports a call without parameters.
22
+ */
23
+ export declare function incompleteCalls(calls: ToolCall[]): boolean;
24
+ /** What the model is told on the request that follows a truncated response. */
25
+ export declare const TRUNCATION_NOTICE: string;
26
+ /**
27
+ * One step below `current` among the model's supported levels, or `undefined` when that cannot
28
+ * be decided cleanly (unknown level names, no current level, or already the lowest).
29
+ */
30
+ export declare function lowerEffort(current: string | undefined, supported: readonly string[] | undefined): string | undefined;
@@ -0,0 +1,83 @@
1
+ import { OUTPUT_TRUNCATED_CODE } from "@alisio/sdk";
2
+ import { describeOutputLimitSource } from "./output-limit.js";
3
+ /**
4
+ * Recovery from a model response that the output-token limit (`limits.maxOutputTokens`) cut
5
+ * off before it was usable. Provider-agnostic: the runner decides from the completed message
6
+ * (`truncated: true`) or from the typed `OutputTruncatedError` signal, never from SDK types.
7
+ */
8
+ /** The run failed because every response was cut off; carries the numbers for the UIs. */
9
+ export class RunTruncationError extends Error {
10
+ code = OUTPUT_TRUNCATED_CODE;
11
+ info;
12
+ constructor(info) {
13
+ super(describeTruncation(info));
14
+ this.name = "RunTruncationError";
15
+ this.info = info;
16
+ }
17
+ }
18
+ /** Plain-text failure message (TUI, headless, logs). English only; the web localizes it. */
19
+ export function describeTruncation(info) {
20
+ const who = info.model ? `The model ${info.model}` : "The model";
21
+ const times = info.attempts === 1 ? "once" : `${info.attempts} times`;
22
+ return (`${who} was cut off ${times} by the output-token limit (${info.maxOutputTokens} tokens, ${describeOutputLimitSource(info.source)}) ` +
23
+ "before it produced a usable response, so the run was stopped. " +
24
+ "Raise limits.maxOutputTokens (/settings or the config file), lower the reasoning effort (/effort), " +
25
+ "or ask for the result in smaller parts.");
26
+ }
27
+ /**
28
+ * COMPATIBILITY: provider plugins published before the typed signal (for example
29
+ * `@alisio/plugin-deepseek` 0.1.1) throw a plain `Error` with this text when a response is cut
30
+ * off before any visible text. It is the only place the text is matched; new providers should
31
+ * yield `completed` with `truncated: true` or throw `OutputTruncatedError` from `@alisio/sdk`.
32
+ */
33
+ const LEGACY_TRUNCATION_TEXT = "cut off by max output tokens before any usable content";
34
+ /** Whether a provider failure means "the response was cut off by the output-token limit". */
35
+ export function isOutputTruncationError(error) {
36
+ if (typeof error !== "object" || error === null)
37
+ return false;
38
+ const { code, message } = error;
39
+ return (code === OUTPUT_TRUNCATED_CODE ||
40
+ (typeof message === "string" && message.includes(LEGACY_TRUNCATION_TEXT)));
41
+ }
42
+ /**
43
+ * Whether a tool call of a truncated response cannot be trusted: missing id or name, or
44
+ * arguments that are not a complete JSON object. Empty arguments only count as incomplete for
45
+ * the LAST call (the one being streamed when the limit hit); an earlier call with no arguments
46
+ * is how a provider reports a call without parameters.
47
+ */
48
+ export function incompleteCalls(calls) {
49
+ return calls.some((call, index) => {
50
+ if (!call.id || !call.name)
51
+ return true;
52
+ const raw = call.arguments.trim();
53
+ if (!raw)
54
+ return index === calls.length - 1;
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ return typeof parsed !== "object" || parsed === null || Array.isArray(parsed);
58
+ }
59
+ catch {
60
+ return true;
61
+ }
62
+ });
63
+ }
64
+ /** What the model is told on the request that follows a truncated response. */
65
+ export const TRUNCATION_NOTICE = "Your previous reply was cut off by the output-token limit, so it was discarded. " +
66
+ "Do not repeat what you already produced. Work in smaller steps: write long files in several parts " +
67
+ "(for example save the script or data to files in several tool calls, or build the dashboard with " +
68
+ "the compact alisio_runtime.html and svg helpers), keep your reasoning short, and continue from where you stopped.";
69
+ const EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
70
+ /**
71
+ * One step below `current` among the model's supported levels, or `undefined` when that cannot
72
+ * be decided cleanly (unknown level names, no current level, or already the lowest).
73
+ */
74
+ export function lowerEffort(current, supported) {
75
+ if (!current || !supported?.length)
76
+ return undefined;
77
+ const rank = (level) => EFFORT_RANK.indexOf(level);
78
+ if (rank(current) < 0 || supported.some((level) => rank(level) < 0))
79
+ return undefined;
80
+ const lower = [...supported].filter((level) => rank(level) < rank(current));
81
+ lower.sort((a, b) => rank(b) - rank(a));
82
+ return lower[0];
83
+ }
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export { BUILTIN_COMMANDS, type BuiltinCommand, CommandCatalog, type CommandExec
27
27
  export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, isSettableSettingKey, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, OCI_IMAGE_PATTERN, overridesSavedProviderProfile, type SettableSettingInfo, type SettableSettingKey, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.ts";
28
28
  export { type ContextBudget, checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.ts";
29
29
  export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ArtifactPublisherFactory, BeginRunInput, CapabilityGate, ContextSource, CoreArtifactPublisher, EndRunInput, EventPage, HookFailure, MessagePage, PageOptions, Policy, PublishedArtifactInfo, RunnerExtensions, RunRecord, RunStatus, Session, SessionStore, StoredEvent, TerminalRunStatus, ToolCallMeta, } from "./core/contracts.ts";
30
+ export { DEFAULT_MAX_OUTPUT_TOKENS, describeOutputLimitSource, MAX_AUTO_OUTPUT_TOKENS, type OutputLimit, type OutputLimitSource, resolveMaxOutputTokens, } from "./core/output-limit.ts";
30
31
  export { ToolRegistry } from "./core/registry.ts";
31
32
  export { AgentRunner, type CompactionResult, defaultTokenBudget, type RunnerOptions, type RunnerSettingsPatch, type RunOptions, } from "./core/runner.ts";
32
33
  export { type ExtensionConflict, ExtensionRegistry } from "./extensions/registry.ts";
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ export { ArtifactRejected, ArtifactStore, readHead as readArtifactHead, slugify
26
26
  export { BUILTIN_COMMANDS, CommandCatalog, } from "./commands/catalog.js";
27
27
  export { configFile, configHome, configSchema, isSettableSettingKey, loadConfig, loadConfigWithProvenance, OCI_IMAGE_PATTERN, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.js";
28
28
  export { checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.js";
29
+ export { DEFAULT_MAX_OUTPUT_TOKENS, describeOutputLimitSource, MAX_AUTO_OUTPUT_TOKENS, resolveMaxOutputTokens, } from "./core/output-limit.js";
29
30
  export { ToolRegistry } from "./core/registry.js";
30
31
  export { AgentRunner, defaultTokenBudget, } from "./core/runner.js";
31
32
  export { ExtensionRegistry } from "./extensions/registry.js";
@@ -125,6 +125,7 @@ function pythonRunDescription(deps) {
125
125
  "data_inspect) to $ALISIO_INPUT_DIR/<name>.sqlite: alisio_runtime.datasets.open(name) returns a",
126
126
  "read-only sqlite3 connection (table `data` or `s_<sheet>`). HTML is shown offline: embed data and scripts",
127
127
  "inline (no fetch, no CDN). Nothing is published when the script fails unless publishOnError.",
128
+ "Keep each call's code short: the model's output is capped per response, so split large scripts or dashboards into several calls or files.",
128
129
  "To run an earlier analysis again unchanged (same script, inputs checked by hash, new artifacts) pass rerunOf: an artifact or execution id of this session, instead of code.",
129
130
  container ? CONTAINER_NOTE : NOT_SANDBOXED,
130
131
  ...(deps.startup.candidates || container
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.1.0-alpha.23",
3
+ "version": "0.1.0-alpha.24",
4
4
  "description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "node": ">=22.16"
42
42
  },
43
43
  "dependencies": {
44
- "@alisio/sdk": "0.1.0-alpha.19",
44
+ "@alisio/sdk": "0.1.0-alpha.20",
45
45
  "@mixmark-io/domino": "2.2.0",
46
46
  "@modelcontextprotocol/client": "2.1.0",
47
47
  "ajv": "8.20.0",