pi-roundtable 0.9.7 → 0.9.8

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,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.8] - 2026-10-10
9
+
10
+ ### Added
11
+
12
+ - A tool's `hold` may return a promise and takes the turn as a second argument, the `ToolTurn` its `run` receives (the speaker, the channel, the agent, and a `signal`), so a plugin can describe a held call with data it fetches as the current speaker, such as an entity's display name looked up with the speaker's own credentials. The confirmation gate awaits it before it posts the card, so the card, `ApprovalDetails.action` (the web chat's `approval.action`) and the held call carry the fetched text. It waits `HOLD_DESCRIBE_TIMEOUT_MS` (10 seconds, exported from `pi-roundtable/kit`); a rejection, a throw or a timeout holds the call under a generic description (`run <tool>`) and never lets it run. `signal` aborts when the turn stops or the wait ends, and a turn stopped meanwhile opens no card and holds nothing. Calls made together are held and shown in the order they were made, each with its own description.
13
+ - `HoldRule.describeInTurn(tool, input, context, turn)`, the same for a hold rule of a plugin's `holdRules`: it may return a promise and the gate asks it instead of `describe` in a turn. `HoldCheck.inTurn` asks a chain's rules as a turn does. `describe` stays required and is what a host with no turn sees.
14
+
15
+ ### Changed
16
+
17
+ - A hold that throws, not only one that rejects, is now held under a generic description in a turn; it used to fail the tool call. A hold that declares only its arguments and answers at once behaves exactly as before, and is still called with the arguments alone where a host checks the hold with no turn, such as a precheck script's tool call. A hold that declares `turn` is not called there: it is described as `run <tool>` and counts as held.
18
+ - Hold lookups are awaited one tool call at a time, because the agent loop checks calls one after another: a message with N calls whose lookups hang waits up to N × `HOLD_DESCRIBE_TIMEOUT_MS`, and an approved call in the confirming turn still waits for its lookup. Keep lookups fast. This is documented in `docs/plugins.md`; behavior is unchanged.
19
+ - A hold that declares `turn` through a wrapper (so its arity is not visible), or returns a thenable without `catch`, is now held as `run <tool>` where there is no turn, instead of failing with an obscure `TypeError`.
20
+
8
21
  ## [0.9.7] - 2026-10-10
9
22
 
10
23
  ### Added
package/docs/plugins.md CHANGED
@@ -714,6 +714,38 @@ export const cleanup = definePlugin({
714
714
  ```
715
715
  <!-- /example -->
716
716
 
717
+ #### A hold that looks things up
718
+
719
+ A hold may need something it must fetch as the speaker before it can describe the call, such as the display name of the entity an id stands for, read with the speaker's own credentials.
720
+ A tool's `hold` may then return a promise and takes the turn as a second argument: the `ToolTurn` its `run` receives, with the `speaker`, the `channel` and the `agent` of the turn that makes the call.
721
+
722
+ ```ts
723
+ defineTool({
724
+ name: "ticket_close",
725
+ description: "Close a ticket.",
726
+ parameters: Type.Object({ id: Type.String() }),
727
+ minTier: "member",
728
+ hold: async ({ id }, turn) => {
729
+ const ticket = await tickets.get(id, turn.speaker, turn.signal);
730
+ return `Close ticket ${ticket.title}`;
731
+ },
732
+ run: ({ id }) => tickets.close(id),
733
+ });
734
+ ```
735
+
736
+ The gate awaits the description before it posts the card, so the card's bold line, the `action` of `ApprovalDetails` (the web chat's `approval.action`) and the held call all carry the fetched text.
737
+
738
+ - `turn.signal` aborts when the turn stops, or when the wait ends. A turn stopped while its description is awaited opens no card and holds nothing.
739
+ - The wait lasts `HOLD_DESCRIBE_TIMEOUT_MS` (10 seconds, exported from `pi-roundtable/kit`). A rejection, a throw or a timeout holds the call under a generic description (`run <tool>`, in the host's locale), never lets it run: the owner still sees the exact input on the card.
740
+ - Returning `undefined` after the lookup lets the call run, as for a hold that answers at once.
741
+ - Calls made together are held, and their cards opened, in the order they were made, each with its own description, whichever lookup returns first.
742
+ - The agent loop runs one tool call's checks after another's, so hold lookups are awaited one call at a time: a model message with N calls whose lookups all hang waits up to N × `HOLD_DESCRIBE_TIMEOUT_MS` before the last card. This holds in the confirming turn too, where an already approved call still waits for its lookup before it is released. Keep lookups fast and give them a short timeout of their own.
743
+ - A hold must change nothing: it may be called again for the same call, such as when the owner's confirming message makes the call again.
744
+ - A hold that declares only its arguments and answers at once is called as it always was. Where a host checks the hold with no turn, such as the `holds` of a test harness or a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s tool call, a hold that declares `turn` is not called; it is described as `run <tool>` and so counts as held.
745
+
746
+ A rule of `holdRules` does the same with `describeInTurn(tool, input, context, turn)`, which may return a promise and which the gate asks instead of `describe` in a turn.
747
+ `describe` is still required and is what a host without a turn sees. `holdChain(rules).inTurn(tool, input, context, turn)` asks the rules as a turn does, in order: each rule's `describeInTurn`, or its `describe` for a rule without one.
748
+
717
749
  A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool.
718
750
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
719
751
  A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owners where the conversation is shared ([who answers a prompt](#who-answers-a-prompt-promptscope)); nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId` and their principal as `principalId`, so a confirming message from another identity of theirs counts after a restart too; a call held without them is the owners' to approve, and one held before 0.9, without a principal, is matched by `speakerId`.
@@ -2893,7 +2925,7 @@ It returns:
2893
2925
  |---|---|
2894
2926
  | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
2895
2927
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
2896
- | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined` |
2928
+ | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined`; `holds.inTurn(tool, input, context, turn)` asks them as a turn does, and returns a promise when a hold looks things up |
2897
2929
  | `runTool(name, args, { speaker, channel, workspace }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads; `workspace` (`{ workspace, scratchDir? }`) is the session's `turn.workspace` |
2898
2930
  | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
2899
2931
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
@@ -3606,6 +3638,7 @@ Import from the entries listed below; source area files are internal.
3606
3638
  | `formatModelRef` | `pi-roundtable/kit` | value |
3607
3639
  | `headline` | `pi-roundtable/kit` | value |
3608
3640
  | `holdChain` | `pi-roundtable/kit` | value |
3641
+ | `HOLD_DESCRIBE_TIMEOUT_MS` | `pi-roundtable/kit` | value |
3609
3642
  | `COMPACT_HEADROOM_TOKENS` | `pi-roundtable/kit` | value |
3610
3643
  | `CompactionTiers` | `pi-roundtable/kit` | value |
3611
3644
  | `compactionEngine` | `pi-roundtable/kit` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
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",
@@ -1,17 +1,14 @@
1
1
  import type { Static, TObject } from "typebox";
2
- import {
3
- AttachmentLookupError,
4
- openAttachment,
5
- type ToolAttachment,
6
- } from "./attachments/tool-attachment.ts";
2
+ import type { ToolAttachment } from "./attachments/tool-attachment.ts";
7
3
  import type { ReplyFile } from "./domain/conversation.ts";
8
4
  import { PluginError } from "./errors.ts";
9
- import type { HoldRule } from "./holds.ts";
5
+ import { type HoldRule, isPromise } from "./holds.ts";
6
+ import { messages } from "./i18n/index.ts";
10
7
  import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
11
- import { attachReplyFile } from "./reply-files.ts";
12
8
  import type { AgentTurnScope, ChannelKey, SessionTool } from "./sessions.ts";
13
9
  import { toolError, toolText } from "./shared/tool-result.ts";
14
10
  import { type Speaker, TIERS, type Tier } from "./speakers.ts";
11
+ import { toolTurn } from "./tool-turn.ts";
15
12
 
16
13
  /** Tool names are lowercase words joined by underscores, like every tool the core ships. */
17
14
  const TOOL_NAME = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
@@ -64,8 +61,22 @@ export interface ToolSpec<Schema extends TObject> {
64
61
  minTier: Tier;
65
62
  /** Whether agents carry the tool in their turns; default true. */
66
63
  agent?: boolean;
67
- /** A short description of what the call would do when it must wait for approval; undefined lets it run. */
68
- hold?: (args: Static<Schema>) => string | undefined;
64
+ /**
65
+ * A short description of what the call would do when it must wait for approval; undefined lets it
66
+ * run. It may answer later, after looking something up as the turn's speaker, such as a name to
67
+ * show instead of an id: `turn` is the turn `run` would get, and its `signal` aborts when the
68
+ * turn stops or the wait ends. The owner's approval card waits for it up to
69
+ * `HOLD_DESCRIBE_TIMEOUT_MS`; a rejection or a timeout holds the call under a generic
70
+ * description, so a hold that fails never lets the call run. It must change nothing.
71
+ *
72
+ * A host that checks the hold with no turn, such as a precheck script's call, never calls a hold
73
+ * that declares `turn`; it sees the generic description. A hold that declares only `args` and
74
+ * answers at once is called there as it always was.
75
+ */
76
+ hold?: (
77
+ args: Static<Schema>,
78
+ turn: ToolTurn,
79
+ ) => string | undefined | Promise<string | undefined>;
69
80
  run(args: Static<Schema>, turn: ToolTurn): Promise<string> | string;
70
81
  }
71
82
 
@@ -124,29 +135,10 @@ export function defineTool<Schema extends TObject>(
124
135
  execute: async (_toolCallId, params, signal) => {
125
136
  try {
126
137
  return toolText(
127
- await spec.run(params as Static<Schema>, {
128
- speaker: context.speaker(),
129
- channel: context.turnChannel,
130
- agent: context.agent,
131
- signal,
132
- ...(context.workspace
133
- ? { workspace: context.workspace }
134
- : {}),
135
- attachFile: attachReplyFile,
136
- attachment: async (file) => {
137
- if (context.attachmentDir === undefined)
138
- throw new ToolRefusal(
139
- "this session keeps no attachments",
140
- );
141
- try {
142
- return await openAttachment(context.attachmentDir, file);
143
- } catch (error) {
144
- if (error instanceof AttachmentLookupError)
145
- throw new ToolRefusal(error.message);
146
- throw error;
147
- }
148
- },
149
- }),
138
+ await spec.run(
139
+ params as Static<Schema>,
140
+ toolTurn(context, signal),
141
+ ),
150
142
  );
151
143
  } catch (error) {
152
144
  if (error instanceof ToolRefusal) return toolError(error.message);
@@ -170,11 +162,38 @@ export function defineTool<Schema extends TObject>(
170
162
  hold: {
171
163
  name: `tool:${name}`,
172
164
  describe: (tool, input) =>
173
- tool === name ? hold(input as Static<Schema>) : undefined,
165
+ tool === name ? holdWithoutTurn(name, hold, input) : undefined,
166
+ describeInTurn: (tool, input, _context, turn) =>
167
+ tool === name ? hold(input as Static<Schema>, turn) : undefined,
174
168
  },
175
169
  };
176
170
  }
177
171
 
172
+ /**
173
+ * A tool's hold as a host with no turn sees it: called with the arguments alone, unless it declares
174
+ * the turn, and described generically when it needs the turn or answers later.
175
+ */
176
+ function holdWithoutTurn<Schema extends TObject>(
177
+ name: string,
178
+ hold: NonNullable<ToolSpec<Schema>["hold"]>,
179
+ input: Record<string, unknown>,
180
+ ): string | undefined {
181
+ if (hold.length >= 2) return messages().holdGeneric(name);
182
+ try {
183
+ const answer = (
184
+ hold as (
185
+ args: Static<Schema>,
186
+ ) => string | undefined | Promise<string | undefined>
187
+ )(input as Static<Schema>);
188
+ if (!isPromise(answer)) return answer;
189
+ // Its answer is not waited for here; a rejection must not surface on its own.
190
+ Promise.resolve(answer).catch(() => undefined);
191
+ } catch {
192
+ // A hold that needs the turn without declaring it, or that fails: it still counts as held.
193
+ }
194
+ return messages().holdGeneric(name);
195
+ }
196
+
178
197
  /**
179
198
  * Defines a plugin. It returns the plugin it was given, typed, after checking its name, so a
180
199
  * mistake shows where the plugin is written.
package/src/core/holds.ts CHANGED
@@ -1,6 +1,13 @@
1
+ import type { ToolTurn } from "./define.ts";
1
2
  import { PluginError } from "./errors.ts";
2
3
  import { type Tier, tierAtLeast } from "./speakers.ts";
3
4
 
5
+ /**
6
+ * How long the confirmation gate waits for a hold that looks things up before it describes the
7
+ * call generically and holds it anyway, in milliseconds.
8
+ */
9
+ export const HOLD_DESCRIBE_TIMEOUT_MS = 10_000;
10
+
4
11
  /** What a hold rule knows about the session making the call. */
5
12
  export interface HoldContext {
6
13
  /** The shared workspace of a session with a shell; calls reaching outside it may be held. */
@@ -27,6 +34,20 @@ export interface HoldRule {
27
34
  * `describe` with an empty input.
28
35
  */
29
36
  mayHold?(tool: string): boolean;
37
+ /**
38
+ * `describe` for a rule that must look something up as the turn's speaker before it can describe
39
+ * the call, which it may answer later. The confirmation gate asks it, instead of `describe`,
40
+ * when a turn makes the call, and waits for the answer up to `HOLD_DESCRIBE_TIMEOUT_MS`; a
41
+ * rejection or a timeout holds the call under a generic description. `turn.signal` aborts when the
42
+ * turn stops or the wait ends. `describe` stays what a host without a turn sees, such as a
43
+ * precheck script's call, and answers at once.
44
+ */
45
+ describeInTurn?(
46
+ tool: string,
47
+ input: Record<string, unknown>,
48
+ context: HoldContext,
49
+ turn: ToolTurn,
50
+ ): string | undefined | Promise<string | undefined>;
30
51
  /**
31
52
  * The lowest tier that may approve a call this rule holds, when it is higher than the tool's own:
32
53
  * for a call that stands for others, such as saving a script whose runs make held calls.
@@ -46,6 +67,17 @@ export type HoldCheck = ((
46
67
  ) => string | undefined) & {
47
68
  /** The name of a rule that may hold some call of `tool` whatever its input; see `HoldRule.mayHold`. */
48
69
  mayHold?(tool: string): string | undefined;
70
+ /**
71
+ * The check as a turn runs it: each rule's `describeInTurn`, or its `describe` for a rule
72
+ * without one, asked in order until one describes the call. It answers at once when every rule
73
+ * asked did, and otherwise as a promise that rejects when a rule's does.
74
+ */
75
+ inTurn?(
76
+ tool: string,
77
+ input: Record<string, unknown>,
78
+ context: HoldContext,
79
+ turn: ToolTurn,
80
+ ): string | undefined | Promise<string | undefined>;
49
81
  /** The highest tier any rule requires to approve the call; see `HoldRule.approvalTier`. */
50
82
  approvalTier?(
51
83
  tool: string,
@@ -81,6 +113,26 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
81
113
  }
82
114
  return undefined;
83
115
  };
116
+ check.inTurn = (tool, input, context, turn) => {
117
+ const ask = (
118
+ from: number,
119
+ ): string | undefined | Promise<string | undefined> => {
120
+ for (let at = from; at < rules.length; at++) {
121
+ const rule = rules[at];
122
+ if (!rule) continue;
123
+ const description = rule.describeInTurn
124
+ ? rule.describeInTurn(tool, input, context, turn)
125
+ : rule.describe(tool, input, context);
126
+ if (isPromise(description))
127
+ return description.then((found) =>
128
+ found !== undefined ? found : ask(at + 1),
129
+ );
130
+ if (description !== undefined) return description;
131
+ }
132
+ return undefined;
133
+ };
134
+ return ask(0);
135
+ };
84
136
  check.mayHold = (tool) => rules.find((rule) => rule.mayHold?.(tool))?.name;
85
137
  check.approvalTier = (tool, input, context) =>
86
138
  rules.reduce<Tier | undefined>(
@@ -90,3 +142,12 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
90
142
  );
91
143
  return check;
92
144
  }
145
+
146
+ /** Whether a hold's answer comes later. */
147
+ export function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
148
+ return (
149
+ typeof value === "object" &&
150
+ value !== null &&
151
+ typeof (value as { then?: unknown }).then === "function"
152
+ );
153
+ }
@@ -28,6 +28,7 @@ export function conversationEn(ctx: CatalogContext) {
28
28
  answerReturned: (text: string) => `↩️ Reply:\n${text}`,
29
29
  thinkingAuto: "auto",
30
30
  confirmTitle: (asker: string) => `${asker} wants to run this action`,
31
+ holdGeneric: (tool: string) => `run ${tool}`,
31
32
  askTitle: (asker: string) => `${asker} has a question for you`,
32
33
  cardFile: (path: string, bytes: number | undefined) =>
33
34
  bytes === undefined
@@ -59,6 +60,7 @@ export function conversationZhTW(
59
60
  answerReturned: (text: string) => `↩️ 回覆:\n${text}`,
60
61
  thinkingAuto: "自動",
61
62
  confirmTitle: (asker: string) => `${asker} 要執行這個動作`,
63
+ holdGeneric: (tool: string) => `執行 ${tool}`,
62
64
  askTitle: (asker: string) => `${asker} 想問你`,
63
65
  cardFile: (path: string, bytes: number | undefined) =>
64
66
  bytes === undefined
@@ -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),
@@ -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/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;