pi-roundtable 0.9.7 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/docs/plugins.md +68 -2
- package/package.json +1 -1
- package/src/core/attachments/attachment-dir.ts +3 -0
- package/src/core/attachments/attachment-expiry.ts +170 -0
- package/src/core/attachments/attachment-port.ts +1 -0
- package/src/core/attachments/attachment-retention.ts +116 -0
- package/src/core/attachments/attachment-store.ts +32 -35
- package/src/core/attachments/attachment-tally.ts +49 -0
- package/src/core/attachments/tool-attachment.ts +14 -2
- package/src/core/config/attachments.ts +28 -0
- package/src/core/config/config.ts +9 -0
- package/src/core/contract/attachments.ts +33 -0
- package/src/core/define-roundtable.ts +3 -0
- package/src/core/define.ts +52 -33
- package/src/core/holds.ts +61 -0
- package/src/core/host.ts +24 -6
- 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/shared/attachment-reader.ts +4 -0
- package/src/core/tool-turn.ts +37 -0
- package/src/index.ts +4 -0
- package/src/kit/holds.ts +3 -2
- package/src/kit/index.ts +1 -1
- package/src/testing.ts +1 -1
|
@@ -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),
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { extractText, getDocumentProxy } from "unpdf";
|
|
3
|
+
import { wasExpired } from "../attachments/attachment-expiry.ts";
|
|
3
4
|
import { isAttachmentName, plainText } from "../attachments/attachment-name.ts";
|
|
5
|
+
import { AttachmentExpiredError } from "../attachments/tool-attachment.ts";
|
|
4
6
|
|
|
5
7
|
/** Characters returned per read; the model can ask for a later offset. */
|
|
6
8
|
export const READ_LIMIT = 60_000;
|
|
@@ -72,6 +74,8 @@ export async function readAttachment(
|
|
|
72
74
|
}
|
|
73
75
|
const handle = Bun.file(join(dir, file));
|
|
74
76
|
if (!(await handle.exists())) {
|
|
77
|
+
if (await wasExpired(dir, file))
|
|
78
|
+
throw new AttachmentExpiredError(plainText(file));
|
|
75
79
|
throw new Error(`no attachment named "${file}" in this channel`);
|
|
76
80
|
}
|
|
77
81
|
let bytes: Uint8Array;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AttachmentLookupError,
|
|
3
|
+
openAttachment,
|
|
4
|
+
} from "./attachments/tool-attachment.ts";
|
|
5
|
+
import type { ToolTurn } from "./define.ts";
|
|
6
|
+
import { ToolRefusal } from "./define.ts";
|
|
7
|
+
import { attachReplyFile } from "./reply-files.ts";
|
|
8
|
+
import type { SessionContext } from "./sessions.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The turn a tool's `run` and `hold` receive, built from the session that makes the call and the
|
|
12
|
+
* signal that stops it.
|
|
13
|
+
*/
|
|
14
|
+
export function toolTurn(
|
|
15
|
+
context: SessionContext,
|
|
16
|
+
signal: AbortSignal | undefined,
|
|
17
|
+
): ToolTurn {
|
|
18
|
+
return {
|
|
19
|
+
speaker: context.speaker(),
|
|
20
|
+
channel: context.turnChannel,
|
|
21
|
+
agent: context.agent,
|
|
22
|
+
signal,
|
|
23
|
+
...(context.workspace ? { workspace: context.workspace } : {}),
|
|
24
|
+
attachFile: attachReplyFile,
|
|
25
|
+
attachment: async (file) => {
|
|
26
|
+
if (context.attachmentDir === undefined)
|
|
27
|
+
throw new ToolRefusal("this session keeps no attachments");
|
|
28
|
+
try {
|
|
29
|
+
return await openAttachment(context.attachmentDir, file);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof AttachmentLookupError)
|
|
32
|
+
throw new ToolRefusal(error.message);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type {
|
|
|
16
16
|
GroupStatus,
|
|
17
17
|
TeamStatus,
|
|
18
18
|
} from "./core/agents/team-status.ts";
|
|
19
|
+
export type { AttachmentRetention } from "./core/attachments/attachment-retention.ts";
|
|
19
20
|
export type { ToolAttachment } from "./core/attachments/tool-attachment.ts";
|
|
20
21
|
export {
|
|
21
22
|
AGENT_SERVER_PLUGIN,
|
|
@@ -26,6 +27,7 @@ export type {
|
|
|
26
27
|
AccessOwnerConfig,
|
|
27
28
|
AccessTierConfig,
|
|
28
29
|
} from "./core/config/access.ts";
|
|
30
|
+
export type { AttachmentsConfig } from "./core/config/attachments.ts";
|
|
29
31
|
export type {
|
|
30
32
|
AdapterConfig,
|
|
31
33
|
BackgroundConfig,
|
|
@@ -38,6 +40,8 @@ export type {
|
|
|
38
40
|
AttachmentPort,
|
|
39
41
|
AttachmentRefusalCode,
|
|
40
42
|
AttachmentUpload,
|
|
43
|
+
ExpiredAttachments,
|
|
44
|
+
ExpireUsedOptions,
|
|
41
45
|
TurnAttachmentOptions,
|
|
42
46
|
} from "./core/contract/attachments.ts";
|
|
43
47
|
export { AttachmentRefusal } from "./core/contract/attachments.ts";
|
package/src/kit/holds.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Plugin helpers, versioned like the main entry; see the plugin guide.
|
|
2
|
-
// The chain a host links from every plugin's hold rules, to test a rule set as the host runs it
|
|
2
|
+
// The chain a host links from every plugin's hold rules, to test a rule set as the host runs it,
|
|
3
|
+
// and how long a hold that looks things up may take.
|
|
3
4
|
|
|
4
|
-
export { holdChain } from "../core/holds.ts";
|
|
5
|
+
export { HOLD_DESCRIBE_TIMEOUT_MS, holdChain } from "../core/holds.ts";
|
package/src/kit/index.ts
CHANGED
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;
|