pi-roundtable 0.9.6 → 0.9.7
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 +21 -8
- package/examples/fake-surface.ts +2 -2
- package/package.json +1 -1
- package/src/core/discord/owner-cards.ts +45 -39
- package/src/core/domain/progress.ts +18 -1
- package/src/core/i18n/conversation.ts +3 -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 +20 -50
- package/src/core/runtime/pi-agent-runtime.ts +4 -2
- package/src/core/runtime/prompt-slot.ts +17 -2
- package/src/core/runtime/turn-progress.ts +14 -2
- package/src/core/testing/surface-contract.ts +4 -3
- package/src/index.ts +3 -1
- package/src/kit/index.ts +2 -0
- package/src/kit/worker.ts +4 -2
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.7] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `ApprovalDetails` and `ApprovalFile` (`{ action, tool, input, files? }`, files as `{ path, bytes? }`): a held call as data, with its whole input and the size of each file it sends by path. `Prompts.confirm` takes it as a new optional last argument, `details`, beside the card's text `message`; the confirmation gate passes it with every card, and a surface that shows text only ignores it (Discord's cards read as before). `approvalDetails` builds it (`approvalDetails(call, workspace?)`), exported from `pi-roundtable/kit` beside `approvalCard`.
|
|
13
|
+
- `Approval` has a new value, `unavailable`: no card could be shown (no one may approve the call, the speaker is below its tier, or the card could not be posted). `expired` now means a card was shown and went unanswered. The Discord and web chat surfaces answer `unavailable` for the first case; a surface that cannot tell the two may keep answering `expired` for both, and the surface contract accepts either.
|
|
14
|
+
- `TurnProgress`'s `tool_end` takes an optional `refused` (`HoldRefusal`: `declined`, `expired`, `pending` or `held`), set when a hold refused the call and absent for a success and for a real failure. A card that was shown and went unanswered is `expired`; one that could not be shown is `held`. It is set through `PromptSlot` (`refuse`, `refusalOf`, in `pi-roundtable/kit`); a turn stopped while its card was open reports none.
|
|
15
|
+
- `approvalCard(call, workspace?, limits?)` takes `CardLimits` (also exported from the kit), `{ valueChars?, totalChars? }`, to set what its card shows of the input.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- An approval card shows an input that fits `totalChars` (1,500 by default) whole, exactly as before. Only an input over it has its string values cut, the longest first and never below `valueChars` (200 by default), each marked `… [N chars]` with the original length in code points, until it fits; so the keys after one long value are no longer lost. If it still cannot fit, the rest is dropped and the card lists the top-level keys it no longer shows. A cut never splits a surrogate pair.
|
|
20
|
+
|
|
8
21
|
## [0.9.6] - 2026-10-10
|
|
9
22
|
|
|
10
23
|
### Fixed
|
package/docs/plugins.md
CHANGED
|
@@ -60,7 +60,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
|
|
|
60
60
|
`ImagePreparationError.reason` is `byte-limit`, `pixel-limit`, or `invalid-image`; hosts can localize refusals without parsing native decoder errors.
|
|
61
61
|
Ordinary 48 MP images and thin 9000×1 images remain admitted.
|
|
62
62
|
This helper is not a downloader or filesystem validator.
|
|
63
|
-
- Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
|
|
63
|
+
- Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard`, `approvalDetails` and `canonicalJson` for the cards of held actions.
|
|
64
64
|
`InterimPoster(posts, { logger, channel, primaryChars?, editMs? })` posts a run's [interim text](#interim-text-what-a-turn-writes-before-its-final-answer) to an `InterimPosts`, such as a dispatch thread's `interim`: feed it `messageEnd(message)` and `toolStart(name)` from the session's events, bind `() => poster.flush()` as the slot's `beforeCard`, and `await poster.flush()` before the final report.
|
|
65
65
|
- Diagnostics: `scrubDiagnostic(text, max = 600)` masks credentials (URL userinfo, token shapes, secret-named assignments and JSON fields, `Authorization`/`Cookie`/`x-api-key` headers, JWTs, PEM blocks), turns control characters other than tab and newline into spaces, and cuts the result at `max` characters.
|
|
66
66
|
It scans only the first `max * 4` characters (at least 4,096), in linear time, so pass it git, gh or provider error text before showing that text to a user.
|
|
@@ -648,7 +648,7 @@ A Pi package should use the host's peer dependency on `pi-roundtable`, not bundl
|
|
|
648
648
|
Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
|
|
649
649
|
|
|
650
650
|
The model attaches a file it already has on disk with the core's `attach_file({ path, filename? })`. A session with a workspace (`SessionContext.workspace`, an agent's) registers it; the path must resolve, symlinks followed, inside the workspace or the scratch dir, a relative path resolves in the workspace, and `filename` defaults to the path's base name. It queues the file with `attachReplyFile`, so `REPLY_FILE_LIMITS` apply and a surface without reply files refuses it as a tool error; its result tells the model the file will appear with its reply. It holds nothing, since it only posts in the channel the turn answers in, and its tier defaults to the owner, like any tool the core's table does not name.
|
|
651
|
-
The Discord tools that upload, `discord_send_message` and `discord_edit_message`, take each file as exactly one of `path` (a local file, read when the call runs, with the same roots; `filename` defaults to its base name) or `dataBase64` with a `filename`, at most 8 MiB in all. A session without a workspace takes only `dataBase64`, and so does remote MCP, whose schemas stay inline only. A path outside the roots, a missing file, or a directory is a tool error naming the path. A held call's card (`approvalCard(call, workspace?)`) lists each file it sends by path with the file's size now, never its bytes.
|
|
651
|
+
The Discord tools that upload, `discord_send_message` and `discord_edit_message`, take each file as exactly one of `path` (a local file, read when the call runs, with the same roots; `filename` defaults to its base name) or `dataBase64` with a `filename`, at most 8 MiB in all. A session without a workspace takes only `dataBase64`, and so does remote MCP, whose schemas stay inline only. A path outside the roots, a missing file, or a directory is a tool error naming the path. A held call's card (`approvalCard(call, workspace?, limits?)`) lists each file it sends by path with the file's size now, never its bytes, and shows an input within `limits.totalChars` (1,500) whole; a longer input has its string values cut, longest first and never below `limits.valueChars` (200), to their start plus `… [N chars]` (N in code points) until it fits, and if it still does not, the rest is dropped and the keys no longer shown are listed. `approvalDetails(call, workspace?)` gives the same call as data (`{ action, tool, input, files? }`, the whole input, files as `{ path, bytes? }`); the gate passes it to `Prompts.confirm` as `details`, for a surface that is not text.
|
|
652
652
|
Calling the helper outside `context.turns.run` or an agent-team turn, or after that turn finishes, throws `ReplyFileError`; direct standalone runtime calls have no reply collector.
|
|
653
653
|
Await work that produces files before returning from the tool.
|
|
654
654
|
A standalone isolated worker may wrap its complete awaited turn in `withReplyFiles(supported, run)` from the main entry.
|
|
@@ -2365,7 +2365,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
|
|
|
2365
2365
|
| `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
|
|
2366
2366
|
| `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`; without a scope, the owners | the action is held until a message approves it |
|
|
2367
2367
|
| `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
|
|
2368
|
-
| `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok }
|
|
2368
|
+
| `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok, refused? }` (`refused`, only on a failed end that a hold refused, says how: `declined` on its approval card, `expired` when a card was shown and went unanswered, `pending` while the card stays open, or `held` when no card could be shown (no one may approve it, or it could not be posted); it is absent for a success and for a real failure). The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
|
|
2369
2369
|
|
|
2370
2370
|
Each message a surface delivers says who wrote it in `actor`, the `ActorFacts` the identity service resolves: `provider` and `subject`, such as `discord` and the user id, `name`, `roles` written `<surface>:role:<name>` (left out where the surface does not know them, such as a Discord DM, which is not the same as none), and `legacyId`, the speaker id 0.8 gave the person, so someone carried over keeps their principal.
|
|
2371
2371
|
A surface that leaves `actor` out has it read from `authorId`, `authorName`, and `authorRoleIds` with its prefix as the provider, and the host warns once that this goes away in 1.0.
|
|
@@ -2388,13 +2388,21 @@ A surface's prompts keep these rules:
|
|
|
2388
2388
|
|
|
2389
2389
|
- The speaker's principal answers their own question on any of their identities the surface knows, and approves a call when their tier is at least its `minTier` (the owner tier when absent) and, for an owner-tier call, the principal is an owner; a surface that can check their tier again when they answer does.
|
|
2390
2390
|
- Where the scope escalates to the owners, every owner may answer too, and an approval above the speaker's tier is the owners' alone.
|
|
2391
|
-
- Where it escalates to no one, nobody else answers, and an approval above the speaker's tier resolves `
|
|
2391
|
+
- Where it escalates to no one, nobody else answers, and an approval above the speaker's tier resolves `unavailable` at once, shown to no one: the call stays held. (`expired` is for a card that was shown and went unanswered; a surface that cannot tell the two may answer `expired` for both.)
|
|
2392
2392
|
- Anyone else is refused, even at the same tier as the speaker.
|
|
2393
2393
|
- Without a scope, as for a turn with no speaker, the prompts are the owners'.
|
|
2394
2394
|
|
|
2395
2395
|
On Discord the owners are every owner with a Discord identity, and the primary owner always; a card in a thread mentions the identity that spoke when it is theirs and one of the principal's Discord identities, every Discord identity of the principal when the speaker is none of them, and the owners when it goes to them, so a single owner's cards read as they did in 0.8.
|
|
2396
2396
|
A principal with no Discord identity, such as the reporter of a background report, answers nothing there, so their card goes to the owners; so does an owner-tier call of a turn whose tier defaulted to owner when its principal is not an owner.
|
|
2397
|
-
The web chat shows prompts to the conversation's person only: its owners are not on it, so an approval above that person's tier
|
|
2397
|
+
The web chat shows prompts to the conversation's person only: its owners are not on it, so an approval above that person's tier comes back `unavailable` at once whatever the scope says.
|
|
2398
|
+
|
|
2399
|
+
#### An approval as data: `ApprovalDetails`
|
|
2400
|
+
|
|
2401
|
+
`confirm(title, message, signal?, minTier?, wait?, details?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
|
|
2402
|
+
The confirmation gate and a coding worker's cards pass both.
|
|
2403
|
+
A surface that shows text, such as Discord, uses `message` and ignores `details`; the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
|
|
2404
|
+
A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
|
|
2405
|
+
`confirm` resolves `approved`, `declined`, `expired` (a card was shown and went unanswered), `unavailable` (no card could be shown: no one may approve the call, the speaker is below its tier, or the card could not be posted), `cancelled` (the turn stopped), or `pending` (with a `wait`). Both `expired` and `unavailable` leave the call held for the owner's next message; the turn's progress reports them as `refused: "expired"` and `refused: "held"`.
|
|
2398
2406
|
|
|
2399
2407
|
#### A prompt that outlives its turn: `PromptWait`
|
|
2400
2408
|
|
|
@@ -2518,7 +2526,7 @@ export class FakeSurface implements ChatSurface {
|
|
|
2518
2526
|
/**
|
|
2519
2527
|
* How a turn asks to approve a held action or answers a question. The scope names who may
|
|
2520
2528
|
* answer: its speaker when their tier holds the call, and the owners unless the conversation
|
|
2521
|
-
* is private, where a call above the speaker's tier goes to no one and
|
|
2529
|
+
* is private, where a call above the speaker's tier goes to no one and comes back `unavailable` at once.
|
|
2522
2530
|
*/
|
|
2523
2531
|
prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
|
|
2524
2532
|
return {
|
|
@@ -2527,7 +2535,7 @@ export class FakeSurface implements ChatSurface {
|
|
|
2527
2535
|
scope !== undefined &&
|
|
2528
2536
|
TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
|
|
2529
2537
|
const owners = scope?.escalate !== "none";
|
|
2530
|
-
if (!theirs && !owners) return "
|
|
2538
|
+
if (!theirs && !owners) return "unavailable";
|
|
2531
2539
|
return this.#ask(channel, title, signal, {
|
|
2532
2540
|
...(theirs ? { principalId: scope.principalId } : {}),
|
|
2533
2541
|
owners,
|
|
@@ -3014,7 +3022,7 @@ It returns:
|
|
|
3014
3022
|
A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
|
|
3015
3023
|
`describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s.
|
|
3016
3024
|
The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
|
|
3017
|
-
It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `
|
|
3025
|
+
It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
|
|
3018
3026
|
A check for a part the surface does not offer passes.
|
|
3019
3027
|
`checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
|
|
3020
3028
|
|
|
@@ -3268,6 +3276,8 @@ Import from the entries listed below; source area files are internal.
|
|
|
3268
3276
|
| `AgentTeam` | `pi-roundtable` | type |
|
|
3269
3277
|
| `AgentTurnScope` | `pi-roundtable` | type |
|
|
3270
3278
|
| `Approval` | `pi-roundtable` | type |
|
|
3279
|
+
| `ApprovalDetails` | `pi-roundtable` | type |
|
|
3280
|
+
| `ApprovalFile` | `pi-roundtable` | type |
|
|
3271
3281
|
| `AskOption` | `pi-roundtable` | type |
|
|
3272
3282
|
| `AttachmentFailure` | `pi-roundtable` | type |
|
|
3273
3283
|
| `AttachmentPort` | `pi-roundtable` | type |
|
|
@@ -3318,6 +3328,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3318
3328
|
| `HeldCall` | `pi-roundtable` | type |
|
|
3319
3329
|
| `HoldCheck` | `pi-roundtable` | type |
|
|
3320
3330
|
| `HoldContext` | `pi-roundtable` | type |
|
|
3331
|
+
| `HoldRefusal` | `pi-roundtable` | type |
|
|
3321
3332
|
| `HoldRule` | `pi-roundtable` | type |
|
|
3322
3333
|
| `HostEnv` | `pi-roundtable` | type |
|
|
3323
3334
|
| `HostEnvironment` | `pi-roundtable` | type |
|
|
@@ -3534,6 +3545,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3534
3545
|
| `AgentTurnRunner` | `pi-roundtable/kit` | type |
|
|
3535
3546
|
| `AssistantLike` | `pi-roundtable/kit` | type |
|
|
3536
3547
|
| `Backlog` | `pi-roundtable/kit` | type |
|
|
3548
|
+
| `CardLimits` | `pi-roundtable/kit` | type |
|
|
3537
3549
|
| `CategoryLayout` | `pi-roundtable/kit` | type |
|
|
3538
3550
|
| `ChannelMessage` | `pi-roundtable/kit` | type |
|
|
3539
3551
|
| `ChannelQueue` | `pi-roundtable/kit` | type |
|
|
@@ -3581,6 +3593,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3581
3593
|
| `YesNoQuestion` | `pi-roundtable/kit` | type |
|
|
3582
3594
|
| `activeToolsExtension` | `pi-roundtable/kit` | value |
|
|
3583
3595
|
| `approvalCard` | `pi-roundtable/kit` | value |
|
|
3596
|
+
| `approvalDetails` | `pi-roundtable/kit` | value |
|
|
3584
3597
|
| `archiveSessions` | `pi-roundtable/kit` | value |
|
|
3585
3598
|
| `attachmentsOf` | `pi-roundtable/kit` | value |
|
|
3586
3599
|
| `callScheduleTool` | `pi-roundtable/kit` | value |
|
package/examples/fake-surface.ts
CHANGED
|
@@ -80,7 +80,7 @@ export class FakeSurface implements ChatSurface {
|
|
|
80
80
|
/**
|
|
81
81
|
* How a turn asks to approve a held action or answers a question. The scope names who may
|
|
82
82
|
* answer: its speaker when their tier holds the call, and the owners unless the conversation
|
|
83
|
-
* is private, where a call above the speaker's tier goes to no one and
|
|
83
|
+
* is private, where a call above the speaker's tier goes to no one and comes back `unavailable` at once.
|
|
84
84
|
*/
|
|
85
85
|
prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
|
|
86
86
|
return {
|
|
@@ -89,7 +89,7 @@ export class FakeSurface implements ChatSurface {
|
|
|
89
89
|
scope !== undefined &&
|
|
90
90
|
TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
|
|
91
91
|
const owners = scope?.escalate !== "none";
|
|
92
|
-
if (!theirs && !owners) return "
|
|
92
|
+
if (!theirs && !owners) return "unavailable";
|
|
93
93
|
return this.#ask(channel, title, signal, {
|
|
94
94
|
...(theirs ? { principalId: scope.principalId } : {}),
|
|
95
95
|
owners,
|
package/package.json
CHANGED
|
@@ -167,42 +167,48 @@ export class OwnerCards implements InteractionModule {
|
|
|
167
167
|
confirm: async (title, message, signal, minTier = "owner", wait) => {
|
|
168
168
|
const audience = await this.#audiences.approval(scope, minTier);
|
|
169
169
|
// A private conversation's call above its person's tier is no one's to approve.
|
|
170
|
-
if (!audience) return "
|
|
171
|
-
return this.#post<Approval>(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
170
|
+
if (!audience) return "unavailable";
|
|
171
|
+
return this.#post<Approval>(
|
|
172
|
+
channelId,
|
|
173
|
+
signal,
|
|
174
|
+
"unavailable",
|
|
175
|
+
"cancelled",
|
|
176
|
+
{
|
|
177
|
+
audience,
|
|
178
|
+
...(wait
|
|
179
|
+
? {
|
|
180
|
+
wait: {
|
|
181
|
+
pending: "pending",
|
|
182
|
+
late: (value, by) =>
|
|
183
|
+
wait.late({
|
|
184
|
+
kind: "approval",
|
|
185
|
+
approved: value === "approved",
|
|
186
|
+
by,
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
: {}),
|
|
191
|
+
title,
|
|
192
|
+
sections: [message],
|
|
193
|
+
rows: (id, disabled) => [
|
|
194
|
+
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
195
|
+
new ButtonBuilder()
|
|
196
|
+
.setCustomId(`${CARD_PREFIX}${id}:yes`)
|
|
197
|
+
.setLabel(messages().cardRunLabel)
|
|
198
|
+
.setEmoji("✅")
|
|
199
|
+
.setStyle(ButtonStyle.Success)
|
|
200
|
+
.setDisabled(disabled),
|
|
201
|
+
new ButtonBuilder()
|
|
202
|
+
.setCustomId(`${CARD_PREFIX}${id}:no`)
|
|
203
|
+
.setLabel(messages().cardCancelLabel)
|
|
204
|
+
.setEmoji("❌")
|
|
205
|
+
.setStyle(ButtonStyle.Secondary)
|
|
206
|
+
.setDisabled(disabled),
|
|
207
|
+
),
|
|
208
|
+
],
|
|
209
|
+
footer: messages().cardApprovalFooter,
|
|
210
|
+
},
|
|
211
|
+
);
|
|
206
212
|
},
|
|
207
213
|
ask: async (title, question, signal, wait) => {
|
|
208
214
|
const audience = await this.#audiences.question(scope);
|
|
@@ -239,13 +245,13 @@ export class OwnerCards implements InteractionModule {
|
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
/**
|
|
242
|
-
* Posts a card and resolves with its answer, `
|
|
248
|
+
* Posts a card and resolves with its answer, `unposted` when it could not be posted, or
|
|
243
249
|
* `cancelled`; with `wait`, and a way to resume, `wait.pending` once the grace period passes.
|
|
244
250
|
*/
|
|
245
251
|
#post<T>(
|
|
246
252
|
channelId: string,
|
|
247
253
|
signal: AbortSignal | undefined,
|
|
248
|
-
|
|
254
|
+
unposted: T,
|
|
249
255
|
cancelled: T,
|
|
250
256
|
card: {
|
|
251
257
|
audience: Audience;
|
|
@@ -350,7 +356,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
350
356
|
})
|
|
351
357
|
.catch((error: unknown) => {
|
|
352
358
|
logger.warn({ channelId, err: error }, "card not posted");
|
|
353
|
-
settle(
|
|
359
|
+
settle(unposted, undefined);
|
|
354
360
|
});
|
|
355
361
|
});
|
|
356
362
|
}
|
|
@@ -4,7 +4,24 @@
|
|
|
4
4
|
* arguments, never their full text, and ends with whether it succeeded. The final reply still
|
|
5
5
|
* arrives through `sendReply`.
|
|
6
6
|
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why a hold refused a tool call, as a tool's end reports it, so a surface need not tell a call
|
|
10
|
+
* the owner turned down from a call that failed: `declined` on its approval card, `expired` when
|
|
11
|
+
* a card was shown and went unanswered (the call is held for the owner's next message),
|
|
12
|
+
* `pending` while the card stays open, and `held` when no card could be shown (no one may approve
|
|
13
|
+
* it, or it could not be posted; the call waits for the owner's next message). A turn that was stopped is none of these.
|
|
14
|
+
*/
|
|
15
|
+
export type HoldRefusal = "declined" | "expired" | "pending" | "held";
|
|
16
|
+
|
|
7
17
|
export type TurnProgress =
|
|
8
18
|
| { type: "text"; delta: string }
|
|
9
19
|
| { type: "tool_start"; id: string; tool: string; preview?: string }
|
|
10
|
-
| {
|
|
20
|
+
| {
|
|
21
|
+
type: "tool_end";
|
|
22
|
+
id: string;
|
|
23
|
+
tool: string;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
/** Set only on a failed end that a hold refused; absent for a success and for a real failure. */
|
|
26
|
+
refused?: HoldRefusal;
|
|
27
|
+
};
|
|
@@ -33,6 +33,8 @@ export function conversationEn(ctx: CatalogContext) {
|
|
|
33
33
|
bytes === undefined
|
|
34
34
|
? `File \`${path}\` (not found now)`
|
|
35
35
|
: `File \`${path}\` (${formatBytes(bytes)})`,
|
|
36
|
+
cardHiddenKeys: (keys: readonly string[]) =>
|
|
37
|
+
`… not shown: ${keys.join(", ")}`,
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -62,5 +64,6 @@ export function conversationZhTW(
|
|
|
62
64
|
bytes === undefined
|
|
63
65
|
? `檔案 \`${path}\`(目前找不到)`
|
|
64
66
|
: `檔案 \`${path}\`(${formatBytes(bytes)})`,
|
|
67
|
+
cardHiddenKeys: (keys: readonly string[]) => `… 未顯示:${keys.join("、")}`,
|
|
65
68
|
};
|
|
66
69
|
}
|
|
@@ -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,10 +1,9 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
4
2
|
import type {
|
|
5
3
|
HeldCall,
|
|
6
4
|
PendingConfirmation,
|
|
7
5
|
} from "../../domain/conversation.ts";
|
|
6
|
+
import type { HoldRefusal } from "../../domain/progress.ts";
|
|
8
7
|
import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
|
|
9
8
|
import { messages } from "../../i18n/index.ts";
|
|
10
9
|
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
@@ -12,9 +11,7 @@ import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
|
|
|
12
11
|
import type { Speaker } from "../../speakers.ts";
|
|
13
12
|
import type { ToolTiers } from "../../tool-tiers.ts";
|
|
14
13
|
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;
|
|
14
|
+
import { approvalCard, approvalDetails } from "./approval-card.ts";
|
|
18
15
|
|
|
19
16
|
/** Held actions older than this cannot be approved any more. */
|
|
20
17
|
export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
|
|
@@ -153,38 +150,50 @@ export class ConfirmationGate {
|
|
|
153
150
|
|
|
154
151
|
/**
|
|
155
152
|
* `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.
|
|
153
|
+
* the card's own message is shown to the person, never to the model. When a hold refuses the
|
|
154
|
+
* call, `refused` says how, for the turn's progress to report beside the failed result.
|
|
157
155
|
*/
|
|
158
156
|
async decide(
|
|
159
157
|
tool: string,
|
|
160
158
|
input: Record<string, unknown>,
|
|
161
159
|
ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
|
|
162
|
-
): Promise<{
|
|
160
|
+
): Promise<{
|
|
161
|
+
reason?: string;
|
|
162
|
+
approvedOnCard?: true;
|
|
163
|
+
/** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
|
|
164
|
+
refused?: HoldRefusal;
|
|
165
|
+
}> {
|
|
163
166
|
const call = this.#needing(tool, input);
|
|
164
167
|
if (!call) return {};
|
|
165
|
-
if (!ask) return { reason: this.#record(call) };
|
|
168
|
+
if (!ask) return { reason: this.#record(call), refused: "held" };
|
|
166
169
|
const answer = await ask.prompts.confirm(
|
|
167
170
|
messages().confirmTitle(ask.asker),
|
|
168
171
|
approvalCard(call, this.#fileRoot),
|
|
169
172
|
ask.signal,
|
|
170
173
|
higherTier(this.#tiers?.minTier(call.tool), call.minTier),
|
|
171
174
|
{ late: (late) => this.#late(call, late) },
|
|
175
|
+
approvalDetails(call, this.#fileRoot),
|
|
172
176
|
);
|
|
173
177
|
if (answer === "approved") return { approvedOnCard: true };
|
|
174
178
|
if (answer === "pending") {
|
|
175
179
|
const o = ownerWords(this.#addressee);
|
|
176
180
|
return {
|
|
177
181
|
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.`,
|
|
182
|
+
refused: "pending",
|
|
178
183
|
};
|
|
179
184
|
}
|
|
180
185
|
if (answer === "declined") {
|
|
181
186
|
const o = ownerWords(this.#addressee);
|
|
182
187
|
return {
|
|
183
188
|
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.`,
|
|
189
|
+
refused: "declined",
|
|
184
190
|
};
|
|
185
191
|
}
|
|
186
192
|
if (answer === "cancelled") return { reason: "The turn was stopped." };
|
|
187
|
-
|
|
193
|
+
// No card was shown: no one may approve it, or it could not be posted.
|
|
194
|
+
if (answer === "unavailable")
|
|
195
|
+
return { reason: this.#record(call), refused: "held" };
|
|
196
|
+
return { reason: this.#record(call), refused: "expired" };
|
|
188
197
|
}
|
|
189
198
|
|
|
190
199
|
/**
|
|
@@ -260,46 +269,6 @@ export function confirmedTurnText(
|
|
|
260
269
|
return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
|
|
261
270
|
}
|
|
262
271
|
|
|
263
|
-
/**
|
|
264
|
-
* What an approval card says: the held action's words, the exact call, and each file it sends by
|
|
265
|
-
* path with the file's size now; relative paths resolve against `workspace`.
|
|
266
|
-
*/
|
|
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
272
|
/** Without a slot, or with an empty one, every call that needs confirmation is held. */
|
|
304
273
|
export function confirmationGateExtension(
|
|
305
274
|
gate: ConfirmationGate,
|
|
@@ -324,12 +293,13 @@ export function confirmationGateExtension(
|
|
|
324
293
|
ask = { prompts, asker: slot.asker };
|
|
325
294
|
if (ctx.signal) ask.signal = ctx.signal;
|
|
326
295
|
}
|
|
327
|
-
const { reason, approvedOnCard } = await gate.decide(
|
|
296
|
+
const { reason, approvedOnCard, refused } = await gate.decide(
|
|
328
297
|
event.toolName,
|
|
329
298
|
event.input as Record<string, unknown>,
|
|
330
299
|
ask,
|
|
331
300
|
);
|
|
332
301
|
if (approvedOnCard) approved.add(event.toolCallId);
|
|
302
|
+
if (refused) slot?.refuse(event.toolCallId, refused);
|
|
333
303
|
return reason ? { block: true, reason } : undefined;
|
|
334
304
|
});
|
|
335
305
|
};
|
|
@@ -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)),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { TurnProgress } from "../domain/progress.ts";
|
|
2
|
+
import type { HoldRefusal, TurnProgress } from "../domain/progress.ts";
|
|
3
3
|
|
|
4
4
|
/** How long text is joined before it is sent, so a fast stream costs a few events a second. */
|
|
5
5
|
export const PROGRESS_INTERVAL_MS = 250;
|
|
@@ -19,6 +19,11 @@ export interface ProgressReporter {
|
|
|
19
19
|
|
|
20
20
|
export interface ProgressReporterOptions {
|
|
21
21
|
intervalMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Why a hold refused a tool's call, asked when a failed tool ends and read once; the gate that
|
|
24
|
+
* refused it is how a surface tells that from a failure.
|
|
25
|
+
*/
|
|
26
|
+
refusalOf?: (toolCallId: string) => HoldRefusal | undefined;
|
|
22
27
|
/** Runs `run` after `ms`; returns a cancel. Replaceable in tests. */
|
|
23
28
|
setTimer?: (run: () => void, ms: number) => () => void;
|
|
24
29
|
}
|
|
@@ -104,7 +109,14 @@ export function progressReporter(
|
|
|
104
109
|
toolEnd(id, tool, isError) {
|
|
105
110
|
if (closed) return;
|
|
106
111
|
flush();
|
|
107
|
-
|
|
112
|
+
const refused = isError ? options.refusalOf?.(id) : undefined;
|
|
113
|
+
send({
|
|
114
|
+
type: "tool_end",
|
|
115
|
+
id,
|
|
116
|
+
tool,
|
|
117
|
+
ok: !isError,
|
|
118
|
+
...(refused ? { refused } : {}),
|
|
119
|
+
});
|
|
108
120
|
},
|
|
109
121
|
close() {
|
|
110
122
|
if (closed) return;
|
|
@@ -332,7 +332,7 @@ const CHECKS: readonly Check[] = [
|
|
|
332
332
|
answeredOnlyByThem(running, running.subject.owner, "private"),
|
|
333
333
|
},
|
|
334
334
|
{
|
|
335
|
-
name: "a private conversation's approval above the person's tier goes to no one: it
|
|
335
|
+
name: "a private conversation's approval above the person's tier goes to no one: it comes back at once, unshown",
|
|
336
336
|
applies: (subject) =>
|
|
337
337
|
subject.surface.prompts !== undefined &&
|
|
338
338
|
speakerOf(subject).tier !== "owner",
|
|
@@ -349,7 +349,8 @@ const CHECKS: readonly Check[] = [
|
|
|
349
349
|
Bun.sleep(1000).then(() => "still open" as const),
|
|
350
350
|
]);
|
|
351
351
|
expectThat(
|
|
352
|
-
|
|
352
|
+
// `expired` is what surfaces answered before `unavailable` existed.
|
|
353
|
+
answer === "unavailable" || answer === "expired",
|
|
353
354
|
`an approval no one may answer came back ${answer}`,
|
|
354
355
|
);
|
|
355
356
|
expectThat(
|
|
@@ -444,7 +445,7 @@ export async function checkSurfaceContract(
|
|
|
444
445
|
* where it offers them, delivers files, ends typing and stop controls idempotently, shows
|
|
445
446
|
* progress, resolves approvals as the person answers or as a stopped turn cancels them, ignores
|
|
446
447
|
* another person's answer to the person's approval, and, in a private conversation, an owner's
|
|
447
|
-
* too, and lets an approval above the person's tier there
|
|
448
|
+
* too, and lets an approval above the person's tier there come back at once, shown to no one. Each
|
|
448
449
|
* check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
|
|
449
450
|
*/
|
|
450
451
|
export function describeSurfaceContract(
|
package/src/index.ts
CHANGED
|
@@ -118,7 +118,7 @@ export type {
|
|
|
118
118
|
InterimTextMode,
|
|
119
119
|
} from "./core/domain/interim.ts";
|
|
120
120
|
export type { TurnConversation, TurnRequest } from "./core/domain/ports.ts";
|
|
121
|
-
export type { TurnProgress } from "./core/domain/progress.ts";
|
|
121
|
+
export type { HoldRefusal, TurnProgress } from "./core/domain/progress.ts";
|
|
122
122
|
export type { DrainOptions } from "./core/drain.ts";
|
|
123
123
|
export {
|
|
124
124
|
HostStoppingError,
|
|
@@ -161,6 +161,8 @@ export type {
|
|
|
161
161
|
export { SYSTEM_PRINCIPAL } from "./core/identity/principal-store.ts";
|
|
162
162
|
export type {
|
|
163
163
|
Approval,
|
|
164
|
+
ApprovalDetails,
|
|
165
|
+
ApprovalFile,
|
|
164
166
|
AskOption,
|
|
165
167
|
LateAnswer,
|
|
166
168
|
OwnerAnswer,
|
package/src/kit/index.ts
CHANGED
|
@@ -158,6 +158,7 @@ export {
|
|
|
158
158
|
toolText,
|
|
159
159
|
} from "./tools.ts";
|
|
160
160
|
export type {
|
|
161
|
+
CardLimits,
|
|
161
162
|
InterimPosterOptions,
|
|
162
163
|
McpEndpoint,
|
|
163
164
|
PromptSlot,
|
|
@@ -165,6 +166,7 @@ export type {
|
|
|
165
166
|
} from "./worker.ts";
|
|
166
167
|
export {
|
|
167
168
|
approvalCard,
|
|
169
|
+
approvalDetails,
|
|
168
170
|
archiveSessions,
|
|
169
171
|
canonicalJson,
|
|
170
172
|
InterimPoster,
|
package/src/kit/worker.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Plugin helpers, versioned like the main entry; see the plugin guide.
|
|
2
2
|
// Running a Pi session of your own: MCP servers for it, its work timeout, its held-action cards.
|
|
3
3
|
|
|
4
|
+
export type { CardLimits } from "../core/runtime/extensions/approval-card.ts";
|
|
4
5
|
export {
|
|
5
6
|
approvalCard,
|
|
6
|
-
|
|
7
|
-
} from "../core/runtime/extensions/
|
|
7
|
+
approvalDetails,
|
|
8
|
+
} from "../core/runtime/extensions/approval-card.ts";
|
|
9
|
+
export { canonicalJson } from "../core/runtime/extensions/confirmation-gate.ts";
|
|
8
10
|
export type { InterimPosterOptions } from "../core/runtime/interim-text.ts";
|
|
9
11
|
export { InterimPoster } from "../core/runtime/interim-text.ts";
|
|
10
12
|
export type { VirtualServer } from "../core/runtime/mcp.ts";
|