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 +13 -0
- package/docs/plugins.md +34 -1
- package/package.json +1 -1
- package/src/core/define.ts +52 -33
- package/src/core/holds.ts +61 -0
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +154 -9
- package/src/core/runtime/session-factory.ts +4 -1
- package/src/core/tool-turn.ts +37 -0
- package/src/kit/holds.ts +3 -2
- package/src/kit/index.ts +1 -1
- package/src/testing.ts +1 -1
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
package/src/core/define.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
68
|
-
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 ?
|
|
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 {
|
|
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
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
/**
|
|
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
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;
|