pi-roundtable 0.9.6 → 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 +26 -0
- package/docs/plugins.md +55 -9
- package/examples/fake-surface.ts +2 -2
- package/package.json +1 -1
- package/src/core/define.ts +52 -33
- package/src/core/discord/owner-cards.ts +45 -39
- package/src/core/domain/progress.ts +18 -1
- package/src/core/holds.ts +61 -0
- package/src/core/i18n/conversation.ts +5 -0
- package/src/core/interactions/prompts.ts +30 -5
- package/src/core/runtime/extensions/approval-card.ts +210 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +172 -57
- package/src/core/runtime/pi-agent-runtime.ts +4 -2
- package/src/core/runtime/prompt-slot.ts +17 -2
- package/src/core/runtime/session-factory.ts +4 -1
- package/src/core/runtime/turn-progress.ts +14 -2
- package/src/core/testing/surface-contract.ts +4 -3
- package/src/core/tool-turn.ts +37 -0
- package/src/index.ts +3 -1
- package/src/kit/holds.ts +3 -2
- package/src/kit/index.ts +3 -1
- package/src/kit/worker.ts +4 -2
- package/src/testing.ts +1 -1
|
@@ -3,17 +3,40 @@ import type { TurnRequest } from "../domain/ports.ts";
|
|
|
3
3
|
import type { Speaker, Tier } from "../speakers.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* How a prompt ended: answered yes or no; `expired` when
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* How a prompt ended: answered yes or no; `expired` when the card was shown and no one answered
|
|
7
|
+
* it in time; `unavailable` when no card could be shown (no one may approve the call, the speaker
|
|
8
|
+
* is below its tier, or the card could not be posted); `cancelled` when its turn stopped; or
|
|
9
|
+
* `pending` when the turn stopped waiting while the card stays open, its answer to come through
|
|
10
|
+
* `PromptWait`. A surface that cannot tell the two may answer `expired` for both.
|
|
9
11
|
*/
|
|
10
12
|
export type Approval =
|
|
11
13
|
| "approved"
|
|
12
14
|
| "declined"
|
|
13
15
|
| "expired"
|
|
16
|
+
| "unavailable"
|
|
14
17
|
| "cancelled"
|
|
15
18
|
| "pending";
|
|
16
19
|
|
|
20
|
+
/** A file a held call sends by path; `bytes` is its size when it was read, absent when it could not be. */
|
|
21
|
+
export interface ApprovalFile {
|
|
22
|
+
path: string;
|
|
23
|
+
bytes?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What an approval is for, as data: the same facts a card's text shows, so a surface that is not
|
|
28
|
+
* text (a web client) need not read them back out of the markdown. `input` is the call's whole
|
|
29
|
+
* input, never cut.
|
|
30
|
+
*/
|
|
31
|
+
export interface ApprovalDetails {
|
|
32
|
+
/** What the call would do, in plain words. */
|
|
33
|
+
action: string;
|
|
34
|
+
tool: string;
|
|
35
|
+
input: Record<string, unknown>;
|
|
36
|
+
/** The files the call sends by path; absent when it sends none. */
|
|
37
|
+
files?: ApprovalFile[];
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
/** An answer given on a prompt after its turn stopped waiting; `by` names who gave it. */
|
|
18
41
|
export type LateAnswer = { by: string } & (
|
|
19
42
|
| { kind: "approval"; approved: boolean }
|
|
@@ -80,7 +103,7 @@ export interface PromptScope {
|
|
|
80
103
|
/**
|
|
81
104
|
* Who else may answer: `owners` in a shared conversation, where an approval above the
|
|
82
105
|
* speaker's tier goes to the owners; `none` in a private one, which no one else sees, so such
|
|
83
|
-
* an approval
|
|
106
|
+
* an approval comes back `unavailable` at once.
|
|
84
107
|
*/
|
|
85
108
|
escalate: "owners" | "none";
|
|
86
109
|
}
|
|
@@ -95,7 +118,8 @@ export interface Prompts {
|
|
|
95
118
|
* Asks to approve an action; `signal` cancels the prompt when the turn stops. `minTier` is the
|
|
96
119
|
* tier the scope's speaker needs to approve it themselves, `owner` when absent; below it the
|
|
97
120
|
* prompt goes to the owners, or, when the scope escalates to no one, resolves `expired` at once.
|
|
98
|
-
* `pending` comes only with a `wait`.
|
|
121
|
+
* `pending` comes only with a `wait`. `details` is the approval as data beside `message`, its
|
|
122
|
+
* text; a surface that shows text only ignores it.
|
|
99
123
|
*/
|
|
100
124
|
confirm(
|
|
101
125
|
title: string,
|
|
@@ -103,6 +127,7 @@ export interface Prompts {
|
|
|
103
127
|
signal?: AbortSignal,
|
|
104
128
|
minTier?: Tier,
|
|
105
129
|
wait?: PromptWait,
|
|
130
|
+
details?: ApprovalDetails,
|
|
106
131
|
): Promise<Approval>;
|
|
107
132
|
/**
|
|
108
133
|
* Asks a question; undefined when no one answered it or it was cancelled, and `pending`, only
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { HeldCall } from "../../domain/conversation.ts";
|
|
4
|
+
import { messages } from "../../i18n/index.ts";
|
|
5
|
+
import type { ApprovalDetails } from "../../interactions/prompts.ts";
|
|
6
|
+
|
|
7
|
+
/** The fewest characters of a long string value in a held call's input its approval card keeps, when the input does not fit whole. */
|
|
8
|
+
const CARD_VALUE_CHARS = 200;
|
|
9
|
+
|
|
10
|
+
/** How many characters of the whole input its approval card shows, at most. */
|
|
11
|
+
const CARD_INPUT_CHARS = 1_500;
|
|
12
|
+
|
|
13
|
+
/** How many hidden key names a card lists, and how long each may be. */
|
|
14
|
+
const CARD_HIDDEN_KEYS = 20;
|
|
15
|
+
const CARD_KEY_CHARS = 40;
|
|
16
|
+
|
|
17
|
+
/** What an approval card shows of a call's input; see `approvalCard`. */
|
|
18
|
+
export interface CardLimits {
|
|
19
|
+
/**
|
|
20
|
+
* The fewest characters of a long string value (at any depth) kept when the input is over
|
|
21
|
+
* `totalChars`; a value is cut only as far as the input needs, never below this. Counted in code points.
|
|
22
|
+
*/
|
|
23
|
+
valueChars?: number;
|
|
24
|
+
/** Longest input shown. An input within it is shown whole; a longer one has its values cut, then the rest dropped. */
|
|
25
|
+
totalChars?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What an approval card says: the held action's words, the exact call, and each file it sends by
|
|
30
|
+
* path with the file's size now; relative paths resolve against `workspace`. An input within
|
|
31
|
+
* `limits.totalChars` is shown whole. A longer one has its string values cut, the longest first
|
|
32
|
+
* and never below `limits.valueChars`, each marked `… [N chars]` with its original length in code
|
|
33
|
+
* points, until it fits, so one long value does not push the keys after it out of the card. If it
|
|
34
|
+
* still does not fit, the rest is dropped and the keys that are no longer shown are listed.
|
|
35
|
+
*/
|
|
36
|
+
export function approvalCard(
|
|
37
|
+
call: HeldCall,
|
|
38
|
+
workspace?: string,
|
|
39
|
+
limits: CardLimits = {},
|
|
40
|
+
): string {
|
|
41
|
+
const input = shownInput(
|
|
42
|
+
call.input,
|
|
43
|
+
limits.valueChars ?? CARD_VALUE_CHARS,
|
|
44
|
+
limits.totalChars ?? CARD_INPUT_CHARS,
|
|
45
|
+
);
|
|
46
|
+
const files = pathFiles(call.input).map(
|
|
47
|
+
(path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
|
|
48
|
+
);
|
|
49
|
+
return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The input as a card shows it within `total` characters; see `approvalCard`. */
|
|
53
|
+
function shownInput(input: string, valueChars: number, total: number): string {
|
|
54
|
+
if (input.length <= total) return input;
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(input);
|
|
58
|
+
} catch {
|
|
59
|
+
return `${prefix(input, total)}…`;
|
|
60
|
+
}
|
|
61
|
+
const longest = longestString(parsed);
|
|
62
|
+
const cutAt = (limit: number) => cutValues(parsed, limit);
|
|
63
|
+
let limit = valueChars;
|
|
64
|
+
if (cutAt(limit).length <= total) {
|
|
65
|
+
// The largest limit that still fits: the longest values give up characters first.
|
|
66
|
+
let low = limit;
|
|
67
|
+
let high = Math.max(low, longest - 1);
|
|
68
|
+
while (low < high) {
|
|
69
|
+
const mid = Math.ceil((low + high) / 2);
|
|
70
|
+
if (cutAt(mid).length <= total) low = mid;
|
|
71
|
+
else high = mid - 1;
|
|
72
|
+
}
|
|
73
|
+
limit = low;
|
|
74
|
+
return cutAt(limit);
|
|
75
|
+
}
|
|
76
|
+
const shown = cutAt(limit);
|
|
77
|
+
const kept = prefix(shown, total);
|
|
78
|
+
return `${kept}…${hiddenKeys(parsed, limit, kept.length)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The longest string value in `value`, at any depth, in code points; 0 when there is none. */
|
|
82
|
+
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
83
|
+
function longestString(value: unknown): number {
|
|
84
|
+
let longest = 0;
|
|
85
|
+
JSON.stringify(value, (_key, v: unknown) => {
|
|
86
|
+
if (typeof v === "string" && v.length > longest)
|
|
87
|
+
longest = Math.max(longest, countPoints(v));
|
|
88
|
+
return v;
|
|
89
|
+
});
|
|
90
|
+
return longest;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `value` as JSON with each string longer than `limit` code points cut and marked `… [N chars]`. */
|
|
94
|
+
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
95
|
+
function cutValues(value: unknown, limit: number): string {
|
|
96
|
+
return JSON.stringify(value, (_key, v: unknown) => {
|
|
97
|
+
if (typeof v !== "string" || v.length <= limit) return v;
|
|
98
|
+
const points = countPoints(v);
|
|
99
|
+
if (points <= limit) return v;
|
|
100
|
+
return `${firstPoints(v, limit)}… [${points} chars]`;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The top-level keys, as a line for the card, whose names fall after the first `shown` characters of the cut input. */
|
|
105
|
+
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
106
|
+
function hiddenKeys(parsed: unknown, limit: number, shown: number): string {
|
|
107
|
+
if (!isPlainObject(parsed)) return "";
|
|
108
|
+
const hidden: string[] = [];
|
|
109
|
+
// Entries are laid out as `{"k":v,"k2":v2}`; an entry starts after the `{` and each comma.
|
|
110
|
+
let offset = 1;
|
|
111
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
112
|
+
if (offset >= shown) hidden.push(key);
|
|
113
|
+
offset += `${JSON.stringify(key)}:${cutValues(value, limit)},`.length;
|
|
114
|
+
}
|
|
115
|
+
if (hidden.length === 0) return "";
|
|
116
|
+
const names = hidden
|
|
117
|
+
.slice(0, CARD_HIDDEN_KEYS)
|
|
118
|
+
.map((key) => JSON.stringify(firstPoints(key, CARD_KEY_CHARS)));
|
|
119
|
+
if (hidden.length > CARD_HIDDEN_KEYS) names.push("…");
|
|
120
|
+
return `\n${messages().cardHiddenKeys(names)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** How many code points `text` has. */
|
|
124
|
+
function countPoints(text: string): number {
|
|
125
|
+
let points = 0;
|
|
126
|
+
for (let i = 0; i < text.length; i++) {
|
|
127
|
+
const code = text.charCodeAt(i);
|
|
128
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) i++;
|
|
129
|
+
points++;
|
|
130
|
+
}
|
|
131
|
+
return points;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The first `count` code points of `text`. */
|
|
135
|
+
function firstPoints(text: string, count: number): string {
|
|
136
|
+
let end = 0;
|
|
137
|
+
for (let points = 0; points < count && end < text.length; points++) {
|
|
138
|
+
const code = text.charCodeAt(end);
|
|
139
|
+
end += code >= 0xd800 && code <= 0xdbff && end + 1 < text.length ? 2 : 1;
|
|
140
|
+
}
|
|
141
|
+
return text.slice(0, end);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The first `max` characters of `text`, backing off a cut that would split a surrogate pair. */
|
|
145
|
+
function prefix(text: string, max: number): string {
|
|
146
|
+
if (text.length <= max) return text;
|
|
147
|
+
const code = text.charCodeAt(max - 1);
|
|
148
|
+
return text.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A held call as data for a card that is not text: the action, the tool, the whole input, and
|
|
153
|
+
* each file it sends by path with its size now (absent when unreadable). Unlike the text card,
|
|
154
|
+
* nothing is cut and nothing is worded for a locale.
|
|
155
|
+
*/
|
|
156
|
+
export function approvalDetails(
|
|
157
|
+
call: HeldCall,
|
|
158
|
+
workspace?: string,
|
|
159
|
+
): ApprovalDetails {
|
|
160
|
+
const files = pathFiles(call.input).map((path) => {
|
|
161
|
+
const bytes = fileSize(path, workspace);
|
|
162
|
+
return bytes === undefined ? { path } : { path, bytes };
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
action: call.action,
|
|
166
|
+
tool: call.tool,
|
|
167
|
+
input: parsedInput(call.input),
|
|
168
|
+
...(files.length > 0 ? { files } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A call's input object; `{}` for input that is not one (a held call's always is). */
|
|
173
|
+
function parsedInput(input: string): Record<string, unknown> {
|
|
174
|
+
try {
|
|
175
|
+
const parsed: unknown = JSON.parse(input);
|
|
176
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
177
|
+
} catch {
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The paths of a call's `files` entries that name one. */
|
|
183
|
+
function pathFiles(input: string): string[] {
|
|
184
|
+
let parsed: unknown;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(input);
|
|
187
|
+
} catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
const files = isPlainObject(parsed) ? parsed.files : undefined;
|
|
191
|
+
if (!Array.isArray(files)) return [];
|
|
192
|
+
return files.flatMap((file: unknown) =>
|
|
193
|
+
isPlainObject(file) && typeof file.path === "string" ? [file.path] : [],
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A file's size in bytes, or undefined when it cannot be read. */
|
|
198
|
+
function fileSize(path: string, workspace: string | undefined) {
|
|
199
|
+
try {
|
|
200
|
+
const info = statSync(resolve(workspace ?? "/", path));
|
|
201
|
+
return info.isFile() ? info.size : undefined;
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
208
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
209
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
210
|
+
}
|
|
@@ -1,20 +1,24 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ToolTurn } from "../../define.ts";
|
|
4
3
|
import type {
|
|
5
4
|
HeldCall,
|
|
6
5
|
PendingConfirmation,
|
|
7
6
|
} from "../../domain/conversation.ts";
|
|
8
|
-
import
|
|
7
|
+
import type { HoldRefusal } from "../../domain/progress.ts";
|
|
8
|
+
import {
|
|
9
|
+
HOLD_DESCRIBE_TIMEOUT_MS,
|
|
10
|
+
type HoldCheck,
|
|
11
|
+
type HoldContext,
|
|
12
|
+
higherTier,
|
|
13
|
+
isPromise,
|
|
14
|
+
} from "../../holds.ts";
|
|
9
15
|
import { messages } from "../../i18n/index.ts";
|
|
10
16
|
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
11
17
|
import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
|
|
12
18
|
import type { Speaker } from "../../speakers.ts";
|
|
13
19
|
import type { ToolTiers } from "../../tool-tiers.ts";
|
|
14
20
|
import type { PromptSlot } from "../prompt-slot.ts";
|
|
15
|
-
|
|
16
|
-
/** How much of a held call's input its approval card shows. */
|
|
17
|
-
const CARD_INPUT_CHARS = 1_500;
|
|
21
|
+
import { approvalCard, approvalDetails } from "./approval-card.ts";
|
|
18
22
|
|
|
19
23
|
/** Held actions older than this cannot be approved any more. */
|
|
20
24
|
export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
|
|
@@ -30,6 +34,20 @@ export function unexpired(
|
|
|
30
34
|
: pending;
|
|
31
35
|
}
|
|
32
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
|
+
|
|
33
51
|
/** JSON with object keys sorted at every level, so equal inputs compare equal. */
|
|
34
52
|
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
35
53
|
export function canonicalJson(value: unknown): string {
|
|
@@ -82,12 +100,18 @@ export class ConfirmationGate {
|
|
|
82
100
|
readonly #tiers: ToolTiers | undefined;
|
|
83
101
|
/** Where the session's relative file paths resolve, for the sizes a card shows. */
|
|
84
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;
|
|
85
108
|
|
|
86
109
|
/**
|
|
87
110
|
* `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
|
|
88
111
|
* and its writes outside the workspace and the context's scratch dir are held too.
|
|
89
112
|
* `fileRoot` is the session's working directory, where a card resolves relative file paths;
|
|
90
|
-
* 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.
|
|
91
115
|
*/
|
|
92
116
|
constructor(
|
|
93
117
|
holds: HoldCheck,
|
|
@@ -96,7 +120,9 @@ export class ConfirmationGate {
|
|
|
96
120
|
context: HoldContext = {},
|
|
97
121
|
tiers?: ToolTiers,
|
|
98
122
|
fileRoot?: string,
|
|
123
|
+
describeTimeoutMs = HOLD_DESCRIBE_TIMEOUT_MS,
|
|
99
124
|
) {
|
|
125
|
+
this.#describeTimeoutMs = describeTimeoutMs;
|
|
100
126
|
this.#tiers = tiers;
|
|
101
127
|
this.#fileRoot = fileRoot ?? context.workspace;
|
|
102
128
|
this.#holds = holds;
|
|
@@ -147,44 +173,62 @@ export class ConfirmationGate {
|
|
|
147
173
|
tool: string,
|
|
148
174
|
input: Record<string, unknown>,
|
|
149
175
|
ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
|
|
176
|
+
turn?: HoldTurn,
|
|
150
177
|
): Promise<string | undefined> {
|
|
151
|
-
return (await this.decide(tool, input, ask)).reason;
|
|
178
|
+
return (await this.decide(tool, input, ask, turn)).reason;
|
|
152
179
|
}
|
|
153
180
|
|
|
154
181
|
/**
|
|
155
182
|
* `review`, plus whether a card's approval released the call, so its result can say so:
|
|
156
|
-
* the card's own message is shown to the person, never to the model.
|
|
183
|
+
* the card's own message is shown to the person, never to the model. When a hold refuses the
|
|
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.
|
|
157
187
|
*/
|
|
158
188
|
async decide(
|
|
159
189
|
tool: string,
|
|
160
190
|
input: Record<string, unknown>,
|
|
161
191
|
ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
|
|
162
|
-
|
|
163
|
-
|
|
192
|
+
turn?: HoldTurn,
|
|
193
|
+
): Promise<{
|
|
194
|
+
reason?: string;
|
|
195
|
+
approvedOnCard?: true;
|
|
196
|
+
/** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
|
|
197
|
+
refused?: HoldRefusal;
|
|
198
|
+
}> {
|
|
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." };
|
|
164
202
|
if (!call) return {};
|
|
165
|
-
if (!ask) return { reason: this.#record(call) };
|
|
203
|
+
if (!ask) return { reason: this.#record(call), refused: "held" };
|
|
166
204
|
const answer = await ask.prompts.confirm(
|
|
167
205
|
messages().confirmTitle(ask.asker),
|
|
168
206
|
approvalCard(call, this.#fileRoot),
|
|
169
207
|
ask.signal,
|
|
170
208
|
higherTier(this.#tiers?.minTier(call.tool), call.minTier),
|
|
171
209
|
{ late: (late) => this.#late(call, late) },
|
|
210
|
+
approvalDetails(call, this.#fileRoot),
|
|
172
211
|
);
|
|
173
212
|
if (answer === "approved") return { approvedOnCard: true };
|
|
174
213
|
if (answer === "pending") {
|
|
175
214
|
const o = ownerWords(this.#addressee);
|
|
176
215
|
return {
|
|
177
216
|
reason: `Awaiting ${o.name}'s approval: this would ${call.action}. Its card stays open, and the call did not run. Do not retry it or ask again in this turn. End your turn now: say briefly that you are waiting for ${o.his} approval on the card. When ${o.he} answers, a new turn tells you.`,
|
|
217
|
+
refused: "pending",
|
|
178
218
|
};
|
|
179
219
|
}
|
|
180
220
|
if (answer === "declined") {
|
|
181
221
|
const o = ownerWords(this.#addressee);
|
|
182
222
|
return {
|
|
183
223
|
reason: `${o.name} declined this on its approval card: it would ${call.action}. Do not retry it; go on without it, or ask ${o.him} what ${o.he} wants instead.`,
|
|
224
|
+
refused: "declined",
|
|
184
225
|
};
|
|
185
226
|
}
|
|
186
227
|
if (answer === "cancelled") return { reason: "The turn was stopped." };
|
|
187
|
-
|
|
228
|
+
// No card was shown: no one may approve it, or it could not be posted.
|
|
229
|
+
if (answer === "unavailable")
|
|
230
|
+
return { reason: this.#record(call), refused: "held" };
|
|
231
|
+
return { reason: this.#record(call), refused: "expired" };
|
|
188
232
|
}
|
|
189
233
|
|
|
190
234
|
/**
|
|
@@ -201,10 +245,112 @@ export class ConfirmationGate {
|
|
|
201
245
|
|
|
202
246
|
/** The call when it needs the owner's confirmation and no approval releases it. */
|
|
203
247
|
#needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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);
|
|
208
354
|
const call: HeldCall = {
|
|
209
355
|
tool,
|
|
210
356
|
input: canonicalJson(input),
|
|
@@ -261,49 +407,13 @@ export function confirmedTurnText(
|
|
|
261
407
|
}
|
|
262
408
|
|
|
263
409
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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.
|
|
266
412
|
*/
|
|
267
|
-
export function approvalCard(call: HeldCall, workspace?: string): string {
|
|
268
|
-
const input =
|
|
269
|
-
call.input.length > CARD_INPUT_CHARS
|
|
270
|
-
? `${call.input.slice(0, CARD_INPUT_CHARS)}…`
|
|
271
|
-
: call.input;
|
|
272
|
-
const files = pathFiles(call.input).map(
|
|
273
|
-
(path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
|
|
274
|
-
);
|
|
275
|
-
return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** The paths of a call's `files` entries that name one. */
|
|
279
|
-
function pathFiles(input: string): string[] {
|
|
280
|
-
let parsed: unknown;
|
|
281
|
-
try {
|
|
282
|
-
parsed = JSON.parse(input);
|
|
283
|
-
} catch {
|
|
284
|
-
return [];
|
|
285
|
-
}
|
|
286
|
-
const files = isPlainObject(parsed) ? parsed.files : undefined;
|
|
287
|
-
if (!Array.isArray(files)) return [];
|
|
288
|
-
return files.flatMap((file: unknown) =>
|
|
289
|
-
isPlainObject(file) && typeof file.path === "string" ? [file.path] : [],
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** A file's size in bytes, or undefined when it cannot be read. */
|
|
294
|
-
function fileSize(path: string, workspace: string | undefined) {
|
|
295
|
-
try {
|
|
296
|
-
const info = statSync(resolve(workspace ?? "/", path));
|
|
297
|
-
return info.isFile() ? info.size : undefined;
|
|
298
|
-
} catch {
|
|
299
|
-
return undefined;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Without a slot, or with an empty one, every call that needs confirmation is held. */
|
|
304
413
|
export function confirmationGateExtension(
|
|
305
414
|
gate: ConfirmationGate,
|
|
306
415
|
slot?: PromptSlot,
|
|
416
|
+
turnOf?: (signal: AbortSignal) => ToolTurn,
|
|
307
417
|
): ExtensionFactory {
|
|
308
418
|
return (pi) => {
|
|
309
419
|
/** Calls a card's approval released, until their results are back. */
|
|
@@ -324,12 +434,17 @@ export function confirmationGateExtension(
|
|
|
324
434
|
ask = { prompts, asker: slot.asker };
|
|
325
435
|
if (ctx.signal) ask.signal = ctx.signal;
|
|
326
436
|
}
|
|
327
|
-
const
|
|
437
|
+
const turn: HoldTurn | undefined = turnOf
|
|
438
|
+
? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
|
|
439
|
+
: undefined;
|
|
440
|
+
const { reason, approvedOnCard, refused } = await gate.decide(
|
|
328
441
|
event.toolName,
|
|
329
442
|
event.input as Record<string, unknown>,
|
|
330
443
|
ask,
|
|
444
|
+
turn,
|
|
331
445
|
);
|
|
332
446
|
if (approvedOnCard) approved.add(event.toolCallId);
|
|
447
|
+
if (refused) slot?.refuse(event.toolCallId, refused);
|
|
333
448
|
return reason ? { block: true, reason } : undefined;
|
|
334
449
|
});
|
|
335
450
|
};
|
|
@@ -228,8 +228,11 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
228
228
|
// Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
|
|
229
229
|
const interim = interimPoster(request, this.#options);
|
|
230
230
|
// The caller's live view: the text as it streams and each tool, never the thinking.
|
|
231
|
+
const slot = this.#sessions.slot(key);
|
|
231
232
|
const progress = request.progress
|
|
232
|
-
? progressReporter(request.progress
|
|
233
|
+
? progressReporter(request.progress, {
|
|
234
|
+
refusalOf: (id) => slot.refusalOf(id),
|
|
235
|
+
})
|
|
233
236
|
: undefined;
|
|
234
237
|
const unsubscribe = session.subscribe((event) => {
|
|
235
238
|
progress?.observe(event);
|
|
@@ -243,7 +246,6 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
243
246
|
interim?.messageEnd(event.message);
|
|
244
247
|
}
|
|
245
248
|
});
|
|
246
|
-
const slot = this.#sessions.slot(key);
|
|
247
249
|
slot.bind(
|
|
248
250
|
request.interactive
|
|
249
251
|
? this.#options.prompts?.(request.channel, promptScopeOf(scoped))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HoldRefusal } from "../domain/progress.ts";
|
|
1
2
|
import { assistantName } from "../i18n/index.ts";
|
|
2
3
|
import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
|
|
3
4
|
|
|
@@ -15,6 +16,7 @@ export class PromptSlot {
|
|
|
15
16
|
#open = 0;
|
|
16
17
|
#openSince = 0;
|
|
17
18
|
#waited = 0;
|
|
19
|
+
#refused = new Map<string, HoldRefusal>();
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Binds the turn's prompts; `asker` names who asks, the assistant or an agent. `beforeCard`
|
|
@@ -30,6 +32,19 @@ export class PromptSlot {
|
|
|
30
32
|
this.#asker = asker;
|
|
31
33
|
this.#open = 0;
|
|
32
34
|
this.#waited = 0;
|
|
35
|
+
this.#refused.clear();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Notes that a hold refused a tool call, for the progress of the turn to report once. */
|
|
39
|
+
refuse(toolCallId: string, refusal: HoldRefusal): void {
|
|
40
|
+
this.#refused.set(toolCallId, refusal);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Why a hold refused a tool call, once; undefined when it did not. */
|
|
44
|
+
refusalOf(toolCallId: string): HoldRefusal | undefined {
|
|
45
|
+
const refusal = this.#refused.get(toolCallId);
|
|
46
|
+
this.#refused.delete(toolCallId);
|
|
47
|
+
return refusal;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
unbind(): void {
|
|
@@ -46,9 +61,9 @@ export class PromptSlot {
|
|
|
46
61
|
const prompts = this.#prompts;
|
|
47
62
|
if (!prompts) return undefined;
|
|
48
63
|
return {
|
|
49
|
-
confirm: (title, message, signal, minTier, wait) =>
|
|
64
|
+
confirm: (title, message, signal, minTier, wait, details) =>
|
|
50
65
|
this.#waiting(() =>
|
|
51
|
-
prompts.confirm(title, message, signal, minTier, wait),
|
|
66
|
+
prompts.confirm(title, message, signal, minTier, wait, details),
|
|
52
67
|
),
|
|
53
68
|
ask: (title, question: OwnerQuestion, signal, wait) =>
|
|
54
69
|
this.#waiting(() => prompts.ask(title, question, signal, wait)),
|