@pinet/agent-goal 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@ pi -e ./agent-goal/index.ts
21
21
  ```text
22
22
  /goal <objective> Create and immediately start a goal
23
23
  /goal Open the minimal goal window (text in headless modes)
24
+ /goal budget turns=<n> [tokens=<n>] Change total turn/token ceilings
24
25
  /goal pause Pause automatic evaluation and continuation
25
26
  /goal resume Resume and immediately continue
26
27
  /goal complete Mark complete manually
@@ -29,13 +30,14 @@ pi -e ./agent-goal/index.ts
29
30
  /goal show Show the compact persistent goal status
30
31
  ```
31
32
 
32
- Pi's footer is the only persistent goal UI and shows compact status and budget usage without duplicating the detailed dashboard. In interactive mode, `/goal` opens the detailed control window with the objective, lifecycle state, budget bars, latest evaluator guidance, and continuation state. Its visible keyboard actions pause or resume the goal, mark it complete, clear it, or close the window; complete and clear require a second keypress, and the window refreshes after state changes. Press Escape, `q`, or Ctrl+C to close it. `/goal` retains the detailed textual dashboard in headless sessions.
33
+ Pi's footer is the only persistent goal UI and shows compact status and budget usage without duplicating the detailed dashboard. In interactive mode, `/goal` opens the detailed control window with the objective, lifecycle state, budget bars, latest evaluator guidance, and continuation state. Press `b` to edit total turn and token ceilings in place; digits edit the selected field, Tab switches fields, Enter saves, and Escape cancels. Other visible keyboard actions pause or resume the goal, mark it complete, clear it, or close the window; complete and clear require a second keypress, and validation errors remain visible in the open window. Press Escape, `q`, or Ctrl+C to close it. `/goal` retains the detailed textual dashboard in headless sessions.
33
34
 
34
35
  Only one goal may exist per Pi session. Clear the existing goal before creating another.
35
36
 
36
- The agent also receives three model-visible tools:
37
+ The agent also receives four model-visible tools:
37
38
 
38
39
  - `create_goal` — create its own bounded, user-aligned durable goal
40
+ - `update_goal_budget` — change the current goal's total turn or token ceiling
39
41
  - `get_goal` — inspect the current objective, status, and budget
40
42
  - `update_goal` — optionally attach a `complete` or `blocked` hint for independent verification
41
43
 
@@ -51,7 +53,9 @@ PI_AGENT_GOAL_MAX_TOKENS=200000
51
53
  PI_AGENT_GOAL_MAX_RUNTIME_MS=14400000
52
54
  ```
53
55
 
54
- Iteration and runtime limits are always reliable. Token accounting uses usage reported by Pi providers. The evaluator reviews every settled run, including the final allowed turn, so a completed goal is not incorrectly classified as budget-limited; only another continuation is prevented. The former `PI_AGENT_GOAL_EVALUATION_INTERVAL` setting is accepted for configuration compatibility but no longer changes evaluation frequency.
56
+ Iteration and runtime limits are always reliable. Token accounting uses usage reported by Pi providers. Operators and the goal-bearing agent may update total turn and token ceilings without recreating the goal. Changes are optimistic and atomic, cannot reduce a ceiling below accounted usage, must reserve capacity for a currently active turn, and cannot exceed a configured default ceiling when one exists. Increasing an exhausted budget reactivates the same goal when capacity is available; no budget change alters the objective or erases usage.
57
+
58
+ The evaluator reviews every settled run, including the final allowed turn, so a completed goal is not incorrectly classified as budget-limited; only another continuation is prevented. The former `PI_AGENT_GOAL_EVALUATION_INTERVAL` setting is accepted for configuration compatibility but no longer changes evaluation frequency.
55
59
 
56
60
  ## Persistence and recovery
57
61
 
@@ -114,7 +118,7 @@ A future Pinet integration can use broker storage and evaluation plus RALPH reco
114
118
 
115
119
  ## Automatic evaluation
116
120
 
117
- The extension registers model-visible `create_goal`, `get_goal`, and `update_goal` tools. The worker can establish its own user-aligned goal and inspect it. `update_goal` is optional: it records a terminal hint rather than mutating goal state directly. Every `agent_settled` event accounts the run and invokes the independent evaluator whether or not the worker supplied that hint.
121
+ The extension registers model-visible `create_goal`, `update_goal_budget`, `get_goal`, and `update_goal` tools. The worker can establish its own user-aligned goal, inspect it, and adjust its bounded capacity. `update_goal` is optional: it records a terminal hint rather than mutating goal state directly. Every `agent_settled` event accounts the run and invokes the independent evaluator whether or not the worker supplied that hint.
118
122
 
119
123
  The evaluator returns one of:
120
124
 
package/dist/dashboard.js CHANGED
@@ -37,6 +37,6 @@ export function formatGoalDashboard(goal, claim) {
37
37
  lines.push(`Reason: ${displayGoalText(goal.blockedReason, 100)}`);
38
38
  if (claim)
39
39
  lines.push(`Continuation: ${claim.state} · attempt ${claim.attempt}`);
40
- lines.push("/goal pause · resume · complete · clear · hide");
40
+ lines.push("/goal budget turns=<n> tokens=<n> · pause · resume · complete · clear · hide");
41
41
  return lines;
42
42
  }
package/dist/domain.d.ts CHANGED
@@ -8,6 +8,10 @@ export interface GoalUsage {
8
8
  iterations: number;
9
9
  tokens: number;
10
10
  }
11
+ export interface GoalBudgetUpdate {
12
+ maxIterations?: number;
13
+ maxTokens?: number;
14
+ }
11
15
  export interface GoalEvaluationRecord {
12
16
  id: string;
13
17
  outcome: GoalEvaluation["outcome"];
@@ -86,6 +90,7 @@ export interface GoalStorage {
86
90
  get(scopeId: string): Promise<AgentGoal | undefined>;
87
91
  create(goal: AgentGoal): Promise<void>;
88
92
  replace(goal: AgentGoal, expectedVersion: number): Promise<boolean>;
93
+ updateBudget(goal: AgentGoal, expectedVersion: number): Promise<boolean>;
89
94
  delete(scopeId: string, expectedVersion: number): Promise<boolean>;
90
95
  getPendingEvaluation(scopeId: string): Promise<GoalPendingEvaluation | undefined>;
91
96
  appendPendingEvaluation(pending: GoalPendingEvaluation): Promise<boolean>;
@@ -143,6 +148,10 @@ export type GoalEvent = {
143
148
  type: "goal.progress_accounted";
144
149
  goal: AgentGoal;
145
150
  tokenDelta: number;
151
+ } | {
152
+ type: "goal.budget_changed";
153
+ goal: AgentGoal;
154
+ previousBudget: GoalBudget;
146
155
  } | {
147
156
  type: "goal.evaluated";
148
157
  goal: AgentGoal;
@@ -1,7 +1,12 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { type Component } from "@earendil-works/pi-tui";
3
3
  import type { AgentGoal, GoalContinuationClaim } from "./domain.js";
4
- export type GoalWindowAction = "pause" | "resume" | "complete" | "clear" | "close";
4
+ export type GoalWindowLifecycleAction = "pause" | "resume" | "complete" | "clear" | "close";
5
+ export type GoalWindowAction = GoalWindowLifecycleAction | {
6
+ type: "budget";
7
+ maxIterations: number;
8
+ maxTokens?: number;
9
+ };
5
10
  export declare class GoalWindow implements Component {
6
11
  private readonly goal;
7
12
  private readonly claim;
@@ -9,8 +14,14 @@ export declare class GoalWindow implements Component {
9
14
  private readonly onAction;
10
15
  private readonly requestRender;
11
16
  private readonly now;
17
+ private readonly actionError?;
12
18
  private pendingConfirmation;
13
- constructor(goal: AgentGoal | undefined, claim: GoalContinuationClaim | undefined, theme: Theme, onAction: (action: GoalWindowAction) => void, requestRender?: () => void, now?: () => number);
19
+ private budgetField;
20
+ private budgetTurns;
21
+ private budgetTokens;
22
+ private replaceBudgetValue;
23
+ private budgetInputError;
24
+ constructor(goal: AgentGoal | undefined, claim: GoalContinuationClaim | undefined, theme: Theme, onAction: (action: GoalWindowAction) => void, requestRender?: () => void, now?: () => number, actionError?: string | undefined);
14
25
  handleInput(data: string): void;
15
26
  render(width: number): string[];
16
27
  invalidate(): void;
@@ -20,14 +20,21 @@ export class GoalWindow {
20
20
  onAction;
21
21
  requestRender;
22
22
  now;
23
+ actionError;
23
24
  pendingConfirmation;
24
- constructor(goal, claim, theme, onAction, requestRender = () => undefined, now = Date.now) {
25
+ budgetField;
26
+ budgetTurns = "";
27
+ budgetTokens = "";
28
+ replaceBudgetValue = false;
29
+ budgetInputError;
30
+ constructor(goal, claim, theme, onAction, requestRender = () => undefined, now = Date.now, actionError) {
25
31
  this.goal = goal;
26
32
  this.claim = claim;
27
33
  this.theme = theme;
28
34
  this.onAction = onAction;
29
35
  this.requestRender = requestRender;
30
36
  this.now = now;
37
+ this.actionError = actionError;
31
38
  }
32
39
  handleInput(data) {
33
40
  const key = data.toLowerCase();
@@ -40,6 +47,11 @@ export class GoalWindow {
40
47
  this.pendingConfirmation = undefined;
41
48
  this.requestRender();
42
49
  }
50
+ else if (this.budgetField) {
51
+ this.budgetField = undefined;
52
+ this.budgetInputError = undefined;
53
+ this.requestRender();
54
+ }
43
55
  else {
44
56
  this.onAction("close");
45
57
  }
@@ -47,6 +59,49 @@ export class GoalWindow {
47
59
  }
48
60
  if (!this.goal)
49
61
  return;
62
+ if (this.budgetField) {
63
+ if (matchesKey(data, "tab") || matchesKey(data, "up") || matchesKey(data, "down")) {
64
+ this.budgetField = this.budgetField === "turns" ? "tokens" : "turns";
65
+ this.replaceBudgetValue = true;
66
+ this.budgetInputError = undefined;
67
+ }
68
+ else if (matchesKey(data, "backspace")) {
69
+ const value = this.budgetField === "turns" ? this.budgetTurns : this.budgetTokens;
70
+ if (this.budgetField === "turns")
71
+ this.budgetTurns = value.slice(0, -1);
72
+ else
73
+ this.budgetTokens = value.slice(0, -1);
74
+ this.replaceBudgetValue = false;
75
+ this.budgetInputError = undefined;
76
+ }
77
+ else if (matchesKey(data, "enter")) {
78
+ const maxIterations = Number(this.budgetTurns);
79
+ if (!Number.isInteger(maxIterations) || maxIterations <= 0) {
80
+ this.budgetInputError = "Turns must be a positive integer";
81
+ }
82
+ else {
83
+ const maxTokens = this.budgetTokens ? Number(this.budgetTokens) : undefined;
84
+ this.onAction({ type: "budget", maxIterations, maxTokens });
85
+ return;
86
+ }
87
+ }
88
+ else if (/^\d+$/.test(data)) {
89
+ const value = this.replaceBudgetValue
90
+ ? data
91
+ : `${this.budgetField === "turns" ? this.budgetTurns : this.budgetTokens}${data}`;
92
+ if (this.budgetField === "turns")
93
+ this.budgetTurns = value;
94
+ else
95
+ this.budgetTokens = value;
96
+ this.replaceBudgetValue = false;
97
+ this.budgetInputError = undefined;
98
+ }
99
+ else {
100
+ return;
101
+ }
102
+ this.requestRender();
103
+ return;
104
+ }
50
105
  if (this.pendingConfirmation) {
51
106
  const confirmationKey = this.pendingConfirmation === "complete" ? "c" : "x";
52
107
  if (key === confirmationKey) {
@@ -58,7 +113,16 @@ export class GoalWindow {
58
113
  }
59
114
  return;
60
115
  }
61
- if (key === "p" && this.goal.status === "active") {
116
+ if (key === "b" && this.goal.status !== "complete") {
117
+ this.budgetField = "turns";
118
+ this.budgetTurns = String(this.goal.budget.maxIterations);
119
+ this.budgetTokens =
120
+ this.goal.budget.maxTokens === undefined ? "" : String(this.goal.budget.maxTokens);
121
+ this.replaceBudgetValue = true;
122
+ this.budgetInputError = undefined;
123
+ this.requestRender();
124
+ }
125
+ else if (key === "p" && this.goal.status === "active") {
62
126
  this.onAction("pause");
63
127
  }
64
128
  else if (key === "r" && (this.goal.status === "paused" || this.goal.status === "blocked")) {
@@ -132,16 +196,27 @@ export class GoalWindow {
132
196
  if (this.claim) {
133
197
  lines.push(row(` ${this.theme.fg("muted", "Continuation")} ${this.claim.state} · attempt ${this.claim.attempt}`));
134
198
  }
199
+ if (this.budgetField) {
200
+ const turns = `${this.budgetField === "turns" ? "›" : " "} Turns ${this.budgetTurns || "_"}`;
201
+ const tokens = `${this.budgetField === "tokens" ? "›" : " "} Tokens ${this.budgetTokens || "unchanged"}`;
202
+ lines.push(row(), row(` ${this.theme.fg("accent", "Edit budget")}`), row(` ${turns}`), row(` ${tokens}`));
203
+ }
204
+ const error = this.budgetInputError ?? this.actionError;
205
+ if (error)
206
+ lines.push(row(` ${this.theme.fg("error", displayGoalText(error, contentWidth))}`));
135
207
  const actions = [
136
208
  this.goal.status === "active" ? "p pause" : undefined,
137
209
  this.goal.status === "paused" || this.goal.status === "blocked" ? "r resume" : undefined,
210
+ this.goal.status !== "complete" ? "b budget" : undefined,
138
211
  this.goal.status !== "complete" ? "c complete" : undefined,
139
212
  "x clear",
140
213
  "q close",
141
214
  ].filter((action) => action !== undefined);
142
215
  const footer = this.pendingConfirmation
143
216
  ? `${this.pendingConfirmation === "complete" ? "c" : "x"} again to confirm ${this.pendingConfirmation} · esc cancel`
144
- : actions.join(" · ");
217
+ : this.budgetField
218
+ ? "digits edit · tab field · enter save · esc cancel"
219
+ : actions.join(" · ");
145
220
  lines.push(row(), row(` ${this.theme.fg("dim", footer)}`));
146
221
  lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
147
222
  return lines;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { GoalBudget, GoalContinuation, GoalEvaluator, GoalEventSink, GoalRetryPolicy, GoalStorage, GoalWakeScheduler } from "./domain.js";
3
3
  export type { AgentGoal, GoalBudget, GoalContinuation, GoalContinuationClaim, GoalContinuationRequest, GoalContinuationResult, GoalEvaluation, GoalEvaluationRecord, GoalEvaluator, GoalEvent, GoalEventSink, GoalPendingEvaluation, GoalProgress, GoalRetryPolicy, GoalStatus, GoalStorage, GoalTerminalCandidate, GoalTerminalCandidateRecord, GoalUsage, GoalWakeScheduler, } from "./domain.js";
4
4
  export { displayGoalText, formatGoalDashboard, formatGoalStatus } from "./dashboard.js";
5
- export { GoalWindow, type GoalWindowAction } from "./goal-window.js";
5
+ export { GoalWindow, type GoalWindowAction, type GoalWindowLifecycleAction, } from "./goal-window.js";
6
6
  export { MemoryGoalStorage } from "./memory-storage.js";
7
7
  export { parseGoalEvaluation, PiGoalEvaluator } from "./pi-evaluator.js";
8
8
  export { countGoalProgressTokens, formatGoalProgress, type GoalProgressMessage, } from "./progress.js";
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { formatGoalDashboard, formatGoalStatus } from "./dashboard.js";
4
- import { GoalWindow } from "./goal-window.js";
4
+ import { GoalWindow, } from "./goal-window.js";
5
5
  import { PiGoalEvaluator } from "./pi-evaluator.js";
6
6
  import { countGoalProgressTokens, formatGoalProgress, } from "./progress.js";
7
7
  import { GoalRuntime } from "./runtime.js";
8
8
  import { SqliteGoalStorage } from "./sqlite-storage.js";
9
9
  export { displayGoalText, formatGoalDashboard, formatGoalStatus } from "./dashboard.js";
10
- export { GoalWindow } from "./goal-window.js";
10
+ export { GoalWindow, } from "./goal-window.js";
11
11
  export { MemoryGoalStorage } from "./memory-storage.js";
12
12
  export { parseGoalEvaluation, PiGoalEvaluator } from "./pi-evaluator.js";
13
13
  export { countGoalProgressTokens, formatGoalProgress, } from "./progress.js";
@@ -44,7 +44,7 @@ export function registerAgentGoal(pi, options = {}) {
44
44
  if (!ctx || ctx.sessionManager.getSessionId() !== goal.scopeId) {
45
45
  return { status: "unavailable", reason: "The goal session is not active" };
46
46
  }
47
- if (!ctx.isIdle() || ctx.hasPendingMessages()) {
47
+ if (!ctx.isIdle()) {
48
48
  return { status: "busy", reason: "The goal session is busy", retryAfterMs: 1_000 };
49
49
  }
50
50
  api.sendMessage({
@@ -78,6 +78,13 @@ export function registerAgentGoal(pi, options = {}) {
78
78
  ctx.ui.setWidget(WIDGET_KEY, undefined);
79
79
  };
80
80
  const applyGoalAction = async (scopeId, action) => {
81
+ if (typeof action === "object") {
82
+ await runtime.updateBudget(scopeId, {
83
+ maxIterations: action.maxIterations,
84
+ maxTokens: action.maxTokens,
85
+ });
86
+ return;
87
+ }
81
88
  switch (action) {
82
89
  case "pause":
83
90
  await runtime.setStatus(scopeId, "paused");
@@ -208,6 +215,53 @@ export function registerAgentGoal(pi, options = {}) {
208
215
  };
209
216
  },
210
217
  });
218
+ pi.registerTool({
219
+ name: "update_goal_budget",
220
+ label: "Update goal budget",
221
+ description: "Adjust this session goal's bounded turn or token ceiling without replacing the goal. Changes remain constrained by configured limits and cannot discard accounted usage.",
222
+ promptSnippet: "Adjust the active session goal's bounded turn or token budget.",
223
+ promptGuidelines: [
224
+ "Only change a goal budget when more or less capacity is genuinely needed for the existing user-aligned objective.",
225
+ "Never use budget changes to broaden the objective or evade configured hard limits.",
226
+ "Inspect the current goal first and keep requested capacity proportionate to the remaining work.",
227
+ ],
228
+ parameters: {
229
+ type: "object",
230
+ properties: {
231
+ maxTurns: {
232
+ type: "integer",
233
+ minimum: 1,
234
+ maximum: defaultBudget.maxIterations,
235
+ description: "New total settled-turn ceiling, including turns already accounted.",
236
+ },
237
+ maxTokens: {
238
+ type: "number",
239
+ exclusiveMinimum: 0,
240
+ ...(defaultBudget.maxTokens === undefined ? {} : { maximum: defaultBudget.maxTokens }),
241
+ description: "New total token ceiling, including tokens already accounted.",
242
+ },
243
+ },
244
+ additionalProperties: false,
245
+ },
246
+ async execute(_toolCallId, rawParams, _signal, _onUpdate, rawCtx) {
247
+ const params = rawParams;
248
+ const ctx = rawCtx;
249
+ const goal = await runtime.updateBudget(ctx.sessionManager.getSessionId(), {
250
+ maxIterations: params.maxTurns,
251
+ maxTokens: params.maxTokens,
252
+ });
253
+ await refreshUi(ctx);
254
+ return {
255
+ content: [
256
+ {
257
+ type: "text",
258
+ text: `Updated goal budget to ${goal.budget.maxIterations} turns${goal.budget.maxTokens === undefined ? "" : ` and ${goal.budget.maxTokens} tokens`}.`,
259
+ },
260
+ ],
261
+ details: { goal },
262
+ };
263
+ },
264
+ });
211
265
  pi.registerTool({
212
266
  name: "get_goal",
213
267
  label: "Get goal",
@@ -283,7 +337,7 @@ export function registerAgentGoal(pi, options = {}) {
283
337
  },
284
338
  });
285
339
  pi.registerCommand("goal", {
286
- description: "Create, inspect, pause, resume, complete, clear, show, or hide this session's goal",
340
+ description: "Create, inspect, adjust budget, pause, resume, complete, clear, show, or hide this session's goal",
287
341
  handler: async (args, rawCtx) => {
288
342
  const ctx = rawCtx;
289
343
  activeContext = ctx;
@@ -292,12 +346,13 @@ export function registerAgentGoal(pi, options = {}) {
292
346
  try {
293
347
  if (!input) {
294
348
  let openedWindow = false;
349
+ let actionError;
295
350
  while (true) {
296
351
  const goal = await runtime.get(scopeId);
297
352
  const claim = await runtime.getContinuationClaim(scopeId);
298
353
  const action = await ctx.ui.custom((tui, theme, _keybindings, done) => {
299
354
  openedWindow = true;
300
- return new GoalWindow(goal, claim, theme, done, () => tui.requestRender());
355
+ return new GoalWindow(goal, claim, theme, done, () => tui.requestRender(), Date.now, actionError);
301
356
  }, {
302
357
  overlay: true,
303
358
  overlayOptions: {
@@ -320,11 +375,42 @@ export function registerAgentGoal(pi, options = {}) {
320
375
  }
321
376
  if (!action || action === "close")
322
377
  return;
323
- await applyGoalAction(scopeId, action);
324
- await refreshUi(ctx);
378
+ try {
379
+ await applyGoalAction(scopeId, action);
380
+ actionError = undefined;
381
+ await refreshUi(ctx);
382
+ }
383
+ catch (error) {
384
+ actionError = error instanceof Error ? error.message : String(error);
385
+ }
325
386
  }
326
387
  }
327
388
  const command = input.toLowerCase();
389
+ if (command === "budget" || command.startsWith("budget ")) {
390
+ const update = {};
391
+ for (const token of input.slice("budget".length).trim().split(/\s+/).filter(Boolean)) {
392
+ const separator = token.indexOf("=");
393
+ if (separator < 1)
394
+ throw new Error(`Invalid goal budget argument: ${token}`);
395
+ const key = token.slice(0, separator).toLowerCase();
396
+ const value = Number(token.slice(separator + 1));
397
+ if (key === "turns" || key === "maxturns" || key === "iterations") {
398
+ update.maxIterations = value;
399
+ }
400
+ else if (key === "tokens" || key === "maxtokens") {
401
+ update.maxTokens = value;
402
+ }
403
+ else {
404
+ throw new Error(`Unknown goal budget field: ${key}`);
405
+ }
406
+ }
407
+ const goal = await runtime.updateBudget(scopeId, update);
408
+ await refreshUi(ctx);
409
+ if (ctx.hasUI) {
410
+ ctx.ui.notify(`Goal budget: ${goal.budget.maxIterations} turns${goal.budget.maxTokens === undefined ? "" : ` · ${goal.budget.maxTokens} tokens`}`, "info");
411
+ }
412
+ return;
413
+ }
328
414
  switch (command) {
329
415
  case "pause":
330
416
  case "resume":
@@ -7,6 +7,7 @@ export declare class MemoryGoalStorage implements GoalStorage {
7
7
  get(scopeId: string): Promise<AgentGoal | undefined>;
8
8
  create(goal: AgentGoal): Promise<void>;
9
9
  replace(goal: AgentGoal, expectedVersion: number): Promise<boolean>;
10
+ updateBudget(goal: AgentGoal, expectedVersion: number): Promise<boolean>;
10
11
  delete(scopeId: string, expectedVersion: number): Promise<boolean>;
11
12
  getPendingEvaluation(scopeId: string): Promise<GoalPendingEvaluation | undefined>;
12
13
  appendPendingEvaluation(pending: GoalPendingEvaluation): Promise<boolean>;
@@ -27,6 +27,25 @@ export class MemoryGoalStorage {
27
27
  this.goals.set(goal.scopeId, cloneGoal(goal));
28
28
  return true;
29
29
  }
30
+ async updateBudget(goal, expectedVersion) {
31
+ const current = this.goals.get(goal.scopeId);
32
+ if (!current || current.version !== expectedVersion || current.id !== goal.id)
33
+ return false;
34
+ this.goals.set(goal.scopeId, cloneGoal(goal));
35
+ const pending = this.pendingEvaluations.get(goal.scopeId);
36
+ if (pending?.goalId === goal.id && pending.goalVersion === expectedVersion) {
37
+ this.pendingEvaluations.set(goal.scopeId, { ...pending, goalVersion: goal.version });
38
+ }
39
+ const candidate = this.terminalCandidates.get(goal.scopeId);
40
+ if (candidate?.goalId === goal.id && candidate.goalVersion === expectedVersion) {
41
+ this.terminalCandidates.set(goal.scopeId, { ...candidate, goalVersion: goal.version });
42
+ }
43
+ const claim = this.claims.get(goal.scopeId);
44
+ if (claim?.goalId === goal.id && claim.goalVersion === expectedVersion) {
45
+ this.claims.set(goal.scopeId, { ...claim, goalVersion: goal.version });
46
+ }
47
+ return true;
48
+ }
30
49
  async delete(scopeId, expectedVersion) {
31
50
  const current = this.goals.get(scopeId);
32
51
  if (!current || current.version !== expectedVersion)
@@ -52,14 +71,13 @@ export class MemoryGoalStorage {
52
71
  }
53
72
  async appendPendingEvaluation(pending) {
54
73
  const goal = this.goals.get(pending.scopeId);
55
- if (!goal || goal.id !== pending.goalId || goal.version !== pending.goalVersion)
74
+ if (!goal || goal.id !== pending.goalId || goal.status !== "active")
56
75
  return false;
57
76
  const stored = this.pendingEvaluations.get(pending.scopeId);
58
- const existing = stored?.goalId === pending.goalId && stored.goalVersion === pending.goalVersion
59
- ? stored
60
- : undefined;
77
+ const existing = stored?.goalId === pending.goalId && stored.goalVersion === goal.version ? stored : undefined;
61
78
  this.pendingEvaluations.set(pending.scopeId, {
62
79
  ...pending,
80
+ goalVersion: goal.version,
63
81
  iterationsDelta: pending.iterationsDelta + (existing?.iterationsDelta ?? 0),
64
82
  progress: {
65
83
  ...pending.progress,
@@ -149,9 +167,16 @@ export class MemoryGoalStorage {
149
167
  }
150
168
  async replaceContinuationClaim(claim, expectedClaimId) {
151
169
  const current = this.claims.get(claim.scopeId);
152
- if (!current || current.claimId !== expectedClaimId)
170
+ const goal = this.goals.get(claim.scopeId);
171
+ if (!current ||
172
+ current.claimId !== expectedClaimId ||
173
+ current.goalId !== claim.goalId ||
174
+ !goal ||
175
+ goal.id !== claim.goalId ||
176
+ goal.status !== "active") {
153
177
  return false;
154
- this.claims.set(claim.scopeId, { ...claim });
178
+ }
179
+ this.claims.set(claim.scopeId, { ...claim, goalVersion: goal.version });
155
180
  return true;
156
181
  }
157
182
  async deleteContinuationClaim(scopeId, expectedClaimId) {
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentGoal, GoalBudget, GoalContinuation, GoalContinuationClaim, GoalEvaluator, GoalEventSink, GoalProgress, GoalRetryPolicy, GoalStatus, GoalStorage, GoalTerminalCandidate, GoalTerminalCandidateRecord, GoalWakeScheduler } from "./domain.js";
1
+ import type { AgentGoal, GoalBudget, GoalBudgetUpdate, GoalContinuation, GoalContinuationClaim, GoalEvaluator, GoalEventSink, GoalProgress, GoalRetryPolicy, GoalStatus, GoalStorage, GoalTerminalCandidate, GoalTerminalCandidateRecord, GoalWakeScheduler } from "./domain.js";
2
2
  export interface GoalRuntimeOptions {
3
3
  defaultBudget?: GoalBudget;
4
4
  retryPolicy?: GoalRetryPolicy;
@@ -29,6 +29,7 @@ export declare class GoalRuntime {
29
29
  getContinuationClaim(scopeId: string): Promise<GoalContinuationClaim | undefined>;
30
30
  getTerminalCandidate(scopeId: string): Promise<GoalTerminalCandidateRecord | undefined>;
31
31
  create(scopeId: string, objective: string, budget?: GoalBudget): Promise<AgentGoal>;
32
+ updateBudget(scopeId: string, update: GoalBudgetUpdate): Promise<AgentGoal>;
32
33
  setStatus(scopeId: string, status: Extract<GoalStatus, "active" | "paused" | "complete">): Promise<AgentGoal>;
33
34
  clear(scopeId: string): Promise<boolean>;
34
35
  requestTerminalCandidate(scopeId: string, candidate: GoalTerminalCandidate): Promise<GoalTerminalCandidateRecord>;
package/dist/runtime.js CHANGED
@@ -88,6 +88,65 @@ export class GoalRuntime {
88
88
  await this.record({ type: "goal.created", goal });
89
89
  return goal;
90
90
  }
91
+ async updateBudget(scopeId, update) {
92
+ const current = await this.requireGoal(scopeId);
93
+ if (current.status === "complete")
94
+ throw new Error("Cannot change a complete goal budget");
95
+ if (update.maxIterations === undefined && update.maxTokens === undefined) {
96
+ throw new Error("A goal budget update requires maxIterations or maxTokens");
97
+ }
98
+ if (update.maxIterations !== undefined &&
99
+ (!Number.isInteger(update.maxIterations) || update.maxIterations <= 0)) {
100
+ throw new Error("Goal maxIterations must be a positive integer");
101
+ }
102
+ if (update.maxTokens !== undefined &&
103
+ (!Number.isFinite(update.maxTokens) || update.maxTokens <= 0)) {
104
+ throw new Error("Goal maxTokens must be a positive finite number");
105
+ }
106
+ if (update.maxIterations !== undefined && update.maxIterations > this.budget.maxIterations) {
107
+ throw new Error(`Goal maxIterations cannot exceed the configured limit of ${this.budget.maxIterations}`);
108
+ }
109
+ if (update.maxTokens !== undefined &&
110
+ this.budget.maxTokens !== undefined &&
111
+ update.maxTokens > this.budget.maxTokens) {
112
+ throw new Error(`Goal maxTokens cannot exceed the configured limit of ${this.budget.maxTokens}`);
113
+ }
114
+ const minimumIterations = current.status === "active" ? current.usage.iterations + 1 : current.usage.iterations;
115
+ if (update.maxIterations !== undefined && update.maxIterations < minimumIterations) {
116
+ throw new Error(current.status === "active"
117
+ ? `Goal maxIterations must leave capacity for the current turn (${minimumIterations} minimum)`
118
+ : `Goal maxIterations cannot be lower than ${current.usage.iterations} accounted turns`);
119
+ }
120
+ const minimumTokens = current.status === "active" ? current.usage.tokens + 1 : current.usage.tokens;
121
+ if (update.maxTokens !== undefined && update.maxTokens < minimumTokens) {
122
+ throw new Error(current.status === "active"
123
+ ? `Goal maxTokens must leave capacity beyond ${current.usage.tokens} accounted tokens`
124
+ : `Goal maxTokens cannot be lower than ${current.usage.tokens} accounted tokens`);
125
+ }
126
+ const nextBudget = {
127
+ ...current.budget,
128
+ ...(update.maxIterations === undefined ? {} : { maxIterations: update.maxIterations }),
129
+ ...(update.maxTokens === undefined ? {} : { maxTokens: update.maxTokens }),
130
+ };
131
+ const candidate = {
132
+ ...current,
133
+ budget: nextBudget,
134
+ version: current.version + 1,
135
+ updatedAt: this.now().toISOString(),
136
+ };
137
+ const exhausted = this.budgetExhausted(candidate);
138
+ const next = current.status === "budget_limited" && !exhausted
139
+ ? { ...candidate, status: "active", blockedReason: undefined }
140
+ : candidate;
141
+ if (!(await this.storage.updateBudget(next, current.version))) {
142
+ throw new Error("Goal changed while its budget was being updated; retry the command");
143
+ }
144
+ await this.record({ type: "goal.budget_changed", goal: next, previousBudget: current.budget });
145
+ if (current.status === "budget_limited" && next.status === "active") {
146
+ await this.continueWithClaim(next, "Continue with the expanded goal budget.");
147
+ }
148
+ return next;
149
+ }
91
150
  async setStatus(scopeId, status) {
92
151
  const current = await this.requireGoal(scopeId);
93
152
  const transitionAllowed = (status === "active" && (current.status === "paused" || current.status === "blocked")) ||
@@ -459,6 +518,22 @@ export class GoalRuntime {
459
518
  for (let attempt = claim.attempt + 1; attempt <= this.retryPolicy.maxAttempts; attempt += 1) {
460
519
  const currentGoal = await this.storage.get(goal.scopeId);
461
520
  const currentClaim = await this.storage.getContinuationClaim(goal.scopeId);
521
+ if (currentGoal?.id === goal.id &&
522
+ currentGoal.status === "active" &&
523
+ currentGoal.version !== goal.version &&
524
+ currentClaim?.claimId === claim.claimId) {
525
+ if (currentClaim.state === "started") {
526
+ this.scheduleRecovery(goal.scopeId, currentClaim.expiresAt);
527
+ }
528
+ else if (currentClaim.state === "deferred" &&
529
+ Date.parse(currentClaim.availableAt) > this.now().getTime()) {
530
+ this.scheduleRecovery(goal.scopeId, currentClaim.availableAt);
531
+ }
532
+ else {
533
+ await this.runContinuationClaim(currentGoal, currentClaim);
534
+ }
535
+ return;
536
+ }
462
537
  if (!currentGoal ||
463
538
  currentGoal.id !== goal.id ||
464
539
  currentGoal.version !== goal.version ||
@@ -5,6 +5,7 @@ export declare class SqliteGoalStorage implements GoalStorage {
5
5
  get(scopeId: string): Promise<AgentGoal | undefined>;
6
6
  create(goal: AgentGoal): Promise<void>;
7
7
  replace(goal: AgentGoal, expectedVersion: number): Promise<boolean>;
8
+ updateBudget(goal: AgentGoal, expectedVersion: number): Promise<boolean>;
8
9
  delete(scopeId: string, expectedVersion: number): Promise<boolean>;
9
10
  getPendingEvaluation(scopeId: string): Promise<GoalPendingEvaluation | undefined>;
10
11
  appendPendingEvaluation(pending: GoalPendingEvaluation): Promise<boolean>;
@@ -179,6 +179,41 @@ export class SqliteGoalStorage {
179
179
  .run(goal.objective, goal.status, goal.blockedReason ?? null, goal.budget.maxIterations, goal.budget.maxTokens ?? null, goal.budget.maxRuntimeMs ?? null, goal.usage.iterations, goal.usage.tokens, goal.lastSettledAt ?? null, goal.lastEvaluation?.id ?? null, goal.lastEvaluation?.outcome ?? null, goal.lastEvaluation?.reason ?? null, goal.lastEvaluation?.at ?? null, goal.version, goal.updatedAt, goal.scopeId, goal.id, expectedVersion);
180
180
  return result.changes === 1;
181
181
  }
182
+ async updateBudget(goal, expectedVersion) {
183
+ this.db.exec("BEGIN IMMEDIATE");
184
+ try {
185
+ const result = this.db
186
+ .prepare(`UPDATE agent_goals SET objective = ?, status = ?, blocked_reason = ?,
187
+ max_iterations = ?, max_tokens = ?, max_runtime_ms = ?, iterations_used = ?,
188
+ tokens_used = ?, last_settled_at = ?, last_evaluation_id = ?,
189
+ last_evaluation_outcome = ?, last_evaluation_reason = ?, last_evaluation_at = ?,
190
+ version = ?, updated_at = ?
191
+ WHERE scope_id = ? AND id = ? AND version = ?`)
192
+ .run(goal.objective, goal.status, goal.blockedReason ?? null, goal.budget.maxIterations, goal.budget.maxTokens ?? null, goal.budget.maxRuntimeMs ?? null, goal.usage.iterations, goal.usage.tokens, goal.lastSettledAt ?? null, goal.lastEvaluation?.id ?? null, goal.lastEvaluation?.outcome ?? null, goal.lastEvaluation?.reason ?? null, goal.lastEvaluation?.at ?? null, goal.version, goal.updatedAt, goal.scopeId, goal.id, expectedVersion);
193
+ if (result.changes !== 1) {
194
+ this.db.exec("ROLLBACK");
195
+ return false;
196
+ }
197
+ this.db
198
+ .prepare(`UPDATE agent_goal_pending_evaluations SET goal_version = ?
199
+ WHERE scope_id = ? AND goal_id = ? AND goal_version = ?`)
200
+ .run(goal.version, goal.scopeId, goal.id, expectedVersion);
201
+ this.db
202
+ .prepare(`UPDATE agent_goal_terminal_candidates SET goal_version = ?
203
+ WHERE scope_id = ? AND goal_id = ? AND goal_version = ?`)
204
+ .run(goal.version, goal.scopeId, goal.id, expectedVersion);
205
+ this.db
206
+ .prepare(`UPDATE agent_goal_continuations SET goal_version = ?
207
+ WHERE scope_id = ? AND goal_id = ? AND goal_version = ?`)
208
+ .run(goal.version, goal.scopeId, goal.id, expectedVersion);
209
+ this.db.exec("COMMIT");
210
+ return true;
211
+ }
212
+ catch (error) {
213
+ this.db.exec("ROLLBACK");
214
+ throw error;
215
+ }
216
+ }
182
217
  async delete(scopeId, expectedVersion) {
183
218
  const result = this.db
184
219
  .prepare("DELETE FROM agent_goals WHERE scope_id = ? AND version = ?")
@@ -217,10 +252,9 @@ export class SqliteGoalStorage {
217
252
  (scope_id, goal_id, goal_version, evaluation_id, iterations_delta, latest_output,
218
253
  token_delta, candidate_outcome, candidate_reason, attempt, available_at, last_error,
219
254
  created_at, updated_at)
220
- SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
221
- WHERE EXISTS (
222
- SELECT 1 FROM agent_goals WHERE scope_id = ? AND id = ? AND version = ?
223
- )
255
+ SELECT ?, ?, goal.version, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
256
+ FROM agent_goals AS goal
257
+ WHERE goal.scope_id = ? AND goal.id = ? AND goal.status = 'active'
224
258
  ON CONFLICT(scope_id) DO UPDATE SET goal_id = excluded.goal_id,
225
259
  goal_version = excluded.goal_version, evaluation_id = excluded.evaluation_id,
226
260
  iterations_delta = CASE
@@ -253,7 +287,7 @@ export class SqliteGoalStorage {
253
287
  AND agent_goal_pending_evaluations.goal_version = excluded.goal_version
254
288
  THEN agent_goal_pending_evaluations.created_at ELSE excluded.created_at END,
255
289
  updated_at = excluded.updated_at`)
256
- .run(pending.scopeId, pending.goalId, pending.goalVersion, pending.evaluationId, pending.iterationsDelta, pending.progress.latestOutput, pending.progress.tokenDelta ?? 0, pending.progress.terminalCandidate?.outcome ?? null, pending.progress.terminalCandidate?.reason ?? null, pending.attempt, pending.availableAt, pending.lastError ?? null, pending.createdAt, pending.updatedAt, pending.scopeId, pending.goalId, pending.goalVersion);
290
+ .run(pending.scopeId, pending.goalId, pending.evaluationId, pending.iterationsDelta, pending.progress.latestOutput, pending.progress.tokenDelta ?? 0, pending.progress.terminalCandidate?.outcome ?? null, pending.progress.terminalCandidate?.reason ?? null, pending.attempt, pending.availableAt, pending.lastError ?? null, pending.createdAt, pending.updatedAt, pending.scopeId, pending.goalId);
257
291
  return result.changes === 1;
258
292
  }
259
293
  async putPendingEvaluation(pending) {
@@ -402,10 +436,14 @@ export class SqliteGoalStorage {
402
436
  }
403
437
  async replaceContinuationClaim(claim, expectedClaimId) {
404
438
  const result = this.db
405
- .prepare(`UPDATE agent_goal_continuations SET goal_id = ?, goal_version = ?, claim_id = ?,
406
- state = ?, reason = ?, attempt = ?, available_at = ?, expires_at = ?,
407
- last_error = ?, updated_at = ? WHERE scope_id = ? AND claim_id = ?`)
408
- .run(claim.goalId, claim.goalVersion, claim.claimId, claim.state, claim.reason, claim.attempt, claim.availableAt, claim.expiresAt, claim.lastError ?? null, claim.updatedAt, claim.scopeId, expectedClaimId);
439
+ .prepare(`UPDATE agent_goal_continuations SET goal_id = ?,
440
+ goal_version = (SELECT version FROM agent_goals WHERE scope_id = ? AND id = ?),
441
+ claim_id = ?, state = ?, reason = ?, attempt = ?, available_at = ?, expires_at = ?,
442
+ last_error = ?, updated_at = ? WHERE scope_id = ? AND claim_id = ?
443
+ AND goal_id = ? AND EXISTS (
444
+ SELECT 1 FROM agent_goals WHERE scope_id = ? AND id = ? AND status = 'active'
445
+ )`)
446
+ .run(claim.goalId, claim.scopeId, claim.goalId, claim.claimId, claim.state, claim.reason, claim.attempt, claim.availableAt, claim.expiresAt, claim.lastError ?? null, claim.updatedAt, claim.scopeId, expectedClaimId, claim.goalId, claim.scopeId, claim.goalId);
409
447
  return result.changes === 1;
410
448
  }
411
449
  async deleteContinuationClaim(scopeId, expectedClaimId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinet/agent-goal",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "type": "module",
5
5
  "description": "Standalone single-agent durable goal loop for Pi",
6
6
  "author": "Will Porcellini <5994936+gugu91@users.noreply.github.com>",