pi-roundtable 0.22.0 → 0.22.2

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.
@@ -70,6 +70,11 @@ export class SkillStore {
70
70
  readonly #skills = new Map<string, SkillEntry>();
71
71
  readonly #groups = new Map<string, SkillGroup>();
72
72
  readonly #carried = new Map<string, string[]>();
73
+ /**
74
+ * The tail of the mutations, which run one at a time: each checks the maps, writes, then
75
+ * updates the maps from what it committed, and none may interleave with another.
76
+ */
77
+ #queue: Promise<unknown> = Promise.resolve();
73
78
 
74
79
  readonly #guild: string;
75
80
 
@@ -152,6 +157,12 @@ export class SkillStore {
152
157
  ]);
153
158
  }
154
159
 
160
+ #serial<T>(write: () => Promise<T>): Promise<T> {
161
+ const run = this.#queue.then(write);
162
+ this.#queue = run.catch(() => undefined);
163
+ return run;
164
+ }
165
+
155
166
  skills(): SkillEntry[] {
156
167
  return [...this.#skills.values()].sort((a, b) =>
157
168
  a.name.localeCompare(b.name),
@@ -189,19 +200,29 @@ export class SkillStore {
189
200
  async addLinked(
190
201
  entries: readonly { name: string; repo: string; path: string }[],
191
202
  ): Promise<void> {
192
- const taken = entries.filter((entry) => this.#skills.has(entry.name));
193
- if (taken.length > 0)
194
- throw new AgentError(
195
- `Already registered: ${taken.map((entry) => entry.name).join(", ")}. Nothing was linked.`,
196
- );
197
- await this.#sql.begin(async (tx) => {
203
+ return this.#serial(async () => {
204
+ const taken = entries.filter((entry) => this.#skills.has(entry.name));
205
+ const refuse = (names: string[]) =>
206
+ new AgentError(
207
+ `Already registered: ${names.join(", ")}. Nothing was linked.`,
208
+ );
209
+ if (taken.length > 0) throw refuse(taken.map((entry) => entry.name));
210
+ try {
211
+ await this.#sql.begin(async (tx) => {
212
+ for (const entry of entries)
213
+ await tx`
214
+ INSERT INTO skills (name, kind, repo, path)
215
+ VALUES (${entry.name}, 'linked', ${entry.repo}, ${entry.path})`;
216
+ });
217
+ } catch (error) {
218
+ // A row the maps do not hold, such as a duplicate within `entries`.
219
+ if (isUniqueViolation(error))
220
+ throw refuse(entries.map((entry) => entry.name));
221
+ throw error;
222
+ }
198
223
  for (const entry of entries)
199
- await tx`
200
- INSERT INTO skills (name, kind, repo, path)
201
- VALUES (${entry.name}, 'linked', ${entry.repo}, ${entry.path})`;
224
+ this.#skills.set(entry.name, { kind: "linked", ...entry });
202
225
  });
203
- for (const entry of entries)
204
- this.#skills.set(entry.name, { kind: "linked", ...entry });
205
226
  }
206
227
 
207
228
  async addWritten(skill: {
@@ -209,12 +230,14 @@ export class SkillStore {
209
230
  description: string;
210
231
  body: string;
211
232
  }): Promise<void> {
212
- if (this.#skills.has(skill.name))
213
- throw new AgentError(`A skill named "${skill.name}" already exists.`);
214
- await this.#sql`
215
- INSERT INTO skills (name, kind, description, body)
216
- VALUES (${skill.name}, 'written', ${skill.description}, ${skill.body})`;
217
- this.#skills.set(skill.name, { kind: "written", ...skill });
233
+ return this.#serial(async () => {
234
+ if (this.#skills.has(skill.name))
235
+ throw new AgentError(`A skill named "${skill.name}" already exists.`);
236
+ await this.#sql`
237
+ INSERT INTO skills (name, kind, description, body)
238
+ VALUES (${skill.name}, 'written', ${skill.description}, ${skill.body})`;
239
+ this.#skills.set(skill.name, { kind: "written", ...skill });
240
+ });
218
241
  }
219
242
 
220
243
  async updateWritten(skill: {
@@ -222,54 +245,84 @@ export class SkillStore {
222
245
  description: string;
223
246
  body: string;
224
247
  }): Promise<void> {
225
- await this.#sql`
226
- UPDATE skills SET description = ${skill.description}, body = ${skill.body},
227
- updated_at = now()
228
- WHERE name = ${skill.name} AND kind = 'written'`;
229
- this.#skills.set(skill.name, { kind: "written", ...skill });
248
+ return this.#serial(async () => {
249
+ await this.#sql`
250
+ UPDATE skills SET description = ${skill.description}, body = ${skill.body},
251
+ updated_at = now()
252
+ WHERE name = ${skill.name} AND kind = 'written'`;
253
+ this.#skills.set(skill.name, { kind: "written", ...skill });
254
+ });
230
255
  }
231
256
 
232
257
  /** Removes a skill and detaches it from every agent and group. */
233
258
  async remove(name: string): Promise<Detached> {
234
- const agents = this.carriers(name);
235
- const groups = this.groups().filter((group) => group.skills.includes(name));
236
- await this.#sql.begin(async (tx) => {
237
- await tx`DELETE FROM skills WHERE name = ${name}`;
238
- await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND skill = ${name}`;
259
+ return this.#serial(async () => {
260
+ const agents = this.carriers(name);
261
+ const groups = this.groups().filter((group) =>
262
+ group.skills.includes(name),
263
+ );
264
+ await this.#sql.begin(async (tx) => {
265
+ await tx`DELETE FROM skills WHERE name = ${name}`;
266
+ await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND skill = ${name}`;
267
+ for (const group of groups)
268
+ await tx`
269
+ UPDATE skill_groups
270
+ SET skills = ${group.skills.filter((s) => s !== name).join(",")}
271
+ WHERE name = ${group.name}`;
272
+ });
273
+ // The maps follow the transaction; reloading them all here would drop a write that
274
+ // committed after its SELECT.
275
+ this.#skills.delete(name);
276
+ for (const agent of agents) {
277
+ const left = this.carriedBy(agent).filter((s) => s !== name);
278
+ if (left.length > 0) this.#carried.set(agent, left);
279
+ else this.#carried.delete(agent);
280
+ }
239
281
  for (const group of groups)
240
- await tx`
241
- UPDATE skill_groups
242
- SET skills = ${group.skills.filter((s) => s !== name).join(",")}
243
- WHERE name = ${group.name}`;
282
+ this.#groups.set(group.name, {
283
+ ...group,
284
+ skills: group.skills.filter((s) => s !== name),
285
+ });
286
+ return { agents, groups: groups.map((group) => group.name) };
244
287
  });
245
- await this.#reload();
246
- return { agents, groups: groups.map((group) => group.name) };
247
288
  }
248
289
 
249
290
  async setGroup(group: SkillGroup): Promise<void> {
250
- await this.#sql`
251
- INSERT INTO skill_groups (name, description, skills)
252
- VALUES (${group.name}, ${group.description}, ${group.skills.join(",")})
253
- ON CONFLICT (name) DO UPDATE
254
- SET description = EXCLUDED.description, skills = EXCLUDED.skills`;
255
- this.#groups.set(group.name, { ...group, skills: [...group.skills] });
291
+ return this.#serial(async () => {
292
+ await this.#sql`
293
+ INSERT INTO skill_groups (name, description, skills)
294
+ VALUES (${group.name}, ${group.description}, ${group.skills.join(",")})
295
+ ON CONFLICT (name) DO UPDATE
296
+ SET description = EXCLUDED.description, skills = EXCLUDED.skills`;
297
+ this.#groups.set(group.name, { ...group, skills: [...group.skills] });
298
+ });
256
299
  }
257
300
 
258
301
  async removeGroup(name: string): Promise<void> {
259
- await this.#sql`DELETE FROM skill_groups WHERE name = ${name}`;
260
- this.#groups.delete(name);
302
+ return this.#serial(async () => {
303
+ await this.#sql`DELETE FROM skill_groups WHERE name = ${name}`;
304
+ this.#groups.delete(name);
305
+ });
261
306
  }
262
307
 
263
308
  /** Replaces the registered skills an agent carries. */
264
309
  async setCarried(agent: string, skills: readonly string[]): Promise<void> {
265
310
  // pi-lens-ignore: no-sort-without-comparator — skill names sort by code unit, the same order on every machine
266
311
  const sorted = [...new Set(skills)].sort();
267
- await this.#sql.begin(async (tx) => {
268
- await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND agent = ${agent}`;
269
- for (const skill of sorted)
270
- await tx`INSERT INTO agent_skills (guild_id, agent, skill) VALUES (${this.#guild}, ${agent}, ${skill})`;
312
+ return this.#serial(async () => {
313
+ await this.#sql.begin(async (tx) => {
314
+ await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND agent = ${agent}`;
315
+ for (const skill of sorted)
316
+ await tx`INSERT INTO agent_skills (guild_id, agent, skill) VALUES (${this.#guild}, ${agent}, ${skill})`;
317
+ });
318
+ if (sorted.length > 0) this.#carried.set(agent, sorted);
319
+ else this.#carried.delete(agent);
271
320
  });
272
- if (sorted.length > 0) this.#carried.set(agent, sorted);
273
- else this.#carried.delete(agent);
274
321
  }
275
322
  }
323
+
324
+ /** PostgreSQL's unique_violation, as Bun's driver reports it. */
325
+ function isUniqueViolation(error: unknown): boolean {
326
+ const fields = error as { errno?: unknown; code?: unknown } | null;
327
+ return fields?.errno === "23505" || fields?.code === "23505";
328
+ }
@@ -0,0 +1,13 @@
1
+ import { TurnTimeoutError } from "../domain/turn-timeout.ts";
2
+ import { messages } from "../i18n/index.ts";
3
+
4
+ /** What a failed turn posts: the stopped notice for a stop, the reason for a time limit, else the failure notice. */
5
+ export function failureNotice(result: {
6
+ error: Error;
7
+ stopped?: true;
8
+ }): string {
9
+ if (result.stopped) return messages().stoppedNotice;
10
+ if (result.error instanceof TurnTimeoutError)
11
+ return messages().turnTimedOutNotice(result.error.timeout);
12
+ return messages().failureNotice;
13
+ }
@@ -10,10 +10,10 @@ import type { TurnResult } from "../domain/conversation.ts";
10
10
  import type { TurnConversation } from "../domain/ports.ts";
11
11
  import type { TurnProgress } from "../domain/progress.ts";
12
12
  import { HostStoppingError, PluginError } from "../errors.ts";
13
- import { messages } from "../i18n/index.ts";
14
13
  import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
15
14
  import type { Logger } from "../log.ts";
16
15
  import type { EventSink } from "../plugin.ts";
16
+ import { failureNotice } from "../presentation/failure-notice.ts";
17
17
  import { splitReply } from "../presentation/reply-splitter.ts";
18
18
  import { thinkingLine } from "../presentation/thinking-line.ts";
19
19
  import { withReplyFiles } from "../reply-files.ts";
@@ -239,12 +239,7 @@ export function conversationTurns(
239
239
 
240
240
  /** The default post: the answer with its thinking line, or the failure or stopped notice. */
241
241
  function replyOf(result: TurnResult) {
242
- if (!result.ok)
243
- return {
244
- chunks: [
245
- result.stopped ? messages().stoppedNotice : messages().failureNotice,
246
- ],
247
- };
242
+ if (!result.ok) return { chunks: [failureNotice(result)] };
248
243
  const thinking = result.thinking ? thinkingLine(result.thinking) : undefined;
249
244
  return {
250
245
  ...(thinking ? { thinking } : {}),
@@ -48,7 +48,8 @@ import { promptImages, SteerableRun } from "./steerable-run.ts";
48
48
  import { thinkingLevel } from "./thinking-level.ts";
49
49
  import { runningCalls } from "./tool-call-scope.ts";
50
50
  import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
51
- import { runtimeTurnBudget, type TurnBudget } from "./turn-budget.ts";
51
+ import type { TurnBudget } from "./turn-budget.ts";
52
+ import { promptFailed, turnLimits } from "./turn-limits.ts";
52
53
  import { progressReporter } from "./turn-progress.ts";
53
54
  import { wrapUpTurn } from "./turn-wrapup.ts";
54
55
  import { workerReport } from "./worker-task.ts";
@@ -240,6 +241,10 @@ export class PiAgentRuntime implements AgentRuntime {
240
241
  const interim = interimPoster(request, this.#options);
241
242
  // The caller's live view: the text as it streams and each tool, never the thinking.
242
243
  const slot = this.#sessions.slot(key);
244
+ // Its time limit, not counting time waiting on the owner's cards, follows what it does to say why it fired.
245
+ const limits = turnLimits(this.#options, slot, request.channel, () =>
246
+ guard.abort(),
247
+ );
243
248
  const progress = request.progress
244
249
  ? progressReporter(request.progress, {
245
250
  refusalOf: (id) => slot.refusalOf(id),
@@ -247,6 +252,7 @@ export class PiAgentRuntime implements AgentRuntime {
247
252
  : undefined;
248
253
  const unsubscribe = session.subscribe((event) => {
249
254
  progress?.observe(event);
255
+ limits.observe(event);
250
256
  if (event.type === "tool_execution_start") {
251
257
  toolCalls.push(event.toolName);
252
258
  interim?.toolStart(event.toolName);
@@ -275,10 +281,7 @@ export class PiAgentRuntime implements AgentRuntime {
275
281
  this.#sessions.drop(key, channelSession);
276
282
  },
277
283
  });
278
- // Time spent waiting on the owner's cards does not count towards the timeout or the cap.
279
- const budget = runtimeTurnBudget(this.#options, slot, request.channel, () =>
280
- guard.abort(),
281
- );
284
+ const { budget } = limits;
282
285
  this.#budgets.set(key, budget);
283
286
  const running = new SteerableRun(
284
287
  session,
@@ -307,10 +310,7 @@ export class PiAgentRuntime implements AgentRuntime {
307
310
  } catch (error) {
308
311
  if (running.stopped)
309
312
  return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
310
- return {
311
- ok: false,
312
- error: new AgentRunError(`prompt failed: ${String(error)}`),
313
- };
313
+ return limits.failure() ?? promptFailed(error);
314
314
  } finally {
315
315
  this.#running.delete(key);
316
316
  this.#budgets.delete(key);
@@ -335,7 +335,7 @@ export class PiAgentRuntime implements AgentRuntime {
335
335
 
336
336
  if (running.stopped)
337
337
  return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
338
- return turnAnswer(turnMessages, running.steered);
338
+ return limits.failure() ?? turnAnswer(turnMessages, running.steered);
339
339
  }
340
340
 
341
341
  async steer(
@@ -99,6 +99,14 @@ export class PromptSlot {
99
99
  };
100
100
  }
101
101
 
102
+ /**
103
+ * The turn's prompts as bound, an open card not counted as waiting; for a card the turn keeps
104
+ * working beside, such as the one its time limit posts.
105
+ */
106
+ get cards(): Prompts | undefined {
107
+ return this.#prompts;
108
+ }
109
+
102
110
  /** Time this turn has spent with at least one card open. */
103
111
  waitedMs(now = Date.now()): number {
104
112
  return this.#waited + (this.#open > 0 ? now - this.#openSince : 0);
@@ -89,17 +89,25 @@ export interface PiAgentRuntimeOptions {
89
89
  /** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
90
90
  toolTiers?: ToolTiers;
91
91
  /**
92
- * A run that works longer, not counting time spent waiting on the owner's cards, is aborted and
93
- * reported as failed; default 10 minutes. Each message steered into the turn gives it this
92
+ * A run that works longer, not counting time spent waiting on the owner's cards, asks the owner
93
+ * on a card whether to go on (see `turnTimeoutCardMs`), or, when it cannot show one, is aborted
94
+ * and reported as failed with the reason; default 10 minutes. Each message steered into the turn gives it this
94
95
  * budget again from that moment, up to `turnMaxMs`.
95
96
  */
96
97
  turnTimeoutMs?: number;
97
98
  /**
98
99
  * The most one turn may work in all, however often it is steered, not counting time spent
99
100
  * waiting on the owner's cards; default three times `turnTimeoutMs`. Once it is reached the turn
100
- * takes no more steering (the message waits for the next turn) and is aborted as on its timeout.
101
+ * takes no more steering (the message waits for the next turn) and asks or is aborted as on its
102
+ * timeout; the owner's Continue moves the cap to a full `turnTimeoutMs` from then.
101
103
  */
102
104
  turnMaxMs?: number;
105
+ /**
106
+ * How long the card a turn posts when it reaches `turnTimeoutMs` or `turnMaxMs` waits for the
107
+ * owner's Continue or Cancel, in wall time, while the turn keeps working; default 5 minutes. With
108
+ * no answer by then the turn is aborted. A turn that cannot show a card is aborted at once.
109
+ */
110
+ turnTimeoutCardMs?: number;
103
111
  /**
104
112
  * How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
105
113
  * disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
@@ -0,0 +1,42 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** What a turn was doing at one moment: tools running and for how long, or compacting, or neither (the model). */
4
+ export interface TurnActivity {
5
+ /** The tools running, the longest-running first, each with how long it has run. */
6
+ running: { tool: string; ms: number }[];
7
+ compacting: boolean;
8
+ /** Every tool called so far, by name, with how often, in the order first called. */
9
+ tools: [string, number][];
10
+ }
11
+
12
+ /** Follows a turn's events to say, when asked, what it is doing; never its inputs. */
13
+ export interface ActivityTracker {
14
+ observe(event: AgentSessionEvent): void;
15
+ now(at: number): TurnActivity;
16
+ }
17
+
18
+ export function activityTracker(
19
+ clock: () => number = Date.now,
20
+ ): ActivityTracker {
21
+ const running = new Map<string, { tool: string; since: number }>();
22
+ const tools = new Map<string, number>();
23
+ let compacting = false;
24
+ return {
25
+ observe(event) {
26
+ if (event.type === "tool_execution_start") {
27
+ running.set(event.toolCallId, { tool: event.toolName, since: clock() });
28
+ tools.set(event.toolName, (tools.get(event.toolName) ?? 0) + 1);
29
+ } else if (event.type === "tool_execution_end") {
30
+ running.delete(event.toolCallId);
31
+ } else if (event.type === "compaction_start") compacting = true;
32
+ else if (event.type === "compaction_end") compacting = false;
33
+ },
34
+ now: (at) => ({
35
+ running: [...running.values()]
36
+ .sort((a, b) => a.since - b.since)
37
+ .map(({ tool, since }) => ({ tool, ms: at - since })),
38
+ compacting,
39
+ tools: [...tools],
40
+ }),
41
+ };
42
+ }
@@ -1,9 +1,16 @@
1
+ import type {
2
+ TurnLimit,
3
+ TurnTimeout,
4
+ TurnTimeoutEnd,
5
+ } from "../domain/turn-timeout.ts";
6
+ import { messages } from "../i18n/index.ts";
7
+ import type { Approval } from "../interactions/prompts.ts";
1
8
  import type { ChannelKey } from "../sessions.ts";
2
9
  import type { PromptSlot } from "./prompt-slot.ts";
3
10
  import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
11
+ import type { ActivityTracker } from "./turn-activity.ts";
4
12
 
5
- /** Which limit ended a turn: its budget since the start or the last steer, or its cap. */
6
- export type TurnLimit = "turn" | "turnMax";
13
+ export type { TurnLimit };
7
14
 
8
15
  /** A turn's time limit, which a steered message renews up to a cap on the whole turn. */
9
16
  export interface TurnBudget {
@@ -12,8 +19,15 @@ export interface TurnBudget {
12
19
  * or undefined when the cap is reached and the turn takes no more steering.
13
20
  */
14
21
  renew(): number | undefined;
15
- /** Whether the turn has worked up to its cap. */
22
+ /**
23
+ * After a limit fired, gives the turn its full budget again from now, as the owner's Continue
24
+ * does; with `raiseCap` the cap moves too, to a full budget from now.
25
+ */
26
+ extend(raiseCap: boolean): void;
27
+ /** Whether the turn has worked up to its cap, or a limit fired and was not extended. */
16
28
  capped(): boolean;
29
+ /** The turn's work so far, not counting time the slot spent waiting on cards. */
30
+ workedMs(): number;
17
31
  cancel(): void;
18
32
  }
19
33
 
@@ -43,13 +57,13 @@ const REARM_FLOOR_MS = 1_000;
43
57
  /**
44
58
  * Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
45
59
  * `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
46
- * towards neither. `expire` is told which limit fired.
60
+ * towards neither. `expire` is told which limit fired and its length.
47
61
  */
48
62
  export function turnBudget(
49
63
  ms: number,
50
64
  maxMs: number,
51
65
  slot: Pick<PromptSlot, "waitedMs">,
52
- expire: (limit: TurnLimit) => void,
66
+ expire: (limit: TurnLimit, limitMs: number) => void,
53
67
  clock: BudgetClock = REAL_CLOCK,
54
68
  ): TurnBudget {
55
69
  const started = clock.now();
@@ -57,7 +71,8 @@ export function turnBudget(
57
71
  const now = clock.now();
58
72
  return now - started - slot.waitedMs(now);
59
73
  };
60
- let deadline = Math.min(ms, maxMs);
74
+ let cap = maxMs;
75
+ let deadline = Math.min(ms, cap);
61
76
  let cancelTimer: (() => void) | undefined;
62
77
  let expired = false;
63
78
  const arm = (delay: number) => {
@@ -65,47 +80,172 @@ export function turnBudget(
65
80
  const left = deadline - worked();
66
81
  if (left > 0) return arm(Math.max(left, REARM_FLOOR_MS));
67
82
  expired = true;
68
- expire(deadline >= maxMs ? "turnMax" : "turn");
83
+ if (deadline >= cap) expire("turnMax", cap);
84
+ else expire("turn", ms);
69
85
  }, delay);
70
86
  };
71
87
  arm(deadline);
72
88
  return {
73
89
  renew() {
74
90
  const now = worked();
75
- if (expired || now >= maxMs) return undefined;
76
- deadline = Math.min(now + ms, maxMs);
91
+ if (expired || now >= cap) return undefined;
92
+ deadline = Math.min(now + ms, cap);
77
93
  cancelTimer?.();
78
94
  arm(deadline - now);
79
95
  return deadline - now;
80
96
  },
81
- capped: () => expired || worked() >= maxMs,
97
+ extend(raiseCap) {
98
+ const now = worked();
99
+ if (raiseCap) cap = Math.max(cap, now + ms);
100
+ deadline = Math.min(now + ms, cap);
101
+ expired = false;
102
+ cancelTimer?.();
103
+ arm(Math.max(deadline - now, REARM_FLOOR_MS));
104
+ },
105
+ capped: () => expired || worked() >= cap,
106
+ workedMs: worked,
82
107
  cancel: () => cancelTimer?.(),
83
108
  };
84
109
  }
85
110
 
111
+ /** What the runtime's turn budget needs besides its options: what the turn is doing, and its clock. */
112
+ export interface TurnBudgetSeams {
113
+ activity: Pick<ActivityTracker, "now">;
114
+ clock?: BudgetClock;
115
+ }
116
+
86
117
  /**
87
118
  * The time limit of one turn of the built-in runtime, from its options: `turnTimeoutMs` (default
88
- * 10 minutes) renewed by each steer up to `turnMaxMs` (default three times that). It logs each
89
- * renewal and, when a limit fires, which one before it calls `abort`.
119
+ * 10 minutes) renewed by each steer up to `turnMaxMs` (default three times that). When a limit
120
+ * fires on a turn that can show the owner a card, the turn keeps working and a card asks whether
121
+ * to go on: Continue extends the limit that fired by a full budget from then, Cancel aborts, and
122
+ * no answer within `turnTimeoutCardMs` (default 5 minutes, wall time) aborts. The card closes
123
+ * when the turn ends first. Without a card the turn is aborted at once. `abort` is told why.
90
124
  */
91
125
  export function runtimeTurnBudget(
92
126
  options: Pick<
93
127
  PiAgentRuntimeOptions,
94
- "turnTimeoutMs" | "turnMaxMs" | "logger"
128
+ "turnTimeoutMs" | "turnMaxMs" | "turnTimeoutCardMs" | "logger"
95
129
  >,
96
- slot: Pick<PromptSlot, "waitedMs">,
130
+ slot: Pick<PromptSlot, "waitedMs" | "cards">,
97
131
  channel: ChannelKey,
98
- abort: () => void,
132
+ abort: (timeout: TurnTimeout) => void,
133
+ seams: TurnBudgetSeams,
99
134
  ): TurnBudget {
100
135
  const { logger, turnTimeoutMs = 10 * 60_000 } = options;
101
136
  const turnMaxMs = options.turnMaxMs ?? 3 * turnTimeoutMs;
102
- const budget = turnBudget(turnTimeoutMs, turnMaxMs, slot, (limit) => {
137
+ const graceMs = options.turnTimeoutCardMs ?? 5 * 60_000;
138
+ const clock = seams.clock ?? REAL_CLOCK;
139
+ /** Closes the open timeout card, if any, with the footer given. */
140
+ let closeCard: ((footer: string) => void) | undefined;
141
+ const reason = (
142
+ limit: TurnLimit,
143
+ limitMs: number,
144
+ end: TurnTimeoutEnd,
145
+ ): TurnTimeout => ({
146
+ limit,
147
+ limitMs,
148
+ workedMs: budget.workedMs(),
149
+ ...seams.activity.now(clock.now()),
150
+ graceMs,
151
+ end,
152
+ });
153
+ const stop = (timeout: TurnTimeout) => {
103
154
  logger.warn(
104
- { channel, limit, turnTimeoutMs, turnMaxMs },
155
+ {
156
+ channel,
157
+ limit: timeout.limit,
158
+ turnTimeoutMs,
159
+ turnMaxMs,
160
+ workedMs: timeout.workedMs,
161
+ running: timeout.running,
162
+ compacting: timeout.compacting,
163
+ tools: timeout.tools,
164
+ end: timeout.end,
165
+ },
105
166
  "turn timed out; aborting",
106
167
  );
107
- abort();
108
- });
168
+ abort(timeout);
169
+ };
170
+ const budget = turnBudget(
171
+ turnTimeoutMs,
172
+ turnMaxMs,
173
+ slot,
174
+ (limit, limitMs) => {
175
+ const prompts = slot.cards;
176
+ if (!prompts) return stop(reason(limit, limitMs, "timeout"));
177
+ const m = messages();
178
+ const asked = reason(limit, limitMs, "timeout");
179
+ logger.info(
180
+ {
181
+ channel,
182
+ limit,
183
+ workedMs: asked.workedMs,
184
+ running: asked.running,
185
+ },
186
+ "turn reached its time limit; asking the owner",
187
+ );
188
+ const controller = new AbortController();
189
+ let open = true;
190
+ const settle = () => {
191
+ if (!open) return false;
192
+ open = false;
193
+ closeCard = undefined;
194
+ cancelGrace();
195
+ return true;
196
+ };
197
+ const cancelGrace = clock.schedule(() => {
198
+ if (!settle()) return;
199
+ const timeout = reason(limit, limitMs, "unanswered");
200
+ controller.abort(m.turnTimeoutUnanswered(timeout));
201
+ stop(timeout);
202
+ }, graceMs);
203
+ closeCard = (footer) => {
204
+ if (settle()) controller.abort(footer);
205
+ };
206
+ const answered = (approval: Approval) => {
207
+ if (!settle()) return;
208
+ if (approval === "approved") {
209
+ budget.extend(limit === "turnMax");
210
+ logger.info(
211
+ { channel, limit, workedMs: budget.workedMs() },
212
+ "owner continued the turn past its time limit",
213
+ );
214
+ } else
215
+ stop(
216
+ reason(
217
+ limit,
218
+ limitMs,
219
+ approval === "declined" ? "cancelled" : "timeout",
220
+ ),
221
+ );
222
+ };
223
+ prompts
224
+ .confirm(
225
+ m.turnTimeoutTitle,
226
+ m.turnTimeoutQuestion(asked, turnTimeoutMs),
227
+ undefined,
228
+ "owner",
229
+ undefined,
230
+ undefined,
231
+ {
232
+ supersede: controller.signal,
233
+ wording: {
234
+ approve: m.turnTimeoutContinue,
235
+ decline: m.turnTimeoutCancel,
236
+ footer: m.turnTimeoutFooter,
237
+ approved: m.turnTimeoutContinued,
238
+ declined: m.turnTimeoutCancelled,
239
+ },
240
+ },
241
+ )
242
+ .then(answered, (error: unknown) => {
243
+ logger.warn({ channel, err: error }, "timeout card failed");
244
+ answered("unavailable");
245
+ });
246
+ },
247
+ clock,
248
+ );
109
249
  return {
110
250
  ...budget,
111
251
  renew() {
@@ -117,5 +257,9 @@ export function runtimeTurnBudget(
117
257
  );
118
258
  return deadlineMs;
119
259
  },
260
+ cancel() {
261
+ budget.cancel();
262
+ closeCard?.(messages().turnTimeoutFinished);
263
+ },
120
264
  };
121
265
  }