@womp/spark-agent 6.4.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/dist/goals.d.ts CHANGED
@@ -22,7 +22,7 @@ export type SparkGoalTry = {
22
22
  /** In the order of the goal's conditions; a condition left to the user has no result. */
23
23
  conditions: Pick<SparkGoalCondition, "met" | "reason">[];
24
24
  };
25
- /** The conditions read from a user message, for the user to confirm before a goal starts. */
25
+ /** The goal the agent proposes for a request, for the user to confirm before it starts. */
26
26
  export declare const sparkGoalProposalSchema: z.ZodObject<{
27
27
  title: z.ZodString;
28
28
  conditions: z.ZodArray<z.ZodObject<{
@@ -31,6 +31,43 @@ export declare const sparkGoalProposalSchema: z.ZodObject<{
31
31
  }, z.core.$strip>>;
32
32
  }, z.core.$strip>;
33
33
  export type SparkGoalProposal = z.infer<typeof sparkGoalProposalSchema>;
34
+ /**
35
+ * How the user answered a proposed goal: run it with these caps, try the request
36
+ * once, or nothing in the time the host waits.
37
+ */
38
+ export declare const sparkGoalDecisionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
39
+ creditBudget: z.ZodOptional<z.ZodNumber>;
40
+ maxTurns: z.ZodOptional<z.ZodNumber>;
41
+ kind: z.ZodLiteral<"goal">;
42
+ }, z.core.$strip>, z.ZodObject<{
43
+ kind: z.ZodLiteral<"once">;
44
+ }, z.core.$strip>, z.ZodObject<{
45
+ kind: z.ZodLiteral<"unanswered">;
46
+ }, z.core.$strip>], "kind">;
47
+ export type SparkGoalDecision = z.infer<typeof sparkGoalDecisionSchema>;
48
+ /** What became of a proposal, as the agent is told: `active` when a goal was already running. */
49
+ export declare const SPARK_GOAL_OUTCOMES: readonly ["started", "declined", "unanswered", "active"];
50
+ export type SparkGoalOutcome = (typeof SPARK_GOAL_OUTCOMES)[number];
51
+ /**
52
+ * A proposal as its message stores it: the `propose_goal` call that made it, and
53
+ * its outcome once there is one. Without an outcome it is waiting for the user
54
+ * while its run is, and was left unanswered once that run has ended.
55
+ */
56
+ export declare const sparkGoalProposalPartSchema: z.ZodObject<{
57
+ title: z.ZodString;
58
+ conditions: z.ZodArray<z.ZodObject<{
59
+ text: z.ZodString;
60
+ measurable: z.ZodBoolean;
61
+ }, z.core.$strip>>;
62
+ toolCallId: z.ZodString;
63
+ outcome: z.ZodOptional<z.ZodEnum<{
64
+ active: "active";
65
+ unanswered: "unanswered";
66
+ started: "started";
67
+ declined: "declined";
68
+ }>>;
69
+ }, z.core.$strip>;
70
+ export type SparkGoalProposalPart = z.infer<typeof sparkGoalProposalPartSchema>;
34
71
  /** The caps a goal confirmation offers before the user changes them. */
35
72
  export declare const GOAL_CONFIRMATION_DEFAULTS: {
36
73
  readonly tries: 5;
@@ -61,20 +98,15 @@ export type SparkGoal = {
61
98
  runId?: string;
62
99
  };
63
100
  export declare const sparkGoalCommandSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
101
+ creditBudget: z.ZodOptional<z.ZodNumber>;
102
+ maxTurns: z.ZodOptional<z.ZodNumber>;
64
103
  action: z.ZodLiteral<"set">;
65
104
  objective: z.ZodString;
66
- title: z.ZodOptional<z.ZodString>;
67
- conditions: z.ZodOptional<z.ZodArray<z.ZodObject<{
68
- text: z.ZodString;
69
- measurable: z.ZodBoolean;
70
- }, z.core.$strip>>>;
105
+ }, z.core.$strip>, z.ZodObject<{
71
106
  creditBudget: z.ZodOptional<z.ZodNumber>;
72
107
  maxTurns: z.ZodOptional<z.ZodNumber>;
73
- }, z.core.$strip>, z.ZodObject<{
74
108
  action: z.ZodLiteral<"update_budget">;
75
109
  goalId: z.ZodString;
76
- creditBudget: z.ZodOptional<z.ZodNumber>;
77
- maxTurns: z.ZodOptional<z.ZodNumber>;
78
110
  }, z.core.$strip>, z.ZodObject<{
79
111
  action: z.ZodEnum<{
80
112
  status: "status";
package/dist/goals.js CHANGED
@@ -11,10 +11,34 @@ export const sparkGoalConditionSchema = z.object({
11
11
  */
12
12
  measurable: z.boolean(),
13
13
  });
14
- /** The conditions read from a user message, for the user to confirm before a goal starts. */
14
+ const goalCaps = {
15
+ creditBudget: z.number().int().positive().max(1_000_000_000).optional(),
16
+ maxTurns: z.number().int().min(1).max(100).optional(),
17
+ };
18
+ /** The goal the agent proposes for a request, for the user to confirm before it starts. */
15
19
  export const sparkGoalProposalSchema = z.object({
16
20
  title: z.string().trim().min(1).max(80),
17
- conditions: z.array(sparkGoalConditionSchema).max(MAX_GOAL_CONDITIONS),
21
+ conditions: z.array(sparkGoalConditionSchema).min(1).max(MAX_GOAL_CONDITIONS),
22
+ });
23
+ /**
24
+ * How the user answered a proposed goal: run it with these caps, try the request
25
+ * once, or nothing in the time the host waits.
26
+ */
27
+ export const sparkGoalDecisionSchema = z.discriminatedUnion("kind", [
28
+ z.object({ kind: z.literal("goal"), ...goalCaps }),
29
+ z.object({ kind: z.literal("once") }),
30
+ z.object({ kind: z.literal("unanswered") }),
31
+ ]);
32
+ /** What became of a proposal, as the agent is told: `active` when a goal was already running. */
33
+ export const SPARK_GOAL_OUTCOMES = ["started", "declined", "unanswered", "active"];
34
+ /**
35
+ * A proposal as its message stores it: the `propose_goal` call that made it, and
36
+ * its outcome once there is one. Without an outcome it is waiting for the user
37
+ * while its run is, and was left unanswered once that run has ended.
38
+ */
39
+ export const sparkGoalProposalPartSchema = sparkGoalProposalSchema.extend({
40
+ toolCallId: z.string().min(1),
41
+ outcome: z.enum(SPARK_GOAL_OUTCOMES).optional(),
18
42
  });
19
43
  /** The caps a goal confirmation offers before the user changes them. */
20
44
  export const GOAL_CONFIRMATION_DEFAULTS = { tries: 5, credits: 2_000 };
@@ -22,17 +46,13 @@ export const sparkGoalCommandSchema = z.discriminatedUnion("action", [
22
46
  z.object({
23
47
  action: z.literal("set"),
24
48
  objective: z.string().trim().min(1).max(MAX_GOAL_LENGTH),
25
- title: sparkGoalProposalSchema.shape.title.optional(),
26
- conditions: sparkGoalProposalSchema.shape.conditions.min(1).optional(),
27
- creditBudget: z.number().int().positive().max(1_000_000_000).optional(),
28
- maxTurns: z.number().int().min(1).max(100).optional(),
49
+ ...goalCaps,
29
50
  }),
30
51
  // Raises or lowers a goal's caps; at least one of them.
31
52
  z.object({
32
53
  action: z.literal("update_budget"),
33
54
  goalId: z.string().min(1),
34
- creditBudget: z.number().int().positive().max(1_000_000_000).optional(),
35
- maxTurns: z.number().int().min(1).max(100).optional(),
55
+ ...goalCaps,
36
56
  }),
37
57
  z.object({ action: z.enum(["status", "pause", "resume", "clear"]) }),
38
58
  ]);
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { runSparkAgent, type SparkAgentOptions, type SparkAgentResult, type SparkAgentRun, } from "./agent.js";
2
2
  export { createSparkTools, type SparkPersistence, type SparkToolCall } from "./agent-tools.js";
3
3
  export { convertSparkMessages } from "./model-history.js";
4
- export type { SparkCompactionLimits } from "./compaction.js";
5
- export * from "./context-budget.js";
4
+ export { applyCompaction, latestCompaction, type SparkCompactionLimits } from "./compaction.js";
5
+ export * from "./context-window.js";
6
6
  export * from "./capabilities.js";
7
7
  export { createSdkSceneEditor, createSdkSceneHost, type SdkSceneEditor, type SdkSceneEditorOptions, type SdkSceneEditIdentity, type SdkSceneServices, } from "./sdk-scene-host.js";
8
8
  export { recoverSceneOperation, type SceneOperation, type SceneOperationStore } from "./scene-operations.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { runSparkAgent, } from "./agent.js";
2
2
  export { createSparkTools } from "./agent-tools.js";
3
3
  export { convertSparkMessages } from "./model-history.js";
4
- export * from "./context-budget.js";
4
+ export { applyCompaction, latestCompaction } from "./compaction.js";
5
+ export * from "./context-window.js";
5
6
  export * from "./capabilities.js";
6
7
  export { createSdkSceneEditor, createSdkSceneHost, } from "./sdk-scene-host.js";
7
8
  export { recoverSceneOperation } from "./scene-operations.js";
@@ -23,16 +23,6 @@ async function liveInput(tools, part) {
23
23
  const validated = await asSchema(tool.inputSchema).validate?.(input);
24
24
  return validated?.success ? { ...part, input: validated.value } : part;
25
25
  }
26
- /** How the worker reads the conditions the user confirmed for the goal a message starts. */
27
- function goalConditionsText({ conditions }) {
28
- const list = (measurable) => conditions.filter((condition) => condition.measurable === measurable).map(({ text }) => `\n- ${text}`).join("");
29
- const measured = list(true);
30
- const reviewed = list(false);
31
- return "[The user confirmed this request as a goal. Keep working until it is met; do not ask whether to continue."
32
- + (measured ? `\nConditions an independent check will verify. Verify each with concrete tool results before claiming completion:${measured}` : "")
33
- + (reviewed ? `\nPreferences the user will review themselves:${reviewed}` : "")
34
- + "]";
35
- }
36
26
  /**
37
27
  * The stored conversation as model messages. UI artifact previews already
38
28
  * represented by a tool result are not model output. The messages before the
@@ -70,10 +60,6 @@ export async function convertSparkMessages(messages, tools, options = {}) {
70
60
  return convertToModelMessages(history, {
71
61
  tools,
72
62
  convertDataPart: (part) => {
73
- if (part.type === "data-goal") {
74
- const goal = sparkDataPartSchemas.goal.safeParse(part.data);
75
- return goal.success ? { type: "text", text: goalConditionsText(goal.data) } : undefined;
76
- }
77
63
  if (part.type !== "data-selection")
78
64
  return undefined;
79
65
  const selection = sparkDataPartSchemas.selection.safeParse(part.data);
@@ -15,6 +15,7 @@ export function createSparkRunExecutor(options) {
15
15
  runId: context.runId,
16
16
  messageId: context.assistantMessageId,
17
17
  signal: context.signal,
18
+ proposeGoal: context.proposeGoal,
18
19
  });
19
20
  return { stream: run.stream, result: run.result.finally(() => host.dispose()) };
20
21
  }
@@ -100,15 +100,6 @@ export function createSparkAgentRouter(options) {
100
100
  const result = await options.service.goalCommand(ownerId, request.params.conversationId, body.command, body.settings, body.message);
101
101
  response.status(result.run ? 202 : 200).json(result);
102
102
  });
103
- router.post("/conversations/:conversationId/goal/proposal", async (request, response) => {
104
- const ownerId = await resolveOwnerId(request);
105
- const body = z.object({ message: messageSchema, settings: settingsSchema }).parse(request.body);
106
- const abortController = new AbortController();
107
- response.on("close", () => { if (!response.writableEnded)
108
- abortController.abort(); });
109
- const proposal = await options.service.proposeGoal(ownerId, request.params.conversationId, body.message, body.settings, abortController.signal);
110
- response.json({ proposal });
111
- });
112
103
  router.get("/conversations/:conversationId/active-run", async (request, response) => {
113
104
  const ownerId = await resolveOwnerId(request);
114
105
  const run = await options.service.getActiveRun(ownerId, request.params.conversationId);
@@ -1,8 +1,7 @@
1
1
  export { MemorySparkArtifactStore } from "./artifacts.js";
2
2
  export { WompifyCreditCapReachedError } from "../wompify.js";
3
3
  export { createSparkGoalEvaluator, GOAL_REFERENCE_IMAGES, type SparkGoalEvaluator, type SparkGoalEvaluation } from "./goal-evaluator.js";
4
- export { createSparkGoalProposer, type SparkGoalProposer } from "./goal-proposer.js";
5
- export { sparkGoalCommandSchema, sparkGoalProposalSchema, type SparkGoal, type SparkGoalCommand, type SparkGoalCondition, type SparkGoalProposal, type SparkGoalTry } from "../goals.js";
4
+ export { sparkGoalCommandSchema, sparkGoalDecisionSchema, sparkGoalProposalSchema, type SparkGoal, type SparkGoalCommand, type SparkGoalCondition, type SparkGoalDecision, type SparkGoalProposal, type SparkGoalTry } from "../goals.js";
6
5
  export type { SparkArtifactStore, StoredSparkArtifact } from "./artifacts.js";
7
6
  export { createSparkRunExecutor, type ResolvedSparkModel, type SparkRunExecutorOptions, } from "./executor.js";
8
7
  export { DEFAULT_ENDING_RETRY_DELAYS_MS, RUN_INTERRUPTED_ERROR, RUN_NOT_SAVED_ERROR, SparkAgentService, SparkServiceError, } from "./service.js";
@@ -1,8 +1,7 @@
1
1
  export { MemorySparkArtifactStore } from "./artifacts.js";
2
2
  export { WompifyCreditCapReachedError } from "../wompify.js";
3
3
  export { createSparkGoalEvaluator, GOAL_REFERENCE_IMAGES } from "./goal-evaluator.js";
4
- export { createSparkGoalProposer } from "./goal-proposer.js";
5
- export { sparkGoalCommandSchema, sparkGoalProposalSchema } from "../goals.js";
4
+ export { sparkGoalCommandSchema, sparkGoalDecisionSchema, sparkGoalProposalSchema } from "../goals.js";
6
5
  export { createSparkRunExecutor, } from "./executor.js";
7
6
  export { DEFAULT_ENDING_RETRY_DELAYS_MS, RUN_INTERRUPTED_ERROR, RUN_NOT_SAVED_ERROR, SparkAgentService, SparkServiceError, } from "./service.js";
8
7
  export { MemorySparkAgentStore } from "./store.js";
@@ -1,7 +1,6 @@
1
1
  import { type SparkUIMessage, type StoredSparkChunk } from "../ui.js";
2
- import { type SparkGoal, type SparkGoalCommand, type SparkGoalProposal } from "../goals.js";
2
+ import { type SparkGoal, type SparkGoalCommand, type SparkGoalDecision, type SparkGoalProposal } from "../goals.js";
3
3
  import { type SparkGoalEvaluator } from "./goal-evaluator.js";
4
- import type { SparkGoalProposer } from "./goal-proposer.js";
5
4
  import type { SparkAgentStore, SparkConversation, SparkConversationSummary, SparkRun, SparkRunExecutor, SparkRunSettings } from "./types.js";
6
5
  /** Why a run ended when its progress could not be stored. */
7
6
  export declare const RUN_NOT_SAVED_ERROR = "This run was stopped because its progress could not be saved.";
@@ -13,8 +12,25 @@ export type SparkAgentServiceOptions = {
13
12
  store: SparkAgentStore;
14
13
  executeRun: SparkRunExecutor;
15
14
  evaluateGoal?: SparkGoalEvaluator;
16
- /** Reads a message's success conditions for the user to confirm before a goal starts. */
17
- proposeGoal?: SparkGoalProposer;
15
+ /**
16
+ * Shows the user a goal the agent proposed and resolves their answer. With it
17
+ * and `evaluateGoal`, the agent has `propose_goal`; the run waits here, and a
18
+ * confirmed goal starts with that run as its first try.
19
+ */
20
+ confirmGoal?(input: {
21
+ ownerId: string;
22
+ conversationId: string;
23
+ runId: string;
24
+ toolCallId: string;
25
+ proposal: SparkGoalProposal;
26
+ signal: AbortSignal;
27
+ }): Promise<SparkGoalDecision>;
28
+ /** Called once a confirmed goal is stored and before the agent goes on, for a host that counts what a goal spends. A goal it refuses does not start. */
29
+ onGoalStart?(input: {
30
+ ownerId: string;
31
+ run: SparkRun;
32
+ goal: SparkGoal;
33
+ }): Promise<void>;
18
34
  /** Charged and pending credits, including evaluations; reject if unavailable. */
19
35
  readGoalCredits?(ownerId: string, goal: SparkGoal): Promise<number>;
20
36
  continuationSettings?(settings: SparkRunSettings): SparkRunSettings;
@@ -44,7 +60,8 @@ export declare class SparkAgentService {
44
60
  private readonly store;
45
61
  private readonly executeRun;
46
62
  private readonly evaluateGoal?;
47
- private readonly proposer?;
63
+ private readonly confirmGoal?;
64
+ private readonly onGoalStart?;
48
65
  private readonly readGoalCredits?;
49
66
  private readonly continuationSettings?;
50
67
  private closing;
@@ -68,11 +85,6 @@ export declare class SparkAgentService {
68
85
  getConversation(ownerId: string, id: string): Promise<SparkConversation>;
69
86
  saveConversationSettings(ownerId: string, id: string, settings: SparkRunSettings): Promise<void>;
70
87
  deleteConversation(ownerId: string, id: string): Promise<void>;
71
- /**
72
- * The conditions a goal started from `message` would verify. Nothing is stored
73
- * or started: the user confirms them, then sends the message with a `set` command.
74
- */
75
- proposeGoal(ownerId: string, conversationId: string, message: SparkUIMessage, settings: SparkRunSettings, signal?: AbortSignal): Promise<SparkGoalProposal>;
76
88
  /** User controls are separate from model execution and work while streaming. */
77
89
  goalCommand(ownerId: string, conversationId: string, command: SparkGoalCommand, settings?: SparkRunSettings, message?: SparkUIMessage): Promise<{
78
90
  goal?: SparkGoal;
@@ -97,6 +109,8 @@ export declare class SparkAgentService {
97
109
  cancelRun(ownerId: string, runId: string): Promise<SparkRun>;
98
110
  streamRun(ownerId: string, runId: string, afterSequence: number, signal: AbortSignal): AsyncGenerator<StoredSparkChunk>;
99
111
  private runInBackground;
112
+ /** Starts the goal the user confirmed, with the run that proposed it as its first try. */
113
+ private startGoal;
100
114
  private refreshGoalCredits;
101
115
  private pauseRunGoal;
102
116
  private advanceGoal;
@@ -2,7 +2,7 @@ import { sparkErrorDiagnostics } from "../error-diagnostics.js";
2
2
  import { validateUIMessages } from "ai";
3
3
  import { foldAllChunks } from "./message-fold.js";
4
4
  import { sparkDataPartSchemas, sparkMessageMetadataSchema, } from "../ui.js";
5
- import { sparkGoalCommandSchema, sparkGoalProposalSchema } from "../goals.js";
5
+ import { MAX_GOAL_LENGTH, sparkGoalCommandSchema, sparkGoalDecisionSchema } from "../goals.js";
6
6
  import { goalVerdictSchema } from "./goal-evaluator.js";
7
7
  import { wompifyContextSchema, WompifyCreditCapReachedError } from "../wompify.js";
8
8
  const TERMINAL_STATUSES = new Set([
@@ -52,14 +52,13 @@ function titleFor(message) {
52
52
  return "New build";
53
53
  return text.length > 44 ? `${text.slice(0, 44)}…` : text;
54
54
  }
55
- /** `confirmedGoal` admits the goal part, which only a `set` command attaches. */
56
- function normalizeUserMessage(message, confirmedGoal = false) {
55
+ function normalizeUserMessage(message) {
57
56
  if (message.role !== "user") {
58
57
  throw new SparkServiceError(400, "A new chat message must have the user role.");
59
58
  }
60
59
  for (const part of message.parts) {
61
60
  // The data schemas are checked with the rest of the message (validateUIMessages).
62
- if (part.type === "text" || part.type === "data-selection" || (confirmedGoal && part.type === "data-goal"))
61
+ if (part.type === "text" || part.type === "data-selection")
63
62
  continue;
64
63
  if (part.type === "file" &&
65
64
  part.mediaType.startsWith("image/") &&
@@ -107,7 +106,8 @@ export class SparkAgentService {
107
106
  store;
108
107
  executeRun;
109
108
  evaluateGoal;
110
- proposer;
109
+ confirmGoal;
110
+ onGoalStart;
111
111
  readGoalCredits;
112
112
  continuationSettings;
113
113
  closing = false;
@@ -126,7 +126,8 @@ export class SparkAgentService {
126
126
  this.store = options.store;
127
127
  this.executeRun = options.executeRun;
128
128
  this.evaluateGoal = options.evaluateGoal;
129
- this.proposer = options.proposeGoal;
129
+ this.confirmGoal = options.confirmGoal;
130
+ this.onGoalStart = options.onGoalStart;
130
131
  this.readGoalCredits = options.readGoalCredits;
131
132
  this.continuationSettings = options.continuationSettings;
132
133
  this.endOrphanedRuns = options.endOrphanedRuns ?? false;
@@ -167,21 +168,6 @@ export class SparkAgentService {
167
168
  }
168
169
  });
169
170
  }
170
- /**
171
- * The conditions a goal started from `message` would verify. Nothing is stored
172
- * or started: the user confirms them, then sends the message with a `set` command.
173
- */
174
- async proposeGoal(ownerId, conversationId, message, settings, signal) {
175
- if (!this.proposer)
176
- throw new SparkServiceError(501, "This host has not configured goal proposals.");
177
- const conversation = await this.getConversation(ownerId, conversationId);
178
- const [input] = await validateUIMessages({ messages: [normalizeUserMessage(message)], metadataSchema: sparkMessageMetadataSchema, dataSchemas: sparkDataPartSchemas });
179
- const timeout = AbortSignal.timeout(30_000);
180
- return sparkGoalProposalSchema.parse(await this.proposer({
181
- ownerId, message: input, messages: conversation.messages, settings,
182
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
183
- }));
184
- }
185
171
  /** User controls are separate from model execution and work while streaming. */
186
172
  async goalCommand(ownerId, conversationId, command, settings, message) {
187
173
  command = sparkGoalCommandSchema.parse(command);
@@ -261,24 +247,19 @@ export class SparkAgentService {
261
247
  throw new SparkServiceError(400, "Run settings are required.");
262
248
  const goal = command.action === "set" ? {
263
249
  id: crypto.randomUUID(), objective: command.objective, status: "active",
264
- ...(command.title ? { title: command.title } : {}),
265
- ...(command.conditions ? { conditions: command.conditions } : {}),
266
250
  settings: runSettings,
267
251
  createdAt: now(), updatedAt: now(), turns: 0,
268
252
  ...(command.maxTurns ? { maxTurns: command.maxTurns } : {}),
269
253
  ...(command.creditBudget ? { creditBudget: command.creditBudget, creditsUsed: 0 } : {}),
270
254
  } : { ...current, settings: runSettings, status: "active", updatedAt: now(),
271
255
  idleTurns: 0, };
272
- // Only the confirmed command says what the goal's conditions are, not the message a client built.
273
- const inputMessage = normalizeUserMessage(message ? { ...message, parts: message.parts.filter((part) => part.type !== "data-goal") } : {
256
+ const inputMessage = normalizeUserMessage(message ?? {
274
257
  id: crypto.randomUUID(), role: "user", parts: [{ type: "text", text: goalStartText(goal, command.action) }],
275
258
  });
276
- if (command.action === "set" && command.conditions)
277
- inputMessage.parts.push({ type: "data-goal", data: { title: command.title ?? titleFor(inputMessage), conditions: command.conditions } });
278
259
  await validateUIMessages({ messages: [inputMessage], metadataSchema: sparkMessageMetadataSchema, dataSchemas: sparkDataPartSchemas });
279
260
  await this.store.saveConversationGoal(conversationId, goal);
280
261
  try {
281
- const run = await this.submitInTurn({ ownerId, conversationId, trigger: "submit-message", message: inputMessage, settings: goal.settings }, command.action === "resume", true);
262
+ const run = await this.submitInTurn({ ownerId, conversationId, trigger: "submit-message", message: inputMessage, settings: goal.settings }, command.action === "resume");
282
263
  return { goal: (await this.getConversation(ownerId, conversationId)).goal, run };
283
264
  }
284
265
  catch (error) {
@@ -342,7 +323,7 @@ export class SparkAgentService {
342
323
  async submitMessage(input) {
343
324
  return this.inConversationTurn(input.conversationId, () => this.submitInTurn(input));
344
325
  }
345
- async submitInTurn(input, goalContinuation = false, confirmedGoal = false) {
326
+ async submitInTurn(input, goalContinuation = false) {
346
327
  if (this.closing)
347
328
  throw new SparkServiceError(503, "The service is shutting down.");
348
329
  const conversation = await this.getConversation(input.ownerId, input.conversationId);
@@ -370,7 +351,7 @@ export class SparkAgentService {
370
351
  inputMessage = previous;
371
352
  }
372
353
  else {
373
- inputMessage = normalizeUserMessage(input.message, confirmedGoal);
354
+ inputMessage = normalizeUserMessage(input.message);
374
355
  if (goalContinuation)
375
356
  inputMessage.metadata = { ...inputMessage.metadata, goalContinuation: true };
376
357
  if (conversation.messages.some(({ id }) => id === inputMessage.id)) {
@@ -548,6 +529,20 @@ export class SparkAgentService {
548
529
  data: { runId: run.id, status: "running" },
549
530
  };
550
531
  await this.appendChunk(run, runningChunk);
532
+ const proposeGoal = this.confirmGoal && this.evaluateGoal && this.store.saveConversationGoal && (async (proposal, call) => {
533
+ if (run.goalId)
534
+ return "active";
535
+ const decision = sparkGoalDecisionSchema.parse(await this.confirmGoal({
536
+ ownerId: conversation.ownerId, conversationId: conversation.id, runId: run.id, toolCallId: call.toolCallId, proposal, signal: call.signal,
537
+ }));
538
+ if (decision.kind !== "goal")
539
+ return decision.kind === "once" ? "declined" : "unanswered";
540
+ const started = await this.startGoal(run, proposal, decision);
541
+ if (!started)
542
+ return "active";
543
+ run = started;
544
+ return "started";
545
+ });
551
546
  const execution = await this.executeRun({
552
547
  ownerId: conversation.ownerId,
553
548
  conversationId: conversation.id,
@@ -556,6 +551,7 @@ export class SparkAgentService {
556
551
  uiMessages: conversation.messages,
557
552
  settings: run.settings,
558
553
  goal: conversation.goal?.status === "active" ? conversation.goal : undefined,
554
+ ...(proposeGoal ? { proposeGoal } : {}),
559
555
  signal: AbortSignal.any([controller.signal, unsaved.signal]),
560
556
  });
561
557
  // A chunk that cannot be stored aborts the execution, and the stream is
@@ -627,6 +623,35 @@ export class SparkAgentService {
627
623
  this.controllers.delete(run.id);
628
624
  }
629
625
  }
626
+ /** Starts the goal the user confirmed, with the run that proposed it as its first try. */
627
+ async startGoal(run, proposal, caps) {
628
+ if (caps.creditBudget !== undefined && !this.readGoalCredits)
629
+ throw new SparkServiceError(501, "This host does not support credit budgets.");
630
+ return this.inConversationTurn(run.conversationId, async () => {
631
+ const conversation = await this.store.getConversation(run.conversationId);
632
+ if (!conversation || conversation.activeRunId !== run.id || conversation.goal?.status === "active")
633
+ return undefined;
634
+ const request = conversation.messages.find(({ id }) => id === run.inputMessageId);
635
+ const createdAt = now();
636
+ const goal = {
637
+ id: crypto.randomUUID(), objective: (request ? messageText(request) : "").slice(0, MAX_GOAL_LENGTH) || proposal.title, ...proposal,
638
+ status: "active", settings: run.settings, createdAt, updatedAt: createdAt, turns: 0, runId: run.id,
639
+ ...(caps.maxTurns ? { maxTurns: caps.maxTurns } : {}),
640
+ ...(caps.creditBudget ? { creditBudget: caps.creditBudget, creditsUsed: 0 } : {}),
641
+ };
642
+ const started = { ...run, goalId: goal.id };
643
+ await this.store.saveConversationGoal(conversation.id, goal);
644
+ try {
645
+ await this.onGoalStart?.({ ownerId: conversation.ownerId, run: started, goal });
646
+ await this.store.saveRun(started);
647
+ }
648
+ catch (error) {
649
+ await this.store.saveConversationGoal(conversation.id, conversation.goal);
650
+ throw error;
651
+ }
652
+ return started;
653
+ });
654
+ }
630
655
  async refreshGoalCredits(ownerId, goal) {
631
656
  if (goal.creditBudget === undefined)
632
657
  return goal;
@@ -1,6 +1,6 @@
1
1
  import { type Express } from "express";
2
2
  import { KakapoAPI } from "@womp/kakapo-sdk";
3
- import type { SparkGoal } from "../goals.js";
3
+ import { type SparkGoal } from "../goals.js";
4
4
  import type { SparkAgentStore } from "./types.js";
5
5
  export type StandaloneSparkAgentOptions = {
6
6
  openAiApiKey?: string;
@@ -19,7 +19,7 @@ import { QuickJSJavaScriptExecutor } from "../quickjs-executor.js";
19
19
  import { sparkModelOptions } from "../model-options.js";
20
20
  import { createSparkRunExecutor } from "./executor.js";
21
21
  import { createSparkGoalEvaluator } from "./goal-evaluator.js";
22
- import { createSparkGoalProposer } from "./goal-proposer.js";
22
+ import { sparkGoalDecisionSchema } from "../goals.js";
23
23
  import { createSparkAgentRouter } from "./express.js";
24
24
  import { SparkAgentService } from "./service.js";
25
25
  import { SqliteSparkStore } from "./sqlite-store.js";
@@ -238,11 +238,16 @@ export async function createStandaloneSparkAgentApp(options) {
238
238
  }
239
239
  let clearingScene = false;
240
240
  let activeRuns = 0;
241
+ /** The goal proposals runs are waiting on, by the tool call that made them. */
242
+ const goalAnswers = new Map();
241
243
  const service = new SparkAgentService({
242
244
  store,
243
245
  evaluateGoal: createSparkGoalEvaluator({ resolveModel, resolveImage: (ownerId, artifactId) => artifactStore.get(ownerId, artifactId) }),
244
- // Reading a request's conditions precedes every send, so it runs at the lowest effort.
245
- proposeGoal: createSparkGoalProposer({ resolveModel: (settings) => resolveModel({ ...settings, reasoningEffort: "low" }) }),
246
+ // The run waits until the UI answers on the route below, or the run is stopped.
247
+ confirmGoal: ({ toolCallId, signal }) => new Promise((resolve, reject) => {
248
+ goalAnswers.set(toolCallId, resolve);
249
+ signal.addEventListener("abort", () => { goalAnswers.delete(toolCallId); reject(signal.reason); }, { once: true });
250
+ }),
246
251
  readGoalCredits: (_ownerId, goal) => estimateGoalCredits(store, goal),
247
252
  executeRun: async (context) => {
248
253
  if (clearingScene)
@@ -310,6 +315,19 @@ export async function createStandaloneSparkAgentApp(options) {
310
315
  clearingScene = false;
311
316
  }
312
317
  });
318
+ app.post(`${apiBasePath}/goal-proposals/:toolCallId`, express.json(), (request, response) => {
319
+ const answer = sparkGoalDecisionSchema.safeParse(request.body);
320
+ const resolve = goalAnswers.get(request.params.toolCallId);
321
+ if (!answer.success || answer.data.kind === "unanswered")
322
+ response.status(400).json({ error: "Answer with a goal or a single try." });
323
+ else if (!resolve)
324
+ response.status(404).json({ error: "This goal proposal is no longer waiting for an answer." });
325
+ else {
326
+ goalAnswers.delete(request.params.toolCallId);
327
+ resolve(answer.data);
328
+ response.status(204).end();
329
+ }
330
+ });
313
331
  app.use(apiBasePath, (request, response, next) => {
314
332
  if (clearingScene && request.method === "POST" && request.path.endsWith("/messages")) {
315
333
  response.status(409).json({ error: "The scene is being cleared. Try again when it finishes." });
@@ -3,6 +3,7 @@ import type { SparkAgentRun } from "../agent.js";
3
3
  import type { ReasoningEffort } from "../models.js";
4
4
  import type { SparkRunStatus, SparkUIMessage, StoredSparkChunk } from "../ui.js";
5
5
  import type { SparkGoal } from "../goals.js";
6
+ import type { SparkGoalOffer } from "../agent-tools.js";
6
7
  export type SparkConversation = {
7
8
  id: string;
8
9
  ownerId: string;
@@ -38,6 +39,8 @@ export type SparkRun = {
38
39
  };
39
40
  export type SparkRunExecutionContext = {
40
41
  goal?: SparkGoal;
42
+ /** Set when the host can ask the user to confirm a goal; the executor hands it to the agent. */
43
+ proposeGoal?: SparkGoalOffer;
41
44
  ownerId: string;
42
45
  conversationId: string;
43
46
  runId: string;
package/dist/ui.d.ts CHANGED
@@ -3,8 +3,8 @@ export { sparkErrorDiagnostics, sparkErrorDiagnosticsSchema, type SparkErrorDiag
3
3
  import type { InferUIMessageChunk, UIMessage } from "ai";
4
4
  import type { SceneScreenshotOptions } from "./capabilities/scene.js";
5
5
  import { z } from "zod";
6
- export type { SparkGoal, SparkGoalCommand, SparkGoalCondition, SparkGoalProposal, SparkGoalTry } from "./goals.js";
7
- import { type SparkGoalProposal } from "./goals.js";
6
+ export type { SparkGoal, SparkGoalCommand, SparkGoalCondition, SparkGoalDecision, SparkGoalOutcome, SparkGoalProposal, SparkGoalProposalPart, SparkGoalTry } from "./goals.js";
7
+ import { type SparkGoalOutcome, type SparkGoalProposal, type SparkGoalProposalPart } from "./goals.js";
8
8
  import type { ExecutionReceipt, SparkSkill } from "./capabilities.js";
9
9
  export { DEFAULT_MODEL_ID, getSparkModel, REASONING_EFFORT_LABELS, resolveReasoningEffort, SPARK_MODELS, } from "./models.js";
10
10
  export type { ModelProvider, ReasoningEffort, SparkModel } from "./models.js";
@@ -92,7 +92,7 @@ export type SparkDataParts = {
92
92
  };
93
93
  selection: SparkSelectionData;
94
94
  compaction: SparkCompaction;
95
- goal: SparkGoalProposal;
95
+ "goal-proposal": SparkGoalProposalPart;
96
96
  };
97
97
  /** Editor node IDs selected when a user message was sent; never rendered as chat text. */
98
98
  export type SparkSelectionData = {
@@ -106,6 +106,12 @@ export type SparkSelectionData = {
106
106
  export type SparkCompaction = {
107
107
  /** The user message the model reads verbatim from; everything before it is summarized. */
108
108
  firstKeptMessageId: string;
109
+ /**
110
+ * Set when the summary was written during its turn: the first step of the
111
+ * recording message the model still reads, counted from 0. Its earlier steps
112
+ * are summarized too.
113
+ */
114
+ firstKeptStep?: number;
109
115
  summary: string;
110
116
  /** Skills read in the summarized messages; their current text is sent after the summary. */
111
117
  skills: string[];
@@ -150,6 +156,13 @@ export type SparkUITools = {
150
156
  skills: SkillReadResult[];
151
157
  };
152
158
  };
159
+ /** Offered only by hosts that can ask the user; see `SparkGoalOffer`. */
160
+ propose_goal: {
161
+ input: SparkGoalProposal;
162
+ output: {
163
+ goal: SparkGoalOutcome;
164
+ };
165
+ };
153
166
  };
154
167
  export type SkillReadResult = ({
155
168
  ok: true;
@@ -356,6 +369,7 @@ export declare const sparkDataPartSchemas: {
356
369
  }, z.core.$strip>;
357
370
  readonly compaction: z.ZodObject<{
358
371
  firstKeptMessageId: z.ZodString;
372
+ firstKeptStep: z.ZodOptional<z.ZodNumber>;
359
373
  summary: z.ZodString;
360
374
  skills: z.ZodArray<z.ZodString>;
361
375
  modelId: z.ZodString;
@@ -364,16 +378,24 @@ export declare const sparkDataPartSchemas: {
364
378
  createdAt: z.ZodString;
365
379
  }, z.core.$strip>;
366
380
  /**
367
- * The conditions the user confirmed for the goal this message starts. The model
368
- * reads it as text (model-history.ts).
381
+ * The goal the agent proposed in this message, for hosts to show: as the
382
+ * confirmation while it waits, and as the goal once it started. The model
383
+ * reads the proposal from its `propose_goal` call, not from this part.
369
384
  */
370
- readonly goal: z.ZodObject<{
385
+ readonly "goal-proposal": z.ZodObject<{
371
386
  title: z.ZodString;
372
387
  conditions: z.ZodArray<z.ZodObject<{
373
388
  text: z.ZodString;
374
389
  measurable: z.ZodBoolean;
375
390
  }, z.core.$strip>>;
391
+ toolCallId: z.ZodString;
392
+ outcome: z.ZodOptional<z.ZodEnum<{
393
+ active: "active";
394
+ unanswered: "unanswered";
395
+ started: "started";
396
+ declined: "declined";
397
+ }>>;
376
398
  }, z.core.$strip>;
377
399
  };
378
400
  export { parseWompifyCommand, WOMPIFY_PRESETS, wompifyPrompt, wompifyContextSchema, type WompifyRequest, type WompifyContext } from "./wompify.js";
379
- export { DEFAULT_GOAL_CREDITS, GOAL_CONFIRMATION_DEFAULTS, MAX_GOAL_LENGTH, sparkGoalProposalSchema } from "./goals.js";
401
+ export { DEFAULT_GOAL_CREDITS, GOAL_CONFIRMATION_DEFAULTS, sparkGoalProposalPartSchema, sparkGoalProposalSchema } from "./goals.js";