pi-roundtable 0.22.0 → 0.22.1
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 +6 -0
- package/docs/plugins.md +9 -1
- package/package.json +1 -1
- package/src/core/agents/team-turns.ts +2 -7
- package/src/core/discord/owner-card-rows.ts +7 -3
- package/src/core/discord/owner-card-types.ts +4 -0
- package/src/core/discord/owner-cards.ts +10 -3
- package/src/core/domain/turn-timeout.ts +38 -0
- package/src/core/i18n/en.ts +2 -0
- package/src/core/i18n/turn-timeout.ts +131 -0
- package/src/core/i18n/zh-tw.ts +2 -0
- package/src/core/interactions/prompts.ts +17 -0
- package/src/core/presentation/failure-notice.ts +13 -0
- package/src/core/routing/conversation-turns.ts +2 -7
- package/src/core/runtime/pi-agent-runtime.ts +10 -10
- package/src/core/runtime/prompt-slot.ts +8 -0
- package/src/core/runtime/runtime-types.ts +11 -3
- package/src/core/runtime/turn-activity.ts +42 -0
- package/src/core/runtime/turn-budget.ts +163 -19
- package/src/core/runtime/turn-limits.ts +61 -0
- package/src/index.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.22.1] - 2026-10-21
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- A turn that reaches its time limit asks before it ends (`pi-roundtable`). When the built-in runtime's 10-minute budget or 30-minute cap fires on a turn that can show the owner a card, the turn keeps working and a card names the limit, the work time, what the turn is doing (the running tool and for how long, waiting on the model, or compacting) and the tools so far, with **Continue** (another full budget from the answer; at the cap the cap moves too) and **Cancel**. No answer within 5 minutes aborts the turn; a turn that finishes first closes the card and replies as usual. A turn that cannot show a card is aborted as before. An aborted turn now fails with `TurnTimeoutError` (an `AgentRunError`, with the reason as `TurnTimeout`: its `TurnLimit`, work time, running tools, tools so far and `TurnTimeoutEnd`), its log warning carries the same fields, and the posted failure says why instead of the generic notice. `ApprovalOptions` gains `wording` (`ApprovalWording`: the buttons' labels and the footers) for a card that is not an approval; the Discord cards honour it. `PiAgentRuntimeOptions` gains `turnTimeoutCardMs`, the card's grace period; the runtime plugin still passes none of the limits, so they stay fixed. See [the turn limits](docs/plugins.md#what-happens-when-the-bot-starts-and-stops).
|
|
13
|
+
|
|
8
14
|
## [0.22.0] - 2026-10-21
|
|
9
15
|
|
|
10
16
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -3526,7 +3526,10 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
|
|
|
3526
3526
|
`shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
|
|
3527
3527
|
Every call shares the same shutdown.
|
|
3528
3528
|
Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
|
|
3529
|
-
The built-in runtime
|
|
3529
|
+
The built-in runtime limits a turn to 10 minutes of work, not counting time spent waiting on the owner's cards. Each message steered into the running turn gives it the full 10 minutes again from that moment, up to a cap of 30 minutes of work for the whole turn; once the cap is reached the turn takes no more steering, so a message waits for the next turn.
|
|
3530
|
+
When a limit fires on a turn that can show a card (an owner's turn or a report turn on a surface with cards, the same turns that can ask with `ask_user` or an approval card), the turn is not aborted: it keeps working and a card asks the owner whether to go on. The card names the limit, how long the turn has worked, what it is doing now (the tool running and for how long, or waiting on the model, or compacting the conversation) and the tools it called so far, by name and count, never their inputs. **Continue** gives the turn another full 10 minutes from the answer; at the cap it moves the cap too, to 10 minutes from the answer, so the owner can keep going in steps. **Cancel** aborts the turn. With no answer within 5 minutes of wall time the turn is aborted. The time the card is open counts as work, unlike an `ask_user` or approval card, because the turn keeps working; while it is open the turn takes no steering. If the turn finishes first, the card closes saying so and the reply is posted as usual; a press after that gets the usual "no longer valid" reply. The card's buttons come from `ApprovalOptions.wording` on `Prompts.confirm`, with `supersede` closing it, so a surface that shows approvals shows it; one that ignores `wording` shows its own approve and decline labels.
|
|
3531
|
+
A turn that cannot show a card (a schedule's turn, a member's turn, a turn without a surface) is aborted at once, as before. Either way an aborted turn fails with a `TurnTimeoutError` (an `AgentRunError`) whose `timeout` (`TurnTimeout`) holds the limit, the work time, the running tools, whether it was compacting, the tools so far, and how it ended (`end`: `"timeout"`, `"cancelled"` by the owner, or `"unanswered"`); its message says the same in English, and the failure the conversation posts says it in the host's language instead of the generic failure notice.
|
|
3532
|
+
The limits are fixed: the runtime plugin passes no option for them, nor for the card's grace period (`turnTimeoutMs`, `turnMaxMs` and `turnTimeoutCardMs` of `PiAgentRuntimeOptions`, for a host that builds the runtime itself), and a limit that waiting on an owner's card pushed back can fire up to a second late. The log says `steer renewed the turn's time budget` with `deadlineMs`, the work time left; `turn reached its time limit; asking the owner` when the card is posted; `owner continued the turn past its time limit` on Continue; and the warning `turn timed out; aborting` names the limit that fired (`limit: "turn"` or `"turnMax"`) with `workedMs`, `running`, `compacting`, `tools` and `end`. Task workers (`task.timeoutMs`), coding workers (`workTimeout`) and sandbox guests (their own `turnTimeoutMs`) keep their own limits, which steering does not renew and which post no card.
|
|
3530
3533
|
A turn whose model ignores its abort (a subprocess that does not exit) is given up on too: a turn that outlives its timeout, or is stopped, gets `turnAbortGraceMs` (15 seconds by default) to end, and then its session is disposed of and the turn is reported failed, so it cannot hold the queue.
|
|
3531
3534
|
`listen()`, called by the command line and a host's own entry point, exits the process with that code.
|
|
3532
3535
|
|
|
@@ -3737,6 +3740,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3737
3740
|
| `ApprovalDetails` | `pi-roundtable` | type |
|
|
3738
3741
|
| `ApprovalFile` | `pi-roundtable` | type |
|
|
3739
3742
|
| `ApprovalOptions` | `pi-roundtable` | type |
|
|
3743
|
+
| `ApprovalWording` | `pi-roundtable` | type |
|
|
3740
3744
|
| `ApprovedBy` | `pi-roundtable` | type |
|
|
3741
3745
|
| `AskOption` | `pi-roundtable` | type |
|
|
3742
3746
|
| `AskSupport` | `pi-roundtable` | type |
|
|
@@ -3963,6 +3967,10 @@ Import from the entries listed below; source area files are internal.
|
|
|
3963
3967
|
| `TurnRequest` | `pi-roundtable` | type |
|
|
3964
3968
|
| `TurnResult` | `pi-roundtable` | type |
|
|
3965
3969
|
| `TurnSelection` | `pi-roundtable` | type |
|
|
3970
|
+
| `TurnLimit` | `pi-roundtable` | type |
|
|
3971
|
+
| `TurnTimeout` | `pi-roundtable` | type |
|
|
3972
|
+
| `TurnTimeoutEnd` | `pi-roundtable` | type |
|
|
3973
|
+
| `TurnTimeoutError` | `pi-roundtable` | value |
|
|
3966
3974
|
| `WebSocketAccept` | `pi-roundtable` | type |
|
|
3967
3975
|
| `WebSocketRoute` | `pi-roundtable` | type |
|
|
3968
3976
|
| `WebSocketSendResult` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
import { AgentError } from "../domain/errors.ts";
|
|
9
9
|
import type { InterimPosts } from "../domain/interim.ts";
|
|
10
10
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { failureNotice } from "../presentation/failure-notice.ts";
|
|
12
12
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
13
13
|
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
14
14
|
import { withReplyFiles } from "../reply-files.ts";
|
|
@@ -35,11 +35,6 @@ import {
|
|
|
35
35
|
|
|
36
36
|
export { MAX_CHAIN_MESSAGES, type TeamTurnsOptions };
|
|
37
37
|
|
|
38
|
-
/** What a failed turn posts: the stopped notice for a stop, the failure notice otherwise. */
|
|
39
|
-
function noticeOf(result: { stopped?: true }): string {
|
|
40
|
-
return result.stopped ? messages().stoppedNotice : messages().failureNotice;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
38
|
/**
|
|
44
39
|
* The agent team's turns: owner messages, background turns, group rounds, and messages between
|
|
45
40
|
* agents, each posted under the answering agent's name.
|
|
@@ -246,7 +241,7 @@ export class TeamTurns {
|
|
|
246
241
|
: undefined;
|
|
247
242
|
await this.#post(postTo, this.#current(agent), {
|
|
248
243
|
...(thinking ? { thinking } : {}),
|
|
249
|
-
chunks: result.ok ? splitReply(result.text) : [
|
|
244
|
+
chunks: result.ok ? splitReply(result.text) : [failureNotice(result)],
|
|
250
245
|
...(result.ok && result.files?.length ? { files: result.files } : {}),
|
|
251
246
|
});
|
|
252
247
|
} catch (error) {
|
|
@@ -123,23 +123,27 @@ export function answeredLine(answer: OwnerAnswer): string {
|
|
|
123
123
|
return messages().cardAnswered(parts);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* An approval's controls: run, cancel, or the labels its wording gives, and, when the card offers
|
|
128
|
+
* a copy, a copy button that stays usable once it ended.
|
|
129
|
+
*/
|
|
127
130
|
export function approvalRows(
|
|
128
131
|
id: string,
|
|
129
132
|
disabled: boolean,
|
|
130
133
|
copy: { label: string } | undefined,
|
|
134
|
+
labels?: { approve: string; decline: string },
|
|
131
135
|
): Rows {
|
|
132
136
|
return [
|
|
133
137
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
134
138
|
new ButtonBuilder()
|
|
135
139
|
.setCustomId(`${CARD_PREFIX}${id}:yes`)
|
|
136
|
-
.setLabel(messages().cardRunLabel)
|
|
140
|
+
.setLabel(labels?.approve ?? messages().cardRunLabel)
|
|
137
141
|
.setEmoji("✅")
|
|
138
142
|
.setStyle(ButtonStyle.Success)
|
|
139
143
|
.setDisabled(disabled),
|
|
140
144
|
new ButtonBuilder()
|
|
141
145
|
.setCustomId(`${CARD_PREFIX}${id}:no`)
|
|
142
|
-
.setLabel(messages().cardCancelLabel)
|
|
146
|
+
.setLabel(labels?.decline ?? messages().cardCancelLabel)
|
|
143
147
|
.setEmoji("❌")
|
|
144
148
|
.setStyle(ButtonStyle.Secondary)
|
|
145
149
|
.setDisabled(disabled),
|
|
@@ -83,6 +83,8 @@ export interface OpenCard {
|
|
|
83
83
|
mention?: boolean;
|
|
84
84
|
/** Who may answer, and what to tell someone else who presses it. */
|
|
85
85
|
audience: Audience;
|
|
86
|
+
/** An approval card's footers once approved or declined, where its wording gives them. */
|
|
87
|
+
outcomes?: { approved: string; declined: string };
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
/** What `#post` needs to post one card; `T` is what the card resolves with. */
|
|
@@ -101,6 +103,8 @@ export interface CardSpec<T> {
|
|
|
101
103
|
build?(id: string, footer: string, disabled: boolean): CardPayload;
|
|
102
104
|
rows?(id: string, disabled: boolean): Rows;
|
|
103
105
|
footer: string;
|
|
106
|
+
/** An approval card's footers once approved or declined, where its wording gives them. */
|
|
107
|
+
outcomes?: { approved: string; declined: string };
|
|
104
108
|
wait?: {
|
|
105
109
|
pending: T;
|
|
106
110
|
late(
|
|
@@ -175,8 +175,10 @@ export class OwnerCards implements InteractionModule {
|
|
|
175
175
|
sections: [message],
|
|
176
176
|
...(options?.supersede ? { supersede: options.supersede } : {}),
|
|
177
177
|
...(copy ? { copy: copy.text } : {}),
|
|
178
|
-
rows: (id, disabled) =>
|
|
179
|
-
|
|
178
|
+
rows: (id, disabled) =>
|
|
179
|
+
approvalRows(id, disabled, copy, options?.wording),
|
|
180
|
+
footer: options?.wording?.footer ?? messages().cardApprovalFooter,
|
|
181
|
+
...(options?.wording ? { outcomes: options.wording } : {}),
|
|
180
182
|
},
|
|
181
183
|
);
|
|
182
184
|
},
|
|
@@ -296,6 +298,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
296
298
|
...(card.question ? { question: card.question } : {}),
|
|
297
299
|
...(card.form ? { form: card.form } : {}),
|
|
298
300
|
...(card.previews ? { previews: card.previews } : {}),
|
|
301
|
+
...(card.outcomes ? { outcomes: card.outcomes } : {}),
|
|
299
302
|
};
|
|
300
303
|
this.#open.set(id, open);
|
|
301
304
|
this.#options
|
|
@@ -417,7 +420,11 @@ export class OwnerCards implements InteractionModule {
|
|
|
417
420
|
return true;
|
|
418
421
|
}
|
|
419
422
|
await interaction.update(
|
|
420
|
-
card.view(
|
|
423
|
+
card.view(
|
|
424
|
+
approved
|
|
425
|
+
? (card.outcomes?.approved ?? messages().cardApproved)
|
|
426
|
+
: (card.outcomes?.declined ?? messages().cardDeclined),
|
|
427
|
+
),
|
|
421
428
|
);
|
|
422
429
|
return true;
|
|
423
430
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AgentRunError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
/** Which limit a turn reached: its budget since the start or the last steer, or its cap. */
|
|
4
|
+
export type TurnLimit = "turn" | "turnMax";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How a timed-out turn ended: at its limit with no card to ask on (`timeout`), cancelled on the
|
|
8
|
+
* timeout card (`cancelled`), or with the card unanswered in its grace period (`unanswered`).
|
|
9
|
+
*/
|
|
10
|
+
export type TurnTimeoutEnd = "timeout" | "cancelled" | "unanswered";
|
|
11
|
+
|
|
12
|
+
/** Why a turn hit a time limit, as data: which limit, how long it worked, and what it was doing. */
|
|
13
|
+
export interface TurnTimeout {
|
|
14
|
+
limit: TurnLimit;
|
|
15
|
+
/** The limit's length: the budget, or the cap as the owner's Continues raised it. */
|
|
16
|
+
limitMs: number;
|
|
17
|
+
/** The turn's work so far, not counting time spent waiting on the owner's other cards. */
|
|
18
|
+
workedMs: number;
|
|
19
|
+
/** The tools running, the longest-running first, each with how long it has run. */
|
|
20
|
+
running: { tool: string; ms: number }[];
|
|
21
|
+
compacting: boolean;
|
|
22
|
+
/** Every tool called so far, by name, with how often. */
|
|
23
|
+
tools: [string, number][];
|
|
24
|
+
/** How long the timeout card waits for an answer. */
|
|
25
|
+
graceMs: number;
|
|
26
|
+
end: TurnTimeoutEnd;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A turn aborted at a time limit; `timeout` says why, and the message says it in English. */
|
|
30
|
+
export class TurnTimeoutError extends AgentRunError {
|
|
31
|
+
override name = "TurnTimeoutError";
|
|
32
|
+
constructor(
|
|
33
|
+
readonly timeout: TurnTimeout,
|
|
34
|
+
message: string,
|
|
35
|
+
) {
|
|
36
|
+
super(message);
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/core/i18n/en.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { discordEn } from "./discord.ts";
|
|
|
7
7
|
import { ownerEn } from "./owner.ts";
|
|
8
8
|
import { schedulesEn } from "./schedules.ts";
|
|
9
9
|
import { timeZonesEn } from "./time-zones.ts";
|
|
10
|
+
import { turnTimeoutEn } from "./turn-timeout.ts";
|
|
10
11
|
import type { CatalogContext } from "./types.ts";
|
|
11
12
|
|
|
12
13
|
export type Messages = ReturnType<typeof createEn>;
|
|
@@ -23,5 +24,6 @@ export function createEn(ctx: CatalogContext) {
|
|
|
23
24
|
...dashboardEn(ctx),
|
|
24
25
|
...channelsEn(ctx),
|
|
25
26
|
...discordEn(ctx),
|
|
27
|
+
...turnTimeoutEn(),
|
|
26
28
|
};
|
|
27
29
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { TurnTimeout } from "../domain/turn-timeout.ts";
|
|
2
|
+
|
|
3
|
+
/** A duration in whole seconds, as "3 min 12 s", "45 s", or "1 h 5 min". */
|
|
4
|
+
function durationEn(ms: number): string {
|
|
5
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
6
|
+
const h = Math.floor(s / 3600);
|
|
7
|
+
const m = Math.floor((s % 3600) / 60);
|
|
8
|
+
const sec = s % 60;
|
|
9
|
+
if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`;
|
|
10
|
+
if (m > 0) return sec > 0 ? `${m} min ${sec} s` : `${m} min`;
|
|
11
|
+
return `${sec} s`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function durationZh(ms: number): string {
|
|
15
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
16
|
+
const h = Math.floor(s / 3600);
|
|
17
|
+
const m = Math.floor((s % 3600) / 60);
|
|
18
|
+
const sec = s % 60;
|
|
19
|
+
if (h > 0) return m > 0 ? `${h} 小時 ${m} 分` : `${h} 小時`;
|
|
20
|
+
if (m > 0) return sec > 0 ? `${m} 分 ${sec} 秒` : `${m} 分`;
|
|
21
|
+
return `${sec} 秒`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The tools called so far, as the interim tool line shows them. */
|
|
25
|
+
function toolList(tools: TurnTimeout["tools"], separator: string): string {
|
|
26
|
+
return tools
|
|
27
|
+
.map(([name, count]) => (count > 1 ? `${name} ×${count}` : name))
|
|
28
|
+
.join(separator);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The timeout card and the notice a turn posts when it ends at a time limit. */
|
|
32
|
+
export function turnTimeoutEn() {
|
|
33
|
+
const doing = (t: TurnTimeout) =>
|
|
34
|
+
t.running.length > 0
|
|
35
|
+
? t.running
|
|
36
|
+
.map(({ tool, ms }) => `${tool}, running for ${durationEn(ms)}`)
|
|
37
|
+
.join("; ")
|
|
38
|
+
: t.compacting
|
|
39
|
+
? "compacting the conversation"
|
|
40
|
+
: "waiting on the model";
|
|
41
|
+
const limit = (t: TurnTimeout) =>
|
|
42
|
+
t.limit === "turn"
|
|
43
|
+
? `its time limit of ${durationEn(t.limitMs)} (since it started or since the last message steered into it)`
|
|
44
|
+
: `its cap of ${durationEn(t.limitMs)} of work in all`;
|
|
45
|
+
const tools = (t: TurnTimeout) =>
|
|
46
|
+
t.tools.length > 0 ? `Tools so far: ${toolList(t.tools, " · ")}.` : "";
|
|
47
|
+
return {
|
|
48
|
+
turnTimeoutTitle: "⏱️ This turn is taking long",
|
|
49
|
+
turnTimeoutQuestion: (t: TurnTimeout, budgetMs: number) =>
|
|
50
|
+
[
|
|
51
|
+
`It reached ${limit(t)} and has worked ${durationEn(t.workedMs)}.`,
|
|
52
|
+
`Now: ${doing(t)}.`,
|
|
53
|
+
tools(t),
|
|
54
|
+
`It keeps working while this card is open. Continue gives it another ${durationEn(budgetMs)}; Cancel stops it. With no answer in ${durationEn(t.graceMs)} it stops.`,
|
|
55
|
+
]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join("\n"),
|
|
58
|
+
turnTimeoutContinue: "Continue",
|
|
59
|
+
turnTimeoutCancel: "Cancel",
|
|
60
|
+
turnTimeoutFooter:
|
|
61
|
+
"The turn is still working; answer whenever you are ready.",
|
|
62
|
+
turnTimeoutContinued: "▶️ Continued.",
|
|
63
|
+
turnTimeoutCancelled: "❌ Cancelled; the turn stops.",
|
|
64
|
+
turnTimeoutFinished: "✅ The turn finished; nothing to answer.",
|
|
65
|
+
turnTimeoutUnanswered: (t: TurnTimeout) =>
|
|
66
|
+
`⏱️ No answer in ${durationEn(t.graceMs)}; the turn was stopped.`,
|
|
67
|
+
turnTimedOutNotice: (t: TurnTimeout) => {
|
|
68
|
+
const what =
|
|
69
|
+
t.running.length > 0
|
|
70
|
+
? `while ${t.running.map(({ tool, ms }) => `${tool} was running (${durationEn(ms)})`).join(" and ")}`
|
|
71
|
+
: `while ${doing(t)}`;
|
|
72
|
+
const head =
|
|
73
|
+
t.end === "cancelled"
|
|
74
|
+
? `⏱️ Cancelled by the owner after ${durationEn(t.workedMs)} ${what}.`
|
|
75
|
+
: t.end === "unanswered"
|
|
76
|
+
? `⏱️ No answer to the timeout card in ${durationEn(t.graceMs)}, so the turn was stopped after ${durationEn(t.workedMs)} ${what}.`
|
|
77
|
+
: `⏱️ The turn reached ${limit(t)} and was stopped after ${durationEn(t.workedMs)} ${what}.`;
|
|
78
|
+
return [head, tools(t)].filter(Boolean).join(" ");
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function turnTimeoutZhTW(): ReturnType<typeof turnTimeoutEn> {
|
|
84
|
+
const doing = (t: TurnTimeout) =>
|
|
85
|
+
t.running.length > 0
|
|
86
|
+
? t.running
|
|
87
|
+
.map(({ tool, ms }) => `${tool},已執行 ${durationZh(ms)}`)
|
|
88
|
+
.join(";")
|
|
89
|
+
: t.compacting
|
|
90
|
+
? "正在壓縮對話"
|
|
91
|
+
: "正在等模型回應";
|
|
92
|
+
const limit = (t: TurnTimeout) =>
|
|
93
|
+
t.limit === "turn"
|
|
94
|
+
? `時間上限 ${durationZh(t.limitMs)}(從開始或最後一則插入的訊息算起)`
|
|
95
|
+
: `總工作時間上限 ${durationZh(t.limitMs)}`;
|
|
96
|
+
const tools = (t: TurnTimeout) =>
|
|
97
|
+
t.tools.length > 0 ? `目前用過的工具:${toolList(t.tools, " · ")}。` : "";
|
|
98
|
+
return {
|
|
99
|
+
turnTimeoutTitle: "⏱️ 這一輪做得比較久",
|
|
100
|
+
turnTimeoutQuestion: (t: TurnTimeout, budgetMs: number) =>
|
|
101
|
+
[
|
|
102
|
+
`已達到${limit(t)},已工作 ${durationZh(t.workedMs)}。`,
|
|
103
|
+
`現在:${doing(t)}。`,
|
|
104
|
+
tools(t),
|
|
105
|
+
`這張卡片開著時它會繼續工作。按「繼續」再給它 ${durationZh(budgetMs)};按「取消」停止。${durationZh(t.graceMs)}內沒有回答就會停止。`,
|
|
106
|
+
]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join("\n"),
|
|
109
|
+
turnTimeoutContinue: "繼續",
|
|
110
|
+
turnTimeoutCancel: "取消",
|
|
111
|
+
turnTimeoutFooter: "這一輪仍在工作;準備好再回答即可。",
|
|
112
|
+
turnTimeoutContinued: "▶️ 已繼續。",
|
|
113
|
+
turnTimeoutCancelled: "❌ 已取消,這一輪會停止。",
|
|
114
|
+
turnTimeoutFinished: "✅ 這一輪已完成,不需要回答。",
|
|
115
|
+
turnTimeoutUnanswered: (t: TurnTimeout) =>
|
|
116
|
+
`⏱️ ${durationZh(t.graceMs)}內沒有回答,這一輪已停止。`,
|
|
117
|
+
turnTimedOutNotice: (t: TurnTimeout) => {
|
|
118
|
+
const what =
|
|
119
|
+
t.running.length > 0
|
|
120
|
+
? `當時 ${t.running.map(({ tool, ms }) => `${tool} 正在執行(${durationZh(ms)})`).join("、")}`
|
|
121
|
+
: `當時${doing(t)}`;
|
|
122
|
+
const head =
|
|
123
|
+
t.end === "cancelled"
|
|
124
|
+
? `⏱️ 擁有者在工作 ${durationZh(t.workedMs)} 後取消了這一輪,${what}。`
|
|
125
|
+
: t.end === "unanswered"
|
|
126
|
+
? `⏱️ 逾時卡片在 ${durationZh(t.graceMs)}內沒有回答,這一輪在工作 ${durationZh(t.workedMs)} 後停止,${what}。`
|
|
127
|
+
: `⏱️ 這一輪達到${limit(t)},在工作 ${durationZh(t.workedMs)} 後停止,${what}。`;
|
|
128
|
+
return [head, tools(t)].filter(Boolean).join(" ");
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/core/i18n/zh-tw.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Messages } from "./en.ts";
|
|
|
8
8
|
import { ownerZhTW } from "./owner.ts";
|
|
9
9
|
import { schedulesZhTW } from "./schedules.ts";
|
|
10
10
|
import { timeZonesZhTW } from "./time-zones.ts";
|
|
11
|
+
import { turnTimeoutZhTW } from "./turn-timeout.ts";
|
|
11
12
|
import type { CatalogContext } from "./types.ts";
|
|
12
13
|
|
|
13
14
|
/** The Traditional Chinese catalog. */
|
|
@@ -22,5 +23,6 @@ export function createZhTW(ctx: CatalogContext): Messages {
|
|
|
22
23
|
...dashboardZhTW(ctx),
|
|
23
24
|
...channelsZhTW(ctx),
|
|
24
25
|
...discordZhTW(ctx),
|
|
26
|
+
...turnTimeoutZhTW(),
|
|
25
27
|
};
|
|
26
28
|
}
|
|
@@ -128,6 +128,23 @@ export interface ApprovalOptions {
|
|
|
128
128
|
* withdrawn call whatever the card answers.
|
|
129
129
|
*/
|
|
130
130
|
supersede?: AbortSignal;
|
|
131
|
+
/**
|
|
132
|
+
* The card's own words, for a card that asks something other than whether to run an action,
|
|
133
|
+
* such as a turn's timeout card; a surface that cannot show them keeps its own.
|
|
134
|
+
*/
|
|
135
|
+
wording?: ApprovalWording;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A card's words in place of an approval's; see `ApprovalOptions.wording`. */
|
|
139
|
+
export interface ApprovalWording {
|
|
140
|
+
/** The labels of the controls that answer approved and declined. */
|
|
141
|
+
approve: string;
|
|
142
|
+
decline: string;
|
|
143
|
+
/** The footer while the card is open. */
|
|
144
|
+
footer: string;
|
|
145
|
+
/** The footer once it was answered approved, or declined. */
|
|
146
|
+
approved: string;
|
|
147
|
+
declined: string;
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
/** The longest footer a withdrawn card shows, in characters. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { TurnTimeoutError } from "../domain/turn-timeout.ts";
|
|
2
|
+
import { messages } from "../i18n/index.ts";
|
|
3
|
+
|
|
4
|
+
/** What a failed turn posts: the stopped notice for a stop, the reason for a time limit, else the failure notice. */
|
|
5
|
+
export function failureNotice(result: {
|
|
6
|
+
error: Error;
|
|
7
|
+
stopped?: true;
|
|
8
|
+
}): string {
|
|
9
|
+
if (result.stopped) return messages().stoppedNotice;
|
|
10
|
+
if (result.error instanceof TurnTimeoutError)
|
|
11
|
+
return messages().turnTimedOutNotice(result.error.timeout);
|
|
12
|
+
return messages().failureNotice;
|
|
13
|
+
}
|
|
@@ -10,10 +10,10 @@ import type { TurnResult } from "../domain/conversation.ts";
|
|
|
10
10
|
import type { TurnConversation } from "../domain/ports.ts";
|
|
11
11
|
import type { TurnProgress } from "../domain/progress.ts";
|
|
12
12
|
import { HostStoppingError, PluginError } from "../errors.ts";
|
|
13
|
-
import { messages } from "../i18n/index.ts";
|
|
14
13
|
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
15
14
|
import type { Logger } from "../log.ts";
|
|
16
15
|
import type { EventSink } from "../plugin.ts";
|
|
16
|
+
import { failureNotice } from "../presentation/failure-notice.ts";
|
|
17
17
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
18
18
|
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
19
19
|
import { withReplyFiles } from "../reply-files.ts";
|
|
@@ -239,12 +239,7 @@ export function conversationTurns(
|
|
|
239
239
|
|
|
240
240
|
/** The default post: the answer with its thinking line, or the failure or stopped notice. */
|
|
241
241
|
function replyOf(result: TurnResult) {
|
|
242
|
-
if (!result.ok)
|
|
243
|
-
return {
|
|
244
|
-
chunks: [
|
|
245
|
-
result.stopped ? messages().stoppedNotice : messages().failureNotice,
|
|
246
|
-
],
|
|
247
|
-
};
|
|
242
|
+
if (!result.ok) return { chunks: [failureNotice(result)] };
|
|
248
243
|
const thinking = result.thinking ? thinkingLine(result.thinking) : undefined;
|
|
249
244
|
return {
|
|
250
245
|
...(thinking ? { thinking } : {}),
|
|
@@ -48,7 +48,8 @@ import { promptImages, SteerableRun } from "./steerable-run.ts";
|
|
|
48
48
|
import { thinkingLevel } from "./thinking-level.ts";
|
|
49
49
|
import { runningCalls } from "./tool-call-scope.ts";
|
|
50
50
|
import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
|
|
51
|
-
import {
|
|
51
|
+
import type { TurnBudget } from "./turn-budget.ts";
|
|
52
|
+
import { promptFailed, turnLimits } from "./turn-limits.ts";
|
|
52
53
|
import { progressReporter } from "./turn-progress.ts";
|
|
53
54
|
import { wrapUpTurn } from "./turn-wrapup.ts";
|
|
54
55
|
import { workerReport } from "./worker-task.ts";
|
|
@@ -240,6 +241,10 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
240
241
|
const interim = interimPoster(request, this.#options);
|
|
241
242
|
// The caller's live view: the text as it streams and each tool, never the thinking.
|
|
242
243
|
const slot = this.#sessions.slot(key);
|
|
244
|
+
// Its time limit, not counting time waiting on the owner's cards, follows what it does to say why it fired.
|
|
245
|
+
const limits = turnLimits(this.#options, slot, request.channel, () =>
|
|
246
|
+
guard.abort(),
|
|
247
|
+
);
|
|
243
248
|
const progress = request.progress
|
|
244
249
|
? progressReporter(request.progress, {
|
|
245
250
|
refusalOf: (id) => slot.refusalOf(id),
|
|
@@ -247,6 +252,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
247
252
|
: undefined;
|
|
248
253
|
const unsubscribe = session.subscribe((event) => {
|
|
249
254
|
progress?.observe(event);
|
|
255
|
+
limits.observe(event);
|
|
250
256
|
if (event.type === "tool_execution_start") {
|
|
251
257
|
toolCalls.push(event.toolName);
|
|
252
258
|
interim?.toolStart(event.toolName);
|
|
@@ -275,10 +281,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
275
281
|
this.#sessions.drop(key, channelSession);
|
|
276
282
|
},
|
|
277
283
|
});
|
|
278
|
-
|
|
279
|
-
const budget = runtimeTurnBudget(this.#options, slot, request.channel, () =>
|
|
280
|
-
guard.abort(),
|
|
281
|
-
);
|
|
284
|
+
const { budget } = limits;
|
|
282
285
|
this.#budgets.set(key, budget);
|
|
283
286
|
const running = new SteerableRun(
|
|
284
287
|
session,
|
|
@@ -307,10 +310,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
307
310
|
} catch (error) {
|
|
308
311
|
if (running.stopped)
|
|
309
312
|
return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
|
|
310
|
-
return
|
|
311
|
-
ok: false,
|
|
312
|
-
error: new AgentRunError(`prompt failed: ${String(error)}`),
|
|
313
|
-
};
|
|
313
|
+
return limits.failure() ?? promptFailed(error);
|
|
314
314
|
} finally {
|
|
315
315
|
this.#running.delete(key);
|
|
316
316
|
this.#budgets.delete(key);
|
|
@@ -335,7 +335,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
335
335
|
|
|
336
336
|
if (running.stopped)
|
|
337
337
|
return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
|
|
338
|
-
return turnAnswer(turnMessages, running.steered);
|
|
338
|
+
return limits.failure() ?? turnAnswer(turnMessages, running.steered);
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
async steer(
|
|
@@ -99,6 +99,14 @@ export class PromptSlot {
|
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The turn's prompts as bound, an open card not counted as waiting; for a card the turn keeps
|
|
104
|
+
* working beside, such as the one its time limit posts.
|
|
105
|
+
*/
|
|
106
|
+
get cards(): Prompts | undefined {
|
|
107
|
+
return this.#prompts;
|
|
108
|
+
}
|
|
109
|
+
|
|
102
110
|
/** Time this turn has spent with at least one card open. */
|
|
103
111
|
waitedMs(now = Date.now()): number {
|
|
104
112
|
return this.#waited + (this.#open > 0 ? now - this.#openSince : 0);
|
|
@@ -89,17 +89,25 @@ export interface PiAgentRuntimeOptions {
|
|
|
89
89
|
/** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
|
|
90
90
|
toolTiers?: ToolTiers;
|
|
91
91
|
/**
|
|
92
|
-
* A run that works longer, not counting time spent waiting on the owner's cards,
|
|
93
|
-
*
|
|
92
|
+
* A run that works longer, not counting time spent waiting on the owner's cards, asks the owner
|
|
93
|
+
* on a card whether to go on (see `turnTimeoutCardMs`), or, when it cannot show one, is aborted
|
|
94
|
+
* and reported as failed with the reason; default 10 minutes. Each message steered into the turn gives it this
|
|
94
95
|
* budget again from that moment, up to `turnMaxMs`.
|
|
95
96
|
*/
|
|
96
97
|
turnTimeoutMs?: number;
|
|
97
98
|
/**
|
|
98
99
|
* The most one turn may work in all, however often it is steered, not counting time spent
|
|
99
100
|
* waiting on the owner's cards; default three times `turnTimeoutMs`. Once it is reached the turn
|
|
100
|
-
* takes no more steering (the message waits for the next turn) and is aborted as on its
|
|
101
|
+
* takes no more steering (the message waits for the next turn) and asks or is aborted as on its
|
|
102
|
+
* timeout; the owner's Continue moves the cap to a full `turnTimeoutMs` from then.
|
|
101
103
|
*/
|
|
102
104
|
turnMaxMs?: number;
|
|
105
|
+
/**
|
|
106
|
+
* How long the card a turn posts when it reaches `turnTimeoutMs` or `turnMaxMs` waits for the
|
|
107
|
+
* owner's Continue or Cancel, in wall time, while the turn keeps working; default 5 minutes. With
|
|
108
|
+
* no answer by then the turn is aborted. A turn that cannot show a card is aborted at once.
|
|
109
|
+
*/
|
|
110
|
+
turnTimeoutCardMs?: number;
|
|
103
111
|
/**
|
|
104
112
|
* How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
|
|
105
113
|
* disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** What a turn was doing at one moment: tools running and for how long, or compacting, or neither (the model). */
|
|
4
|
+
export interface TurnActivity {
|
|
5
|
+
/** The tools running, the longest-running first, each with how long it has run. */
|
|
6
|
+
running: { tool: string; ms: number }[];
|
|
7
|
+
compacting: boolean;
|
|
8
|
+
/** Every tool called so far, by name, with how often, in the order first called. */
|
|
9
|
+
tools: [string, number][];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Follows a turn's events to say, when asked, what it is doing; never its inputs. */
|
|
13
|
+
export interface ActivityTracker {
|
|
14
|
+
observe(event: AgentSessionEvent): void;
|
|
15
|
+
now(at: number): TurnActivity;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function activityTracker(
|
|
19
|
+
clock: () => number = Date.now,
|
|
20
|
+
): ActivityTracker {
|
|
21
|
+
const running = new Map<string, { tool: string; since: number }>();
|
|
22
|
+
const tools = new Map<string, number>();
|
|
23
|
+
let compacting = false;
|
|
24
|
+
return {
|
|
25
|
+
observe(event) {
|
|
26
|
+
if (event.type === "tool_execution_start") {
|
|
27
|
+
running.set(event.toolCallId, { tool: event.toolName, since: clock() });
|
|
28
|
+
tools.set(event.toolName, (tools.get(event.toolName) ?? 0) + 1);
|
|
29
|
+
} else if (event.type === "tool_execution_end") {
|
|
30
|
+
running.delete(event.toolCallId);
|
|
31
|
+
} else if (event.type === "compaction_start") compacting = true;
|
|
32
|
+
else if (event.type === "compaction_end") compacting = false;
|
|
33
|
+
},
|
|
34
|
+
now: (at) => ({
|
|
35
|
+
running: [...running.values()]
|
|
36
|
+
.sort((a, b) => a.since - b.since)
|
|
37
|
+
.map(({ tool, since }) => ({ tool, ms: at - since })),
|
|
38
|
+
compacting,
|
|
39
|
+
tools: [...tools],
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TurnLimit,
|
|
3
|
+
TurnTimeout,
|
|
4
|
+
TurnTimeoutEnd,
|
|
5
|
+
} from "../domain/turn-timeout.ts";
|
|
6
|
+
import { messages } from "../i18n/index.ts";
|
|
7
|
+
import type { Approval } from "../interactions/prompts.ts";
|
|
1
8
|
import type { ChannelKey } from "../sessions.ts";
|
|
2
9
|
import type { PromptSlot } from "./prompt-slot.ts";
|
|
3
10
|
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
11
|
+
import type { ActivityTracker } from "./turn-activity.ts";
|
|
4
12
|
|
|
5
|
-
|
|
6
|
-
export type TurnLimit = "turn" | "turnMax";
|
|
13
|
+
export type { TurnLimit };
|
|
7
14
|
|
|
8
15
|
/** A turn's time limit, which a steered message renews up to a cap on the whole turn. */
|
|
9
16
|
export interface TurnBudget {
|
|
@@ -12,8 +19,15 @@ export interface TurnBudget {
|
|
|
12
19
|
* or undefined when the cap is reached and the turn takes no more steering.
|
|
13
20
|
*/
|
|
14
21
|
renew(): number | undefined;
|
|
15
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* After a limit fired, gives the turn its full budget again from now, as the owner's Continue
|
|
24
|
+
* does; with `raiseCap` the cap moves too, to a full budget from now.
|
|
25
|
+
*/
|
|
26
|
+
extend(raiseCap: boolean): void;
|
|
27
|
+
/** Whether the turn has worked up to its cap, or a limit fired and was not extended. */
|
|
16
28
|
capped(): boolean;
|
|
29
|
+
/** The turn's work so far, not counting time the slot spent waiting on cards. */
|
|
30
|
+
workedMs(): number;
|
|
17
31
|
cancel(): void;
|
|
18
32
|
}
|
|
19
33
|
|
|
@@ -43,13 +57,13 @@ const REARM_FLOOR_MS = 1_000;
|
|
|
43
57
|
/**
|
|
44
58
|
* Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
|
|
45
59
|
* `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
|
|
46
|
-
* towards neither. `expire` is told which limit fired.
|
|
60
|
+
* towards neither. `expire` is told which limit fired and its length.
|
|
47
61
|
*/
|
|
48
62
|
export function turnBudget(
|
|
49
63
|
ms: number,
|
|
50
64
|
maxMs: number,
|
|
51
65
|
slot: Pick<PromptSlot, "waitedMs">,
|
|
52
|
-
expire: (limit: TurnLimit) => void,
|
|
66
|
+
expire: (limit: TurnLimit, limitMs: number) => void,
|
|
53
67
|
clock: BudgetClock = REAL_CLOCK,
|
|
54
68
|
): TurnBudget {
|
|
55
69
|
const started = clock.now();
|
|
@@ -57,7 +71,8 @@ export function turnBudget(
|
|
|
57
71
|
const now = clock.now();
|
|
58
72
|
return now - started - slot.waitedMs(now);
|
|
59
73
|
};
|
|
60
|
-
let
|
|
74
|
+
let cap = maxMs;
|
|
75
|
+
let deadline = Math.min(ms, cap);
|
|
61
76
|
let cancelTimer: (() => void) | undefined;
|
|
62
77
|
let expired = false;
|
|
63
78
|
const arm = (delay: number) => {
|
|
@@ -65,47 +80,172 @@ export function turnBudget(
|
|
|
65
80
|
const left = deadline - worked();
|
|
66
81
|
if (left > 0) return arm(Math.max(left, REARM_FLOOR_MS));
|
|
67
82
|
expired = true;
|
|
68
|
-
|
|
83
|
+
if (deadline >= cap) expire("turnMax", cap);
|
|
84
|
+
else expire("turn", ms);
|
|
69
85
|
}, delay);
|
|
70
86
|
};
|
|
71
87
|
arm(deadline);
|
|
72
88
|
return {
|
|
73
89
|
renew() {
|
|
74
90
|
const now = worked();
|
|
75
|
-
if (expired || now >=
|
|
76
|
-
deadline = Math.min(now + ms,
|
|
91
|
+
if (expired || now >= cap) return undefined;
|
|
92
|
+
deadline = Math.min(now + ms, cap);
|
|
77
93
|
cancelTimer?.();
|
|
78
94
|
arm(deadline - now);
|
|
79
95
|
return deadline - now;
|
|
80
96
|
},
|
|
81
|
-
|
|
97
|
+
extend(raiseCap) {
|
|
98
|
+
const now = worked();
|
|
99
|
+
if (raiseCap) cap = Math.max(cap, now + ms);
|
|
100
|
+
deadline = Math.min(now + ms, cap);
|
|
101
|
+
expired = false;
|
|
102
|
+
cancelTimer?.();
|
|
103
|
+
arm(Math.max(deadline - now, REARM_FLOOR_MS));
|
|
104
|
+
},
|
|
105
|
+
capped: () => expired || worked() >= cap,
|
|
106
|
+
workedMs: worked,
|
|
82
107
|
cancel: () => cancelTimer?.(),
|
|
83
108
|
};
|
|
84
109
|
}
|
|
85
110
|
|
|
111
|
+
/** What the runtime's turn budget needs besides its options: what the turn is doing, and its clock. */
|
|
112
|
+
export interface TurnBudgetSeams {
|
|
113
|
+
activity: Pick<ActivityTracker, "now">;
|
|
114
|
+
clock?: BudgetClock;
|
|
115
|
+
}
|
|
116
|
+
|
|
86
117
|
/**
|
|
87
118
|
* The time limit of one turn of the built-in runtime, from its options: `turnTimeoutMs` (default
|
|
88
|
-
* 10 minutes) renewed by each steer up to `turnMaxMs` (default three times that).
|
|
89
|
-
*
|
|
119
|
+
* 10 minutes) renewed by each steer up to `turnMaxMs` (default three times that). When a limit
|
|
120
|
+
* fires on a turn that can show the owner a card, the turn keeps working and a card asks whether
|
|
121
|
+
* to go on: Continue extends the limit that fired by a full budget from then, Cancel aborts, and
|
|
122
|
+
* no answer within `turnTimeoutCardMs` (default 5 minutes, wall time) aborts. The card closes
|
|
123
|
+
* when the turn ends first. Without a card the turn is aborted at once. `abort` is told why.
|
|
90
124
|
*/
|
|
91
125
|
export function runtimeTurnBudget(
|
|
92
126
|
options: Pick<
|
|
93
127
|
PiAgentRuntimeOptions,
|
|
94
|
-
"turnTimeoutMs" | "turnMaxMs" | "logger"
|
|
128
|
+
"turnTimeoutMs" | "turnMaxMs" | "turnTimeoutCardMs" | "logger"
|
|
95
129
|
>,
|
|
96
|
-
slot: Pick<PromptSlot, "waitedMs">,
|
|
130
|
+
slot: Pick<PromptSlot, "waitedMs" | "cards">,
|
|
97
131
|
channel: ChannelKey,
|
|
98
|
-
abort: () => void,
|
|
132
|
+
abort: (timeout: TurnTimeout) => void,
|
|
133
|
+
seams: TurnBudgetSeams,
|
|
99
134
|
): TurnBudget {
|
|
100
135
|
const { logger, turnTimeoutMs = 10 * 60_000 } = options;
|
|
101
136
|
const turnMaxMs = options.turnMaxMs ?? 3 * turnTimeoutMs;
|
|
102
|
-
const
|
|
137
|
+
const graceMs = options.turnTimeoutCardMs ?? 5 * 60_000;
|
|
138
|
+
const clock = seams.clock ?? REAL_CLOCK;
|
|
139
|
+
/** Closes the open timeout card, if any, with the footer given. */
|
|
140
|
+
let closeCard: ((footer: string) => void) | undefined;
|
|
141
|
+
const reason = (
|
|
142
|
+
limit: TurnLimit,
|
|
143
|
+
limitMs: number,
|
|
144
|
+
end: TurnTimeoutEnd,
|
|
145
|
+
): TurnTimeout => ({
|
|
146
|
+
limit,
|
|
147
|
+
limitMs,
|
|
148
|
+
workedMs: budget.workedMs(),
|
|
149
|
+
...seams.activity.now(clock.now()),
|
|
150
|
+
graceMs,
|
|
151
|
+
end,
|
|
152
|
+
});
|
|
153
|
+
const stop = (timeout: TurnTimeout) => {
|
|
103
154
|
logger.warn(
|
|
104
|
-
{
|
|
155
|
+
{
|
|
156
|
+
channel,
|
|
157
|
+
limit: timeout.limit,
|
|
158
|
+
turnTimeoutMs,
|
|
159
|
+
turnMaxMs,
|
|
160
|
+
workedMs: timeout.workedMs,
|
|
161
|
+
running: timeout.running,
|
|
162
|
+
compacting: timeout.compacting,
|
|
163
|
+
tools: timeout.tools,
|
|
164
|
+
end: timeout.end,
|
|
165
|
+
},
|
|
105
166
|
"turn timed out; aborting",
|
|
106
167
|
);
|
|
107
|
-
abort();
|
|
108
|
-
}
|
|
168
|
+
abort(timeout);
|
|
169
|
+
};
|
|
170
|
+
const budget = turnBudget(
|
|
171
|
+
turnTimeoutMs,
|
|
172
|
+
turnMaxMs,
|
|
173
|
+
slot,
|
|
174
|
+
(limit, limitMs) => {
|
|
175
|
+
const prompts = slot.cards;
|
|
176
|
+
if (!prompts) return stop(reason(limit, limitMs, "timeout"));
|
|
177
|
+
const m = messages();
|
|
178
|
+
const asked = reason(limit, limitMs, "timeout");
|
|
179
|
+
logger.info(
|
|
180
|
+
{
|
|
181
|
+
channel,
|
|
182
|
+
limit,
|
|
183
|
+
workedMs: asked.workedMs,
|
|
184
|
+
running: asked.running,
|
|
185
|
+
},
|
|
186
|
+
"turn reached its time limit; asking the owner",
|
|
187
|
+
);
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
let open = true;
|
|
190
|
+
const settle = () => {
|
|
191
|
+
if (!open) return false;
|
|
192
|
+
open = false;
|
|
193
|
+
closeCard = undefined;
|
|
194
|
+
cancelGrace();
|
|
195
|
+
return true;
|
|
196
|
+
};
|
|
197
|
+
const cancelGrace = clock.schedule(() => {
|
|
198
|
+
if (!settle()) return;
|
|
199
|
+
const timeout = reason(limit, limitMs, "unanswered");
|
|
200
|
+
controller.abort(m.turnTimeoutUnanswered(timeout));
|
|
201
|
+
stop(timeout);
|
|
202
|
+
}, graceMs);
|
|
203
|
+
closeCard = (footer) => {
|
|
204
|
+
if (settle()) controller.abort(footer);
|
|
205
|
+
};
|
|
206
|
+
const answered = (approval: Approval) => {
|
|
207
|
+
if (!settle()) return;
|
|
208
|
+
if (approval === "approved") {
|
|
209
|
+
budget.extend(limit === "turnMax");
|
|
210
|
+
logger.info(
|
|
211
|
+
{ channel, limit, workedMs: budget.workedMs() },
|
|
212
|
+
"owner continued the turn past its time limit",
|
|
213
|
+
);
|
|
214
|
+
} else
|
|
215
|
+
stop(
|
|
216
|
+
reason(
|
|
217
|
+
limit,
|
|
218
|
+
limitMs,
|
|
219
|
+
approval === "declined" ? "cancelled" : "timeout",
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
prompts
|
|
224
|
+
.confirm(
|
|
225
|
+
m.turnTimeoutTitle,
|
|
226
|
+
m.turnTimeoutQuestion(asked, turnTimeoutMs),
|
|
227
|
+
undefined,
|
|
228
|
+
"owner",
|
|
229
|
+
undefined,
|
|
230
|
+
undefined,
|
|
231
|
+
{
|
|
232
|
+
supersede: controller.signal,
|
|
233
|
+
wording: {
|
|
234
|
+
approve: m.turnTimeoutContinue,
|
|
235
|
+
decline: m.turnTimeoutCancel,
|
|
236
|
+
footer: m.turnTimeoutFooter,
|
|
237
|
+
approved: m.turnTimeoutContinued,
|
|
238
|
+
declined: m.turnTimeoutCancelled,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
.then(answered, (error: unknown) => {
|
|
243
|
+
logger.warn({ channel, err: error }, "timeout card failed");
|
|
244
|
+
answered("unavailable");
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
clock,
|
|
248
|
+
);
|
|
109
249
|
return {
|
|
110
250
|
...budget,
|
|
111
251
|
renew() {
|
|
@@ -117,5 +257,9 @@ export function runtimeTurnBudget(
|
|
|
117
257
|
);
|
|
118
258
|
return deadlineMs;
|
|
119
259
|
},
|
|
260
|
+
cancel() {
|
|
261
|
+
budget.cancel();
|
|
262
|
+
closeCard?.(messages().turnTimeoutFinished);
|
|
263
|
+
},
|
|
120
264
|
};
|
|
121
265
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TurnResult } from "../domain/conversation.ts";
|
|
3
|
+
import { AgentRunError } from "../domain/errors.ts";
|
|
4
|
+
import { type TurnTimeout, TurnTimeoutError } from "../domain/turn-timeout.ts";
|
|
5
|
+
import { catalogFor } from "../i18n/index.ts";
|
|
6
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
7
|
+
import type { PromptSlot } from "./prompt-slot.ts";
|
|
8
|
+
import { activityTracker } from "./turn-activity.ts";
|
|
9
|
+
import { runtimeTurnBudget, type TurnBudget } from "./turn-budget.ts";
|
|
10
|
+
|
|
11
|
+
/** One turn's time limit, with what it follows of the turn to say why it fired. */
|
|
12
|
+
export interface TurnLimits {
|
|
13
|
+
budget: TurnBudget;
|
|
14
|
+
/** Follows the turn's events: its tools and its compactions. */
|
|
15
|
+
observe(event: AgentSessionEvent): void;
|
|
16
|
+
/** The turn's failure when a limit aborted it, its reason as a `TurnTimeoutError`; else undefined. */
|
|
17
|
+
failure(): TurnResult | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The answer to a turn whose prompt threw, when no limit aborted it. */
|
|
21
|
+
export function promptFailed(error: unknown): TurnResult {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: new AgentRunError(`prompt failed: ${String(error)}`),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A timed-out turn's reason in English, for its error and the logs. */
|
|
29
|
+
export function timeoutMessage(timeout: TurnTimeout): string {
|
|
30
|
+
return catalogFor("en").turnTimedOutNotice(timeout);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The time limit of a turn of the built-in runtime; see `runtimeTurnBudget`. `abort` aborts the turn. */
|
|
34
|
+
export function turnLimits(
|
|
35
|
+
options: Parameters<typeof runtimeTurnBudget>[0],
|
|
36
|
+
slot: Pick<PromptSlot, "waitedMs" | "cards">,
|
|
37
|
+
channel: ChannelKey,
|
|
38
|
+
abort: () => void,
|
|
39
|
+
): TurnLimits {
|
|
40
|
+
const activity = activityTracker();
|
|
41
|
+
let timedOut: TurnTimeout | undefined;
|
|
42
|
+
const budget = runtimeTurnBudget(
|
|
43
|
+
options,
|
|
44
|
+
slot,
|
|
45
|
+
channel,
|
|
46
|
+
(timeout) => {
|
|
47
|
+
timedOut = timeout;
|
|
48
|
+
abort();
|
|
49
|
+
},
|
|
50
|
+
{ activity },
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
budget,
|
|
54
|
+
observe: (event) => activity.observe(event),
|
|
55
|
+
failure: () =>
|
|
56
|
+
timedOut && {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: new TurnTimeoutError(timedOut, timeoutMessage(timedOut)),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -139,6 +139,12 @@ export type {
|
|
|
139
139
|
} from "./core/domain/interim.ts";
|
|
140
140
|
export type { TurnConversation, TurnRequest } from "./core/domain/ports.ts";
|
|
141
141
|
export type { HoldRefusal, TurnProgress } from "./core/domain/progress.ts";
|
|
142
|
+
export {
|
|
143
|
+
type TurnLimit,
|
|
144
|
+
type TurnTimeout,
|
|
145
|
+
type TurnTimeoutEnd,
|
|
146
|
+
TurnTimeoutError,
|
|
147
|
+
} from "./core/domain/turn-timeout.ts";
|
|
142
148
|
export type { DrainOptions } from "./core/drain.ts";
|
|
143
149
|
export {
|
|
144
150
|
HostStoppingError,
|
|
@@ -194,6 +200,7 @@ export type {
|
|
|
194
200
|
ApprovalDetails,
|
|
195
201
|
ApprovalFile,
|
|
196
202
|
ApprovalOptions,
|
|
203
|
+
ApprovalWording,
|
|
197
204
|
ApprovedBy,
|
|
198
205
|
AskOption,
|
|
199
206
|
AskSupport,
|