@womp/spark-agent 6.2.0 → 6.4.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 +69 -0
- package/README.md +12 -2
- package/dist/agent.d.ts +12 -2
- package/dist/agent.js +32 -1
- package/dist/client.d.ts +4 -2
- package/dist/client.js +13 -1
- package/dist/compaction.d.ts +64 -0
- package/dist/compaction.js +296 -0
- package/dist/context-budget.d.ts +2 -0
- package/dist/context-budget.js +14 -6
- package/dist/goals.d.ts +46 -3
- package/dist/goals.js +23 -18
- package/dist/index.d.ts +1 -0
- package/dist/model-history.d.ts +13 -3
- package/dist/model-history.js +24 -3
- package/dist/server/executor.d.ts +2 -0
- package/dist/server/executor.js +2 -10
- package/dist/server/express.js +9 -0
- package/dist/server/goal-evaluator.d.ts +13 -1
- package/dist/server/goal-evaluator.js +36 -4
- package/dist/server/goal-proposer.d.ts +15 -0
- package/dist/server/goal-proposer.js +64 -0
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.js +4 -3
- package/dist/server/service.d.ts +10 -1
- package/dist/server/service.js +63 -11
- package/dist/server/standalone.d.ts +8 -0
- package/dist/server/standalone.js +66 -35
- package/dist/ui.d.ts +43 -2
- package/dist/ui.js +16 -1
- package/dist/user-images.d.ts +10 -0
- package/dist/user-images.js +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,75 @@ 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
|
+
## 6.4.0 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Conversation compaction. Once a conversation reaches 256k tokens (by the provider's count for its
|
|
12
|
+
last step, or an estimate), the run's own model summarizes everything before the most recent
|
|
13
|
+
16k tokens of whole turns, before the turn's first step. Later requests send the summary, the
|
|
14
|
+
current text of the skills read in the summarized messages, and the newest four images the user
|
|
15
|
+
attached to them (`COMPACTION_USER_IMAGES`), in their place. The summary is
|
|
16
|
+
recorded as a `data-compaction` part (`SparkCompaction`) on the turn's assistant message, so
|
|
17
|
+
hosts store it with the message; the chat still has every message. A summary that fails leaves
|
|
18
|
+
the turn uncompacted, and context trimming still applies.
|
|
19
|
+
- `SparkAgentOptions.history`: the stored conversation, converted and compacted by the run. Pass it
|
|
20
|
+
instead of `messages` to get compaction; `createSparkRunExecutor` now does.
|
|
21
|
+
- `convertSparkMessages` takes `{ compaction, skills }` and applies the latest compaction in the
|
|
22
|
+
messages it is given.
|
|
23
|
+
- `SparkAgentOptions.compaction` and `SparkRunExecutorOptions.compaction` (`SparkCompactionLimits`)
|
|
24
|
+
lower the threshold and the recent tail kept, for a host that exercises compaction without a
|
|
25
|
+
production-sized conversation, such as local development. Absent, the defaults apply.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Goal evaluation attaches the newest `GOAL_REFERENCE_IMAGES` images the user attached to the
|
|
30
|
+
conversation, however far back, as the reference, ahead of the inspection screenshots. It reads
|
|
31
|
+
them only from `data:` URLs, so a host that stores them elsewhere passes `messages` with those
|
|
32
|
+
images inline; others are listed as unavailable.
|
|
33
|
+
- When a conversation is over its context window, trimming omits the agent's older screenshots
|
|
34
|
+
before the older images the user attached.
|
|
35
|
+
|
|
36
|
+
### Migration
|
|
37
|
+
|
|
38
|
+
- Hosts that validate stored messages with `sparkDataPartSchemas` must take this version before any
|
|
39
|
+
host writing compactions reads the same conversations, or the new part fails validation.
|
|
40
|
+
|
|
41
|
+
## 6.3.0 — 2026-10-09
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Goals start from the conditions in an ordinary message. `SparkAgentService.proposeGoal`,
|
|
46
|
+
`POST /conversations/:id/goal/proposal` and `SparkChatTransport.proposeGoal` return a title and
|
|
47
|
+
the message's success conditions, each marked `measurable` or left to the user's review. Hosts
|
|
48
|
+
supply `SparkAgentServiceOptions.proposeGoal`; `createSparkGoalProposer` is the shared one.
|
|
49
|
+
- The goal `set` command accepts `title` and `conditions`. The service attaches them to the
|
|
50
|
+
initiating message as a `data-goal` part, the evaluator judges each measurable condition after
|
|
51
|
+
every try, and `SparkGoal.conditions` records `met` and `reason`. A goal with conditions completes
|
|
52
|
+
only when no measurable condition is unmet. `SparkChatTransport.sendMessages` forwards
|
|
53
|
+
`body.goal`, so a confirmed goal is sent with its message.
|
|
54
|
+
- `SparkGoal.tries` keeps each check a goal with conditions has had: the try's number, the run that
|
|
55
|
+
made it, the evaluator's reason and every condition's result, so a host can show a goal's history.
|
|
56
|
+
- The goal `update_budget` command accepts `maxTurns` as well as `creditBudget`, either or both, so
|
|
57
|
+
a goal that used its tries can be given more and resumed.
|
|
58
|
+
- `GOAL_CONFIRMATION_DEFAULTS` (5 tries, 2,000 credits) and `MAX_GOAL_LENGTH` from
|
|
59
|
+
`@womp/spark-agent/ui`, for the confirmation a host shows and the objective it sends.
|
|
60
|
+
- The standalone UI reads each request's conditions, asks the user to confirm them with a tries and
|
|
61
|
+
a credit cap or to try once, and shows the goal's progress. It estimates credits from model cost
|
|
62
|
+
at a development rate; it does not bill.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- A goal's initiating user message can carry a `data-goal` part. Hosts that render or mirror user
|
|
67
|
+
message parts should show it as the confirmed goal or skip it. A host that gives the model its
|
|
68
|
+
own copy of the message, as Crow does from the Swan thread, must keep the part on that copy:
|
|
69
|
+
it is how the worker reads the conditions.
|
|
70
|
+
|
|
71
|
+
### Removed
|
|
72
|
+
|
|
73
|
+
- `parseSparkGoalCommand`. A goal starts from the confirmation, not from a typed `/goal`; hosts
|
|
74
|
+
send the `set`, `pause`, `resume` and `clear` commands from their own controls.
|
|
75
|
+
|
|
7
76
|
## 6.2.0 — 2026-10-09
|
|
8
77
|
|
|
9
78
|
### Added
|
package/README.md
CHANGED
|
@@ -35,8 +35,8 @@ Kakapo, Express, or React at runtime.
|
|
|
35
35
|
|
|
36
36
|
## Running in a host application
|
|
37
37
|
|
|
38
|
-
`runSparkAgent` accepts a configured model,
|
|
39
|
-
and an artifact resolver. It exposes exactly `read_skill` and `execute_javascript` to the model.
|
|
38
|
+
`runSparkAgent` accepts a configured model, the stored conversation (`history`, compacted once it
|
|
39
|
+
is long) or model messages, an executor, a capability composition, and an artifact resolver. It exposes exactly `read_skill` and `execute_javascript` to the model.
|
|
40
40
|
Skills carry instructions, declarations, and JavaScript examples. Kakapo skill declarations are
|
|
41
41
|
generated from its typed API at build time and packaged as JavaScript strings.
|
|
42
42
|
|
|
@@ -138,6 +138,7 @@ Closing or reloading the page only detaches the SSE subscriber. It does not canc
|
|
|
138
138
|
POST /agent/conversations/:conversationId/messages
|
|
139
139
|
PATCH /agent/conversations/:conversationId/settings
|
|
140
140
|
POST /agent/conversations/:conversationId/goal
|
|
141
|
+
POST /agent/conversations/:conversationId/goal/proposal
|
|
141
142
|
GET /agent/runs/:runId/stream?after=0
|
|
142
143
|
POST /agent/runs/:runId/cancel
|
|
143
144
|
```
|
|
@@ -146,6 +147,15 @@ Conversation settings contain `modelId` and `reasoningEffort`; save them with
|
|
|
146
147
|
`transport.saveConversationSettings(id, settings)`. The goal endpoint accepts `set`, `pause`,
|
|
147
148
|
`resume`, `clear`, and `update_budget` commands.
|
|
148
149
|
|
|
150
|
+
A goal starts from an ordinary message. `transport.proposeGoal(id, message, settings)` returns a
|
|
151
|
+
title and the success conditions read from the message, each marked `measurable` or left to the
|
|
152
|
+
user's review; it stores and starts nothing. After the user confirms them with a tries cap
|
|
153
|
+
(`maxTurns`) and a credit cap, send the message with `body.goal` set to a `set` command carrying
|
|
154
|
+
`title`, `conditions`, `maxTurns` and `creditBudget`. The service attaches the confirmed conditions
|
|
155
|
+
to that message as a `data-goal` part, evaluates each measurable condition after every try, and
|
|
156
|
+
records the result on `goal.conditions`. Send the message without `body.goal` to try once. Hosts
|
|
157
|
+
enable proposals with `SparkAgentServiceOptions.proposeGoal`, such as `createSparkGoalProposer`.
|
|
158
|
+
|
|
149
159
|
The SSE response contains ordered persistence envelopes around standard AI SDK chunks:
|
|
150
160
|
|
|
151
161
|
```ts
|
package/dist/agent.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { type SparkPersistence } from "./agent-tools.js";
|
|
|
3
3
|
import type { SparkArtifactResolver, SparkCapabilities } from "./capabilities.js";
|
|
4
4
|
import type { JavaScriptExecutor } from "./executor.js";
|
|
5
5
|
import { type ContextBudgetReport } from "./context-budget.js";
|
|
6
|
-
import type
|
|
6
|
+
import { type SparkCompactionLimits } from "./compaction.js";
|
|
7
|
+
import type { SparkUIMessage, SparkUIMessageChunk } from "./ui.js";
|
|
7
8
|
export type SparkAgentResult = {
|
|
8
9
|
text: string;
|
|
9
10
|
steps: number;
|
|
@@ -12,7 +13,14 @@ export type SparkAgentResult = {
|
|
|
12
13
|
finishReason: string;
|
|
13
14
|
};
|
|
14
15
|
export type SparkAgentOptions = {
|
|
15
|
-
messages
|
|
16
|
+
/** The conversation as model messages, sent as given. Pass `history` instead to have long conversations compacted. */
|
|
17
|
+
messages?: ModelMessage[];
|
|
18
|
+
/**
|
|
19
|
+
* 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.
|
|
22
|
+
*/
|
|
23
|
+
history?: SparkUIMessage[];
|
|
16
24
|
model: LanguageModel;
|
|
17
25
|
modelId: string;
|
|
18
26
|
providerOptions?: Parameters<typeof streamText>[0]["providerOptions"];
|
|
@@ -30,6 +38,8 @@ export type SparkAgentOptions = {
|
|
|
30
38
|
prepareMessages?(messages: ModelMessage[]): Promise<ModelMessage[]>;
|
|
31
39
|
/** The model's context window. Absent: `contextWindowFor(modelId)`. */
|
|
32
40
|
contextWindow?: number;
|
|
41
|
+
/** When `history` is compacted. Absent: the defaults in ./compaction.ts. */
|
|
42
|
+
compaction?: SparkCompactionLimits;
|
|
33
43
|
/** Called when a step's messages had to be trimmed to fit, or still do not fit. */
|
|
34
44
|
onContextBudget?(report: ContextBudgetReport): void;
|
|
35
45
|
};
|
package/dist/agent.js
CHANGED
|
@@ -5,6 +5,8 @@ import { createSparkTools } from "./agent-tools.js";
|
|
|
5
5
|
import { sparkSystemPrompt } from "./prompt.js";
|
|
6
6
|
import { calculateStepCost } from "./pricing.js";
|
|
7
7
|
import { contextWindowFor, fitMessagesToContext } from "./context-budget.js";
|
|
8
|
+
import { compactSparkHistory } from "./compaction.js";
|
|
9
|
+
import { convertSparkMessages } from "./model-history.js";
|
|
8
10
|
function errorMessage(error) {
|
|
9
11
|
return sparkErrorDiagnostics(error).message;
|
|
10
12
|
}
|
|
@@ -37,6 +39,9 @@ export function runSparkAgent(options) {
|
|
|
37
39
|
if (Number.isInteger(maxSteps) === false || maxSteps < 1) {
|
|
38
40
|
throw new Error("maxSteps must be a positive integer.");
|
|
39
41
|
}
|
|
42
|
+
if ((options.messages === undefined) === (options.history === undefined)) {
|
|
43
|
+
throw new Error("Pass either messages or history.");
|
|
44
|
+
}
|
|
40
45
|
const startedAt = Date.now();
|
|
41
46
|
const controller = new AbortController();
|
|
42
47
|
const onAbort = () => controller.abort(options.signal?.reason);
|
|
@@ -125,10 +130,36 @@ export function runSparkAgent(options) {
|
|
|
125
130
|
write: (chunk) => writer.write(chunk),
|
|
126
131
|
});
|
|
127
132
|
const readSystem = () => sparkSystemPrompt(options.capabilities.skills, options.instructions);
|
|
133
|
+
let messages = options.messages ?? [];
|
|
134
|
+
if (options.history !== undefined) {
|
|
135
|
+
const history = options.history;
|
|
136
|
+
const compacted = await compactSparkHistory({
|
|
137
|
+
history,
|
|
138
|
+
convert: (compaction) => convertSparkMessages(history, tools, { compaction, skills: options.capabilities.skills }),
|
|
139
|
+
model: options.model,
|
|
140
|
+
modelId: options.modelId,
|
|
141
|
+
system: readSystem(),
|
|
142
|
+
tools,
|
|
143
|
+
providerOptions: options.providerOptions,
|
|
144
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
145
|
+
contextWindow,
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
limits: options.compaction,
|
|
148
|
+
onCompacting: () => writer.write({
|
|
149
|
+
type: "data-run-status",
|
|
150
|
+
data: { runId: options.runId, status: "running", label: "Making room so we can keep talking" },
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
messages = compacted.messages;
|
|
154
|
+
if (compacted.compaction !== undefined) {
|
|
155
|
+
writer.write({ type: "data-compaction", id: `compaction-${options.messageId}`, data: compacted.compaction });
|
|
156
|
+
}
|
|
157
|
+
controller.signal.throwIfAborted();
|
|
158
|
+
}
|
|
128
159
|
const generation = streamText({
|
|
129
160
|
model: options.model,
|
|
130
161
|
system: readSystem(),
|
|
131
|
-
messages
|
|
162
|
+
messages,
|
|
132
163
|
tools,
|
|
133
164
|
toolChoice: "auto",
|
|
134
165
|
abortSignal: controller.signal,
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ChatRequestOptions, ChatTransport, UIMessageChunk } from "ai";
|
|
2
2
|
import type { SparkRunSettings } from "./server/types.js";
|
|
3
|
-
import type { SparkGoal, SparkGoalCommand } from "./goals.js";
|
|
4
|
-
export type { SparkGoal, SparkGoalCommand } from "./goals.js";
|
|
3
|
+
import type { SparkGoal, SparkGoalCommand, SparkGoalProposal } from "./goals.js";
|
|
4
|
+
export type { SparkGoal, SparkGoalCommand, SparkGoalCondition, SparkGoalProposal, SparkGoalTry } from "./goals.js";
|
|
5
5
|
import type { SparkConversation, SparkConversationSummary, SparkRun } from "./server/types.js";
|
|
6
6
|
import type { SparkUIMessage } from "./ui.js";
|
|
7
7
|
export type { SparkConversation, SparkConversationSummary, SparkRun, SparkRunSettings, } from "./server/types.js";
|
|
@@ -44,6 +44,8 @@ export declare class SparkChatTransport implements ChatTransport<SparkUIMessage>
|
|
|
44
44
|
goal?: SparkGoal;
|
|
45
45
|
run?: SparkRun;
|
|
46
46
|
}>;
|
|
47
|
+
/** The success conditions read from a message, to confirm before sending it as a goal. */
|
|
48
|
+
proposeGoal(id: string, message: SparkUIMessage, settings: SparkRunSettings, signal?: AbortSignal): Promise<SparkGoalProposal>;
|
|
47
49
|
getGoal(id: string): Promise<SparkGoal | undefined>;
|
|
48
50
|
getRun(id: string): Promise<SparkRun>;
|
|
49
51
|
saveConversationSettings(id: string, settings: SparkRunSettings): Promise<void>;
|
package/dist/client.js
CHANGED
|
@@ -51,7 +51,8 @@ export class SparkChatTransport {
|
|
|
51
51
|
: options.messages.find(({ id }) => id === options.messageId) ?? options.messages.at(-1);
|
|
52
52
|
if (message === undefined)
|
|
53
53
|
throw new Error("Spark Agent requires a message to send.");
|
|
54
|
-
const
|
|
54
|
+
const requestBody = options.body;
|
|
55
|
+
const settings = this.settingsFrom(requestBody);
|
|
55
56
|
const response = await this.request(`/conversations/${encodeURIComponent(options.chatId)}/messages`, {
|
|
56
57
|
method: "POST",
|
|
57
58
|
body: JSON.stringify({
|
|
@@ -59,6 +60,8 @@ export class SparkChatTransport {
|
|
|
59
60
|
message,
|
|
60
61
|
messageId: options.messageId,
|
|
61
62
|
settings,
|
|
63
|
+
// A confirmed goal: the message starts it, and the run is its first try.
|
|
64
|
+
...(requestBody?.goal === undefined ? {} : { goal: requestBody.goal }),
|
|
62
65
|
}),
|
|
63
66
|
signal: options.abortSignal,
|
|
64
67
|
}, options.headers);
|
|
@@ -126,6 +129,15 @@ export class SparkChatTransport {
|
|
|
126
129
|
throw await responseError(response);
|
|
127
130
|
return response.json();
|
|
128
131
|
}
|
|
132
|
+
/** The success conditions read from a message, to confirm before sending it as a goal. */
|
|
133
|
+
async proposeGoal(id, message, settings, signal) {
|
|
134
|
+
const response = await this.request(`/conversations/${encodeURIComponent(id)}/goal/proposal`, {
|
|
135
|
+
method: "POST", body: JSON.stringify({ message, settings }), signal,
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok)
|
|
138
|
+
throw await responseError(response);
|
|
139
|
+
return (await response.json()).proposal;
|
|
140
|
+
}
|
|
129
141
|
async getGoal(id) {
|
|
130
142
|
const response = await this.request(`/conversations/${encodeURIComponent(id)}/goal`, { method: "GET" });
|
|
131
143
|
if (!response.ok)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { generateText, type LanguageModel, type ModelMessage, type ToolSet } from "ai";
|
|
2
|
+
import type { SparkSkill } from "./capabilities.js";
|
|
3
|
+
import { type SparkCompaction, type SparkUIMessage } from "./ui.js";
|
|
4
|
+
/** Compact once the conversation reaches this many tokens. */
|
|
5
|
+
export declare const COMPACTION_THRESHOLD_TOKENS = 256000;
|
|
6
|
+
/** Recent whole turns kept verbatim, up to this many tokens, besides the current turn. */
|
|
7
|
+
export declare const COMPACTION_KEEP_RECENT_TOKENS = 16000;
|
|
8
|
+
/** The user's newest images from the summarized messages that the summary message carries. */
|
|
9
|
+
export declare const COMPACTION_USER_IMAGES = 4;
|
|
10
|
+
/**
|
|
11
|
+
* When a conversation is compacted, for a host that exercises compaction
|
|
12
|
+
* without filling a production-sized conversation, such as local development.
|
|
13
|
+
*/
|
|
14
|
+
export type SparkCompactionLimits = {
|
|
15
|
+
/** Default COMPACTION_THRESHOLD_TOKENS. */
|
|
16
|
+
thresholdTokens?: number;
|
|
17
|
+
/** Default COMPACTION_KEEP_RECENT_TOKENS. */
|
|
18
|
+
keepRecentTokens?: number;
|
|
19
|
+
};
|
|
20
|
+
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
|
+
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.]";
|
|
22
|
+
/** The latest compaction in the conversation whose kept message is still in it. */
|
|
23
|
+
export declare function latestCompaction(history: SparkUIMessage[]): {
|
|
24
|
+
compaction: SparkCompaction;
|
|
25
|
+
carrier: number;
|
|
26
|
+
firstKept: number;
|
|
27
|
+
} | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* The conversation as the model reads it: the messages before the compaction's
|
|
30
|
+
* 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.
|
|
34
|
+
*/
|
|
35
|
+
export declare function applyCompaction(history: SparkUIMessage[], options?: {
|
|
36
|
+
compaction?: SparkCompaction;
|
|
37
|
+
skills?: readonly SparkSkill[];
|
|
38
|
+
}): SparkUIMessage[];
|
|
39
|
+
export type CompactSparkHistoryOptions = {
|
|
40
|
+
history: SparkUIMessage[];
|
|
41
|
+
/** `history` as model messages, with `compaction` applied when given. */
|
|
42
|
+
convert(compaction?: SparkCompaction): Promise<ModelMessage[]>;
|
|
43
|
+
model: LanguageModel;
|
|
44
|
+
modelId: string;
|
|
45
|
+
system: string;
|
|
46
|
+
/** The run's tools; the summary request sends their definitions without `execute`. */
|
|
47
|
+
tools: ToolSet;
|
|
48
|
+
providerOptions?: Parameters<typeof generateText>[0]["providerOptions"];
|
|
49
|
+
maxOutputTokens?: number;
|
|
50
|
+
contextWindow?: number;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
/** Called just before the summary request, which can take a while. */
|
|
53
|
+
onCompacting?(): void;
|
|
54
|
+
limits?: SparkCompactionLimits;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The turn's model messages, compacted first when the conversation has reached
|
|
58
|
+
* the threshold. `compaction` is the new one, for the host to record; it is
|
|
59
|
+
* absent when nothing was compacted, including when the summary failed.
|
|
60
|
+
*/
|
|
61
|
+
export declare function compactSparkHistory(options: CompactSparkHistoryOptions): Promise<{
|
|
62
|
+
messages: ModelMessage[];
|
|
63
|
+
compaction?: SparkCompaction;
|
|
64
|
+
}>;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Replace a long conversation's earlier messages with a summary.
|
|
2
|
+
//
|
|
3
|
+
// WHEN. Once per turn, before its first step: when the conversation reaches
|
|
4
|
+
// COMPACTION_THRESHOLD_TOKENS, everything before the most recent
|
|
5
|
+
// COMPACTION_KEEP_RECENT_TOKENS of whole turns is summarized by the run's own
|
|
6
|
+
// model. Every later request sends the summary in their place. Below the
|
|
7
|
+
// threshold nothing happens, and a summary that fails leaves the turn as it was:
|
|
8
|
+
// ./context-budget.ts still keeps every request inside the model's window.
|
|
9
|
+
//
|
|
10
|
+
// WHERE IT IS KEPT. The summary is a `data-compaction` part on the assistant
|
|
11
|
+
// message of the turn that wrote it (SparkCompaction, ./ui.ts), so every host
|
|
12
|
+
// stores and reloads it with the message, and the chat still shows every
|
|
13
|
+
// message it replaces. `applyCompaction`, called by `convertSparkMessages`,
|
|
14
|
+
// sends the latest one in place of the messages before `firstKeptMessageId`.
|
|
15
|
+
// A later compaction is written from the conversation as the model then sees
|
|
16
|
+
// it -- the earlier summary first -- so the summary is updated, not restarted.
|
|
17
|
+
//
|
|
18
|
+
// THE CACHE. The summary request is the previous request's prefix -- the same
|
|
19
|
+
// system prompt, tool definitions and messages -- plus one instruction, so the
|
|
20
|
+
// provider's prompt cache serves almost all of it. Tool definitions are sent
|
|
21
|
+
// without `execute`: a call the model makes anyway is never run, and fails the
|
|
22
|
+
// summary instead.
|
|
23
|
+
//
|
|
24
|
+
// SKILLS. A skill read in the summarized messages would otherwise be lost with
|
|
25
|
+
// them. The compaction names them, and its summary message carries their
|
|
26
|
+
// current text, ahead of the summary, so the model does not have to read them again.
|
|
27
|
+
//
|
|
28
|
+
// IMAGES. The images the user attached to the summarized messages are what the
|
|
29
|
+
// work is often built from, so the summary message carries the newest
|
|
30
|
+
// COMPACTION_USER_IMAGES of them too, as the same file parts; the summary
|
|
31
|
+
// describes the rest. They are still on the stored messages: nothing records them.
|
|
32
|
+
import { generateText } from "ai";
|
|
33
|
+
import { CHARS_PER_TOKEN, IMAGE_TOKENS, estimateRequestTokens, fitMessagesToContext, } from "./context-budget.js";
|
|
34
|
+
import { sparkDataPartSchemas } from "./ui.js";
|
|
35
|
+
import { userImages } from "./user-images.js";
|
|
36
|
+
/** Compact once the conversation reaches this many tokens. */
|
|
37
|
+
export const COMPACTION_THRESHOLD_TOKENS = 256_000;
|
|
38
|
+
/** Recent whole turns kept verbatim, up to this many tokens, besides the current turn. */
|
|
39
|
+
export const COMPACTION_KEEP_RECENT_TOKENS = 16_000;
|
|
40
|
+
/** The user's newest images from the summarized messages that the summary message carries. */
|
|
41
|
+
export const COMPACTION_USER_IMAGES = 4;
|
|
42
|
+
export 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.
|
|
43
|
+
If the conversation begins with an earlier summary, carry its content forward and update it.
|
|
44
|
+
Use these sections:
|
|
45
|
+
## Goal
|
|
46
|
+
## User preferences and constraints
|
|
47
|
+
## Decisions and current scene state (objects with their names and IDs)
|
|
48
|
+
## Done
|
|
49
|
+
## Open and next steps
|
|
50
|
+
## Artifacts and images (each artifact ID, and what each image the user attached shows)
|
|
51
|
+
## Errors and approaches to avoid
|
|
52
|
+
Keep exact names, IDs, numbers and the user's own wording for requirements. Stay under 1,500 words.`;
|
|
53
|
+
export 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.]";
|
|
54
|
+
/** The latest compaction in the conversation whose kept message is still in it. */
|
|
55
|
+
export function latestCompaction(history) {
|
|
56
|
+
for (let carrier = history.length - 1; carrier >= 0; carrier -= 1) {
|
|
57
|
+
const message = history[carrier];
|
|
58
|
+
if (message?.role !== "assistant")
|
|
59
|
+
continue;
|
|
60
|
+
for (let i = message.parts.length - 1; i >= 0; i -= 1) {
|
|
61
|
+
const part = message.parts[i];
|
|
62
|
+
if (part?.type !== "data-compaction")
|
|
63
|
+
continue;
|
|
64
|
+
const parsed = sparkDataPartSchemas.compaction.safeParse(part.data);
|
|
65
|
+
if (!parsed.success)
|
|
66
|
+
continue;
|
|
67
|
+
const firstKept = history.findIndex(({ id }) => id === parsed.data.firstKeptMessageId);
|
|
68
|
+
if (firstKept >= 0)
|
|
69
|
+
return { compaction: parsed.data, carrier, firstKept };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The conversation as the model reads it: the messages before the compaction's
|
|
76
|
+
* kept message replaced by its summary, with the newest COMPACTION_USER_IMAGES
|
|
77
|
+
* images the user attached to them. `compaction` defaults to the latest one in `history`; `skills` are the
|
|
78
|
+
* host's, to send the current text of the skills it names. Without a
|
|
79
|
+
* compaction, `history` is returned as it is.
|
|
80
|
+
*/
|
|
81
|
+
export function applyCompaction(history, options = {}) {
|
|
82
|
+
const compaction = options.compaction ?? latestCompaction(history)?.compaction;
|
|
83
|
+
if (compaction === undefined)
|
|
84
|
+
return history;
|
|
85
|
+
const firstKept = history.findIndex(({ id }) => id === compaction.firstKeptMessageId);
|
|
86
|
+
if (firstKept < 0)
|
|
87
|
+
return history;
|
|
88
|
+
// The skills go before the summary, in a part of their own: they change less
|
|
89
|
+
// often, so the next compaction leaves them in the cached prefix.
|
|
90
|
+
const skills = skillsText(compaction.skills, options.skills);
|
|
91
|
+
const parts = [{ type: "text", text: COMPACTION_NOTE }];
|
|
92
|
+
if (skills !== undefined)
|
|
93
|
+
parts.push({ type: "text", text: skills });
|
|
94
|
+
const shown = new Set(userImages(history.slice(firstKept)).map(({ url }) => url));
|
|
95
|
+
const images = userImages(history.slice(0, firstKept))
|
|
96
|
+
.filter(({ url }) => !shown.has(url))
|
|
97
|
+
.slice(-COMPACTION_USER_IMAGES);
|
|
98
|
+
if (images.length > 0) {
|
|
99
|
+
parts.push({ type: "text", text: "The newest images the user attached to the messages the summary replaces, in the order sent:" }, ...images);
|
|
100
|
+
}
|
|
101
|
+
parts.push({ type: "text", text: `<conversation-summary>\n${compaction.summary}\n</conversation-summary>` });
|
|
102
|
+
return [{ id: `compaction-${compaction.firstKeptMessageId}`, role: "user", parts }, ...history.slice(firstKept)];
|
|
103
|
+
}
|
|
104
|
+
function skillsText(names, available) {
|
|
105
|
+
if (names.length === 0)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (available === undefined) {
|
|
108
|
+
return `Skills read earlier in this conversation (read them again before using their APIs): ${names.join(", ")}`;
|
|
109
|
+
}
|
|
110
|
+
const skills = names.flatMap((name) => available.filter((skill) => skill.name === name));
|
|
111
|
+
if (skills.length === 0)
|
|
112
|
+
return undefined;
|
|
113
|
+
return [
|
|
114
|
+
"Skills already read in this conversation, as read_skill returns them now; they need no reading again:",
|
|
115
|
+
...skills.map((skill) => `<skill name="${skill.name}" version="${skill.version}">\n${skill.instructions}\n\n${skill.declarations}${skill.examples.length > 0 ? `\n\nExamples:\n${skill.examples.join("\n\n")}` : ""}\n</skill>`),
|
|
116
|
+
].join("\n\n");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The turn's model messages, compacted first when the conversation has reached
|
|
120
|
+
* the threshold. `compaction` is the new one, for the host to record; it is
|
|
121
|
+
* absent when nothing was compacted, including when the summary failed.
|
|
122
|
+
*/
|
|
123
|
+
export async function compactSparkHistory(options) {
|
|
124
|
+
const { history } = options;
|
|
125
|
+
const messages = await options.convert();
|
|
126
|
+
const threshold = Math.min(options.limits?.thresholdTokens ?? COMPACTION_THRESHOLD_TOKENS, options.contextWindow === undefined ? Infinity : options.contextWindow - (options.maxOutputTokens ?? 0));
|
|
127
|
+
const previous = latestCompaction(history);
|
|
128
|
+
const size = conversationTokens(history, previous?.carrier ?? -1) ?? estimate(messages, options);
|
|
129
|
+
if (size < threshold)
|
|
130
|
+
return { messages };
|
|
131
|
+
const firstKept = cutIndex(history, previous?.firstKept ?? 0, options.limits?.keepRecentTokens ?? COMPACTION_KEEP_RECENT_TOKENS);
|
|
132
|
+
if (firstKept === undefined)
|
|
133
|
+
return { messages };
|
|
134
|
+
// The request ends before the current user message: the previous request's
|
|
135
|
+
// prefix, which the provider has cached.
|
|
136
|
+
const current = lastIndex(messages, ({ role }) => role === "user");
|
|
137
|
+
if (current <= 0)
|
|
138
|
+
return { messages };
|
|
139
|
+
options.onCompacting?.();
|
|
140
|
+
let summary;
|
|
141
|
+
try {
|
|
142
|
+
summary = await summarize(messages.slice(0, current), options);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (options.signal?.aborted)
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
options.signal?.throwIfAborted();
|
|
149
|
+
if (summary === undefined)
|
|
150
|
+
return { messages };
|
|
151
|
+
const kept = readSkills(history.slice(firstKept));
|
|
152
|
+
const skills = [
|
|
153
|
+
...new Set([...(previous?.compaction.skills ?? []), ...readSkills(history.slice(previous?.firstKept ?? 0, firstKept))]),
|
|
154
|
+
].filter((name) => !kept.has(name));
|
|
155
|
+
const compaction = {
|
|
156
|
+
firstKeptMessageId: history[firstKept].id,
|
|
157
|
+
summary,
|
|
158
|
+
skills,
|
|
159
|
+
modelId: options.modelId,
|
|
160
|
+
tokensBefore: estimate(messages, options),
|
|
161
|
+
tokensAfter: 0,
|
|
162
|
+
createdAt: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
const compacted = await options.convert(compaction);
|
|
165
|
+
compaction.tokensAfter = estimate(compacted, options);
|
|
166
|
+
return { messages: compacted, compaction };
|
|
167
|
+
}
|
|
168
|
+
async function summarize(messages, options) {
|
|
169
|
+
const definitions = Object.fromEntries(Object.entries(options.tools).map(([name, tool]) => {
|
|
170
|
+
const { execute: _execute, onInputStart: _start, onInputDelta: _delta, onInputAvailable: _available, ...definition } = tool;
|
|
171
|
+
return [name, definition];
|
|
172
|
+
}));
|
|
173
|
+
const fitted = fitMessagesToContext(messages, {
|
|
174
|
+
contextWindow: options.contextWindow,
|
|
175
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
176
|
+
system: options.system,
|
|
177
|
+
tools: definitions,
|
|
178
|
+
}).messages;
|
|
179
|
+
const result = await generateText({
|
|
180
|
+
model: options.model,
|
|
181
|
+
system: options.system,
|
|
182
|
+
tools: definitions,
|
|
183
|
+
toolChoice: "auto",
|
|
184
|
+
messages: [...fitted, { role: "user", content: [{ type: "text", text: COMPACTION_PROMPT }] }],
|
|
185
|
+
providerOptions: options.providerOptions,
|
|
186
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
187
|
+
abortSignal: options.signal,
|
|
188
|
+
});
|
|
189
|
+
const text = result.text.trim();
|
|
190
|
+
return result.finishReason === "stop" && result.toolCalls.length === 0 && text.length > 0 ? text : undefined;
|
|
191
|
+
}
|
|
192
|
+
function estimate(messages, options) {
|
|
193
|
+
return estimateRequestTokens(messages, { system: options.system, tools: options.tools });
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The conversation's size from the provider's count for its last step, plus an
|
|
197
|
+
* estimate of what came after it. Undefined when there is no count to go by:
|
|
198
|
+
* the request is then estimated whole, which errs high.
|
|
199
|
+
*/
|
|
200
|
+
function conversationTokens(history, carrier) {
|
|
201
|
+
const step = lastStepTokens(history, carrier);
|
|
202
|
+
if (step === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
return step.tokens + sum(history.slice(step.index + 1).map(uiMessageTokens));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Input and output tokens of the conversation's last recorded step, when that
|
|
208
|
+
* step was sent after the latest compaction -- otherwise it measured messages
|
|
209
|
+
* the model no longer reads.
|
|
210
|
+
*/
|
|
211
|
+
function lastStepTokens(history, carrier) {
|
|
212
|
+
for (let index = history.length - 1; index > carrier; index -= 1) {
|
|
213
|
+
const message = history[index];
|
|
214
|
+
if (message?.role !== "assistant")
|
|
215
|
+
continue;
|
|
216
|
+
for (let i = message.parts.length - 1; i >= 0; i -= 1) {
|
|
217
|
+
const part = message.parts[i];
|
|
218
|
+
if (part?.type !== "data-step-metrics")
|
|
219
|
+
continue;
|
|
220
|
+
const usage = part.data.usage;
|
|
221
|
+
if (typeof usage?.inputTokens !== "number")
|
|
222
|
+
continue;
|
|
223
|
+
return { index, tokens: usage.inputTokens + (typeof usage.outputTokens === "number" ? usage.outputTokens : 0) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The first message to keep: the earliest user message after `floor` such that
|
|
230
|
+
* it and everything up to the current user message fit `keepRecent` tokens.
|
|
231
|
+
* The current user message when no earlier turn fits. Undefined when nothing
|
|
232
|
+
* after `floor` would be summarized.
|
|
233
|
+
*/
|
|
234
|
+
function cutIndex(history, floor, keepRecent) {
|
|
235
|
+
const current = lastIndex(history, ({ role }) => role === "user");
|
|
236
|
+
if (current <= floor)
|
|
237
|
+
return undefined;
|
|
238
|
+
let cut = current;
|
|
239
|
+
let tokens = 0;
|
|
240
|
+
for (let i = current - 1; i > floor; i -= 1) {
|
|
241
|
+
tokens += uiMessageTokens(history[i]);
|
|
242
|
+
if (tokens > keepRecent)
|
|
243
|
+
break;
|
|
244
|
+
if (history[i].role === "user")
|
|
245
|
+
cut = i;
|
|
246
|
+
}
|
|
247
|
+
return cut;
|
|
248
|
+
}
|
|
249
|
+
/** Skills read successfully in these messages, in the order first read. */
|
|
250
|
+
function readSkills(messages) {
|
|
251
|
+
const names = new Set();
|
|
252
|
+
for (const message of messages) {
|
|
253
|
+
if (message.role !== "assistant")
|
|
254
|
+
continue;
|
|
255
|
+
for (const part of message.parts) {
|
|
256
|
+
const isReadSkill = part.type === "tool-read_skill" || (part.type === "dynamic-tool" && part.toolName === "read_skill");
|
|
257
|
+
if (!isReadSkill || part.state !== "output-available")
|
|
258
|
+
continue;
|
|
259
|
+
const skills = part.output?.skills;
|
|
260
|
+
if (!Array.isArray(skills))
|
|
261
|
+
continue;
|
|
262
|
+
for (const skill of skills) {
|
|
263
|
+
if (skill?.ok === true && typeof skill.name === "string")
|
|
264
|
+
names.add(skill.name);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return names;
|
|
269
|
+
}
|
|
270
|
+
/** A stored message's size, roughly: its JSON, with images at their model cost and data URLs left out. */
|
|
271
|
+
function uiMessageTokens(message) {
|
|
272
|
+
let images = 0;
|
|
273
|
+
const json = JSON.stringify(message.parts, (key, value) => {
|
|
274
|
+
if (typeof value === "string" && key === "url" && value.startsWith("data:"))
|
|
275
|
+
return "";
|
|
276
|
+
if (typeof value === "object" && value !== null) {
|
|
277
|
+
const item = value;
|
|
278
|
+
if (item.type === "file" && typeof item.mediaType === "string" && item.mediaType.startsWith("image/"))
|
|
279
|
+
images += 1;
|
|
280
|
+
if (item.kind === "image" && item.inspect !== false)
|
|
281
|
+
images += 1;
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
});
|
|
285
|
+
return Math.ceil((json?.length ?? 0) / CHARS_PER_TOKEN) + images * IMAGE_TOKENS;
|
|
286
|
+
}
|
|
287
|
+
function sum(values) {
|
|
288
|
+
return values.reduce((total, value) => total + value, 0);
|
|
289
|
+
}
|
|
290
|
+
function lastIndex(items, test) {
|
|
291
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
292
|
+
if (test(items[i]))
|
|
293
|
+
return i;
|
|
294
|
+
}
|
|
295
|
+
return -1;
|
|
296
|
+
}
|
package/dist/context-budget.d.ts
CHANGED
|
@@ -61,3 +61,5 @@ export type ContextBudgetResult = {
|
|
|
61
61
|
export declare function fitMessagesToContext(messages: ModelMessage[], options: ContextBudgetOptions): ContextBudgetResult;
|
|
62
62
|
/** The estimate for a list of messages, as `fitMessagesToContext` counts them. */
|
|
63
63
|
export declare function estimateMessagesTokens(messages: ModelMessage[]): number;
|
|
64
|
+
/** The estimate for a whole request: its messages, system prompt and tool definitions. */
|
|
65
|
+
export declare function estimateRequestTokens(messages: ModelMessage[], options: Pick<ContextBudgetOptions, "system" | "tools">): number;
|