pi-roundtable 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.17.1] - 2026-10-16
9
+
10
+ ### Changed
11
+
12
+ - A message steered into a running turn renews the turn's time budget: the built-in runtime gives the turn its full `turnTimeoutMs` (10 minutes by default) again from the steer, so a turn the owner kept steering is no longer aborted, and its final reply lost, at 10 minutes from its start. The new runtime option `turnMaxMs` caps the work of one turn, steered or not, at three times `turnTimeoutMs` by default (30 minutes); once it is reached the turn takes no more steering, the message waits for the next turn, and the turn is aborted as on its timeout. Time waiting on the owner's cards counts towards neither. A renewal is logged at info (`steer renewed the turn's time budget`, with `channel` and `deadlineMs`, the work time left), and the timeout warning names the limit that fired (`limit: "turn"` or `"turnMax"`). Task workers, coding workers and sandbox guests keep their own timeouts.
13
+
8
14
  ## [0.17.0] - 2026-10-16
9
15
 
10
16
  ### Added
package/docs/plugins.md CHANGED
@@ -3395,6 +3395,7 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
3395
3395
  `shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
3396
3396
  Every call shares the same shutdown.
3397
3397
  Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
3398
+ The built-in runtime aborts a turn that works 10 minutes (`turnTimeoutMs`), not counting time spent waiting on the owner's cards. Each message steered into the running turn gives it the full 10 minutes again from that moment, up to a cap of 30 minutes of work for the whole turn (`turnMaxMs`, three times `turnTimeoutMs` by default); once the cap is reached the turn takes no more steering, so a message waits for the next turn, and the turn is aborted as on its timeout. The log says `steer renewed the turn's time budget` with `deadlineMs`, the work time left, and the timeout warning names the limit that fired (`limit: "turn"` or `"turnMax"`). Task workers (`task.timeoutMs`), coding workers (`workTimeout`) and sandbox guests (their own `turnTimeoutMs`) keep their own limits, which steering does not renew.
3398
3399
  A turn whose model ignores its abort (a subprocess that does not exit) is given up on too: a turn that outlives its timeout, or is stopped, gets `turnAbortGraceMs` (15 seconds by default) to end, and then its session is disposed of and the turn is reported failed, so it cannot hold the queue.
3399
3400
  `listen()`, called by the command line and a host's own entry point, exits the process with that code.
3400
3401
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@ import { loadsMemory } from "./extensions/private-memory.ts";
33
33
  import { missingToolsError } from "./extensions/self-compact-guard.ts";
34
34
  import { interimPoster } from "./interim-text.ts";
35
35
  import { preflightTools } from "./preflight-tools.ts";
36
- import { PromptSlot, workTimeout } from "./prompt-slot.ts";
36
+ import { PromptSlot } from "./prompt-slot.ts";
37
37
  import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
38
38
  import { type RunningTurn, runningTurn } from "./running-turn.ts";
39
39
  import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
@@ -48,6 +48,7 @@ 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
52
  import { progressReporter } from "./turn-progress.ts";
52
53
  import { wrapUpTurn } from "./turn-wrapup.ts";
53
54
  import { workerReport } from "./worker-task.ts";
@@ -75,6 +76,8 @@ export class PiAgentRuntime implements AgentRuntime {
75
76
  readonly #turns = new Map<ChannelKey, RunningTurn>();
76
77
  /** Each conversation's turn in progress, which the owner may steer or stop. */
77
78
  readonly #running = new Map<ChannelKey, SteerableRun>();
79
+ /** Each running turn's time limit, which a steered message renews. */
80
+ readonly #budgets = new Map<ChannelKey, TurnBudget>();
78
81
  readonly #memoryTurns = new Map<ChannelKey, () => void>();
79
82
 
80
83
  /** Builds the runtime without starting a session; the preflight proves it can run. */
@@ -114,7 +117,7 @@ export class PiAgentRuntime implements AgentRuntime {
114
117
 
115
118
  // pi-lens-ignore: high-complexity, high-fan-out, mixed-async-styles — one turn's lifecycle end to end; batch 4 splits it with the channel router
116
119
  async runTurn(request: TurnRequest): Promise<TurnResult> {
117
- const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
120
+ const { logger } = this.#options;
118
121
  if (!request.speaker) return unspokenTurn();
119
122
  // A turn that is only asked for now, such as a group's next member, starts nothing.
120
123
  if (this.#options.stopping?.())
@@ -272,17 +275,14 @@ export class PiAgentRuntime implements AgentRuntime {
272
275
  this.#sessions.drop(key, channelSession);
273
276
  },
274
277
  });
275
- // Time spent waiting on the owner's cards does not count towards the timeout.
276
- const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
277
- logger.warn(
278
- { channel: request.channel, turnTimeoutMs },
279
- "turn timed out; aborting",
280
- );
281
- guard.abort();
282
- });
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
+ );
282
+ this.#budgets.set(key, budget);
283
283
  const running = new SteerableRun(
284
284
  session,
285
- () => request.steerable === true && !gate.pending(),
285
+ () => request.steerable === true && !gate.pending() && !budget.capped(),
286
286
  () => guard.abort(),
287
287
  );
288
288
  this.#running.set(key, running);
@@ -313,9 +313,10 @@ export class PiAgentRuntime implements AgentRuntime {
313
313
  };
314
314
  } finally {
315
315
  this.#running.delete(key);
316
+ this.#budgets.delete(key);
316
317
  this.#turns.delete(key);
317
318
  this.#memoryTurns.delete(key);
318
- cancelTimeout();
319
+ budget.cancel();
319
320
  guard.release();
320
321
  slot.unbind();
321
322
  unsubscribe();
@@ -358,8 +359,10 @@ export class PiAgentRuntime implements AgentRuntime {
358
359
  withAttachmentsBlock(text, attachments),
359
360
  promptImages(attachments),
360
361
  );
361
- if (steered)
362
+ if (steered) {
362
363
  this.#options.logger.info({ channel }, "message steered into the turn");
364
+ this.#budgets.get(channel)?.renew();
365
+ }
363
366
  return steered;
364
367
  } catch (error) {
365
368
  this.#options.logger.warn({ channel, err: error }, "steer refused");
@@ -88,8 +88,18 @@ export interface PiAgentRuntimeOptions {
88
88
  formatting?: (channel: ChannelKey) => string | undefined;
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
- /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
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
94
+ * budget again from that moment, up to `turnMaxMs`.
95
+ */
92
96
  turnTimeoutMs?: number;
97
+ /**
98
+ * The most one turn may work in all, however often it is steered, not counting time spent
99
+ * 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
+ */
102
+ turnMaxMs?: number;
93
103
  /**
94
104
  * How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
95
105
  * disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
@@ -0,0 +1,112 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+ import type { PromptSlot } from "./prompt-slot.ts";
3
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
4
+
5
+ /** Which limit ended a turn: its budget since the start or the last steer, or its cap. */
6
+ export type TurnLimit = "turn" | "turnMax";
7
+
8
+ /** A turn's time limit, which a steered message renews up to a cap on the whole turn. */
9
+ export interface TurnBudget {
10
+ /**
11
+ * Gives the turn its full budget again from now, up to the cap; the work time left, in ms,
12
+ * or undefined when the cap is reached and the turn takes no more steering.
13
+ */
14
+ renew(): number | undefined;
15
+ /** Whether the turn has worked up to its cap. */
16
+ capped(): boolean;
17
+ cancel(): void;
18
+ }
19
+
20
+ /** The clock a turn budget reads; the real one by default, a fake one in tests. */
21
+ export interface BudgetClock {
22
+ now(): number;
23
+ schedule(run: () => void, ms: number): () => void;
24
+ }
25
+
26
+ const REAL_CLOCK: BudgetClock = {
27
+ now: () => Date.now(),
28
+ schedule: (run, ms) => {
29
+ const timer = setTimeout(run, ms);
30
+ return () => clearTimeout(timer);
31
+ },
32
+ };
33
+
34
+ /**
35
+ * Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
36
+ * `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
37
+ * towards neither. `expire` is told which limit fired.
38
+ */
39
+ export function turnBudget(
40
+ ms: number,
41
+ maxMs: number,
42
+ slot: Pick<PromptSlot, "waitedMs">,
43
+ expire: (limit: TurnLimit) => void,
44
+ clock: BudgetClock = REAL_CLOCK,
45
+ ): TurnBudget {
46
+ const started = clock.now();
47
+ const worked = () => {
48
+ const now = clock.now();
49
+ return now - started - slot.waitedMs(now);
50
+ };
51
+ let deadline = Math.min(ms, maxMs);
52
+ let cancelTimer: (() => void) | undefined;
53
+ let expired = false;
54
+ const arm = (delay: number) => {
55
+ cancelTimer = clock.schedule(() => {
56
+ const left = deadline - worked();
57
+ if (left > 0) return arm(left);
58
+ expired = true;
59
+ expire(deadline >= maxMs ? "turnMax" : "turn");
60
+ }, delay);
61
+ };
62
+ arm(deadline);
63
+ return {
64
+ renew() {
65
+ const now = worked();
66
+ if (expired || now >= maxMs) return undefined;
67
+ deadline = Math.min(now + ms, maxMs);
68
+ cancelTimer?.();
69
+ arm(deadline - now);
70
+ return deadline - now;
71
+ },
72
+ capped: () => expired || worked() >= maxMs,
73
+ cancel: () => cancelTimer?.(),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The time limit of one turn of the built-in runtime, from its options: `turnTimeoutMs` (default
79
+ * 10 minutes) renewed by each steer up to `turnMaxMs` (default three times that). It logs each
80
+ * renewal and, when a limit fires, which one before it calls `abort`.
81
+ */
82
+ export function runtimeTurnBudget(
83
+ options: Pick<
84
+ PiAgentRuntimeOptions,
85
+ "turnTimeoutMs" | "turnMaxMs" | "logger"
86
+ >,
87
+ slot: Pick<PromptSlot, "waitedMs">,
88
+ channel: ChannelKey,
89
+ abort: () => void,
90
+ ): TurnBudget {
91
+ const { logger, turnTimeoutMs = 10 * 60_000 } = options;
92
+ const turnMaxMs = options.turnMaxMs ?? 3 * turnTimeoutMs;
93
+ const budget = turnBudget(turnTimeoutMs, turnMaxMs, slot, (limit) => {
94
+ logger.warn(
95
+ { channel, limit, turnTimeoutMs, turnMaxMs },
96
+ "turn timed out; aborting",
97
+ );
98
+ abort();
99
+ });
100
+ return {
101
+ ...budget,
102
+ renew() {
103
+ const deadlineMs = budget.renew();
104
+ if (deadlineMs !== undefined)
105
+ logger.info(
106
+ { channel, deadlineMs },
107
+ "steer renewed the turn's time budget",
108
+ );
109
+ return deadlineMs;
110
+ },
111
+ };
112
+ }