pi-roundtable 0.9.0 → 0.9.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/docs/plugins.md +46 -10
  3. package/package.json +1 -1
  4. package/src/core/attachments/attachment-port.ts +34 -0
  5. package/src/core/attachments/attachment-store.ts +248 -0
  6. package/src/core/attachments/model-images.ts +39 -0
  7. package/src/core/attachments/tool-attachment.ts +53 -0
  8. package/src/core/attachments/turn-attachments.ts +4 -21
  9. package/src/core/builtin/modules.ts +10 -1
  10. package/src/core/config/config.ts +9 -0
  11. package/src/core/contract/attachments.ts +71 -0
  12. package/src/core/contract/channels.ts +5 -0
  13. package/src/core/define-roundtable.ts +2 -0
  14. package/src/core/define.ts +25 -0
  15. package/src/core/drain.ts +56 -5
  16. package/src/core/errors.ts +13 -0
  17. package/src/core/host-background.ts +56 -0
  18. package/src/core/host.ts +33 -60
  19. package/src/core/i18n/conversation.ts +2 -0
  20. package/src/core/modules/schedules/scheduler.ts +13 -2
  21. package/src/core/plugin.ts +6 -0
  22. package/src/core/routing/channel-queue.ts +18 -1
  23. package/src/core/routing/channel-router.ts +59 -7
  24. package/src/core/routing/conversation-turns.ts +7 -2
  25. package/src/core/runtime/abort-guard.ts +58 -0
  26. package/src/core/runtime/conversation-sessions.ts +15 -0
  27. package/src/core/runtime/pi-agent-runtime.ts +61 -78
  28. package/src/core/runtime/runtime-plugin.ts +1 -0
  29. package/src/core/runtime/runtime-types.ts +8 -0
  30. package/src/core/runtime/session-factory.ts +1 -0
  31. package/src/core/runtime/steerable-run.ts +12 -3
  32. package/src/core/runtime/thinking-level.ts +47 -0
  33. package/src/core/runtime/turn-wrapup.ts +63 -0
  34. package/src/core/sessions.ts +2 -0
  35. package/src/index.ts +8 -0
  36. package/src/testing.ts +9 -1
@@ -12,7 +12,8 @@ import {
12
12
  } from "../contract/channels.ts";
13
13
  import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
14
14
  import { IdentityError } from "../domain/errors.ts";
15
- import { PluginError } from "../errors.ts";
15
+ import { HostStoppingError, PluginError } from "../errors.ts";
16
+ import { messages } from "../i18n/index.ts";
16
17
  import type { ActorFacts } from "../identity/actor-facts.ts";
17
18
  import type { Contact, ContactAssessor } from "../identity/contact.ts";
18
19
  import {
@@ -37,8 +38,11 @@ export interface ChannelRouterOptions {
37
38
  contacts?: ContactAssessor;
38
39
  /** Whom a background turn runs as; without it, only the host's own turns run. */
39
40
  principals?: Pick<IdentityService, "speakerFor">;
40
- /** The surfaces, so a conversation started over is marked in a channel that shows one; without it, none is. */
41
- surfaces?: Pick<SurfacePort, "of">;
41
+ /**
42
+ * The surfaces, so a conversation started over is marked in a channel that shows one, and a
43
+ * message that arrives while the host shuts down is told so; without it, neither is.
44
+ */
45
+ surfaces?: Pick<SurfacePort, "of"> & Partial<Pick<SurfacePort, "sendReply">>;
42
46
  /** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
43
47
  forwardJoinMs?: number;
44
48
  }
@@ -56,6 +60,12 @@ export function orderClaims(
56
60
  return claims.toSorted((a, b) => b.priority - a.priority);
57
61
  }
58
62
 
63
+ /** The outcome of a background turn the host did not start because it is shutting down. */
64
+ const stopping = (): ScheduledOutcome => ({
65
+ status: "skipped",
66
+ reason: new HostStoppingError().message,
67
+ });
68
+
59
69
  /**
60
70
  * Routes every conversation operation to the claim that owns its channel, each inside the
61
71
  * channel's queue: messages after joining a bare forward to its text, background turns,
@@ -69,6 +79,8 @@ export class ChannelRouter implements ConversationPort {
69
79
  readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
70
80
  /** The surfaces already warned that their messages carry no actor. */
71
81
  readonly #warned = new Set<string>();
82
+ /** The channels already told that the host is restarting, so a burst of messages gets one notice. */
83
+ readonly #told = new Set<ChannelKey>();
72
84
 
73
85
  constructor(options: ChannelRouterOptions) {
74
86
  this.#options = options;
@@ -100,7 +112,13 @@ export class ChannelRouter implements ConversationPort {
100
112
  if (!claim) return;
101
113
  const started = await this.#inOrder(message.channel, async () => {
102
114
  const admission = await this.#admit(claim, message);
103
- return admission && (await this.#start(message, admission));
115
+ if (!admission) return undefined;
116
+ // Once the host shuts down only the turns already running finish: this one starts nothing.
117
+ if (this.#options.queue.closed) {
118
+ await this.#refuse(message, admission);
119
+ return undefined;
120
+ }
121
+ return this.#start(message, admission);
104
122
  });
105
123
  await started?.done;
106
124
  }
@@ -209,6 +227,33 @@ export class ChannelRouter implements ConversationPort {
209
227
  };
210
228
  }
211
229
 
230
+ /**
231
+ * Drops a message the host no longer starts a turn for because it is shutting down, and tells
232
+ * its channel once that it restarts; no one is told for a bot's or an integration's message, such
233
+ * as a report, since a notice there would only start the next one.
234
+ */
235
+ async #refuse(message: InboundMessage, admission: Admission): Promise<void> {
236
+ const { logger, surfaces } = this.#options;
237
+ const { channel } = message;
238
+ if (admission.kind === "turn") admission.dropped?.();
239
+ else
240
+ admission.unanswered({
241
+ status: "skipped",
242
+ reason: new HostStoppingError().message,
243
+ });
244
+ logger.info({ channel }, "message not run: the host is shutting down");
245
+ if (message.authorIsBot || message.integration || this.#told.has(channel))
246
+ return;
247
+ this.#told.add(channel);
248
+ try {
249
+ await surfaces?.sendReply?.(channel, {
250
+ chunks: [messages().restartingNotice],
251
+ });
252
+ } catch (error) {
253
+ logger.warn({ channel, err: error }, "restart notice not posted");
254
+ }
255
+ }
256
+
212
257
  /**
213
258
  * Starts what the claim admitted, in the channel's queue, and returns while it runs: a message
214
259
  * during a turn is first offered to it, then marked as waiting.
@@ -238,12 +283,17 @@ export class ChannelRouter implements ConversationPort {
238
283
  return admission.run();
239
284
  })
240
285
  // pi-lens-ignore: no-unknown-parameters
241
- .catch((error: unknown) =>
286
+ .catch(async (error: unknown) => {
287
+ // The host began shutting down while this message waited behind a running turn.
288
+ if (error instanceof HostStoppingError) {
289
+ if (queued) void busy?.unreact(QUEUED_MARK);
290
+ return this.#refuse(message, admission);
291
+ }
242
292
  logger.error(
243
293
  { channel: message.channel, err: error },
244
294
  admission.failure,
245
- ),
246
- );
295
+ );
296
+ });
247
297
  return { done };
248
298
  }
249
299
 
@@ -257,6 +307,7 @@ export class ChannelRouter implements ConversationPort {
257
307
  * handed to another target's claim, and so is one whose author may not run it now.
258
308
  */
259
309
  async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
310
+ if (this.#options.queue.closed) return stopping();
260
311
  if (!this.#options.targets(turn.target))
261
312
  return {
262
313
  status: "skipped",
@@ -277,6 +328,7 @@ export class ChannelRouter implements ConversationPort {
277
328
  return claim.background({ ...turn, speaker: runs.speaker });
278
329
  });
279
330
  } catch (error) {
331
+ if (error instanceof HostStoppingError) return stopping();
280
332
  return { status: "failed", error: String(error) };
281
333
  }
282
334
  }
@@ -8,7 +8,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
8
8
  import type { TurnResult } from "../domain/conversation.ts";
9
9
  import type { TurnConversation } from "../domain/ports.ts";
10
10
  import type { TurnProgress } from "../domain/progress.ts";
11
- import { PluginError } from "../errors.ts";
11
+ import { HostStoppingError, PluginError } from "../errors.ts";
12
12
  import { messages } from "../i18n/index.ts";
13
13
  import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
14
14
  import type { Logger } from "../log.ts";
@@ -61,7 +61,8 @@ export interface ConversationTurns {
61
61
  * task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
62
62
  * `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
63
63
  * posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
64
- * It rejects during setup (NotLinkedError), when the host has no runtime to run the turn on,
64
+ * It rejects during setup (NotLinkedError), once the host is shutting down (HostStoppingError),
65
+ * when the host has no runtime to run the turn on,
65
66
  * when the conversation cannot be recorded in the host's registry (a database error, or a
66
67
  * `private` conversation without a speaker), and when the conversation is recorded private to a
67
68
  * principal other than the speaker's, unless the speaker is the host's own (`SYSTEM_PRINCIPAL`).
@@ -75,6 +76,8 @@ export interface ConversationTurns {
75
76
  export interface ConversationTurnsOptions {
76
77
  /** Throws NotLinkedError until every plugin is set up and linked. */
77
78
  linked: () => void;
79
+ /** Whether the host is shutting down, so no turn starts; undefined or false while it serves. */
80
+ stopping?: () => boolean;
78
81
  /** The runtime that runs the turn; throws PluginError when the host has none yet. */
79
82
  runtime: () => AgentRuntime;
80
83
  /** Where each turn records its conversation; absent, or undefined, records none. */
@@ -143,6 +146,8 @@ export function conversationTurns(
143
146
  async run(input) {
144
147
  const { channel, kind, speaker } = input;
145
148
  options.linked();
149
+ // Refused before anything is shown or recorded, like a turn the host cannot run.
150
+ if (options.stopping?.()) throw new HostStoppingError();
146
151
  // A host without a runtime is a setup mistake, so it is refused before anything is shown.
147
152
  const runtime = options.runtime();
148
153
  // A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
@@ -0,0 +1,58 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import { AgentRunError } from "../domain/errors.ts";
3
+
4
+ /** How long a run told to abort gets to end before its session is disposed of; default for the guard. */
5
+ const ABORT_GRACE_MS = 15_000;
6
+
7
+ /**
8
+ * Aborts a Pi session's run and does not trust the abort to work. A model behind a subprocess that
9
+ * ignores the abort would leave the run, and the queue behind it, waiting for ever, so a run still
10
+ * going once the grace is over has its session disposed of, which drops its model connection, and
11
+ * `gaveUp` rejects so the caller can end the turn as failed.
12
+ */
13
+ export class AbortGuard {
14
+ /** Rejects when the abort did not end the run in time; never resolves. */
15
+ readonly gaveUp: Promise<never>;
16
+ readonly #session: Pick<AgentSession, "abort" | "dispose">;
17
+ readonly #graceMs: number;
18
+ readonly #onGiveUp: () => void;
19
+ #reject: (error: Error) => void = () => undefined;
20
+ #timer: ReturnType<typeof setTimeout> | undefined;
21
+
22
+ constructor(
23
+ session: Pick<AgentSession, "abort" | "dispose">,
24
+ options: { graceMs?: number; onGiveUp?: () => void } = {},
25
+ ) {
26
+ this.#session = session;
27
+ this.#graceMs = options.graceMs ?? ABORT_GRACE_MS;
28
+ this.#onGiveUp = options.onGiveUp ?? (() => undefined);
29
+ this.gaveUp = new Promise<never>((_resolve, reject) => {
30
+ this.#reject = reject;
31
+ });
32
+ // Nobody waits on it unless the abort fails.
33
+ this.gaveUp.catch(() => undefined);
34
+ }
35
+
36
+ /** Aborts the run, and starts the grace the first time. */
37
+ abort(): void {
38
+ void this.#session.abort();
39
+ this.#timer ??= setTimeout(() => {
40
+ this.#onGiveUp();
41
+ try {
42
+ this.#session.dispose();
43
+ } catch {
44
+ // A session that cannot be disposed of is dropped all the same.
45
+ }
46
+ this.#reject(
47
+ new AgentRunError(
48
+ "the run did not stop after it was aborted, so its session was disposed of",
49
+ ),
50
+ );
51
+ }, this.#graceMs);
52
+ }
53
+
54
+ /** The run ended: no session is disposed of any more. */
55
+ release(): void {
56
+ clearTimeout(this.#timer);
57
+ }
58
+ }
@@ -262,6 +262,21 @@ export class ConversationSessions {
262
262
  );
263
263
  }
264
264
 
265
+ /**
266
+ * Forgets a session that was disposed of while its turn still ran, so the conversation's next
267
+ * turn opens a new one from its files; a newer session of the conversation stays.
268
+ */
269
+ drop(key: ChannelKey, disposed: ChannelSession): void {
270
+ const open = this.#sessions.get(key);
271
+ if (!open) return;
272
+ void open
273
+ .then((current) => {
274
+ if (current === disposed && this.#sessions.get(key) === open)
275
+ this.#sessions.delete(key);
276
+ })
277
+ .catch(() => undefined);
278
+ }
279
+
265
280
  /** Drops the open session and everything held for the conversation, leaving its files. */
266
281
  // pi-lens-ignore: mixed-async-styles — disposal failures of a dropped session are swallowed in a chain on purpose
267
282
  async forget(channel: ChannelKey): Promise<void> {
@@ -1,6 +1,5 @@
1
1
  import { rmSync } from "node:fs";
2
2
  import {
3
- type AgentSession,
4
3
  type ModelRuntime,
5
4
  SessionManager,
6
5
  } from "@earendil-works/pi-coding-agent";
@@ -16,20 +15,16 @@ import type {
16
15
  } from "../domain/conversation.ts";
17
16
  import { AgentRunError } from "../domain/errors.ts";
18
17
  import type { TurnRequest } from "../domain/ports.ts";
18
+ import { HostStoppingError } from "../errors.ts";
19
19
  import { assistantName } from "../i18n/index.ts";
20
20
  import { promptScopeOf } from "../interactions/prompts.ts";
21
- import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
22
21
  import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
23
22
  import { withoutReplyFiles } from "../reply-files.ts";
24
23
  import type { TransientTask } from "../sessions.ts";
25
24
  import type { Speaker, Tier } from "../speakers.ts";
26
25
  import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
27
- import {
28
- type AgentModelSettings,
29
- bridgeTurnRefusal,
30
- chosenAgentModel,
31
- useChosenAgentModel,
32
- } from "./bridge-guard.ts";
26
+ import { AbortGuard } from "./abort-guard.ts";
27
+ import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
33
28
  import { ConversationSessions } from "./conversation-sessions.ts";
34
29
  import { confirmedTurnText } from "./extensions/confirmation-gate.ts";
35
30
  import { loadsMemory } from "./extensions/private-memory.ts";
@@ -47,14 +42,11 @@ import {
47
42
  } from "./session-conversation.ts";
48
43
  import { SessionFactory } from "./session-factory.ts";
49
44
  import { promptImages, SteerableRun } from "./steerable-run.ts";
45
+ import { thinkingLevel } from "./thinking-level.ts";
50
46
  import { runningCalls } from "./tool-call-scope.ts";
51
- import {
52
- lastReply,
53
- transcriptOf,
54
- turnAnswer,
55
- unspokenTurn,
56
- } from "./turn-answer.ts";
47
+ import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
57
48
  import { progressReporter } from "./turn-progress.ts";
49
+ import { wrapUpTurn } from "./turn-wrapup.ts";
58
50
  import { workerReport } from "./worker-task.ts";
59
51
 
60
52
  export {
@@ -118,6 +110,12 @@ export class PiAgentRuntime implements AgentRuntime {
118
110
  async runTurn(request: TurnRequest): Promise<TurnResult> {
119
111
  const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
120
112
  if (!request.speaker) return unspokenTurn();
113
+ // A turn that is only asked for now, such as a group's next member, starts nothing.
114
+ if (this.#options.stopping?.())
115
+ return {
116
+ ok: false,
117
+ error: new AgentRunError(new HostStoppingError().message),
118
+ };
121
119
  const key = request.agent?.session ?? request.channel;
122
120
  // Resolve current scope; refuse a foreign private speaker before touching its session.
123
121
  const conversation = await this.#sessions.conversation(key, request);
@@ -147,7 +145,17 @@ export class PiAgentRuntime implements AgentRuntime {
147
145
  : undefined;
148
146
  if (bridgeRefused)
149
147
  return { ok: false, error: new AgentRunError(bridgeRefused) };
150
- const level = await this.#thinkingLevel(key, session, request, agentModel);
148
+ const level = await thinkingLevel(
149
+ {
150
+ modelRuntime: this.#modelRuntime,
151
+ effort: this.#options.effort,
152
+ judged: this.#sessions.judged,
153
+ },
154
+ key,
155
+ session,
156
+ request,
157
+ agentModel,
158
+ );
151
159
  if (session.thinkingLevel !== level) session.setThinkingLevel(level);
152
160
  const gate = await this.#sessions.gate(key, request.agent !== undefined);
153
161
  const pending = gate.pending();
@@ -240,17 +248,29 @@ export class PiAgentRuntime implements AgentRuntime {
240
248
  request.agent?.name ?? assistantName(),
241
249
  interim ? () => interim.flush() : undefined,
242
250
  );
251
+ // A model that ignores the abort must not hold the turn: its session is disposed of instead.
252
+ const guard = new AbortGuard(session, {
253
+ graceMs: this.#options.turnAbortGraceMs,
254
+ onGiveUp: () => {
255
+ logger.error(
256
+ { channel: request.channel },
257
+ "turn did not stop after its abort; disposing of its session",
258
+ );
259
+ this.#sessions.drop(key, channelSession);
260
+ },
261
+ });
243
262
  // Time spent waiting on the owner's cards does not count towards the timeout.
244
263
  const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
245
264
  logger.warn(
246
265
  { channel: request.channel, turnTimeoutMs },
247
266
  "turn timed out; aborting",
248
267
  );
249
- void session.abort();
268
+ guard.abort();
250
269
  });
251
270
  const running = new SteerableRun(
252
271
  session,
253
272
  () => request.steerable === true && !gate.pending(),
273
+ () => guard.abort(),
254
274
  );
255
275
  this.#running.set(key, running);
256
276
 
@@ -260,7 +280,7 @@ export class PiAgentRuntime implements AgentRuntime {
260
280
  request.confirmed && pending
261
281
  ? confirmedTurnText(pending, request.text, who)
262
282
  : request.text;
263
- await running.run(() =>
283
+ const prompted = running.run(() =>
264
284
  session.prompt(
265
285
  withAttachmentsBlock(text, attachments),
266
286
  attachments.images.length > 0
@@ -268,6 +288,9 @@ export class PiAgentRuntime implements AgentRuntime {
268
288
  : undefined,
269
289
  ),
270
290
  );
291
+ // A run abandoned by the guard may still reject later; nobody waits for it then.
292
+ prompted.catch(() => undefined);
293
+ await Promise.race([prompted, guard.gaveUp]);
271
294
  } catch (error) {
272
295
  if (running.stopped)
273
296
  return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
@@ -280,42 +303,20 @@ export class PiAgentRuntime implements AgentRuntime {
280
303
  this.#turns.delete(key);
281
304
  this.#memoryTurns.delete(key);
282
305
  cancelTimeout();
306
+ guard.release();
283
307
  slot.unbind();
284
308
  unsubscribe();
285
309
  progress?.close();
286
310
  await interim?.flush();
287
- gate.endTurn();
288
- const usage = session.getContextUsage();
289
- if (usage)
290
- this.#sessions.usage.set(key, {
291
- tokens: usage.tokens,
292
- contextWindow: usage.contextWindow,
293
- });
294
- const held = gate.pending();
295
- await this.#options.confirmations
296
- .save(key, held)
297
- // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
298
- .catch((error: unknown) =>
299
- logger.error({ channel: key, err: error }, "held actions not saved"),
300
- );
301
- if (held)
302
- logger.info(
303
- { channel: key, held: held.calls.map((call) => call.action) },
304
- "actions held for confirmation",
305
- );
306
- logger.info(
307
- {
308
- channel: key,
309
- selection: request.selection.id,
310
- toolCalls,
311
- thinking: session.thinkingLevel,
312
- // pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
313
- ...(request.agent
314
- ? { model: `${session.model?.provider}/${session.model?.id}` }
315
- : {}),
316
- },
317
- "turn finished",
318
- );
311
+ await wrapUpTurn({
312
+ key,
313
+ request,
314
+ session,
315
+ gate,
316
+ toolCalls,
317
+ options: this.#options,
318
+ recordUsage: (usage) => this.#sessions.usage.set(key, usage),
319
+ });
319
320
  }
320
321
 
321
322
  if (running.stopped)
@@ -368,6 +369,8 @@ export class PiAgentRuntime implements AgentRuntime {
368
369
  ): Promise<string> {
369
370
  const { dataDir } = this.#options;
370
371
  const { signal } = task;
372
+ if (this.#options.stopping?.())
373
+ throw new AgentRunError(new HostStoppingError().message);
371
374
  // A task works at the tier of the turn that started it; without one there is no tier to take.
372
375
  const turn = this.#turns.get(scope.turn);
373
376
  if (!turn)
@@ -393,7 +396,11 @@ export class PiAgentRuntime implements AgentRuntime {
393
396
  const unsubscribe = session.subscribe((event) => {
394
397
  if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
395
398
  });
396
- const abort = () => void session.abort();
399
+ // A worker whose model ignores the abort is given up on, like a turn's.
400
+ const guard = new AbortGuard(session, {
401
+ graceMs: this.#options.turnAbortGraceMs,
402
+ });
403
+ const abort = () => guard.abort();
397
404
  signal?.addEventListener("abort", abort, { once: true });
398
405
  const timer = setTimeout(abort, task.timeoutMs);
399
406
  try {
@@ -411,13 +418,16 @@ export class PiAgentRuntime implements AgentRuntime {
411
418
  (name) => registered.has(name) && this.#tiers.allows(tier, name),
412
419
  );
413
420
  session.setActiveToolsByName([...worker.tools]);
414
- await withoutReplyFiles(() => session.prompt(task.text));
421
+ const prompted = withoutReplyFiles(() => session.prompt(task.text));
422
+ prompted.catch(() => undefined);
423
+ await Promise.race([prompted, guard.gaveUp]);
415
424
  return workerReport(
416
425
  session.messages,
417
426
  "the worker stopped before it reported",
418
427
  );
419
428
  } finally {
420
429
  clearTimeout(timer);
430
+ guard.release();
421
431
  signal?.removeEventListener("abort", abort);
422
432
  unsubscribe();
423
433
  session.dispose();
@@ -467,33 +477,6 @@ export class PiAgentRuntime implements AgentRuntime {
467
477
  return transcriptOf(await this.#sessions.messages(channel), limit);
468
478
  }
469
479
 
470
- /** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
471
- async #thinkingLevel(
472
- key: ChannelKey,
473
- session: AgentSession,
474
- request: TurnRequest,
475
- agentModel?: AgentModelSettings,
476
- ): Promise<ThinkingLevel> {
477
- const setting = request.agent
478
- ? await useChosenAgentModel(
479
- session,
480
- this.#modelRuntime,
481
- request.agent.name,
482
- agentModel,
483
- )
484
- : AUTO_THINKING;
485
- if (setting !== AUTO_THINKING) {
486
- this.#sessions.judged.delete(key);
487
- return setting;
488
- }
489
- const level = await this.#options.effort.judge(request.text, {
490
- reply: lastReply(session.messages),
491
- level: this.#sessions.judged.get(key),
492
- });
493
- this.#sessions.judged.set(key, level);
494
- return level;
495
- }
496
-
497
480
  dispose(): void {
498
481
  this.#sessions.dispose();
499
482
  }
@@ -168,6 +168,7 @@ export function runtimePlugin(
168
168
  }),
169
169
  confirmations: heldActions,
170
170
  toolTiers: context.toolTiers,
171
+ stopping: () => context.queue.closed,
171
172
  get agents() {
172
173
  return agents();
173
174
  },
@@ -80,6 +80,14 @@ export interface PiAgentRuntimeOptions {
80
80
  toolTiers?: ToolTiers;
81
81
  /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
82
82
  turnTimeoutMs?: number;
83
+ /**
84
+ * How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
85
+ * disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
86
+ * abort would otherwise hold the turn, and the shutdown drain, for ever.
87
+ */
88
+ turnAbortGraceMs?: number;
89
+ /** Whether the host is shutting down: a turn or a task is then refused before it starts. */
90
+ stopping?: () => boolean;
83
91
  /**
84
92
  * Whether a turn given a place for interim posts shows the text it writes before its final
85
93
  * answer as it goes; default "on". "off" posts only the final reply.
@@ -332,6 +332,7 @@ export class SessionFactory {
332
332
  },
333
333
  conversation,
334
334
  addressee,
335
+ attachmentDir,
335
336
  get memory() {
336
337
  return state.memory;
337
338
  },
@@ -33,13 +33,22 @@ export class SteerableRun {
33
33
  readonly #canSteer: () => boolean;
34
34
  /** Steered prompts in arrival order, each with its images. */
35
35
  readonly #steers: { text: string; images: PromptImages }[] = [];
36
+ readonly #abort: () => void;
36
37
  #stopped = false;
37
38
  #done = false;
38
39
 
39
- /** `canSteer` is asked at each steer: the turn is the owner's and holds no actions. */
40
- constructor(session: SteeringSession, canSteer: () => boolean) {
40
+ /**
41
+ * `canSteer` is asked at each steer: the turn is the owner's and holds no actions. `abort`
42
+ * ends the session's run when the turn is stopped; by default the session's own abort.
43
+ */
44
+ constructor(
45
+ session: SteeringSession,
46
+ canSteer: () => boolean,
47
+ abort: () => void = () => void session.abort(),
48
+ ) {
41
49
  this.#session = session;
42
50
  this.#canSteer = canSteer;
51
+ this.#abort = abort;
43
52
  }
44
53
 
45
54
  get stopped(): boolean {
@@ -106,7 +115,7 @@ export class SteerableRun {
106
115
  if (this.#done) return false;
107
116
  this.#stopped = true;
108
117
  this.#session.clearQueue();
109
- void this.#session.abort();
118
+ this.#abort();
110
119
  return true;
111
120
  }
112
121
 
@@ -0,0 +1,47 @@
1
+ import type {
2
+ AgentSession,
3
+ ModelRuntime,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { ChannelKey } from "../domain/conversation.ts";
6
+ import type { TurnRequest } from "../domain/ports.ts";
7
+ import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
8
+ import {
9
+ type AgentModelSettings,
10
+ useChosenAgentModel,
11
+ } from "./bridge-guard.ts";
12
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
13
+ import { lastReply } from "./turn-answer.ts";
14
+
15
+ /** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
16
+ export async function thinkingLevel(
17
+ parts: {
18
+ modelRuntime: ModelRuntime;
19
+ effort: PiAgentRuntimeOptions["effort"];
20
+ /** The level last judged for each conversation. */
21
+ judged: Map<ChannelKey, ThinkingLevel>;
22
+ },
23
+ key: ChannelKey,
24
+ session: AgentSession,
25
+ request: TurnRequest,
26
+ agentModel?: AgentModelSettings,
27
+ ): Promise<ThinkingLevel> {
28
+ const { modelRuntime, effort, judged } = parts;
29
+ const setting = request.agent
30
+ ? await useChosenAgentModel(
31
+ session,
32
+ modelRuntime,
33
+ request.agent.name,
34
+ agentModel,
35
+ )
36
+ : AUTO_THINKING;
37
+ if (setting !== AUTO_THINKING) {
38
+ judged.delete(key);
39
+ return setting;
40
+ }
41
+ const level = await effort.judge(request.text, {
42
+ reply: lastReply(session.messages),
43
+ level: judged.get(key),
44
+ });
45
+ judged.set(key, level);
46
+ return level;
47
+ }
@@ -0,0 +1,63 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import type { ChannelKey } from "../domain/conversation.ts";
3
+ import type { TurnRequest } from "../domain/ports.ts";
4
+ import type { ConfirmationGate } from "./extensions/confirmation-gate.ts";
5
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
6
+
7
+ /**
8
+ * What a turn leaves behind when it ends, whatever way it ended: the gate stops counting the turn,
9
+ * the conversation's context usage and held actions are recorded, and the turn is logged. A
10
+ * session disposed of under a turn that would not stop has no usage to read.
11
+ */
12
+ export async function wrapUpTurn(parts: {
13
+ key: ChannelKey;
14
+ request: TurnRequest;
15
+ session: AgentSession;
16
+ gate: ConfirmationGate;
17
+ toolCalls: readonly string[];
18
+ options: Pick<PiAgentRuntimeOptions, "confirmations" | "logger">;
19
+ recordUsage: (usage: {
20
+ tokens: number | null;
21
+ contextWindow: number;
22
+ }) => void;
23
+ }): Promise<void> {
24
+ const { key, request, session, gate, toolCalls, options, recordUsage } =
25
+ parts;
26
+ const { logger } = options;
27
+ gate.endTurn();
28
+ try {
29
+ const usage = session.getContextUsage();
30
+ if (usage)
31
+ recordUsage({
32
+ tokens: usage.tokens,
33
+ contextWindow: usage.contextWindow,
34
+ });
35
+ } catch (error) {
36
+ logger.warn({ channel: key, err: error }, "context usage not read");
37
+ }
38
+ const held = gate.pending();
39
+ await options.confirmations
40
+ .save(key, held)
41
+ // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
42
+ .catch((error: unknown) =>
43
+ logger.error({ channel: key, err: error }, "held actions not saved"),
44
+ );
45
+ if (held)
46
+ logger.info(
47
+ { channel: key, held: held.calls.map((call) => call.action) },
48
+ "actions held for confirmation",
49
+ );
50
+ logger.info(
51
+ {
52
+ channel: key,
53
+ selection: request.selection.id,
54
+ toolCalls,
55
+ thinking: session.thinkingLevel,
56
+ // pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
57
+ ...(request.agent
58
+ ? { model: `${session.model?.provider}/${session.model?.id}` }
59
+ : {}),
60
+ },
61
+ "turn finished",
62
+ );
63
+ }