pi-roundtable 0.26.1 → 0.26.3
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 +45 -0
- package/docs/plugins.md +12 -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/contract/channels.ts +5 -0
- package/src/core/contract/surface.ts +5 -0
- 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/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 +31 -27
- package/src/core/runtime/extensions/hold-lookups.ts +21 -0
- 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
|
@@ -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: {
|
|
@@ -40,8 +40,14 @@ import {
|
|
|
40
40
|
unexpired,
|
|
41
41
|
} from "./held-calls.ts";
|
|
42
42
|
import { HoldDescriber } from "./hold-describer.ts";
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
type HoldTurn,
|
|
45
|
+
heldCall,
|
|
46
|
+
type Judgment,
|
|
47
|
+
STOPPED,
|
|
48
|
+
} from "./hold-lookups.ts";
|
|
44
49
|
import { lateOutcome } from "./late-answer.ts";
|
|
50
|
+
import { LateReleases } from "./late-releases.ts";
|
|
45
51
|
|
|
46
52
|
export { confirmationGateExtension } from "./confirmation-gate-extension.ts";
|
|
47
53
|
export {
|
|
@@ -70,14 +76,13 @@ export class ConfirmationGate {
|
|
|
70
76
|
#selectionId: string | undefined;
|
|
71
77
|
/** The running turn's speaker, whom the actions it holds belong to, and who approves those a message confirmed. */
|
|
72
78
|
#speaker: SpeakerRef | undefined;
|
|
79
|
+
#lateSpender: SpeakerRef | undefined;
|
|
73
80
|
/** Whether the running turn's conversation is private to its speaker, who then alone answers its cards. */
|
|
74
81
|
#private = false;
|
|
75
|
-
/** The running turn's auto-approver, asked before any card opens. */
|
|
76
82
|
#auto: AutoApprover | undefined;
|
|
77
83
|
#pending: PendingConfirmation | undefined;
|
|
78
84
|
#approved: Release[] = [];
|
|
79
|
-
|
|
80
|
-
#lateApproved: Release[] = [];
|
|
85
|
+
readonly #late = new LateReleases();
|
|
81
86
|
/** Who approved each call that is running, and on which card, by tool call id, until its result is back. */
|
|
82
87
|
readonly #grants = new Grants();
|
|
83
88
|
readonly #holds: HoldCheck;
|
|
@@ -162,7 +167,7 @@ export class ConfirmationGate {
|
|
|
162
167
|
* the approver the calls' `run` sees; a call that named approvers is released only when the
|
|
163
168
|
* speaker is one of them or an owner, and otherwise stays held. `isPrivate` says the turn's
|
|
164
169
|
* conversation is private to its speaker: they alone then answer its cards. `auto` may approve
|
|
165
|
-
* a call
|
|
170
|
+
* a call without a card (yolo mode). `byMessage`: a person's message started the turn.
|
|
166
171
|
*/
|
|
167
172
|
beginTurn(
|
|
168
173
|
selectionId: string,
|
|
@@ -171,9 +176,11 @@ export class ConfirmationGate {
|
|
|
171
176
|
speaker?: SpeakerRef,
|
|
172
177
|
isPrivate = false,
|
|
173
178
|
auto?: AutoApprover,
|
|
179
|
+
byMessage = false,
|
|
174
180
|
): void {
|
|
175
181
|
this.#selectionId = selectionId;
|
|
176
182
|
this.#speaker = speaker;
|
|
183
|
+
this.#lateSpender = byMessage ? speaker : undefined;
|
|
177
184
|
this.#private = isPrivate;
|
|
178
185
|
this.#auto = auto;
|
|
179
186
|
this.#addressee = addressee ?? this.#owner;
|
|
@@ -184,7 +191,6 @@ export class ConfirmationGate {
|
|
|
184
191
|
call,
|
|
185
192
|
...(by ? { by } : {}),
|
|
186
193
|
}));
|
|
187
|
-
// What the speaker could not release stays held for those who may.
|
|
188
194
|
const kept = (held?.calls ?? []).filter(
|
|
189
195
|
(call) => confirmed && !released?.calls.includes(call),
|
|
190
196
|
);
|
|
@@ -214,17 +220,15 @@ export class ConfirmationGate {
|
|
|
214
220
|
#sweep(): void {
|
|
215
221
|
const kept = (call: HeldCall) =>
|
|
216
222
|
(this.#pending?.calls.some((c) => sameCall(c, call)) ?? false) ||
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
);
|
|
223
|
+
this.#approved.some((r) => sameCall(r.call, call)) ||
|
|
224
|
+
this.#late.has(call);
|
|
220
225
|
this.#withdrawals.sweep(kept);
|
|
221
226
|
}
|
|
222
227
|
|
|
223
228
|
/** A plugin withdrew `call`: nothing that could run it stays, and what is held is kept anew. */
|
|
224
229
|
#forget(call: HeldCall): void {
|
|
225
|
-
|
|
226
|
-
this.#
|
|
227
|
-
this.#lateApproved = this.#lateApproved.filter(other);
|
|
230
|
+
this.#approved = this.#approved.filter((r) => !sameCall(r.call, call));
|
|
231
|
+
this.#late.drop(call);
|
|
228
232
|
const pending = this.#pending;
|
|
229
233
|
if (!pending?.calls.some((c) => sameCall(c, call))) return;
|
|
230
234
|
// In place, so a partial release's kept set stays the object `remembered` compares.
|
|
@@ -338,7 +342,7 @@ export class ConfirmationGate {
|
|
|
338
342
|
// A late answer on the card: the text of the turn that tells the model, and the release it earned.
|
|
339
343
|
late: (late) => {
|
|
340
344
|
const outcome = lateOutcome(call, late, watch, needing.onDeclined);
|
|
341
|
-
if (outcome?.release) this.#
|
|
345
|
+
if (outcome?.release) this.#late.add(outcome.release);
|
|
342
346
|
return outcome?.text;
|
|
343
347
|
},
|
|
344
348
|
},
|
|
@@ -385,7 +389,7 @@ export class ConfirmationGate {
|
|
|
385
389
|
/** What a call needs when a rule holds it; undefined when none does. */
|
|
386
390
|
#needing(tool: string, input: Record<string, unknown>): Needing | undefined {
|
|
387
391
|
const action = this.#holds(tool, input, this.#context);
|
|
388
|
-
if (!action) return
|
|
392
|
+
if (!action) return this.#ranUnheld(tool, input);
|
|
389
393
|
const classes = this.#holds.classesOf?.(tool, input, this.#context);
|
|
390
394
|
return this.#unlessApproved(tool, input, { action, classes });
|
|
391
395
|
}
|
|
@@ -407,10 +411,18 @@ export class ConfirmationGate {
|
|
|
407
411
|
if (!turn || !this.#holds.inTurn) return this.#needing(tool, input);
|
|
408
412
|
return this.#describer.describe(tool, input, turn, (judged) => {
|
|
409
413
|
if (judged === STOPPED) return STOPPED;
|
|
410
|
-
return judged
|
|
414
|
+
return judged
|
|
415
|
+
? this.#unlessApproved(tool, input, judged)
|
|
416
|
+
: this.#ranUnheld(tool, input);
|
|
411
417
|
});
|
|
412
418
|
}
|
|
413
419
|
|
|
420
|
+
/** A call no hold stops runs as the one a late approval was for, so that approval is spent. */
|
|
421
|
+
#ranUnheld(tool: string, input: Record<string, unknown>): undefined {
|
|
422
|
+
this.#late.drop({ tool, input: canonicalJson(input) });
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
414
426
|
/**
|
|
415
427
|
* The call for a description, unless an approval releases it, which that approval then spends
|
|
416
428
|
* and whose approver the release names.
|
|
@@ -420,21 +432,13 @@ export class ConfirmationGate {
|
|
|
420
432
|
input: Record<string, unknown>,
|
|
421
433
|
judged: Judgment,
|
|
422
434
|
): Needing {
|
|
423
|
-
const
|
|
424
|
-
const call
|
|
425
|
-
tool,
|
|
426
|
-
input: canonicalJson(input),
|
|
427
|
-
action: judged.action,
|
|
428
|
-
...(minTier ? { minTier } : {}),
|
|
429
|
-
...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
|
|
430
|
-
...(judged.classes && judged.classes.length > 0
|
|
431
|
-
? { holdClasses: [...judged.classes] }
|
|
432
|
-
: {}),
|
|
433
|
-
};
|
|
435
|
+
const tier = this.#holds.approvalTier?.(tool, input, this.#context);
|
|
436
|
+
const call = heldCall(tool, input, judged, tier);
|
|
434
437
|
// A plugin that already withdrew the call lets no approval of it release it.
|
|
435
438
|
const spent = judged.supersede?.aborted
|
|
436
439
|
? undefined
|
|
437
|
-
: spendApproval([this.#approved
|
|
440
|
+
: (spendApproval([this.#approved], call) ??
|
|
441
|
+
this.#late.spend(call, this.#lateSpender));
|
|
438
442
|
if (spent) {
|
|
439
443
|
this.#withdrawals.endCall(spent.call);
|
|
440
444
|
return { released: spent };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolTurn } from "../../define.ts";
|
|
2
|
+
import type { HeldCall } from "../../domain/conversation.ts";
|
|
2
3
|
import {
|
|
3
4
|
type HoldCheck,
|
|
4
5
|
type HoldContext,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
} from "../../holds.ts";
|
|
8
9
|
import { messages } from "../../i18n/index.ts";
|
|
9
10
|
import type { ApprovalDetails } from "../../interactions/prompts.ts";
|
|
11
|
+
import { canonicalJson } from "./held-calls.ts";
|
|
10
12
|
|
|
11
13
|
/** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
|
|
12
14
|
export interface HoldTurn {
|
|
@@ -195,3 +197,22 @@ function withoutApprovers(
|
|
|
195
197
|
found === STOPPED || found === undefined ? found : described(found, tool);
|
|
196
198
|
return isPromise(answer) ? answer.then(wrap) : wrap(answer);
|
|
197
199
|
}
|
|
200
|
+
|
|
201
|
+
/** The held call for a tool call and the description a hold gave it. */
|
|
202
|
+
export function heldCall(
|
|
203
|
+
tool: string,
|
|
204
|
+
input: Record<string, unknown>,
|
|
205
|
+
judged: Judgment,
|
|
206
|
+
minTier: HeldCall["minTier"],
|
|
207
|
+
): HeldCall {
|
|
208
|
+
return {
|
|
209
|
+
tool,
|
|
210
|
+
input: canonicalJson(input),
|
|
211
|
+
action: judged.action,
|
|
212
|
+
...(minTier ? { minTier } : {}),
|
|
213
|
+
...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
|
|
214
|
+
...(judged.classes && judged.classes.length > 0
|
|
215
|
+
? { holdClasses: [...judged.classes] }
|
|
216
|
+
: {}),
|
|
217
|
+
};
|
|
218
|
+
}
|