@womp/spark-agent 6.5.0 → 7.0.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/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ Notable changes to `@womp/spark-agent`, newest first. Versions follow
4
4
  [Semantic Versioning](https://semver.org/). Add an entry with each version bump; describe what
5
5
  changed for hosts and users, and how to migrate across breaking changes.
6
6
 
7
+ ## 7.0.0 — 2026-10-12
8
+
9
+ Compaction is now the only thing that takes messages out of what the model reads, and it can run
10
+ before any step of a turn, not just the first. The per-step trimming that replaced earlier images,
11
+ tool results and whole turns with placeholder text is gone: it rewrote the start of the prompt
12
+ whenever it fired, which threw away the provider's prompt cache on every later step. A request
13
+ that is still over the model's context window after compaction is sent as it is and fails with
14
+ the provider's error.
15
+
16
+ ### Changed
17
+
18
+ - A run given `history` checks the conversation before every step. Once it reaches the compaction
19
+ threshold during a turn, everything before the current user message and the turn's steps before
20
+ its most recent `COMPACTION_KEEP_RECENT_TOKENS` are summarized; the user's message is kept
21
+ verbatim. The summary request is the first kept step's request plus one instruction, so it is
22
+ served from the prompt cache. A compaction that would save less than
23
+ `COMPACTION_MIN_SAVED_RATIO` of the threshold is skipped, so a turn whose own message is over
24
+ the threshold does not summarize on every step.
25
+ - A summary covers only the messages it replaces, before a turn as well as during one. It used to
26
+ tell of the kept messages too, and the model, reading them again after it, took that work for
27
+ done twice.
28
+ - `SparkCompaction.firstKeptStep` records a compaction written during its turn: the first step of
29
+ the recording assistant message the model still reads. `applyCompaction` (and so
30
+ `convertSparkMessages`) leaves the earlier steps out, so the next turn rebuilds exactly the
31
+ messages the last step was sent. A run may now record more than one `data-compaction` part; the
32
+ latest is the one in force. Hosts must store and reload message parts in order, including
33
+ `step-start`.
34
+ - When to compact is measured from the provider's count for the conversation's last step after the
35
+ latest compaction, including the steps of the message that recorded it.
36
+
37
+ ### Added
38
+
39
+ - `applyCompaction` and `latestCompaction` are exported from the package root, so a host can tell
40
+ which stored messages, and which of their images, the model will be sent.
41
+
42
+ ### Removed
43
+
44
+ - `fitMessagesToContext`, `ContextBudgetOptions`, `ContextBudgetReport`, `ContextBudgetResult`,
45
+ `TRIM_TRIGGER_RATIO`, `TRIM_TARGET_RATIO`, `SMALL_TOOL_OUTPUT_TOKENS`,
46
+ `PROTECTED_CURRENT_STEPS` and the `OMITTED_HISTORY_TEXT`, `OMITTED_HISTORY_IMAGE_TEXT` and
47
+ `OMITTED_TOOL_OUTPUT_TEXT` notes.
48
+ - `SparkAgentOptions.onContextBudget`.
49
+
50
+ ### Migration
51
+
52
+ - Remove any `onContextBudget` handler and any host-side trimming. Pass `history` to have long
53
+ conversations compacted; a run given `messages` is sent exactly what it is given.
54
+ - `contextWindowFor`, `estimateMessagesTokens`, `estimateRequestTokens`, `CHARS_PER_TOKEN` and
55
+ `IMAGE_TOKENS` are unchanged; they now live in `src/context-window.ts`.
56
+
7
57
  ## 6.5.0 — 2026-10-12
8
58
 
9
59
  The agent proposes a goal during its turn, instead of the host asking a separate model call to
package/README.md CHANGED
@@ -75,9 +75,11 @@ The default model-step limit is 100; reaching it returns `status: "incomplete"`,
75
75
  restart automatically to bypass it. A generation that ends on any other non-`stop` reason fails the
76
76
  run with the unified and provider finish reasons in its error and diagnostics.
77
77
 
78
- Before each model step, Spark trims older images, large tool results, and whole earlier turns to
79
- keep the request inside the model's context window. The latest user message and the most recent
80
- steps are never trimmed.
78
+ When a run is given its stored `history`, Spark compacts it before any model step once the
79
+ conversation reaches the compaction threshold: earlier turns, and earlier steps of a long turn, are
80
+ replaced by a summary recorded on the run's message as a `data-compaction` part. Nothing else
81
+ shortens a request; one that is still over the model's context window fails with the provider's
82
+ error.
81
83
 
82
84
  ### Artifacts
83
85
 
@@ -355,7 +357,8 @@ features, patch for backward-compatible fixes.
355
357
 
356
358
  - `src/agent.ts` — AI SDK stream and Spark tool orchestration
357
359
  - `src/prompt.ts` — shared system prompt
358
- - `src/context-budget.ts` — per-step context-window trimming
360
+ - `src/compaction.ts` — conversation compaction before each model step
361
+ - `src/context-window.ts` — model context windows and request size estimates
359
362
  - `src/ui.ts` — browser-safe protocol types and validation schemas
360
363
  - `src/client.ts` — durable `ChatTransport` implementation
361
364
  - `src/server/` — service, storage contracts, Express adapter, artifacts, and standalone composition
package/dist/agent.d.ts CHANGED
@@ -2,7 +2,6 @@ import { streamText, type LanguageModel, type ModelMessage } from "ai";
2
2
  import { type SparkGoalOffer, type SparkPersistence } from "./agent-tools.js";
3
3
  import type { SparkArtifactResolver, SparkCapabilities } from "./capabilities.js";
4
4
  import type { JavaScriptExecutor } from "./executor.js";
5
- import { type ContextBudgetReport } from "./context-budget.js";
6
5
  import { type SparkCompactionLimits } from "./compaction.js";
7
6
  import type { SparkUIMessage, SparkUIMessageChunk } from "./ui.js";
8
7
  export type SparkAgentResult = {
@@ -17,8 +16,8 @@ export type SparkAgentOptions = {
17
16
  messages?: ModelMessage[];
18
17
  /**
19
18
  * The stored conversation. It is converted with `convertSparkMessages`, and
20
- * compacted first once it is long enough (./compaction.ts); the new
21
- * compaction is written to this run's message as a `data-compaction` part.
19
+ * compacted before any step once it is long enough (./compaction.ts); each
20
+ * new compaction is written to this run's message as a `data-compaction` part.
22
21
  */
23
22
  history?: SparkUIMessage[];
24
23
  model: LanguageModel;
@@ -38,12 +37,10 @@ export type SparkAgentOptions = {
38
37
  /** Gives the model `propose_goal`; absent, it has no way to offer a goal. */
39
38
  proposeGoal?: SparkGoalOffer;
40
39
  prepareMessages?(messages: ModelMessage[]): Promise<ModelMessage[]>;
41
- /** The model's context window. Absent: `contextWindowFor(modelId)`. */
40
+ /** The model's context window, which caps when `history` is compacted. Absent: `contextWindowFor(modelId)`. */
42
41
  contextWindow?: number;
43
42
  /** When `history` is compacted. Absent: the defaults in ./compaction.ts. */
44
43
  compaction?: SparkCompactionLimits;
45
- /** Called when a step's messages had to be trimmed to fit, or still do not fit. */
46
- onContextBudget?(report: ContextBudgetReport): void;
47
44
  };
48
45
  export type SparkAgentRun = {
49
46
  stream: ReadableStream<SparkUIMessageChunk>;
package/dist/agent.js CHANGED
@@ -4,8 +4,8 @@ import { createUIMessageStream, stepCountIs, streamText, } from "ai";
4
4
  import { createSparkTools } from "./agent-tools.js";
5
5
  import { sparkSystemPrompt } from "./prompt.js";
6
6
  import { calculateStepCost } from "./pricing.js";
7
- import { contextWindowFor, fitMessagesToContext } from "./context-budget.js";
8
- import { compactSparkHistory } from "./compaction.js";
7
+ import { contextWindowFor } from "./context-window.js";
8
+ import { compactSparkHistory, createStepCompaction, latestCompaction } from "./compaction.js";
9
9
  import { convertSparkMessages } from "./model-history.js";
10
10
  function errorMessage(error) {
11
11
  return sparkErrorDiagnostics(error).message;
@@ -131,11 +131,14 @@ export function runSparkAgent(options) {
131
131
  });
132
132
  const readSystem = () => sparkSystemPrompt(options.capabilities.skills, options.instructions);
133
133
  let messages = options.messages ?? [];
134
+ // Compaction before each later step; only for a stored conversation,
135
+ // where the compaction can be recorded.
136
+ let compactStep;
134
137
  if (options.history !== undefined) {
135
138
  const history = options.history;
136
- const compacted = await compactSparkHistory({
137
- history,
138
- convert: (compaction) => convertSparkMessages(history, tools, { compaction, skills: options.capabilities.skills }),
139
+ const skills = options.capabilities.skills;
140
+ let compactions = 0;
141
+ const summaryOptions = {
139
142
  model: options.model,
140
143
  modelId: options.modelId,
141
144
  system: readSystem(),
@@ -149,12 +152,29 @@ export function runSparkAgent(options) {
149
152
  type: "data-run-status",
150
153
  data: { runId: options.runId, status: "running", label: "Making room so we can keep talking" },
151
154
  }),
155
+ };
156
+ const record = (compaction) => writer.write({
157
+ type: "data-compaction",
158
+ id: `compaction-${options.messageId}${compactions++ === 0 ? "" : `-${compactions}`}`,
159
+ data: compaction,
160
+ });
161
+ const compacted = await compactSparkHistory({
162
+ ...summaryOptions,
163
+ history,
164
+ convert: (compaction) => convertSparkMessages(history, tools, { compaction, skills }),
152
165
  });
153
166
  messages = compacted.messages;
154
- if (compacted.compaction !== undefined) {
155
- writer.write({ type: "data-compaction", id: `compaction-${options.messageId}`, data: compacted.compaction });
156
- }
167
+ if (compacted.compaction !== undefined)
168
+ record(compacted.compaction);
157
169
  controller.signal.throwIfAborted();
170
+ compactStep = createStepCompaction({
171
+ ...summaryOptions,
172
+ history,
173
+ previous: compacted.compaction ?? latestCompaction(history)?.compaction,
174
+ skills,
175
+ convert: (stored) => convertSparkMessages(stored, tools, { skills }),
176
+ onCompaction: record,
177
+ });
158
178
  }
159
179
  const generation = streamText({
160
180
  model: options.model,
@@ -166,25 +186,23 @@ export function runSparkAgent(options) {
166
186
  stopWhen: stepCountIs(maxSteps),
167
187
  maxOutputTokens: options.maxOutputTokens,
168
188
  providerOptions: options.providerOptions,
169
- prepareStep: async ({ messages }) => {
189
+ prepareStep: async ({ messages, steps }) => {
170
190
  controller.signal.throwIfAborted();
171
- const prepared = options.prepareMessages === undefined
191
+ // The first step was compacted above, before the request was built.
192
+ // The AI SDK sends each later step what the one before it was sent,
193
+ // so a compaction here holds for the rest of the turn.
194
+ const last = steps.at(-1)?.usage;
195
+ const current = compactStep === undefined || steps.length === 0
172
196
  ? messages
173
- : await options.prepareMessages(messages);
197
+ : await compactStep(messages, { inputTokens: last?.inputTokens, outputTokens: last?.outputTokens });
198
+ controller.signal.throwIfAborted();
199
+ const prepared = options.prepareMessages === undefined
200
+ ? current
201
+ : await options.prepareMessages(current);
174
202
  controller.signal.throwIfAborted();
175
- const system = readSystem();
176
- // After the host's own preparation, so what is measured is what is sent.
177
- const fitted = fitMessagesToContext(prepared, {
178
- contextWindow,
179
- maxOutputTokens: options.maxOutputTokens,
180
- system,
181
- tools,
182
- });
183
- if (fitted.report && (fitted.messages !== prepared || !fitted.report.fits))
184
- options.onContextBudget?.(fitted.report);
185
203
  return {
186
- system,
187
- messages: fitted.messages,
204
+ system: readSystem(),
205
+ messages: prepared,
188
206
  };
189
207
  },
190
208
  onError: ({ error }) => {
@@ -3,10 +3,12 @@ import type { SparkSkill } from "./capabilities.js";
3
3
  import { type SparkCompaction, type SparkUIMessage } from "./ui.js";
4
4
  /** Compact once the conversation reaches this many tokens. */
5
5
  export declare const COMPACTION_THRESHOLD_TOKENS = 256000;
6
- /** Recent whole turns kept verbatim, up to this many tokens, besides the current turn. */
6
+ /** Recent whole turns, or recent steps of the current turn, kept verbatim, up to this many tokens. */
7
7
  export declare const COMPACTION_KEEP_RECENT_TOKENS = 16000;
8
8
  /** The user's newest images from the summarized messages that the summary message carries. */
9
9
  export declare const COMPACTION_USER_IMAGES = 4;
10
+ /** A compaction during a turn saves at least this share of the threshold, or is not made. */
11
+ export declare const COMPACTION_MIN_SAVED_RATIO = 0.25;
10
12
  /**
11
13
  * When a conversation is compacted, for a host that exercises compaction
12
14
  * without filling a production-sized conversation, such as local development.
@@ -19,27 +21,28 @@ export type SparkCompactionLimits = {
19
21
  };
20
22
  export declare const COMPACTION_PROMPT = "Write a checkpoint summary of this conversation so far. It replaces the earlier messages in your context, so the work must be able to continue from it alone. Reply with the summary only, without calling tools. This request is not part of the conversation: leave it out of the summary.\nIf the conversation begins with an earlier summary, carry its content forward and update it.\nUse these sections:\n## Goal\n## User preferences and constraints\n## Decisions and current scene state (objects with their names and IDs)\n## Done\n## Open and next steps\n## Artifacts and images (each artifact ID, and what each image the user attached shows)\n## Errors and approaches to avoid\nKeep exact names, IDs, numbers and the user's own wording for requirements. Stay under 1,500 words.";
21
23
  export declare const COMPACTION_NOTE = "[Earlier messages in this conversation were replaced by the summary below. The scene itself is unchanged; inspect it for its exact state.]";
24
+ export declare const COMPACTION_STEPS_NOTE = "[Earlier messages in this conversation, and the earlier steps of your work on the message after this one, were replaced by the summary below. The scene itself is unchanged; inspect it for its exact state.]";
22
25
  /** The latest compaction in the conversation whose kept message is still in it. */
23
26
  export declare function latestCompaction(history: SparkUIMessage[]): {
24
27
  compaction: SparkCompaction;
25
28
  carrier: number;
29
+ part: number;
26
30
  firstKept: number;
27
31
  } | undefined;
28
32
  /**
29
33
  * The conversation as the model reads it: the messages before the compaction's
30
34
  * kept message replaced by its summary, with the newest COMPACTION_USER_IMAGES
31
- * images the user attached to them. `compaction` defaults to the latest one in `history`; `skills` are the
32
- * host's, to send the current text of the skills it names. Without a
33
- * compaction, `history` is returned as it is.
35
+ * images the user attached to them, and -- for the latest compaction, when it was
36
+ * written during its turn -- the recording message's steps before
37
+ * `firstKeptStep` left out with them. `compaction` defaults to the latest one in
38
+ * `history`; `skills` are the host's, to send the current text of the skills it
39
+ * names. Without a compaction, `history` is returned as it is.
34
40
  */
35
41
  export declare function applyCompaction(history: SparkUIMessage[], options?: {
36
42
  compaction?: SparkCompaction;
37
43
  skills?: readonly SparkSkill[];
38
44
  }): SparkUIMessage[];
39
- export type CompactSparkHistoryOptions = {
40
- history: SparkUIMessage[];
41
- /** `history` as model messages, with `compaction` applied when given. */
42
- convert(compaction?: SparkCompaction): Promise<ModelMessage[]>;
45
+ type SummaryOptions = {
43
46
  model: LanguageModel;
44
47
  modelId: string;
45
48
  system: string;
@@ -53,6 +56,11 @@ export type CompactSparkHistoryOptions = {
53
56
  onCompacting?(): void;
54
57
  limits?: SparkCompactionLimits;
55
58
  };
59
+ export type CompactSparkHistoryOptions = SummaryOptions & {
60
+ history: SparkUIMessage[];
61
+ /** `history` as model messages, with `compaction` applied when given. */
62
+ convert(compaction?: SparkCompaction): Promise<ModelMessage[]>;
63
+ };
56
64
  /**
57
65
  * The turn's model messages, compacted first when the conversation has reached
58
66
  * the threshold. `compaction` is the new one, for the host to record; it is
@@ -62,3 +70,28 @@ export declare function compactSparkHistory(options: CompactSparkHistoryOptions)
62
70
  messages: ModelMessage[];
63
71
  compaction?: SparkCompaction;
64
72
  }>;
73
+ export type StepCompactionOptions = SummaryOptions & {
74
+ /** The stored conversation, ending with the current user message. */
75
+ history: SparkUIMessage[];
76
+ /** The compaction the turn started from: the one written before its first step, else `history`'s latest. */
77
+ previous?: SparkCompaction;
78
+ /** The host's skills, for the summary message's skill text. */
79
+ skills?: readonly SparkSkill[];
80
+ /** Stored messages as model messages. */
81
+ convert(messages: SparkUIMessage[]): Promise<ModelMessage[]>;
82
+ /** A new compaction, for the host to record on the turn's message. */
83
+ onCompaction(compaction: SparkCompaction): void;
84
+ };
85
+ /** What the provider reported for the step before. */
86
+ export type StepUsage = {
87
+ inputTokens?: number;
88
+ outputTokens?: number;
89
+ };
90
+ /**
91
+ * Compaction before each later step of a turn. Returns the step's messages,
92
+ * compacted when the conversation has reached the threshold; the AI SDK carries
93
+ * what a step was sent into the next, so a compaction holds for the rest of the
94
+ * turn.
95
+ */
96
+ export declare function createStepCompaction(options: StepCompactionOptions): (messages: ModelMessage[], last?: StepUsage) => Promise<ModelMessage[]>;
97
+ export {};