pi-roundtable 0.26.0 → 0.26.2
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 +58 -0
- package/docs/plugins.md +39 -7
- package/package.json +1 -1
- package/src/core/agents/agent-claim.ts +1 -0
- package/src/core/agents/agent-conversations.ts +94 -0
- package/src/core/agents/agent-messages.ts +247 -56
- package/src/core/agents/agent-outbox.ts +91 -0
- package/src/core/agents/agent-prompt.ts +40 -1
- package/src/core/agents/agent-schema.ts +6 -0
- package/src/core/agents/agent-store.ts +22 -3
- package/src/core/agents/agent-team-fixture.ts +81 -88
- package/src/core/agents/agent-team-seeds.ts +29 -0
- package/src/core/agents/agent-team.ts +17 -14
- package/src/core/agents/agent-tools.ts +34 -5
- package/src/core/agents/group-turns.ts +1 -1
- package/src/core/agents/message-target.ts +79 -0
- package/src/core/agents/team-editing.ts +30 -2
- package/src/core/agents/team-keys.ts +7 -0
- package/src/core/agents/team-options.ts +3 -0
- package/src/core/agents/team-text.ts +4 -0
- package/src/core/agents/team-turn-types.ts +28 -0
- package/src/core/agents/team-turns.ts +36 -25
- package/src/core/assets/prompts/shared-guest.md +1 -3
- package/src/core/assets/prompts/shared.md +1 -3
- package/src/core/builtin/agent-server.ts +5 -0
- package/src/core/builtin/modules.ts +6 -0
- package/src/core/config/config.ts +10 -2
- package/src/core/config/conversations.ts +21 -2
- package/src/core/contract/channels.ts +5 -0
- package/src/core/contract/surface.ts +5 -0
- package/src/core/conversations/conversation-retention.ts +22 -6
- package/src/core/conversations/conversations-plugin.ts +7 -1
- package/src/core/define-roundtable.ts +5 -1
- package/src/core/discord/channel-context.ts +3 -1
- package/src/core/discord/embed-text.ts +45 -0
- package/src/core/discord/inbound-message.ts +7 -2
- package/src/core/domain/ports.ts +17 -0
- package/src/core/domain/turn-timeout.ts +2 -0
- package/src/core/holds.ts +9 -0
- package/src/core/i18n/turn-timeout.ts +8 -2
- package/src/core/modules/background/background-turns.ts +3 -0
- package/src/core/modules/schedules/precheck-catalog.ts +49 -0
- package/src/core/modules/schedules/schedule-store.ts +27 -3
- package/src/core/modules/schedules/schedule-tools.ts +43 -86
- package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
- package/src/core/modules/schedules/schedules.ts +26 -2
- package/src/core/modules/schedules/time-limit.ts +27 -0
- package/src/core/presentation/reply-body.ts +8 -0
- package/src/core/routing/conversation-turns.ts +21 -2
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +36 -36
- package/src/core/runtime/extensions/gate-refusals.ts +32 -0
- package/src/core/runtime/extensions/hold-lookups.ts +31 -2
- package/src/core/runtime/extensions/late-answer.ts +5 -2
- package/src/core/runtime/extensions/late-releases.ts +76 -0
- package/src/core/runtime/pi-agent-runtime.ts +21 -19
- package/src/core/runtime/turn-answer.ts +5 -1
- package/src/core/runtime/turn-budget.ts +1 -0
- package/src/core/runtime/turn-limits.ts +12 -2
- package/src/core/runtime/turn-tools.ts +26 -0
- package/src/core/shared/schedule-tools.ts +25 -1
|
@@ -75,7 +75,10 @@ export function turnTimeoutEn() {
|
|
|
75
75
|
: t.end === "unanswered"
|
|
76
76
|
? `⏱️ No answer to the timeout card in ${durationEn(t.graceMs)}, so the turn was stopped after ${durationEn(t.workedMs)} ${what}.`
|
|
77
77
|
: `⏱️ The turn reached ${limit(t)} and was stopped after ${durationEn(t.workedMs)} ${what}.`;
|
|
78
|
-
|
|
78
|
+
const unasked = t.unasked
|
|
79
|
+
? "No one was in the conversation to ask for more time. To let turns like this run longer, turn on long-running for this conversation, or give the schedule a longer time limit."
|
|
80
|
+
: "";
|
|
81
|
+
return [head, tools(t), unasked].filter(Boolean).join(" ");
|
|
79
82
|
},
|
|
80
83
|
};
|
|
81
84
|
}
|
|
@@ -125,7 +128,10 @@ export function turnTimeoutZhTW(): ReturnType<typeof turnTimeoutEn> {
|
|
|
125
128
|
: t.end === "unanswered"
|
|
126
129
|
? `⏱️ 逾時卡片在 ${durationZh(t.graceMs)}內沒有回答,這一輪在工作 ${durationZh(t.workedMs)} 後停止,${what}。`
|
|
127
130
|
: `⏱️ 這一輪達到${limit(t)},在工作 ${durationZh(t.workedMs)} 後停止,${what}。`;
|
|
128
|
-
|
|
131
|
+
const unasked = t.unasked
|
|
132
|
+
? "當時對話裡沒有人可以問要不要再給時間。要讓這類回合跑久一點,可以替這個對話開啟長時間執行,或替排程設定較長的時間上限。"
|
|
133
|
+
: "";
|
|
134
|
+
return [head, tools(t), unasked].filter(Boolean).join(" ");
|
|
129
135
|
},
|
|
130
136
|
};
|
|
131
137
|
}
|
|
@@ -76,6 +76,9 @@ export class ConversationBackgroundTurns implements BackgroundTurns {
|
|
|
76
76
|
tier: schedule.createdTier,
|
|
77
77
|
turnId: `schedule-${schedule.id}-${firedAt.getTime()}`,
|
|
78
78
|
text: scheduledTurnText(schedule, firedAt, finding),
|
|
79
|
+
...(schedule.timeLimitMinutes
|
|
80
|
+
? { timeLimitMs: schedule.timeLimitMinutes * 60_000 }
|
|
81
|
+
: {}),
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PRECHECK_SCRIPT_CHARS } from "./prechecks.ts";
|
|
2
|
+
import type { ScheduleToolContext } from "./schedule-tools.ts";
|
|
3
|
+
|
|
4
|
+
/** How long schedule_list waits for the script runner to say what a script may call. */
|
|
5
|
+
const DESCRIBE_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
/** The prechecks a schedule may name or write, for schedule_list; empty when the host offers neither. */
|
|
8
|
+
export async function precheckCatalog(
|
|
9
|
+
ctx: ScheduleToolContext,
|
|
10
|
+
): Promise<string> {
|
|
11
|
+
const all = ctx.prechecks?.list() ?? [];
|
|
12
|
+
const runner = ctx.prechecks?.scriptRunner?.();
|
|
13
|
+
const named =
|
|
14
|
+
all.length === 0
|
|
15
|
+
? ""
|
|
16
|
+
: `\n\nPrechecks you can attach with precheck on schedule_create or schedule_update; the host runs one before each turn and wakes you only when it finds something:\n${all.map((p) => `- ${p.name}: ${p.description.replace(/\n/g, " ")}`).join("\n")}`;
|
|
17
|
+
if (!runner) return named;
|
|
18
|
+
// The list still answers when the host cannot say, or is slow to say, what a script may reach here.
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
20
|
+
const late = new Promise<string>((resolve) => {
|
|
21
|
+
timer = setTimeout(
|
|
22
|
+
() => resolve("it did not answer in time"),
|
|
23
|
+
DESCRIBE_TIMEOUT_MS,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
const described = Promise.resolve()
|
|
27
|
+
.then(() =>
|
|
28
|
+
runner.describe({
|
|
29
|
+
channel: ctx.channel,
|
|
30
|
+
target: ctx.target.name,
|
|
31
|
+
tier: ctx.author.tier,
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
.then(
|
|
35
|
+
(text) => ({ text }),
|
|
36
|
+
(error: unknown) => ({
|
|
37
|
+
error: error instanceof Error ? error.message : String(error),
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const answer = await Promise.race([
|
|
41
|
+
described,
|
|
42
|
+
late.then((error) => ({ error })),
|
|
43
|
+
]).finally(() => clearTimeout(timer));
|
|
44
|
+
const guide =
|
|
45
|
+
"text" in answer
|
|
46
|
+
? answer.text
|
|
47
|
+
: `The host could not say what a script may call here (${answer.error}); a script set now may fail when it runs.`;
|
|
48
|
+
return `${named}\n\nYou can write a precheck of your own instead, with precheck_script on schedule_create or schedule_update (null removes it): a JavaScript module, at most ${PRECHECK_SCRIPT_CHARS} characters, that the host runs in a sandbox before each turn. A schedule has a precheck or a precheck_script, not both.\n${guide}`;
|
|
49
|
+
}
|
|
@@ -30,6 +30,8 @@ export interface Schedule {
|
|
|
30
30
|
* absent for a script saved before tools were recorded, which must be saved again if it calls a held tool.
|
|
31
31
|
*/
|
|
32
32
|
precheckTools?: PrecheckTool[];
|
|
33
|
+
/** How long, in minutes, each of its turns may work; absent, the runtime's default time limit. */
|
|
34
|
+
timeLimitMinutes?: number;
|
|
33
35
|
lastRun?: Date;
|
|
34
36
|
lastStatus?: string;
|
|
35
37
|
}
|
|
@@ -50,6 +52,8 @@ export interface NewSchedule {
|
|
|
50
52
|
precheckScript?: string;
|
|
51
53
|
/** The tools the script may call, as approved; required with `precheckScript`. */
|
|
52
54
|
precheckTools?: PrecheckTool[];
|
|
55
|
+
/** How long, in minutes, each of its turns may work. */
|
|
56
|
+
timeLimitMinutes?: number;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
export interface ScheduleChange {
|
|
@@ -63,6 +67,8 @@ export interface ScheduleChange {
|
|
|
63
67
|
precheckScript?: string | null;
|
|
64
68
|
/** The tools a new script may call, as approved; required with a new `precheckScript`. */
|
|
65
69
|
precheckTools?: PrecheckTool[];
|
|
70
|
+
/** How long, in minutes, each turn may work; null returns it to the runtime's default. */
|
|
71
|
+
timeLimitMinutes?: number | null;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
interface Row {
|
|
@@ -81,6 +87,7 @@ interface Row {
|
|
|
81
87
|
precheck: string | null;
|
|
82
88
|
precheck_script: string | null;
|
|
83
89
|
precheck_tools: string | null;
|
|
90
|
+
time_limit_minutes: number | null;
|
|
84
91
|
last_run: Date | null;
|
|
85
92
|
last_status: string | null;
|
|
86
93
|
}
|
|
@@ -105,6 +112,9 @@ function toSchedule(row: Row): Schedule {
|
|
|
105
112
|
? // pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
|
|
106
113
|
{ precheckTools: JSON.parse(row.precheck_tools) as PrecheckTool[] }
|
|
107
114
|
: {}),
|
|
115
|
+
...(row.time_limit_minutes
|
|
116
|
+
? { timeLimitMinutes: row.time_limit_minutes }
|
|
117
|
+
: {}),
|
|
108
118
|
...(row.last_run ? { lastRun: row.last_run } : {}),
|
|
109
119
|
...(row.last_status ? { lastStatus: row.last_status } : {}),
|
|
110
120
|
};
|
|
@@ -126,12 +136,14 @@ async function insert(sql: SQL, schedule: NewSchedule): Promise<Schedule> {
|
|
|
126
136
|
checked(schedule);
|
|
127
137
|
const rows: Row[] = await sql`
|
|
128
138
|
INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
|
|
129
|
-
created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools
|
|
139
|
+
created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools,
|
|
140
|
+
time_limit_minutes)
|
|
130
141
|
VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
|
|
131
142
|
${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
|
|
132
143
|
${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
|
|
133
144
|
${schedule.precheck ?? null}, ${schedule.precheckScript ?? null},
|
|
134
|
-
${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null}
|
|
145
|
+
${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null},
|
|
146
|
+
${schedule.timeLimitMinutes ?? null})
|
|
135
147
|
RETURNING *`;
|
|
136
148
|
const [row] = rows;
|
|
137
149
|
if (!row) throw new Error("the schedule insert returned no row");
|
|
@@ -195,6 +207,13 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
195
207
|
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck_tools text`;
|
|
196
208
|
},
|
|
197
209
|
},
|
|
210
|
+
{
|
|
211
|
+
// Schedules made before have none: their turns keep the runtime's default time limit.
|
|
212
|
+
name: "schedules-time-limit",
|
|
213
|
+
up: async (sql) => {
|
|
214
|
+
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS time_limit_minutes integer`;
|
|
215
|
+
},
|
|
216
|
+
},
|
|
198
217
|
];
|
|
199
218
|
}
|
|
200
219
|
|
|
@@ -282,6 +301,10 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
282
301
|
: current.precheckTools
|
|
283
302
|
? JSON.stringify(current.precheckTools)
|
|
284
303
|
: null;
|
|
304
|
+
const timeLimit =
|
|
305
|
+
change.timeLimitMinutes === undefined
|
|
306
|
+
? (current.timeLimitMinutes ?? null)
|
|
307
|
+
: change.timeLimitMinutes;
|
|
285
308
|
const rows: Row[] = await this.#sql`
|
|
286
309
|
UPDATE schedules SET
|
|
287
310
|
title = ${change.title ?? current.title},
|
|
@@ -290,7 +313,8 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
290
313
|
next_run = ${change.nextRun ?? current.nextRun},
|
|
291
314
|
precheck = ${precheck},
|
|
292
315
|
precheck_script = ${precheckScript},
|
|
293
|
-
precheck_tools = ${precheckTools}
|
|
316
|
+
precheck_tools = ${precheckTools},
|
|
317
|
+
time_limit_minutes = ${timeLimit}
|
|
294
318
|
WHERE id = ${id}
|
|
295
319
|
RETURNING *`;
|
|
296
320
|
return rows[0] ? toSchedule(rows[0]) : undefined;
|
|
@@ -8,12 +8,9 @@ import type { ScheduleStore } from "../../services.ts";
|
|
|
8
8
|
import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
|
|
9
9
|
import { type Tier, tierAtLeast } from "../../speakers.ts";
|
|
10
10
|
import { timeZone, zonedStamp } from "../../time.ts";
|
|
11
|
+
import { precheckCatalog } from "./precheck-catalog.ts";
|
|
11
12
|
import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
|
|
12
|
-
import {
|
|
13
|
-
PRECHECK_SCRIPT_CHARS,
|
|
14
|
-
type PrecheckFinding,
|
|
15
|
-
type PrecheckRegistry,
|
|
16
|
-
} from "./prechecks.ts";
|
|
13
|
+
import { PRECHECK_SCRIPT_CHARS, type PrecheckRegistry } from "./prechecks.ts";
|
|
17
14
|
import {
|
|
18
15
|
describeRecurrence,
|
|
19
16
|
nextRun,
|
|
@@ -22,11 +19,10 @@ import {
|
|
|
22
19
|
type RecurrenceInput,
|
|
23
20
|
} from "./recurrence.ts";
|
|
24
21
|
import type { NewSchedule, Schedule } from "./schedule-store.ts";
|
|
22
|
+
import { timeLimitInput, timeLimitText } from "./time-limit.ts";
|
|
25
23
|
|
|
26
24
|
const TITLE_CHARS = 80;
|
|
27
25
|
const LIST_PROMPT_PREVIEW = 200;
|
|
28
|
-
/** How long schedule_list waits for the script runner to say what a script may call. */
|
|
29
|
-
const DESCRIBE_TIMEOUT_MS = 10_000;
|
|
30
26
|
const DAY_MS = 86_400_000;
|
|
31
27
|
|
|
32
28
|
export interface ScheduleToolContext {
|
|
@@ -40,6 +36,12 @@ export interface ScheduleToolContext {
|
|
|
40
36
|
> &
|
|
41
37
|
Partial<Pick<ScheduleStore, "all" | "createWithin">>;
|
|
42
38
|
channel: ChannelKey;
|
|
39
|
+
/**
|
|
40
|
+
* Every conversation whose schedules the asker lists, changes, and cancels, `channel` among
|
|
41
|
+
* them: an agent's own across all its conversations. Without it, `channel` alone. A new
|
|
42
|
+
* schedule is always made in `channel`.
|
|
43
|
+
*/
|
|
44
|
+
channels?: readonly ChannelKey[];
|
|
43
45
|
/** Whose schedules these are; its `schedules` limits apply, and without them nothing is scheduled. */
|
|
44
46
|
target: BackgroundTarget;
|
|
45
47
|
/**
|
|
@@ -158,48 +160,6 @@ function toolsText(tools: readonly PrecheckTool[] | undefined): string {
|
|
|
158
160
|
.join(", ");
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
/** The prechecks a schedule may name or write, for schedule_list; empty when the host offers neither. */
|
|
162
|
-
async function precheckCatalog(ctx: ScheduleToolContext): Promise<string> {
|
|
163
|
-
const all = ctx.prechecks?.list() ?? [];
|
|
164
|
-
const runner = ctx.prechecks?.scriptRunner?.();
|
|
165
|
-
const named =
|
|
166
|
-
all.length === 0
|
|
167
|
-
? ""
|
|
168
|
-
: `\n\nPrechecks you can attach with precheck on schedule_create or schedule_update; the host runs one before each turn and wakes you only when it finds something:\n${all.map((p) => `- ${p.name}: ${p.description.replace(/\n/g, " ")}`).join("\n")}`;
|
|
169
|
-
if (!runner) return named;
|
|
170
|
-
// The list still answers when the host cannot say, or is slow to say, what a script may reach here.
|
|
171
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
172
|
-
const late = new Promise<string>((resolve) => {
|
|
173
|
-
timer = setTimeout(
|
|
174
|
-
() => resolve("it did not answer in time"),
|
|
175
|
-
DESCRIBE_TIMEOUT_MS,
|
|
176
|
-
);
|
|
177
|
-
});
|
|
178
|
-
const described = Promise.resolve()
|
|
179
|
-
.then(() =>
|
|
180
|
-
runner.describe({
|
|
181
|
-
channel: ctx.channel,
|
|
182
|
-
target: ctx.target.name,
|
|
183
|
-
tier: ctx.author.tier,
|
|
184
|
-
}),
|
|
185
|
-
)
|
|
186
|
-
.then(
|
|
187
|
-
(text) => ({ text }),
|
|
188
|
-
(error: unknown) => ({
|
|
189
|
-
error: error instanceof Error ? error.message : String(error),
|
|
190
|
-
}),
|
|
191
|
-
);
|
|
192
|
-
const answer = await Promise.race([
|
|
193
|
-
described,
|
|
194
|
-
late.then((error) => ({ error })),
|
|
195
|
-
]).finally(() => clearTimeout(timer));
|
|
196
|
-
const guide =
|
|
197
|
-
"text" in answer
|
|
198
|
-
? answer.text
|
|
199
|
-
: `The host could not say what a script may call here (${answer.error}); a script set now may fail when it runs.`;
|
|
200
|
-
return `${named}\n\nYou can write a precheck of your own instead, with precheck_script on schedule_create or schedule_update (null removes it): a JavaScript module, at most ${PRECHECK_SCRIPT_CHARS} characters, that the host runs in a sandbox before each turn. A schedule has a precheck or a precheck_script, not both.\n${guide}`;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
163
|
function hasTiming(input: Input): boolean {
|
|
204
164
|
return [
|
|
205
165
|
"in_minutes",
|
|
@@ -225,7 +185,7 @@ function timing(ctx: ScheduleToolContext, input: Input): [Recurrence, Date] {
|
|
|
225
185
|
return [recurrence, next];
|
|
226
186
|
}
|
|
227
187
|
|
|
228
|
-
function line(schedule: Schedule): string {
|
|
188
|
+
function line(schedule: Schedule, where?: ChannelKey): string {
|
|
229
189
|
const prompt =
|
|
230
190
|
schedule.prompt.length > LIST_PROMPT_PREVIEW
|
|
231
191
|
? `${schedule.prompt.slice(0, LIST_PROMPT_PREVIEW)}…`
|
|
@@ -235,10 +195,12 @@ function line(schedule: Schedule): string {
|
|
|
235
195
|
: schedule.precheckScript
|
|
236
196
|
? `; precheck script (${schedule.precheckScript.length} characters; tools: ${toolsText(schedule.precheckTools)})`
|
|
237
197
|
: "";
|
|
198
|
+
const limit = timeLimitText(schedule.timeLimitMinutes);
|
|
238
199
|
const last = schedule.lastRun
|
|
239
200
|
? `; last run ${zonedStamp(schedule.lastRun)} (${schedule.lastStatus ?? "?"})`
|
|
240
201
|
: "";
|
|
241
|
-
|
|
202
|
+
const fires = where ? `; fires in ${where}` : "";
|
|
203
|
+
return `- #${schedule.id} ${schedule.title}: ${describeRecurrence(schedule.recurrence)}, next ${zonedStamp(schedule.nextRun)}${fires}; set by ${schedule.createdByName}${precheck}${limit}${last}\n ${prompt.replace(/\n/g, " ")}`;
|
|
242
204
|
}
|
|
243
205
|
|
|
244
206
|
/** The principal who created a schedule. */
|
|
@@ -262,11 +224,15 @@ async function visible(
|
|
|
262
224
|
);
|
|
263
225
|
}
|
|
264
226
|
|
|
227
|
+
/** The conversations whose schedules the asker reaches. */
|
|
228
|
+
const scopeOf = (ctx: ScheduleToolContext): readonly ChannelKey[] =>
|
|
229
|
+
ctx.channels ?? [ctx.channel];
|
|
230
|
+
|
|
265
231
|
async function own(ctx: ScheduleToolContext, input: Input): Promise<Schedule> {
|
|
266
232
|
const schedule = await ctx.store.get(id(input));
|
|
267
233
|
if (
|
|
268
234
|
!schedule ||
|
|
269
|
-
schedule.channel
|
|
235
|
+
!scopeOf(ctx).includes(schedule.channel) ||
|
|
270
236
|
!(await visible(ctx, schedule))
|
|
271
237
|
)
|
|
272
238
|
throw new ScheduleError(
|
|
@@ -368,6 +334,8 @@ export async function callScheduleTool(
|
|
|
368
334
|
const script = given("precheck_script")
|
|
369
335
|
? precheckScript(ctx, input.precheck_script)
|
|
370
336
|
: undefined;
|
|
337
|
+
const timeLimitMinutes =
|
|
338
|
+
timeLimitInput(input.time_limit_minutes) ?? undefined;
|
|
371
339
|
const existing = await ctx.store.forChannel(ctx.channel);
|
|
372
340
|
if (existing.length >= limits.perChannel)
|
|
373
341
|
throw new ScheduleError(
|
|
@@ -384,6 +352,7 @@ export async function callScheduleTool(
|
|
|
384
352
|
createdByName: ctx.author.name,
|
|
385
353
|
createdTier: ctx.author.tier,
|
|
386
354
|
...(precheck ? { precheck } : {}),
|
|
355
|
+
...(timeLimitMinutes ? { timeLimitMinutes } : {}),
|
|
387
356
|
...(script
|
|
388
357
|
? { precheckScript: script.script, precheckTools: script.tools }
|
|
389
358
|
: {}),
|
|
@@ -393,7 +362,7 @@ export async function callScheduleTool(
|
|
|
393
362
|
: script
|
|
394
363
|
? "; its precheck script runs first"
|
|
395
364
|
: "";
|
|
396
|
-
return `Scheduled #${made.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}.`;
|
|
365
|
+
return `Scheduled #${made.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}${timeLimitText(made.timeLimitMinutes)}.`;
|
|
397
366
|
}
|
|
398
367
|
case "schedule_list": {
|
|
399
368
|
if (input.id !== undefined) {
|
|
@@ -404,12 +373,18 @@ export async function callScheduleTool(
|
|
|
404
373
|
return `${line(schedule).split("\n")[0]}\n\nPrompt:\n${schedule.prompt}${script}`;
|
|
405
374
|
}
|
|
406
375
|
const all: Schedule[] = [];
|
|
407
|
-
for (const
|
|
408
|
-
|
|
376
|
+
for (const channel of scopeOf(ctx))
|
|
377
|
+
for (const schedule of await ctx.store.forChannel(channel))
|
|
378
|
+
if (await visible(ctx, schedule)) all.push(schedule);
|
|
379
|
+
// Across several conversations each line says where it fires.
|
|
380
|
+
const across = scopeOf(ctx).length > 1;
|
|
381
|
+
if (across) all.sort((a, b) => a.nextRun.getTime() - b.nextRun.getTime());
|
|
409
382
|
const listed =
|
|
410
383
|
all.length === 0
|
|
411
|
-
?
|
|
412
|
-
|
|
384
|
+
? across
|
|
385
|
+
? "You have no schedules in any of your conversations."
|
|
386
|
+
: "This channel has no schedules."
|
|
387
|
+
: `It is ${zonedStamp(ctx.now)} in ${messages().zoneName(timeZone())}.\n${all.map((s) => line(s, across ? s.channel : undefined)).join("\n")}`;
|
|
413
388
|
return `${listed}${await precheckCatalog(ctx)}`;
|
|
414
389
|
}
|
|
415
390
|
case "schedule_update": {
|
|
@@ -442,25 +417,31 @@ export async function callScheduleTool(
|
|
|
442
417
|
change.precheckScript = script.script;
|
|
443
418
|
change.precheckTools = script.tools;
|
|
444
419
|
}
|
|
420
|
+
const timeLimit = timeLimitInput(input.time_limit_minutes);
|
|
421
|
+
if (timeLimit !== undefined) change.timeLimitMinutes = timeLimit;
|
|
445
422
|
if (change.precheck) change.precheckScript = null;
|
|
446
423
|
if (change.precheckScript) change.precheck = null;
|
|
447
424
|
if (Object.keys(change).length === 0)
|
|
448
425
|
throw new ScheduleError(
|
|
449
|
-
"give a title, prompt, timing, precheck, or
|
|
426
|
+
"give a title, prompt, timing, precheck, precheck_script, or time_limit_minutes to change",
|
|
450
427
|
);
|
|
451
|
-
const updated = await ctx.store.update(
|
|
428
|
+
const updated = await ctx.store.update(
|
|
429
|
+
schedule.channel,
|
|
430
|
+
schedule.id,
|
|
431
|
+
change,
|
|
432
|
+
);
|
|
452
433
|
if (!updated) throw new ScheduleError(`schedule #${schedule.id} is gone`);
|
|
453
434
|
const checked = updated.precheck
|
|
454
435
|
? `; precheck ${updated.precheck} runs first`
|
|
455
436
|
: updated.precheckScript
|
|
456
437
|
? "; its precheck script runs first"
|
|
457
438
|
: "";
|
|
458
|
-
return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}.`;
|
|
439
|
+
return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}${timeLimitText(updated.timeLimitMinutes)}.`;
|
|
459
440
|
}
|
|
460
441
|
case "schedule_cancel": {
|
|
461
442
|
notTheHost(ctx, "cancel no schedules; ask the owner to cancel it");
|
|
462
443
|
const schedule = changeable(ctx, await own(ctx, input));
|
|
463
|
-
await ctx.store.remove(schedule.id,
|
|
444
|
+
await ctx.store.remove(schedule.id, schedule.channel);
|
|
464
445
|
return `Cancelled #${schedule.id} "${schedule.title}".`;
|
|
465
446
|
}
|
|
466
447
|
default:
|
|
@@ -468,28 +449,4 @@ export async function callScheduleTool(
|
|
|
468
449
|
}
|
|
469
450
|
}
|
|
470
451
|
|
|
471
|
-
|
|
472
|
-
function findingText(finding: PrecheckFinding): string[] {
|
|
473
|
-
return "error" in finding
|
|
474
|
-
? [
|
|
475
|
-
"",
|
|
476
|
-
`### Precheck failed: ${finding.precheck}`,
|
|
477
|
-
`The precheck that decides whether this task needs you failed, so you were woken anyway: ${finding.error}. Check what it watches yourself, and say so if it needs fixing.`,
|
|
478
|
-
]
|
|
479
|
-
: ["", `### Precheck found (${finding.precheck}):`, finding.context];
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/** What a scheduled run receives as its message, with what its precheck found when it has one. */
|
|
483
|
-
export function scheduledTurnText(
|
|
484
|
-
schedule: Schedule,
|
|
485
|
-
firedAt: Date,
|
|
486
|
-
finding?: PrecheckFinding,
|
|
487
|
-
): string {
|
|
488
|
-
return [
|
|
489
|
-
`## Scheduled task #${schedule.id}: ${schedule.title}`,
|
|
490
|
-
`${schedule.createdByName} set this schedule (${describeRecurrence(schedule.recurrence)}). It is due now, ${zonedStamp(firedAt)} ${messages().zoneTime(timeZone())}. Nobody wrote a new message: carry out the task below and write what you would post in this channel. If the task keeps a record for later runs, update it with schedule_update on #${schedule.id}.`,
|
|
491
|
-
"",
|
|
492
|
-
schedule.prompt,
|
|
493
|
-
...(finding ? findingText(finding) : []),
|
|
494
|
-
].join("\n");
|
|
495
|
-
}
|
|
452
|
+
export { scheduledTurnText } from "./scheduled-turn-text.ts";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { messages } from "../../i18n/index.ts";
|
|
2
|
+
import { timeZone, zonedStamp } from "../../time.ts";
|
|
3
|
+
import type { PrecheckFinding } from "./prechecks.ts";
|
|
4
|
+
import { describeRecurrence } from "./recurrence.ts";
|
|
5
|
+
import type { Schedule } from "./schedule-store.ts";
|
|
6
|
+
|
|
7
|
+
/** What the schedule's precheck found, or how it failed, under its own heading after the task. */
|
|
8
|
+
function findingText(finding: PrecheckFinding): string[] {
|
|
9
|
+
return "error" in finding
|
|
10
|
+
? [
|
|
11
|
+
"",
|
|
12
|
+
`### Precheck failed: ${finding.precheck}`,
|
|
13
|
+
`The precheck that decides whether this task needs you failed, so you were woken anyway: ${finding.error}. Check what it watches yourself, and say so if it needs fixing.`,
|
|
14
|
+
]
|
|
15
|
+
: ["", `### Precheck found (${finding.precheck}):`, finding.context];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What a scheduled run receives as its message, with what its precheck found when it has one. */
|
|
19
|
+
export function scheduledTurnText(
|
|
20
|
+
schedule: Schedule,
|
|
21
|
+
firedAt: Date,
|
|
22
|
+
finding?: PrecheckFinding,
|
|
23
|
+
): string {
|
|
24
|
+
return [
|
|
25
|
+
`## Scheduled task #${schedule.id}: ${schedule.title}`,
|
|
26
|
+
`${schedule.createdByName} set this schedule (${describeRecurrence(schedule.recurrence)}). It is due now, ${zonedStamp(firedAt)} ${messages().zoneTime(timeZone())}. Nobody wrote a new message: carry out the task below and write what you would post in this channel. If the task keeps a record for later runs, update it with schedule_update on #${schedule.id}.`,
|
|
27
|
+
"",
|
|
28
|
+
schedule.prompt,
|
|
29
|
+
...(finding ? findingText(finding) : []),
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
@@ -68,6 +68,18 @@ function withAgentOption(spec: ScheduleToolSpec): ScheduleToolSpec {
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** The tools that reach every one of an agent's conversations say so, since they read "this channel's" otherwise. */
|
|
72
|
+
function withAgentScope(spec: ScheduleToolSpec): ScheduleToolSpec {
|
|
73
|
+
if (
|
|
74
|
+
!["schedule_list", "schedule_update", "schedule_cancel"].includes(spec.name)
|
|
75
|
+
)
|
|
76
|
+
return spec;
|
|
77
|
+
return {
|
|
78
|
+
...spec,
|
|
79
|
+
description: `${spec.description} As an agent you reach all your own schedules, whichever of your conversations (Discord, the web app) they were made in; schedule_list says where each fires. A new schedule belongs to the conversation you are in.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
/**
|
|
72
84
|
* Registers the schedule tools for one conversation; a schedule's runs speak for the person whose
|
|
73
85
|
* turn set it up, and in a turn nobody is named for the tools refuse. With `agents`,
|
|
@@ -79,6 +91,11 @@ export function schedulesExtension(
|
|
|
79
91
|
agents?: AgentChannelLookup,
|
|
80
92
|
/** The person the running turn is for; their schedules run at their tier. */
|
|
81
93
|
speaker: () => Speaker | undefined = () => undefined,
|
|
94
|
+
/**
|
|
95
|
+
* An agent's conversations, asked when a tool runs: its schedules in all of them are listed,
|
|
96
|
+
* changed, and cancelled, `channel`'s among them; without it, `channel`'s alone.
|
|
97
|
+
*/
|
|
98
|
+
conversations?: () => readonly ChannelKey[],
|
|
82
99
|
): ExtensionFactory {
|
|
83
100
|
const {
|
|
84
101
|
store,
|
|
@@ -94,7 +111,8 @@ export function schedulesExtension(
|
|
|
94
111
|
timeZone: timeZone(),
|
|
95
112
|
precheckScripts: prechecks?.scriptRunner?.() !== undefined,
|
|
96
113
|
}).map((base) => {
|
|
97
|
-
const
|
|
114
|
+
const named = agents ? withAgentOption(base) : base;
|
|
115
|
+
const spec = conversations ? withAgentScope(named) : named;
|
|
98
116
|
return {
|
|
99
117
|
...spec,
|
|
100
118
|
run: async (input: ToolInput) => {
|
|
@@ -104,14 +122,20 @@ export function schedulesExtension(
|
|
|
104
122
|
"schedules are kept only in a turn someone is named for, whose schedules they are",
|
|
105
123
|
);
|
|
106
124
|
const peer =
|
|
107
|
-
|
|
125
|
+
spec.name === "schedule_list" &&
|
|
126
|
+
agents &&
|
|
127
|
+
typeof input.agent === "string" &&
|
|
128
|
+
input.agent
|
|
108
129
|
? agents(input.agent, channel)
|
|
109
130
|
: undefined;
|
|
110
131
|
const target = peer ?? (await channelFor(channel, author.principalId));
|
|
132
|
+
// Another agent's schedules are read in its one channel; an agent's own span its conversations.
|
|
133
|
+
const own = peer ? undefined : conversations?.();
|
|
111
134
|
const answer = await callScheduleTool(
|
|
112
135
|
{
|
|
113
136
|
store,
|
|
114
137
|
channel: target,
|
|
138
|
+
...(own ? { channels: [...new Set([target, ...own])] } : {}),
|
|
115
139
|
target: kept,
|
|
116
140
|
author: {
|
|
117
141
|
principalId: author.principalId,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ScheduleError } from "../../domain/errors.ts";
|
|
2
|
+
import { MAX_SCHEDULE_TIME_LIMIT_MINUTES } from "../../shared/schedule-tools.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A schedule's time limit from a tool's input: undefined when it names none, null when it asks
|
|
6
|
+
* for the runtime's default again, else whole minutes within the schedule limit's maximum.
|
|
7
|
+
*/
|
|
8
|
+
export function timeLimitInput(value: unknown): number | null | undefined {
|
|
9
|
+
if (value === undefined || value === null) return value;
|
|
10
|
+
if (
|
|
11
|
+
typeof value !== "number" ||
|
|
12
|
+
!Number.isInteger(value) ||
|
|
13
|
+
value < 1 ||
|
|
14
|
+
value > MAX_SCHEDULE_TIME_LIMIT_MINUTES
|
|
15
|
+
)
|
|
16
|
+
throw new ScheduleError(
|
|
17
|
+
`time_limit_minutes must be a whole number of minutes from 1 to ${MAX_SCHEDULE_TIME_LIMIT_MINUTES}`,
|
|
18
|
+
);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The time limit as the schedule's lines and replies say it; empty for the default. */
|
|
23
|
+
export function timeLimitText(minutes: number | undefined): string {
|
|
24
|
+
return minutes === undefined
|
|
25
|
+
? ""
|
|
26
|
+
: `; each run may work up to ${minutes} min`;
|
|
27
|
+
}
|
|
@@ -23,3 +23,11 @@ export function replyBody(result: TurnResult): {
|
|
|
23
23
|
...(result.files?.length ? { files: result.files } : {}),
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a turn ended with nothing to post: an unattended turn, such as a schedule's, that had
|
|
29
|
+
* nothing to say. It is no failure and shows no notice.
|
|
30
|
+
*/
|
|
31
|
+
export function isQuiet(result: TurnResult): boolean {
|
|
32
|
+
return result.ok && !result.text.trim() && !result.files?.length;
|
|
33
|
+
}
|
|
@@ -13,7 +13,7 @@ import { HostStoppingError, PluginError } from "../errors.ts";
|
|
|
13
13
|
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
14
14
|
import type { Logger } from "../log.ts";
|
|
15
15
|
import type { EventSink } from "../plugin.ts";
|
|
16
|
-
import { replyBody } from "../presentation/reply-body.ts";
|
|
16
|
+
import { isQuiet, replyBody } from "../presentation/reply-body.ts";
|
|
17
17
|
import { withReplyFiles } from "../reply-files.ts";
|
|
18
18
|
import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
|
|
19
19
|
import type { Speaker } from "../speakers.ts";
|
|
@@ -45,8 +45,17 @@ export interface ConversationTurnInput {
|
|
|
45
45
|
selection?: TurnSelection;
|
|
46
46
|
/** The owner's own chat turn, which their next messages may steer. */
|
|
47
47
|
steerable?: boolean;
|
|
48
|
+
/** The speaker's message started the turn (not a schedule or report): it may spend a late approval. */
|
|
49
|
+
byMessage?: boolean;
|
|
48
50
|
/** The turn may ask the owner on the surface's prompts, such as approvals of held actions. */
|
|
49
51
|
interactive?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* No person waits on the answer, as for a schedule's turn: an empty final answer then ends the
|
|
54
|
+
* turn quietly, with nothing posted and `reply` not called, instead of failing it.
|
|
55
|
+
*/
|
|
56
|
+
unattended?: boolean;
|
|
57
|
+
/** How long the turn may work, in ms, in place of the runtime's default; a schedule's own limit. */
|
|
58
|
+
timeLimitMs?: number;
|
|
50
59
|
/**
|
|
51
60
|
* The speaker confirmed the channel's held actions, so they may run in this turn, except those
|
|
52
61
|
* that named approvers the speaker is neither among nor an owner of, which stay held.
|
|
@@ -205,7 +214,12 @@ export function conversationTurns(
|
|
|
205
214
|
: {}),
|
|
206
215
|
...(input.confirmed ? { confirmed: true } : {}),
|
|
207
216
|
...(input.steerable ? { steerable: true } : {}),
|
|
217
|
+
...(input.byMessage ? { byMessage: true } : {}),
|
|
208
218
|
...(input.interactive ? { interactive: true } : {}),
|
|
219
|
+
...(input.unattended ? { unattended: true } : {}),
|
|
220
|
+
...(input.timeLimitMs === undefined
|
|
221
|
+
? {}
|
|
222
|
+
: { timeLimitMs: input.timeLimitMs }),
|
|
209
223
|
...(interim ? { interim } : {}),
|
|
210
224
|
progress,
|
|
211
225
|
}),
|
|
@@ -223,7 +237,12 @@ export function conversationTurns(
|
|
|
223
237
|
try {
|
|
224
238
|
if (!result.ok && !result.stopped)
|
|
225
239
|
logger.error({ channel, kind, err: result.error }, "turn failed");
|
|
226
|
-
if (
|
|
240
|
+
if (isQuiet(result))
|
|
241
|
+
logger.info(
|
|
242
|
+
{ channel, kind },
|
|
243
|
+
"unattended turn had nothing to say; nothing posted",
|
|
244
|
+
);
|
|
245
|
+
else if (input.reply) await input.reply(result);
|
|
227
246
|
else await surfaces.sendReply(channel, replyBody(result));
|
|
228
247
|
} catch (error) {
|
|
229
248
|
logger.error({ channel, kind, err: error }, "reply not posted");
|
|
@@ -87,7 +87,7 @@ export function askUserExtension(
|
|
|
87
87
|
const prompts = slot.prompts;
|
|
88
88
|
if (!prompts)
|
|
89
89
|
return toolText(
|
|
90
|
-
`${o.name} cannot answer buttons in this turn
|
|
90
|
+
`${o.name} cannot answer buttons in this turn: no one wrote to you, a schedule or another agent started it, so nothing waits. Do not call ask_user again. Write the question as your final answer instead; it is posted to this channel for ${o.him} to read, and ${o.his} answer comes in a new turn.`,
|
|
91
91
|
);
|
|
92
92
|
const p = params as {
|
|
93
93
|
questions: {
|