pi-roundtable 0.8.0 → 0.9.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.
Files changed (158) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +618 -86
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +150 -86
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +87 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +82 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +42 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/drain.ts +56 -5
  69. package/src/core/errors.ts +13 -0
  70. package/src/core/host-background.ts +56 -0
  71. package/src/core/host.ts +52 -68
  72. package/src/core/i18n/cards.ts +8 -4
  73. package/src/core/i18n/conversation.ts +2 -0
  74. package/src/core/identity/access-policy.ts +227 -0
  75. package/src/core/identity/actor-facts.ts +65 -0
  76. package/src/core/identity/caching-principal-store.ts +111 -0
  77. package/src/core/identity/config-owner.ts +145 -0
  78. package/src/core/identity/contact.ts +27 -0
  79. package/src/core/identity/identity-plugin.ts +56 -0
  80. package/src/core/identity/identity-schema.ts +282 -0
  81. package/src/core/identity/identity-service.ts +489 -0
  82. package/src/core/identity/identity-view.ts +77 -0
  83. package/src/core/identity/plugin-identities.ts +109 -0
  84. package/src/core/identity/principal-reads.ts +38 -0
  85. package/src/core/identity/principal-store.ts +478 -0
  86. package/src/core/identity/principal-tables.ts +53 -0
  87. package/src/core/identity/seen-throttle.ts +57 -0
  88. package/src/core/identity/ulid.ts +36 -0
  89. package/src/core/identity.ts +34 -5
  90. package/src/core/interactions/prompts.ts +127 -0
  91. package/src/core/modules/background/background-turns.ts +56 -17
  92. package/src/core/modules/background/personal-target.ts +50 -0
  93. package/src/core/modules/delegation/delegate.ts +28 -14
  94. package/src/core/modules/delegation/delegator.ts +23 -7
  95. package/src/core/modules/memory/owner-memory.ts +123 -50
  96. package/src/core/modules/notify/notify.ts +53 -13
  97. package/src/core/modules/notify/session-notify.ts +110 -0
  98. package/src/core/modules/schedules/prechecks.ts +8 -2
  99. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  100. package/src/core/modules/schedules/schedule-store.ts +51 -20
  101. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  102. package/src/core/modules/schedules/scheduler.ts +56 -4
  103. package/src/core/modules/schedules/schedules.ts +43 -14
  104. package/src/core/plugin.ts +48 -0
  105. package/src/core/presence/direct-channels.ts +104 -0
  106. package/src/core/registry/contributions.ts +63 -3
  107. package/src/core/routing/channel-queue.ts +18 -1
  108. package/src/core/routing/channel-router.ts +274 -21
  109. package/src/core/routing/conversation-turns.ts +45 -10
  110. package/src/core/routing/surface-port.ts +32 -2
  111. package/src/core/routing/system-turns.ts +29 -0
  112. package/src/core/runtime/abort-guard.ts +58 -0
  113. package/src/core/runtime/bridge-guard.ts +225 -0
  114. package/src/core/runtime/compaction-tiers.ts +5 -1
  115. package/src/core/runtime/conversation-sessions.ts +139 -12
  116. package/src/core/runtime/extensions/ask-user.ts +1 -1
  117. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  118. package/src/core/runtime/extensions/private-memory.ts +447 -0
  119. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  120. package/src/core/runtime/pi-agent-runtime.ts +174 -190
  121. package/src/core/runtime/preflight-tools.ts +47 -0
  122. package/src/core/runtime/prompt-slot.ts +4 -4
  123. package/src/core/runtime/reader-history.ts +129 -0
  124. package/src/core/runtime/runtime-plugin.ts +51 -5
  125. package/src/core/runtime/runtime-types.ts +48 -12
  126. package/src/core/runtime/session-conversation.ts +143 -0
  127. package/src/core/runtime/session-factory.ts +115 -15
  128. package/src/core/runtime/session-scope.ts +78 -0
  129. package/src/core/runtime/steerable-run.ts +21 -3
  130. package/src/core/runtime/thinking-level.ts +47 -0
  131. package/src/core/runtime/tool-call-scope.ts +43 -0
  132. package/src/core/runtime/turn-answer.ts +30 -1
  133. package/src/core/runtime/turn-wrapup.ts +63 -0
  134. package/src/core/services.ts +27 -1
  135. package/src/core/sessions.ts +30 -0
  136. package/src/core/speakers.ts +21 -19
  137. package/src/core/testing/background.ts +31 -0
  138. package/src/core/testing/fixture-database.ts +55 -0
  139. package/src/core/testing/map-identity.ts +76 -0
  140. package/src/core/testing/module-sessions.ts +134 -0
  141. package/src/core/testing/modules.ts +108 -21
  142. package/src/core/testing/owner.ts +17 -0
  143. package/src/core/testing/surface-contract.ts +118 -41
  144. package/src/core/testing/test-host.ts +79 -16
  145. package/src/core/testing/tool-set.ts +32 -0
  146. package/src/core/tool-set.snapshot.json +4 -4
  147. package/src/core/tool-tiers.ts +57 -3
  148. package/src/discord/index.ts +8 -0
  149. package/src/index.ts +48 -8
  150. package/src/kit/compaction.ts +13 -0
  151. package/src/kit/index.ts +10 -0
  152. package/src/kit/support.ts +1 -1
  153. package/src/testing.ts +55 -16
  154. package/templates/.env.example +1 -1
  155. package/templates/adapters/web/README.md +7 -0
  156. package/templates/adapters/web/roundtable.config.ts +14 -10
  157. package/templates/roundtable.config.ts +12 -1
  158. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,7 +1,5 @@
1
1
  import { rmSync } from "node:fs";
2
- import { join } from "node:path";
3
2
  import {
4
- type AgentSession,
5
3
  type ModelRuntime,
6
4
  SessionManager,
7
5
  } from "@earendil-works/pi-coding-agent";
@@ -15,42 +13,40 @@ import type {
15
13
  TranscriptEntry,
16
14
  TurnResult,
17
15
  } from "../domain/conversation.ts";
18
- import { AgentRunError, ConfigError } from "../domain/errors.ts";
16
+ import { AgentRunError } from "../domain/errors.ts";
19
17
  import type { TurnRequest } from "../domain/ports.ts";
18
+ import { HostStoppingError } from "../errors.ts";
20
19
  import { assistantName } from "../i18n/index.ts";
21
- import {
22
- AUTO_THINKING,
23
- parseModelRef,
24
- type ThinkingLevel,
25
- type ThinkingSetting,
26
- } from "../models.ts";
20
+ import { promptScopeOf } from "../interactions/prompts.ts";
21
+ import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
27
22
  import { withoutReplyFiles } from "../reply-files.ts";
28
- import { planOrder, type TransientTask } from "../sessions.ts";
29
- import { textOf } from "../shared/session-messages.ts";
30
- import { addressee, type Speaker, type Tier } from "../speakers.ts";
23
+ import type { TransientTask } from "../sessions.ts";
24
+ import type { Speaker, Tier } from "../speakers.ts";
31
25
  import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
26
+ import { AbortGuard } from "./abort-guard.ts";
27
+ import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
32
28
  import { ConversationSessions } from "./conversation-sessions.ts";
33
- import { ASK_USER_TOOL } from "./extensions/ask-user.ts";
34
- import {
35
- ConfirmationGate,
36
- confirmedTurnText,
37
- } from "./extensions/confirmation-gate.ts";
38
- import {
39
- COMPACT_TOOL,
40
- missingToolsError,
41
- } from "./extensions/self-compact-guard.ts";
29
+ import { confirmedTurnText } from "./extensions/confirmation-gate.ts";
30
+ import { loadsMemory } from "./extensions/private-memory.ts";
31
+ import { missingToolsError } from "./extensions/self-compact-guard.ts";
42
32
  import { interimPoster } from "./interim-text.ts";
33
+ import { preflightTools } from "./preflight-tools.ts";
43
34
  import { PromptSlot, workTimeout } from "./prompt-slot.ts";
44
- import {
45
- type PiAgentRuntimeOptions,
46
- TRANSCRIPT_ENTRY_CHARS,
47
- type TurnMessage,
48
- } from "./runtime-types.ts";
35
+ import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
36
+ import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
49
37
  import { archiveSessions } from "./session-archive.ts";
38
+ import {
39
+ memoryReader,
40
+ refusedSpeaker,
41
+ turnAddressee,
42
+ } from "./session-conversation.ts";
50
43
  import { SessionFactory } from "./session-factory.ts";
51
- import { type PromptImages, SteerableRun } from "./steerable-run.ts";
52
- import { lastReply, turnAnswer } from "./turn-answer.ts";
44
+ import { promptImages, SteerableRun } from "./steerable-run.ts";
45
+ import { thinkingLevel } from "./thinking-level.ts";
46
+ import { runningCalls } from "./tool-call-scope.ts";
47
+ import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
53
48
  import { progressReporter } from "./turn-progress.ts";
49
+ import { wrapUpTurn } from "./turn-wrapup.ts";
54
50
  import { workerReport } from "./worker-task.ts";
55
51
 
56
52
  export {
@@ -60,6 +56,8 @@ export {
60
56
  sessionExtensions,
61
57
  } from "./runtime-types.ts";
62
58
 
59
+ const MEMORY = new Set<string>(MEMORY_TOOLS);
60
+
63
61
  /** AgentRuntime over the Pi SDK: one persistent session per channel, each with its own loader. */
64
62
  const STOPPED = "stopped by the owner";
65
63
 
@@ -73,10 +71,11 @@ export class PiAgentRuntime implements AgentRuntime {
73
71
  /** The tier and speaker of each conversation's running turn. */
74
72
  readonly #turns = new Map<
75
73
  ChannelKey,
76
- { tier: Tier; speaker: Speaker | undefined }
74
+ { tier: Tier; speaker: Speaker; withheld: boolean }
77
75
  >();
78
76
  /** Each conversation's turn in progress, which the owner may steer or stop. */
79
77
  readonly #running = new Map<ChannelKey, SteerableRun>();
78
+ readonly #memoryTurns = new Map<ChannelKey, () => void>();
80
79
 
81
80
  /** Builds the runtime without starting a session; the preflight proves it can run. */
82
81
  constructor(options: PiAgentRuntimeOptions) {
@@ -85,7 +84,8 @@ export class PiAgentRuntime implements AgentRuntime {
85
84
  this.#tiers = options.toolTiers ?? toolTiers();
86
85
  this.#factory = new SessionFactory(options, {
87
86
  speaker: (channel) => this.#turns.get(channel)?.speaker,
88
- runTask: (channel, task) => this.#runTask(channel, task),
87
+ withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
88
+ runTask: (scope, task) => this.#runTask(scope, task),
89
89
  });
90
90
  this.#sessions = new ConversationSessions(
91
91
  options,
@@ -94,50 +94,80 @@ export class PiAgentRuntime implements AgentRuntime {
94
94
  );
95
95
  }
96
96
 
97
- /**
98
- * Proves the model and every required tool resolve, in a throwaway session that runs no turn;
99
- * startup stops here before anything connects.
100
- */
97
+ /** Resolves the model and required tools in throwaway shared and private sessions, without a turn. */
101
98
  async preflight(): Promise<void> {
102
- const probe = await this.#factory.create(
103
- "probe:startup",
104
- SessionManager.inMemory(this.#factory.workDir()),
105
- new ConfirmationGate(this.#factory.link().holds, this.#options.owner),
106
- new PromptSlot(),
107
- join(this.#factory.workDir(), "probe-attachments"),
108
- undefined,
109
- "owner",
110
- );
111
- const registered = new Set(
112
- probe.session.getAllTools().map((tool) => tool.name),
99
+ const required = this.#factory.requiredTools();
100
+ const registered = await preflightTools(
101
+ this.#factory,
102
+ this.#options.owner,
103
+ required,
113
104
  );
114
- probe.session.dispose();
115
- const expected = [
116
- ...this.#factory.link().requiredTools,
117
- COMPACT_TOOL,
118
- ASK_USER_TOOL,
119
- ...planOrder(this.#factory.plan).flatMap(
120
- (tool) => tool.snapshot().requiredTools ?? [],
121
- ),
122
- ];
123
- const missing = expected.filter((name) => !registered.has(name));
105
+ const missing = required.filter((name) => !registered.has(name));
124
106
  if (missing.length > 0) throw missingToolsError(missing);
125
107
  }
126
108
 
127
109
  // 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
128
110
  async runTurn(request: TurnRequest): Promise<TurnResult> {
129
111
  const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
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
+ };
130
119
  const key = request.agent?.session ?? request.channel;
131
- const channelSession = await this.#sessions.freshSession(key, request);
120
+ // Resolve current scope; refuse a foreign private speaker before touching its session.
121
+ const conversation = await this.#sessions.conversation(key, request);
122
+ const refused = refusedSpeaker(conversation, request.speaker);
123
+ if (refused) return { ok: false, error: new AgentRunError(refused) };
124
+ const scoped = { ...request, conversation };
125
+ const channelSession = await this.#sessions.freshSession(key, scoped);
126
+ const who = turnAddressee(
127
+ request.speaker,
128
+ channelSession,
129
+ this.#options.owner,
130
+ );
132
131
  const { session } = channelSession;
133
- const level = await this.#thinkingLevel(key, session, request);
132
+ const agentModel = chosenAgentModel(request.agent?.name, this.#options);
133
+ const bridgeRefused =
134
+ conversation.visibility === "shared"
135
+ ? await bridgeTurnRefusal(
136
+ request.agent?.name,
137
+ this.#options,
138
+ {
139
+ messages: session.messages,
140
+ sessionManager: session.sessionManager,
141
+ reader: request.speaker.principalId,
142
+ },
143
+ agentModel,
144
+ )
145
+ : undefined;
146
+ if (bridgeRefused)
147
+ return { ok: false, error: new AgentRunError(bridgeRefused) };
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
+ );
134
159
  if (session.thinkingLevel !== level) session.setThinkingLevel(level);
135
160
  const gate = await this.#sessions.gate(key, request.agent !== undefined);
136
161
  const pending = gate.pending();
137
162
  const registered = new Set(session.getAllTools().map((tool) => tool.name));
138
- // The speaker's tier limits the tools; a turn nobody spoke in, such as the owner's own chat, is the owner's.
139
- const tier = request.speaker?.tier ?? "owner";
163
+ // The speaker's tier limits the tools.
164
+ const { tier } = request.speaker;
140
165
  // An approving turn also has the held calls' tools, which a dispatched worker may have used.
166
+ // The agent server may keep a speaker below the owner tier out of memory: no tools, no block.
167
+ const withheld =
168
+ request.agent !== undefined &&
169
+ this.#options.agents?.memory === "owners" &&
170
+ tier !== "owner";
141
171
  const wanted = toolsForTier(
142
172
  [
143
173
  ...new Set([
@@ -149,8 +179,17 @@ export class PiAgentRuntime implements AgentRuntime {
149
179
  ],
150
180
  tier,
151
181
  this.#tiers,
182
+ ).filter((name) => !withheld || !MEMORY.has(name));
183
+ const markPrivateMemory = recordMemoryTurn(
184
+ session.sessionManager,
185
+ request.speaker.principalId,
186
+ !withheld &&
187
+ channelSession.memory !== "none" &&
188
+ memoryReader(conversation, request.speaker) !== undefined &&
189
+ loadsMemory(registered),
152
190
  );
153
- this.#turns.set(key, { tier, speaker: request.speaker });
191
+ this.#memoryTurns.set(key, markPrivateMemory);
192
+ this.#turns.set(key, { tier, speaker: request.speaker, withheld });
154
193
  const missing = wanted.filter((name) => !registered.has(name));
155
194
  if (missing.length > 0) {
156
195
  logger.warn(
@@ -167,8 +206,8 @@ export class PiAgentRuntime implements AgentRuntime {
167
206
  gate.beginTurn(
168
207
  request.selection.id,
169
208
  request.confirmed === true,
170
- addressee(request.speaker, this.#options.owner),
171
- request.speaker?.id,
209
+ who,
210
+ request.speaker,
172
211
  );
173
212
  if (request.confirmed && pending) {
174
213
  logger.info(
@@ -196,6 +235,7 @@ export class PiAgentRuntime implements AgentRuntime {
196
235
  interim?.toolStart(event.toolName);
197
236
  }
198
237
  if (event.type === "message_end") {
238
+ if (carriesMemory(event.message)) markPrivateMemory();
199
239
  turnMessages.push(event.message);
200
240
  interim?.messageEnd(event.message);
201
241
  }
@@ -203,22 +243,34 @@ export class PiAgentRuntime implements AgentRuntime {
203
243
  const slot = this.#sessions.slot(key);
204
244
  slot.bind(
205
245
  request.interactive
206
- ? this.#options.prompts?.(request.channel, request.speaker)
246
+ ? this.#options.prompts?.(request.channel, promptScopeOf(scoped))
207
247
  : undefined,
208
248
  request.agent?.name ?? assistantName(),
209
249
  interim ? () => interim.flush() : undefined,
210
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
+ });
211
262
  // Time spent waiting on the owner's cards does not count towards the timeout.
212
263
  const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
213
264
  logger.warn(
214
265
  { channel: request.channel, turnTimeoutMs },
215
266
  "turn timed out; aborting",
216
267
  );
217
- void session.abort();
268
+ guard.abort();
218
269
  });
219
270
  const running = new SteerableRun(
220
271
  session,
221
272
  () => request.steerable === true && !gate.pending(),
273
+ () => guard.abort(),
222
274
  );
223
275
  this.#running.set(key, running);
224
276
 
@@ -226,13 +278,9 @@ export class PiAgentRuntime implements AgentRuntime {
226
278
  const attachments = request.attachments ?? NO_ATTACHMENTS;
227
279
  const text =
228
280
  request.confirmed && pending
229
- ? confirmedTurnText(
230
- pending,
231
- request.text,
232
- addressee(request.speaker, this.#options.owner),
233
- )
281
+ ? confirmedTurnText(pending, request.text, who)
234
282
  : request.text;
235
- await running.run(() =>
283
+ const prompted = running.run(() =>
236
284
  session.prompt(
237
285
  withAttachmentsBlock(text, attachments),
238
286
  attachments.images.length > 0
@@ -240,6 +288,9 @@ export class PiAgentRuntime implements AgentRuntime {
240
288
  : undefined,
241
289
  ),
242
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]);
243
294
  } catch (error) {
244
295
  if (running.stopped)
245
296
  return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
@@ -250,43 +301,22 @@ export class PiAgentRuntime implements AgentRuntime {
250
301
  } finally {
251
302
  this.#running.delete(key);
252
303
  this.#turns.delete(key);
304
+ this.#memoryTurns.delete(key);
253
305
  cancelTimeout();
306
+ guard.release();
254
307
  slot.unbind();
255
308
  unsubscribe();
256
309
  progress?.close();
257
310
  await interim?.flush();
258
- gate.endTurn();
259
- const usage = session.getContextUsage();
260
- if (usage)
261
- this.#sessions.usage.set(key, {
262
- tokens: usage.tokens,
263
- contextWindow: usage.contextWindow,
264
- });
265
- const held = gate.pending();
266
- await this.#options.confirmations
267
- .save(key, held)
268
- // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
269
- .catch((error: unknown) =>
270
- logger.error({ channel: key, err: error }, "held actions not saved"),
271
- );
272
- if (held)
273
- logger.info(
274
- { channel: key, held: held.calls.map((call) => call.action) },
275
- "actions held for confirmation",
276
- );
277
- logger.info(
278
- {
279
- channel: key,
280
- selection: request.selection.id,
281
- toolCalls,
282
- thinking: session.thinkingLevel,
283
- // pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
284
- ...(request.agent
285
- ? { model: `${session.model?.provider}/${session.model?.id}` }
286
- : {}),
287
- },
288
- "turn finished",
289
- );
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
+ });
290
320
  }
291
321
 
292
322
  if (running.stopped)
@@ -331,53 +361,73 @@ export class PiAgentRuntime implements AgentRuntime {
331
361
  return stopped;
332
362
  }
333
363
 
334
- /**
335
- * Runs a task in a fresh, unsaved session with the selected tools and the channel's
336
- * confirmation gate, and returns its final text.
337
- */
364
+ /** Runs a task in an unsaved session with the selected tools and conversation's gate. */
338
365
  // pi-lens-ignore: high-fan-out — builds, runs, and disposes one transient session; batch 4 turns it into the core's child-run API
339
- async #runTask(channel: ChannelKey, task: TransientTask): Promise<string> {
366
+ async #runTask(
367
+ scope: { turn: ChannelKey; home: ChannelKey },
368
+ task: TransientTask,
369
+ ): Promise<string> {
340
370
  const { dataDir } = this.#options;
341
371
  const { signal } = task;
342
- // A worker asks nothing: its held actions wait for the owner's next message.
372
+ if (this.#options.stopping?.())
373
+ throw new AgentRunError(new HostStoppingError().message);
374
+ // A task works at the tier of the turn that started it; without one there is no tier to take.
375
+ const turn = this.#turns.get(scope.turn);
376
+ if (!turn)
377
+ throw new AgentRunError(
378
+ `a task runs beside a turn of its conversation, at that turn's tier, and ${scope.turn} has no turn running`,
379
+ );
380
+ // A worker inherits the starting turn's conversation and memory policy, without asking.
381
+ const parent = await this.#sessions.session(scope.turn);
382
+ const { conversation, memory } = parent;
343
383
  const worker = await this.#factory.create(
344
- channel,
384
+ scope.home,
345
385
  SessionManager.inMemory(this.#factory.workDir()),
346
- await this.#sessions.gate(channel, false),
386
+ await this.#sessions.gate(scope.home, false),
347
387
  new PromptSlot(),
348
- ownerAttachmentDir(dataDir, channel),
388
+ ownerAttachmentDir(dataDir, scope.home),
349
389
  undefined,
350
390
  "owner",
391
+ conversation,
392
+ { turn: scope.turn, memory },
351
393
  );
352
394
  const { session } = worker;
353
395
  const toolCalls: string[] = [];
354
396
  const unsubscribe = session.subscribe((event) => {
355
397
  if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
356
398
  });
357
- 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();
358
404
  signal?.addEventListener("abort", abort, { once: true });
359
405
  const timer = setTimeout(abort, task.timeoutMs);
360
406
  try {
361
407
  const registered = new Set(session.getAllTools().map((t) => t.name));
362
- const excluded = new Set([...task.exclude, ASK_USER_TOOL]);
363
- // A task works at the tier of the turn that started it.
364
- const tier = this.#turns.get(channel)?.tier ?? "owner";
408
+ // A worker that loaded the reader's memory may report it: the call that started it, and the
409
+ // calls that one runs within, then record their results as the reader's.
410
+ if (memoryReader(conversation, turn.speaker) && loadsMemory(registered)) {
411
+ parent.draws.drawn(runningCalls());
412
+ this.#memoryTurns.get(scope.turn)?.();
413
+ }
414
+ const { tier } = turn;
365
415
  worker.tools = this.#factory
366
- .toolsFor(task.selection)
416
+ .taskTools(task.selection, task.exclude)
367
417
  .filter(
368
- (name) =>
369
- !excluded.has(name) &&
370
- registered.has(name) &&
371
- this.#tiers.allows(tier, name),
418
+ (name) => registered.has(name) && this.#tiers.allows(tier, name),
372
419
  );
373
420
  session.setActiveToolsByName([...worker.tools]);
374
- 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]);
375
424
  return workerReport(
376
425
  session.messages,
377
426
  "the worker stopped before it reported",
378
427
  );
379
428
  } finally {
380
429
  clearTimeout(timer);
430
+ guard.release();
381
431
  signal?.removeEventListener("abort", abort);
382
432
  unsubscribe();
383
433
  session.dispose();
@@ -416,84 +466,18 @@ export class PiAgentRuntime implements AgentRuntime {
416
466
  async heldActions(
417
467
  session: ChannelKey,
418
468
  ): Promise<PendingConfirmation | undefined> {
419
- const gate = await this.#sessions.gate(session, true);
420
- return gate.pending();
469
+ return this.#sessions.held(session);
421
470
  }
422
471
 
423
472
  async recentTranscript(
424
473
  channel: ChannelKey,
425
474
  limit: number,
426
475
  ): Promise<TranscriptEntry[]> {
427
- const { session } = await this.#sessions.session(channel);
428
- const entries: TranscriptEntry[] = [];
429
- for (const message of session.messages) {
430
- if (message.role !== "user" && message.role !== "assistant") continue;
431
- const text = textOf(message.content).trim();
432
- if (text)
433
- entries.push({
434
- role: message.role,
435
- text: text.slice(0, TRANSCRIPT_ENTRY_CHARS),
436
- });
437
- }
438
- return entries.slice(-limit);
439
- }
440
-
441
- /**
442
- * The turn's thinking level: an agent's fixed setting, or the judge's pick, which is kept for the
443
- * next turn while the judge is unsure. An agent session also switches to the agent's model here.
444
- */
445
- async #thinkingLevel(
446
- key: ChannelKey,
447
- session: AgentSession,
448
- request: TurnRequest,
449
- ): Promise<ThinkingLevel> {
450
- const setting = request.agent
451
- ? await this.#useAgentModel(session, request.agent.name)
452
- : AUTO_THINKING;
453
- if (setting !== AUTO_THINKING) {
454
- this.#sessions.judged.delete(key);
455
- return setting;
456
- }
457
- const level = await this.#options.effort.judge(request.text, {
458
- reply: lastReply(session.messages),
459
- level: this.#sessions.judged.get(key),
460
- });
461
- this.#sessions.judged.set(key, level);
462
- return level;
463
- }
464
-
465
- /** Switches an agent session to the agent's current model; returns its thinking setting. */
466
- async #useAgentModel(
467
- session: AgentSession,
468
- name: string,
469
- ): Promise<ThinkingSetting> {
470
- const agents = this.#options.agents;
471
- if (!agents)
472
- throw new ConfigError("agent turns need the runtime's agents option");
473
- const { model, thinking } = agents.modelOf(name);
474
- const ref = parseModelRef(model);
475
- if (
476
- session.model?.provider !== ref?.provider ||
477
- session.model?.id !== ref?.id
478
- ) {
479
- const resolved = ref && this.#modelRuntime.getModel(ref.provider, ref.id);
480
- if (!resolved)
481
- throw new AgentRunError(
482
- `${name}'s model ${model} is not available on this host`,
483
- );
484
- await session.setModel(resolved);
485
- }
486
- return thinking;
476
+ await this.#sessions.held(channel);
477
+ return transcriptOf(await this.#sessions.messages(channel), limit);
487
478
  }
488
479
 
489
480
  dispose(): void {
490
481
  this.#sessions.dispose();
491
482
  }
492
483
  }
493
-
494
- function promptImages(attachments: TurnAttachments): PromptImages {
495
- return attachments.images.map((image) => ({
496
- type: "image" as const,
497
- ...image,
498
- }));
499
- }
@@ -0,0 +1,47 @@
1
+ import { join } from "node:path";
2
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { ConfirmationGate } from "./extensions/confirmation-gate.ts";
4
+ import { PromptSlot } from "./prompt-slot.ts";
5
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
6
+ import type { SessionFactory } from "./session-factory.ts";
7
+
8
+ /**
9
+ * Tools available in either startup scope, without running a turn or retaining a session.
10
+ * The private probe is built only when the shared one lacks a required tool, so a host
11
+ * without scoped tools starts exactly as before.
12
+ */
13
+ export async function preflightTools(
14
+ factory: SessionFactory,
15
+ owner: PiAgentRuntimeOptions["owner"],
16
+ required: readonly string[],
17
+ ): Promise<Set<string>> {
18
+ const registered = new Set<string>();
19
+ for (const conversation of [
20
+ { visibility: "shared" },
21
+ { visibility: "private", principalId: owner.id },
22
+ ] as const) {
23
+ if (
24
+ conversation.visibility === "private" &&
25
+ required.every((name) => registered.has(name))
26
+ )
27
+ break;
28
+ const probe = await factory.create(
29
+ conversation.visibility === "shared"
30
+ ? "probe:startup"
31
+ : "probe:startup-private",
32
+ SessionManager.inMemory(factory.workDir()),
33
+ new ConfirmationGate(factory.link().holds, owner),
34
+ new PromptSlot(),
35
+ join(factory.workDir(), "probe-attachments"),
36
+ undefined,
37
+ "owner",
38
+ conversation,
39
+ );
40
+ try {
41
+ for (const tool of probe.session.getAllTools()) registered.add(tool.name);
42
+ } finally {
43
+ probe.session.dispose();
44
+ }
45
+ }
46
+ return registered;
47
+ }
@@ -1,5 +1,5 @@
1
- import type { OwnerPrompts, OwnerQuestion } from "../domain/owner-prompts.ts";
2
1
  import { assistantName } from "../i18n/index.ts";
2
+ import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
3
3
 
4
4
  /**
5
5
  * A run's way to ask the owner while it works. A turn they started or a report turn binds the
@@ -9,7 +9,7 @@ import { assistantName } from "../i18n/index.ts";
9
9
  * leaves out.
10
10
  */
11
11
  export class PromptSlot {
12
- #prompts: OwnerPrompts | undefined;
12
+ #prompts: Prompts | undefined;
13
13
  #beforeCard: (() => Promise<void>) | undefined;
14
14
  #asker = assistantName();
15
15
  #open = 0;
@@ -21,7 +21,7 @@ export class PromptSlot {
21
21
  * runs before each card opens, such as posting the text the turn wrote before it.
22
22
  */
23
23
  bind(
24
- prompts: OwnerPrompts | undefined,
24
+ prompts: Prompts | undefined,
25
25
  asker: string,
26
26
  beforeCard?: () => Promise<void>,
27
27
  ): void {
@@ -42,7 +42,7 @@ export class PromptSlot {
42
42
  }
43
43
 
44
44
  /** The turn's prompts, each open card counted as waiting; undefined when none is bound. */
45
- get prompts(): OwnerPrompts | undefined {
45
+ get prompts(): Prompts | undefined {
46
46
  const prompts = this.#prompts;
47
47
  if (!prompts) return undefined;
48
48
  return {