pi-roundtable 0.9.7 → 0.9.9

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.
@@ -1,10 +1,17 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { ToolTurn } from "../../define.ts";
2
3
  import type {
3
4
  HeldCall,
4
5
  PendingConfirmation,
5
6
  } from "../../domain/conversation.ts";
6
7
  import type { HoldRefusal } from "../../domain/progress.ts";
7
- import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
8
+ import {
9
+ HOLD_DESCRIBE_TIMEOUT_MS,
10
+ type HoldCheck,
11
+ type HoldContext,
12
+ higherTier,
13
+ isPromise,
14
+ } from "../../holds.ts";
8
15
  import { messages } from "../../i18n/index.ts";
9
16
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
10
17
  import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
@@ -27,6 +34,20 @@ export function unexpired(
27
34
  : pending;
28
35
  }
29
36
 
37
+ /** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
38
+ export interface HoldTurn {
39
+ /** Aborts when the turn stops; a call being described then opens no card. */
40
+ signal?: AbortSignal;
41
+ /** The turn a hold receives, with `signal` as the signal that ends its wait. */
42
+ toolTurn(signal: AbortSignal): ToolTurn;
43
+ }
44
+
45
+ /** The answer of a description wait the turn's stop cut short. */
46
+ const STOPPED = Symbol("stopped");
47
+
48
+ /** What waiting for a call's description comes to: its text, or the turn stopped first. */
49
+ type Described = string | undefined | typeof STOPPED;
50
+
30
51
  /** JSON with object keys sorted at every level, so equal inputs compare equal. */
31
52
  // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
32
53
  export function canonicalJson(value: unknown): string {
@@ -79,12 +100,18 @@ export class ConfirmationGate {
79
100
  readonly #tiers: ToolTiers | undefined;
80
101
  /** Where the session's relative file paths resolve, for the sizes a card shows. */
81
102
  readonly #fileRoot: string | undefined;
103
+ readonly #describeTimeoutMs: number;
104
+ /** Settles when the descriptions asked for so far have all been used, in the order they were asked. */
105
+ #describing: Promise<void> = Promise.resolve();
106
+ /** Descriptions asked for and not used yet. */
107
+ #undescribed = 0;
82
108
 
83
109
  /**
84
110
  * `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
85
111
  * and its writes outside the workspace and the context's scratch dir are held too.
86
112
  * `fileRoot` is the session's working directory, where a card resolves relative file paths;
87
- * it defaults to the workspace.
113
+ * it defaults to the workspace. A hold that looks things up gets `describeTimeoutMs` to answer
114
+ * (`HOLD_DESCRIBE_TIMEOUT_MS` by default) before its call is held under a generic description.
88
115
  */
89
116
  constructor(
90
117
  holds: HoldCheck,
@@ -93,7 +120,9 @@ export class ConfirmationGate {
93
120
  context: HoldContext = {},
94
121
  tiers?: ToolTiers,
95
122
  fileRoot?: string,
123
+ describeTimeoutMs = HOLD_DESCRIBE_TIMEOUT_MS,
96
124
  ) {
125
+ this.#describeTimeoutMs = describeTimeoutMs;
97
126
  this.#tiers = tiers;
98
127
  this.#fileRoot = fileRoot ?? context.workspace;
99
128
  this.#holds = holds;
@@ -144,26 +173,32 @@ export class ConfirmationGate {
144
173
  tool: string,
145
174
  input: Record<string, unknown>,
146
175
  ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
176
+ turn?: HoldTurn,
147
177
  ): Promise<string | undefined> {
148
- return (await this.decide(tool, input, ask)).reason;
178
+ return (await this.decide(tool, input, ask, turn)).reason;
149
179
  }
150
180
 
151
181
  /**
152
182
  * `review`, plus whether a card's approval released the call, so its result can say so:
153
183
  * the card's own message is shown to the person, never to the model. When a hold refuses the
154
184
  * call, `refused` says how, for the turn's progress to report beside the failed result.
185
+ * With `turn`, a hold that looks things up as the turn's speaker is waited for before any card
186
+ * opens, and a turn that stops meanwhile opens none.
155
187
  */
156
188
  async decide(
157
189
  tool: string,
158
190
  input: Record<string, unknown>,
159
191
  ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
192
+ turn?: HoldTurn,
160
193
  ): Promise<{
161
194
  reason?: string;
162
195
  approvedOnCard?: true;
163
196
  /** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
164
197
  refused?: HoldRefusal;
165
198
  }> {
166
- const call = this.#needing(tool, input);
199
+ const found = this.#needingIn(tool, input, turn);
200
+ const call = isPromise(found) ? await found : found;
201
+ if (call === STOPPED) return { reason: "The turn was stopped." };
167
202
  if (!call) return {};
168
203
  if (!ask) return { reason: this.#record(call), refused: "held" };
169
204
  const answer = await ask.prompts.confirm(
@@ -210,10 +245,112 @@ export class ConfirmationGate {
210
245
 
211
246
  /** The call when it needs the owner's confirmation and no approval releases it. */
212
247
  #needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
213
- const context = this.#context;
214
- const action = this.#holds(tool, input, context);
215
- if (!action) return undefined;
216
- const minTier = this.#holds.approvalTier?.(tool, input, context);
248
+ const action = this.#holds(tool, input, this.#context);
249
+ return action ? this.#unlessApproved(tool, input, action) : undefined;
250
+ }
251
+
252
+ /**
253
+ * `#needing` inside a turn, where a hold may look things up first: the call when it needs the
254
+ * owner's confirmation, or `STOPPED` when the turn stopped while its description was awaited.
255
+ * It answers at once when every hold asked does and no earlier call is still being described.
256
+ */
257
+ #needingIn(
258
+ tool: string,
259
+ input: Record<string, unknown>,
260
+ turn: HoldTurn | undefined,
261
+ ):
262
+ | HeldCall
263
+ | undefined
264
+ | typeof STOPPED
265
+ | Promise<HeldCall | undefined | typeof STOPPED> {
266
+ const inTurn = this.#holds.inTurn;
267
+ if (!turn || !inTurn) return this.#needing(tool, input);
268
+ const controller = new AbortController();
269
+ if (turn.signal?.aborted) controller.abort();
270
+ let answer: string | undefined | Promise<string | undefined>;
271
+ try {
272
+ answer = inTurn.call(
273
+ this.#holds,
274
+ tool,
275
+ input,
276
+ this.#context,
277
+ turn.toolTurn(controller.signal),
278
+ );
279
+ } catch {
280
+ answer = messages().holdGeneric(tool);
281
+ }
282
+ const described: Described | Promise<Described> = isPromise(answer)
283
+ ? this.#awaited(answer, tool, controller, turn.signal)
284
+ : answer;
285
+ return this.#inOrder(described, (action) => {
286
+ if (action === STOPPED) return STOPPED;
287
+ return action ? this.#unlessApproved(tool, input, action) : undefined;
288
+ });
289
+ }
290
+
291
+ /**
292
+ * What a hold that answers later comes to: its text; the generic description when it rejects or
293
+ * outlasts the timeout, so the call is still held; `STOPPED` when the turn stops first. Whatever
294
+ * the hold is still doing is aborted when the wait ends.
295
+ */
296
+ #awaited(
297
+ answer: Promise<string | undefined>,
298
+ tool: string,
299
+ hold: AbortController,
300
+ stop: AbortSignal | undefined,
301
+ ): Promise<Described> {
302
+ return new Promise<Described>((resolve) => {
303
+ const onStop = () => end(STOPPED);
304
+ const timer = setTimeout(
305
+ () => end(messages().holdGeneric(tool)),
306
+ this.#describeTimeoutMs,
307
+ );
308
+ let ended = false;
309
+ const end = (described: Described) => {
310
+ if (ended) return;
311
+ ended = true;
312
+ clearTimeout(timer);
313
+ stop?.removeEventListener("abort", onStop);
314
+ hold.abort();
315
+ resolve(described);
316
+ };
317
+ // Handled first, so a rejection after the wait ended is never left unhandled.
318
+ answer.then(end, () => end(messages().holdGeneric(tool)));
319
+ if (stop?.aborted) return end(STOPPED);
320
+ stop?.addEventListener("abort", onStop, { once: true });
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Hands `described` to `use` after every description asked for before it has been used, so
326
+ * calls made together are held, and their cards opened, in the order they were made whichever
327
+ * description comes back first.
328
+ */
329
+ #inOrder<T, R>(
330
+ described: T | Promise<T>,
331
+ use: (described: T) => R,
332
+ ): R | Promise<R> {
333
+ if (this.#undescribed === 0 && !isPromise(described)) return use(described);
334
+ this.#undescribed++;
335
+ const used = this.#describing.then(() => described).then(use);
336
+ this.#describing = used.then(
337
+ () => {
338
+ this.#undescribed--;
339
+ },
340
+ () => {
341
+ this.#undescribed--;
342
+ },
343
+ );
344
+ return used;
345
+ }
346
+
347
+ /** The call for `action`, unless an approval releases it, which that approval then spends. */
348
+ #unlessApproved(
349
+ tool: string,
350
+ input: Record<string, unknown>,
351
+ action: string,
352
+ ): HeldCall | undefined {
353
+ const minTier = this.#holds.approvalTier?.(tool, input, this.#context);
217
354
  const call: HeldCall = {
218
355
  tool,
219
356
  input: canonicalJson(input),
@@ -269,10 +406,14 @@ export function confirmedTurnText(
269
406
  return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
270
407
  }
271
408
 
272
- /** Without a slot, or with an empty one, every call that needs confirmation is held. */
409
+ /**
410
+ * Without a slot, or with an empty one, every call that needs confirmation is held. `turnOf` builds
411
+ * the turn a hold that looks things up receives; without it, such a hold is not asked in a turn.
412
+ */
273
413
  export function confirmationGateExtension(
274
414
  gate: ConfirmationGate,
275
415
  slot?: PromptSlot,
416
+ turnOf?: (signal: AbortSignal) => ToolTurn,
276
417
  ): ExtensionFactory {
277
418
  return (pi) => {
278
419
  /** Calls a card's approval released, until their results are back. */
@@ -293,10 +434,14 @@ export function confirmationGateExtension(
293
434
  ask = { prompts, asker: slot.asker };
294
435
  if (ctx.signal) ask.signal = ctx.signal;
295
436
  }
437
+ const turn: HoldTurn | undefined = turnOf
438
+ ? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
439
+ : undefined;
296
440
  const { reason, approvedOnCard, refused } = await gate.decide(
297
441
  event.toolName,
298
442
  event.input as Record<string, unknown>,
299
443
  ask,
444
+ turn,
300
445
  );
301
446
  if (approvedOnCard) approved.add(event.toolCallId);
302
447
  if (refused) slot?.refuse(event.toolCallId, refused);
@@ -31,6 +31,7 @@ import { packageDir } from "../shared/package-dir.ts";
31
31
  import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
32
32
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
33
33
  import { currentToolNames } from "../tool-tiers.ts";
34
+ import { toolTurn } from "../tool-turn.ts";
34
35
  import {
35
36
  CompactionTiers,
36
37
  compactionEngine,
@@ -373,7 +374,9 @@ export class SessionFactory {
373
374
  ...(context.workspace
374
375
  ? { attachFile: attachFileExtension(context.workspace) }
375
376
  : {}),
376
- confirmationGate: confirmationGateExtension(gate, slot),
377
+ confirmationGate: confirmationGateExtension(gate, slot, (signal) =>
378
+ toolTurn(context, signal),
379
+ ),
377
380
  askUser: askUserExtension(slot, addressee, () =>
378
381
  turnAddressee(
379
382
  this.#deps.speaker(turnKey),
@@ -1,6 +1,8 @@
1
1
  import { join } from "node:path";
2
2
  import { extractText, getDocumentProxy } from "unpdf";
3
+ import { wasExpired } from "../attachments/attachment-expiry.ts";
3
4
  import { isAttachmentName, plainText } from "../attachments/attachment-name.ts";
5
+ import { AttachmentExpiredError } from "../attachments/tool-attachment.ts";
4
6
 
5
7
  /** Characters returned per read; the model can ask for a later offset. */
6
8
  export const READ_LIMIT = 60_000;
@@ -72,6 +74,8 @@ export async function readAttachment(
72
74
  }
73
75
  const handle = Bun.file(join(dir, file));
74
76
  if (!(await handle.exists())) {
77
+ if (await wasExpired(dir, file))
78
+ throw new AttachmentExpiredError(plainText(file));
75
79
  throw new Error(`no attachment named "${file}" in this channel`);
76
80
  }
77
81
  let bytes: Uint8Array;
@@ -0,0 +1,37 @@
1
+ import {
2
+ AttachmentLookupError,
3
+ openAttachment,
4
+ } from "./attachments/tool-attachment.ts";
5
+ import type { ToolTurn } from "./define.ts";
6
+ import { ToolRefusal } from "./define.ts";
7
+ import { attachReplyFile } from "./reply-files.ts";
8
+ import type { SessionContext } from "./sessions.ts";
9
+
10
+ /**
11
+ * The turn a tool's `run` and `hold` receive, built from the session that makes the call and the
12
+ * signal that stops it.
13
+ */
14
+ export function toolTurn(
15
+ context: SessionContext,
16
+ signal: AbortSignal | undefined,
17
+ ): ToolTurn {
18
+ return {
19
+ speaker: context.speaker(),
20
+ channel: context.turnChannel,
21
+ agent: context.agent,
22
+ signal,
23
+ ...(context.workspace ? { workspace: context.workspace } : {}),
24
+ attachFile: attachReplyFile,
25
+ attachment: async (file) => {
26
+ if (context.attachmentDir === undefined)
27
+ throw new ToolRefusal("this session keeps no attachments");
28
+ try {
29
+ return await openAttachment(context.attachmentDir, file);
30
+ } catch (error) {
31
+ if (error instanceof AttachmentLookupError)
32
+ throw new ToolRefusal(error.message);
33
+ throw error;
34
+ }
35
+ },
36
+ };
37
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export type {
16
16
  GroupStatus,
17
17
  TeamStatus,
18
18
  } from "./core/agents/team-status.ts";
19
+ export type { AttachmentRetention } from "./core/attachments/attachment-retention.ts";
19
20
  export type { ToolAttachment } from "./core/attachments/tool-attachment.ts";
20
21
  export {
21
22
  AGENT_SERVER_PLUGIN,
@@ -26,6 +27,7 @@ export type {
26
27
  AccessOwnerConfig,
27
28
  AccessTierConfig,
28
29
  } from "./core/config/access.ts";
30
+ export type { AttachmentsConfig } from "./core/config/attachments.ts";
29
31
  export type {
30
32
  AdapterConfig,
31
33
  BackgroundConfig,
@@ -38,6 +40,8 @@ export type {
38
40
  AttachmentPort,
39
41
  AttachmentRefusalCode,
40
42
  AttachmentUpload,
43
+ ExpiredAttachments,
44
+ ExpireUsedOptions,
41
45
  TurnAttachmentOptions,
42
46
  } from "./core/contract/attachments.ts";
43
47
  export { AttachmentRefusal } from "./core/contract/attachments.ts";
package/src/kit/holds.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // Plugin helpers, versioned like the main entry; see the plugin guide.
2
- // The chain a host links from every plugin's hold rules, to test a rule set as the host runs it.
2
+ // The chain a host links from every plugin's hold rules, to test a rule set as the host runs it,
3
+ // and how long a hold that looks things up may take.
3
4
 
4
- export { holdChain } from "../core/holds.ts";
5
+ export { HOLD_DESCRIBE_TIMEOUT_MS, holdChain } from "../core/holds.ts";
package/src/kit/index.ts CHANGED
@@ -52,7 +52,7 @@ export {
52
52
  THINKING_LEVELS,
53
53
  thinkingLabel,
54
54
  } from "./domain.ts";
55
- export { holdChain } from "./holds.ts";
55
+ export { HOLD_DESCRIBE_TIMEOUT_MS, holdChain } from "./holds.ts";
56
56
  export type {
57
57
  JevCompactInput,
58
58
  JevCompactOptions,
package/src/testing.ts CHANGED
@@ -249,7 +249,7 @@ export interface RecordedEvent {
249
249
 
250
250
  export interface TestPluginResult {
251
251
  contribution: Contribution;
252
- /** The hold rules the plugin contributed, chained as the host links them: the description of a call that must be approved first, or undefined. */
252
+ /** The hold rules the plugin contributed, chained as the host links them: the description of a call that must be approved first, or undefined. `holds.inTurn(tool, input, context, turn)` asks them as a turn does, so a hold that looks things up (and may return a promise) is called with the turn; the plain call has no turn, and counts a hold that declares `turn` as held under `run <tool>`. */
253
253
  holds: HoldCheck;
254
254
  /** What the plugin sees as `context.conversations`, for a test to drive its claims. */
255
255
  conversations: ConversationPort;