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
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
approverOf,
|
|
21
21
|
askSpeaker,
|
|
22
22
|
awaitingText,
|
|
23
|
-
|
|
23
|
+
declinedRefusal,
|
|
24
24
|
type GateDecision,
|
|
25
25
|
type Needing,
|
|
26
26
|
namesOf,
|
|
@@ -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.
|
|
@@ -337,8 +341,8 @@ export class ConfirmationGate {
|
|
|
337
341
|
wait: {
|
|
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
|
-
const outcome = lateOutcome(call, late, watch);
|
|
341
|
-
if (outcome?.release) this.#
|
|
344
|
+
const outcome = lateOutcome(call, late, watch, needing.onDeclined);
|
|
345
|
+
if (outcome?.release) this.#late.add(outcome.release);
|
|
342
346
|
return outcome?.text;
|
|
343
347
|
},
|
|
344
348
|
},
|
|
@@ -372,13 +376,8 @@ export class ConfirmationGate {
|
|
|
372
376
|
reason: awaitingText(this.#addressee, call),
|
|
373
377
|
refused: "pending",
|
|
374
378
|
};
|
|
375
|
-
if (answer === "declined")
|
|
376
|
-
watch
|
|
377
|
-
return {
|
|
378
|
-
reason: declinedText(this.#addressee, call),
|
|
379
|
-
refused: "declined",
|
|
380
|
-
};
|
|
381
|
-
}
|
|
379
|
+
if (answer === "declined")
|
|
380
|
+
return declinedRefusal(this.#addressee, call, watch, needing.onDeclined);
|
|
382
381
|
if (answer === "cancelled") {
|
|
383
382
|
watch?.end();
|
|
384
383
|
return { reason: "The turn was stopped." };
|
|
@@ -390,7 +389,7 @@ export class ConfirmationGate {
|
|
|
390
389
|
/** What a call needs when a rule holds it; undefined when none does. */
|
|
391
390
|
#needing(tool: string, input: Record<string, unknown>): Needing | undefined {
|
|
392
391
|
const action = this.#holds(tool, input, this.#context);
|
|
393
|
-
if (!action) return
|
|
392
|
+
if (!action) return this.#ranUnheld(tool, input);
|
|
394
393
|
const classes = this.#holds.classesOf?.(tool, input, this.#context);
|
|
395
394
|
return this.#unlessApproved(tool, input, { action, classes });
|
|
396
395
|
}
|
|
@@ -412,10 +411,18 @@ export class ConfirmationGate {
|
|
|
412
411
|
if (!turn || !this.#holds.inTurn) return this.#needing(tool, input);
|
|
413
412
|
return this.#describer.describe(tool, input, turn, (judged) => {
|
|
414
413
|
if (judged === STOPPED) return STOPPED;
|
|
415
|
-
return judged
|
|
414
|
+
return judged
|
|
415
|
+
? this.#unlessApproved(tool, input, judged)
|
|
416
|
+
: this.#ranUnheld(tool, input);
|
|
416
417
|
});
|
|
417
418
|
}
|
|
418
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
|
+
|
|
419
426
|
/**
|
|
420
427
|
* The call for a description, unless an approval releases it, which that approval then spends
|
|
421
428
|
* and whose approver the release names.
|
|
@@ -425,21 +432,13 @@ export class ConfirmationGate {
|
|
|
425
432
|
input: Record<string, unknown>,
|
|
426
433
|
judged: Judgment,
|
|
427
434
|
): Needing {
|
|
428
|
-
const
|
|
429
|
-
const call
|
|
430
|
-
tool,
|
|
431
|
-
input: canonicalJson(input),
|
|
432
|
-
action: judged.action,
|
|
433
|
-
...(minTier ? { minTier } : {}),
|
|
434
|
-
...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
|
|
435
|
-
...(judged.classes && judged.classes.length > 0
|
|
436
|
-
? { holdClasses: [...judged.classes] }
|
|
437
|
-
: {}),
|
|
438
|
-
};
|
|
435
|
+
const tier = this.#holds.approvalTier?.(tool, input, this.#context);
|
|
436
|
+
const call = heldCall(tool, input, judged, tier);
|
|
439
437
|
// A plugin that already withdrew the call lets no approval of it release it.
|
|
440
438
|
const spent = judged.supersede?.aborted
|
|
441
439
|
? undefined
|
|
442
|
-
: spendApproval([this.#approved
|
|
440
|
+
: (spendApproval([this.#approved], call) ??
|
|
441
|
+
this.#late.spend(call, this.#lateSpender));
|
|
443
442
|
if (spent) {
|
|
444
443
|
this.#withdrawals.endCall(spent.call);
|
|
445
444
|
return { released: spent };
|
|
@@ -449,6 +448,7 @@ export class ConfirmationGate {
|
|
|
449
448
|
...(judged.copy ? { copy: judged.copy } : {}),
|
|
450
449
|
...(judged.supersede ? { supersede: judged.supersede } : {}),
|
|
451
450
|
...(judged.speakerOnly ? { speakerOnly: judged.speakerOnly } : {}),
|
|
451
|
+
...(judged.onDeclined ? { onDeclined: judged.onDeclined } : {}),
|
|
452
452
|
};
|
|
453
453
|
}
|
|
454
454
|
|
|
@@ -37,6 +37,8 @@ export type Needing =
|
|
|
37
37
|
supersede?: AbortSignal;
|
|
38
38
|
/** The card is the speaker's alone; see `HoldDescription`. */
|
|
39
39
|
speakerOnly?: true;
|
|
40
|
+
/** Called when the call's card is declined; see `HoldDescription`. */
|
|
41
|
+
onDeclined?: () => void | Promise<void>;
|
|
40
42
|
}
|
|
41
43
|
| { released: Release };
|
|
42
44
|
|
|
@@ -76,6 +78,36 @@ export function declinedText(addressee: OwnerIdentity, call: HeldCall): string {
|
|
|
76
78
|
return `${o.name} declined this on its approval card: it would ${call.action}. Do not retry it; go on without it, or ask ${o.him} what ${o.he} wants instead.`;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Tells the plugin that held a call that its card was declined (`HoldDescription.onDeclined`).
|
|
83
|
+
* The call is refused whatever the plugin does, so what its hook throws or rejects with is ignored.
|
|
84
|
+
*/
|
|
85
|
+
export function notifyDeclined(
|
|
86
|
+
onDeclined: (() => void | Promise<void>) | undefined,
|
|
87
|
+
): void {
|
|
88
|
+
if (!onDeclined) return;
|
|
89
|
+
try {
|
|
90
|
+
Promise.resolve(onDeclined()).catch(() => undefined);
|
|
91
|
+
} catch {
|
|
92
|
+
// A hook that throws changes nothing: the call is refused already.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The refusal of a call declined on its card the turn was still waiting on: ends the plugin's
|
|
98
|
+
* watch of the call, tells it through `onDeclined`, and gives the turn the text and the reason.
|
|
99
|
+
*/
|
|
100
|
+
export function declinedRefusal(
|
|
101
|
+
addressee: OwnerIdentity,
|
|
102
|
+
call: HeldCall,
|
|
103
|
+
watch: { end(): void } | undefined,
|
|
104
|
+
onDeclined: (() => void | Promise<void>) | undefined,
|
|
105
|
+
): { reason: string; refused: "declined" } {
|
|
106
|
+
watch?.end();
|
|
107
|
+
notifyDeclined(onDeclined);
|
|
108
|
+
return { reason: declinedText(addressee, call), refused: "declined" };
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
/** The text for a held call that the speaker's own message confirms. */
|
|
80
112
|
export function askSpeaker(addressee: OwnerIdentity, call: HeldCall): string {
|
|
81
113
|
const o = ownerWords(addressee);
|
|
@@ -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 {
|
|
@@ -36,6 +38,8 @@ export interface Judgment {
|
|
|
36
38
|
speakerOnly?: true;
|
|
37
39
|
/** The hold classes of the call; see `HoldRule.holdClasses`. Absent when the holds gave none. */
|
|
38
40
|
classes?: readonly string[] | undefined;
|
|
41
|
+
/** Called when the call's card is declined; see `HoldDescription`. */
|
|
42
|
+
onDeclined?: () => void | Promise<void>;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export type Judged = Judgment | undefined | typeof STOPPED;
|
|
@@ -59,11 +63,14 @@ function isSignal(value: unknown): value is AbortSignal {
|
|
|
59
63
|
export function described(
|
|
60
64
|
answer: HoldDescription,
|
|
61
65
|
tool: string,
|
|
62
|
-
): Pick<
|
|
66
|
+
): Pick<
|
|
67
|
+
Judgment,
|
|
68
|
+
"action" | "copy" | "supersede" | "speakerOnly" | "classes" | "onDeclined"
|
|
69
|
+
> {
|
|
63
70
|
if (typeof answer === "string") return { action: answer };
|
|
64
71
|
if (typeof answer !== "object" || answer === null)
|
|
65
72
|
return { action: messages().holdGeneric(tool) };
|
|
66
|
-
const { text, copy, supersede, approval, classes } = answer;
|
|
73
|
+
const { text, copy, supersede, approval, classes, onDeclined } = answer;
|
|
67
74
|
if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
|
|
68
75
|
const named = Array.isArray(classes)
|
|
69
76
|
? classes.filter((id): id is string => typeof id === "string" && id !== "")
|
|
@@ -72,6 +79,7 @@ export function described(
|
|
|
72
79
|
...(isSignal(supersede) ? { supersede } : {}),
|
|
73
80
|
...(approval === "speaker" ? { speakerOnly: true as const } : {}),
|
|
74
81
|
...(named.length > 0 ? { classes: named } : {}),
|
|
82
|
+
...(typeof onDeclined === "function" ? { onDeclined } : {}),
|
|
75
83
|
};
|
|
76
84
|
if (
|
|
77
85
|
!copy ||
|
|
@@ -142,6 +150,7 @@ export function joinApprovers(
|
|
|
142
150
|
supersede,
|
|
143
151
|
speakerOnly,
|
|
144
152
|
classes,
|
|
153
|
+
onDeclined,
|
|
145
154
|
} = described(action, tool);
|
|
146
155
|
const joined = (approvers: readonly string[] | undefined): Judgment => ({
|
|
147
156
|
action: text,
|
|
@@ -149,6 +158,7 @@ export function joinApprovers(
|
|
|
149
158
|
...(supersede ? { supersede } : {}),
|
|
150
159
|
...(speakerOnly ? { speakerOnly } : {}),
|
|
151
160
|
...(classes ? { classes } : {}),
|
|
161
|
+
...(onDeclined ? { onDeclined } : {}),
|
|
152
162
|
...(approvers ? { approvers } : {}),
|
|
153
163
|
});
|
|
154
164
|
const controller = new AbortController();
|
|
@@ -187,3 +197,22 @@ function withoutApprovers(
|
|
|
187
197
|
found === STOPPED || found === undefined ? found : described(found, tool);
|
|
188
198
|
return isPromise(answer) ? answer.then(wrap) : wrap(answer);
|
|
189
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HeldCall } from "../../domain/conversation.ts";
|
|
2
2
|
import type { LateAnswer } from "../../interactions/prompts.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { notifyDeclined, type Release } from "./gate-refusals.ts";
|
|
4
4
|
import { namedApprover } from "./held-calls.ts";
|
|
5
5
|
import type { Watch } from "./withdrawals.ts";
|
|
6
6
|
|
|
@@ -8,12 +8,14 @@ import type { Watch } from "./withdrawals.ts";
|
|
|
8
8
|
* What a card answered after its turn stopped waiting comes to: the text of the turn that tells
|
|
9
9
|
* the model, and, for an approval that stands, the release that lets the identical call run once
|
|
10
10
|
* in whichever turn makes it next. Undefined when the answer changes nothing: a question's, a
|
|
11
|
-
* card its plugin withdrew, or an approval by someone the call does not name.
|
|
11
|
+
* card its plugin withdrew, or an approval by someone the call does not name. A card declined
|
|
12
|
+
* tells the plugin through `onDeclined`.
|
|
12
13
|
*/
|
|
13
14
|
export function lateOutcome(
|
|
14
15
|
call: HeldCall,
|
|
15
16
|
late: LateAnswer,
|
|
16
17
|
watch: Watch | undefined,
|
|
18
|
+
onDeclined?: () => void | Promise<void>,
|
|
17
19
|
): { text: string; release?: Release } | undefined {
|
|
18
20
|
// A card the plugin withdrew answers nothing, whatever a surface that kept it open reports.
|
|
19
21
|
if (watch?.withdrawn || late.kind !== "approval") return undefined;
|
|
@@ -21,6 +23,7 @@ export function lateOutcome(
|
|
|
21
23
|
if (late.approved && !namedApprover(call, late.approvedBy)) return undefined;
|
|
22
24
|
if (!late.approved) {
|
|
23
25
|
watch?.end();
|
|
26
|
+
notifyDeclined(onDeclined);
|
|
24
27
|
return {
|
|
25
28
|
text: `(${late.by} declined on its card the action you were waiting on: it would ${call.action}. It did not run; do not make the call again. Go on without it, or ask what ${late.by} wants instead.)`,
|
|
26
29
|
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { HeldCall } from "../../domain/conversation.ts";
|
|
2
|
+
import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
|
|
3
|
+
import type { Release, SpeakerRef } from "./gate-refusals.ts";
|
|
4
|
+
import { sameCall } from "./held-calls.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How long a late approval's release waits for the call it was granted for. The turn that
|
|
8
|
+
* answers a late card re-makes the call at once, so an hour covers a slow model or a queue of
|
|
9
|
+
* turns and no more; a release nobody spent by then belongs to a call that is long gone.
|
|
10
|
+
*/
|
|
11
|
+
export const LATE_RELEASE_TTL_MS = 3_600_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The releases that cards answered after their turn stopped waiting earned. A release lets the
|
|
15
|
+
* identical call run once, only while it is fresh and only in a turn spoken by the person who gave it
|
|
16
|
+
* (or by an owner), only in a turn a person's message started, and it is dropped when the identical call
|
|
17
|
+
* runs without a hold, since that run is the call the person approved.
|
|
18
|
+
*/
|
|
19
|
+
export class LateReleases {
|
|
20
|
+
#list: { release: Release; at: number }[] = [];
|
|
21
|
+
|
|
22
|
+
add(release: Release, now = Date.now()): void {
|
|
23
|
+
this.#prune(now);
|
|
24
|
+
this.#list.push({ release, at: now });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Takes the release of `call` that `speaker` may spend out of the list; undefined when none
|
|
29
|
+
* is fresh and theirs. A release whose approver is unknown is anyone's.
|
|
30
|
+
*/
|
|
31
|
+
spend(
|
|
32
|
+
call: Pick<HeldCall, "tool" | "input">,
|
|
33
|
+
speaker: SpeakerRef | undefined,
|
|
34
|
+
now = Date.now(),
|
|
35
|
+
): Release | undefined {
|
|
36
|
+
this.#prune(now);
|
|
37
|
+
const at = this.#list.findIndex(
|
|
38
|
+
({ release }) =>
|
|
39
|
+
sameCall(release.call, call) && maySpend(release, speaker),
|
|
40
|
+
);
|
|
41
|
+
if (at === -1) return undefined;
|
|
42
|
+
const [spent] = this.#list.splice(at, 1);
|
|
43
|
+
return spent?.release;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Drops every release of `call`, whoever gave it. */
|
|
47
|
+
drop(call: Pick<HeldCall, "tool" | "input">): void {
|
|
48
|
+
this.#list = this.#list.filter(
|
|
49
|
+
({ release }) => !sameCall(release.call, call),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether a fresh release of `call` waits, for whoever may spend it. */
|
|
54
|
+
has(call: Pick<HeldCall, "tool" | "input">, now = Date.now()): boolean {
|
|
55
|
+
this.#prune(now);
|
|
56
|
+
return this.#list.some(({ release }) => sameCall(release.call, call));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#prune(now: number): void {
|
|
60
|
+
this.#list = this.#list.filter(({ at }) => now - at <= LATE_RELEASE_TTL_MS);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether `speaker` may spend `release`: they gave it, or are an owner; any speaker, when its
|
|
66
|
+
* giver is unknown. The gate hands only the speaker of a turn a person's
|
|
67
|
+
* message started; every other turn (schedule, agent message, report, error report) has none and
|
|
68
|
+
* spends nothing, so a card is shown again there. The system principal never spends one.
|
|
69
|
+
*/
|
|
70
|
+
function maySpend(release: Release, speaker: SpeakerRef | undefined): boolean {
|
|
71
|
+
if (!speaker || speaker.principalId === SYSTEM_PRINCIPAL) return false;
|
|
72
|
+
if (!release.by) return true;
|
|
73
|
+
return (
|
|
74
|
+
speaker.tier === "owner" || speaker.principalId === release.by.principalId
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -21,10 +21,9 @@ import type { TurnRequest } from "../domain/ports.ts";
|
|
|
21
21
|
import { HostStoppingError } from "../errors.ts";
|
|
22
22
|
import { assistantName } from "../i18n/index.ts";
|
|
23
23
|
import { promptScopeOf } from "../interactions/prompts.ts";
|
|
24
|
-
import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
|
|
25
24
|
import { withoutReplyFiles } from "../reply-files.ts";
|
|
26
25
|
import type { TransientTask } from "../sessions.ts";
|
|
27
|
-
import { type ToolTiers,
|
|
26
|
+
import { type ToolTiers, toolTiers } from "../tool-tiers.ts";
|
|
28
27
|
import { AbortGuard } from "./abort-guard.ts";
|
|
29
28
|
import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
|
|
30
29
|
import { ConversationSessions } from "./conversation-sessions.ts";
|
|
@@ -52,6 +51,7 @@ import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
|
|
|
52
51
|
import type { TurnBudget } from "./turn-budget.ts";
|
|
53
52
|
import { promptFailed, turnLimits } from "./turn-limits.ts";
|
|
54
53
|
import { progressReporter } from "./turn-progress.ts";
|
|
54
|
+
import { turnTools } from "./turn-tools.ts";
|
|
55
55
|
import { wrapUpTurn } from "./turn-wrapup.ts";
|
|
56
56
|
import { workerReport } from "./worker-task.ts";
|
|
57
57
|
|
|
@@ -62,8 +62,6 @@ export {
|
|
|
62
62
|
sessionExtensions,
|
|
63
63
|
} from "./runtime-types.ts";
|
|
64
64
|
|
|
65
|
-
const MEMORY = new Set<string>(MEMORY_TOOLS);
|
|
66
|
-
|
|
67
65
|
/** AgentRuntime over the Pi SDK: one persistent session per channel, each with its own loader. */
|
|
68
66
|
const STOPPED = "stopped by the owner";
|
|
69
67
|
|
|
@@ -183,18 +181,15 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
183
181
|
request.agent !== undefined &&
|
|
184
182
|
this.#options.agents?.memory === "owners" &&
|
|
185
183
|
tier !== "owner";
|
|
186
|
-
const wanted =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
]),
|
|
194
|
-
],
|
|
195
|
-
tier,
|
|
184
|
+
const wanted = turnTools(
|
|
185
|
+
{
|
|
186
|
+
selected: this.#factory.toolsFor(request.selection),
|
|
187
|
+
held: request.confirmed && pending ? pending.calls : [],
|
|
188
|
+
tier,
|
|
189
|
+
withheld,
|
|
190
|
+
},
|
|
196
191
|
this.#tiers,
|
|
197
|
-
)
|
|
192
|
+
);
|
|
198
193
|
const markPrivateMemory = recordMemoryTurn(
|
|
199
194
|
session.sessionManager,
|
|
200
195
|
request.speaker.principalId,
|
|
@@ -221,6 +216,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
221
216
|
request.speaker,
|
|
222
217
|
conversation.visibility === "private",
|
|
223
218
|
turnYolo(this.#options, request),
|
|
219
|
+
request.byMessage === true,
|
|
224
220
|
);
|
|
225
221
|
if (request.confirmed && pending && pending.calls.length > 0) {
|
|
226
222
|
logger.info(
|
|
@@ -231,7 +227,6 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
231
227
|
"confirmed actions released",
|
|
232
228
|
);
|
|
233
229
|
}
|
|
234
|
-
|
|
235
230
|
// Collected as they end: a compaction during the turn shortens session.messages.
|
|
236
231
|
const turnMessages: TurnMessage[] = [];
|
|
237
232
|
const toolCalls: string[] = [];
|
|
@@ -240,8 +235,12 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
240
235
|
// The caller's live view: the text as it streams and each tool, never the thinking.
|
|
241
236
|
const slot = this.#sessions.slot(key);
|
|
242
237
|
// Its time limit, not counting time waiting on the owner's cards, follows what it does to say why it fired.
|
|
243
|
-
const limits = turnLimits(
|
|
244
|
-
|
|
238
|
+
const limits = turnLimits(
|
|
239
|
+
this.#options,
|
|
240
|
+
slot,
|
|
241
|
+
request.channel,
|
|
242
|
+
() => guard.abort(),
|
|
243
|
+
request.timeLimitMs,
|
|
245
244
|
);
|
|
246
245
|
const progress = request.progress
|
|
247
246
|
? progressReporter(request.progress, {
|
|
@@ -333,7 +332,10 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
333
332
|
|
|
334
333
|
if (running.stopped)
|
|
335
334
|
return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
|
|
336
|
-
return
|
|
335
|
+
return (
|
|
336
|
+
limits.failure() ??
|
|
337
|
+
turnAnswer(turnMessages, running.steered, request.unattended !== true)
|
|
338
|
+
);
|
|
337
339
|
}
|
|
338
340
|
|
|
339
341
|
async steer(
|
|
@@ -77,11 +77,14 @@ export function thinkingOf(messages: readonly Message[]): string {
|
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* A finished turn's result from the messages it added. A steered run may have answered before
|
|
80
|
-
* the steer arrived, so every final answer of it is kept.
|
|
80
|
+
* the steer arrived, so every final answer of it is kept. A turn nobody waits on (`attended`
|
|
81
|
+
* false: a schedule's, an agent's message) may end with nothing to say, such as "send nothing if
|
|
82
|
+
* there is no progress"; that is an empty answer, not a failure. For an attended turn it is one.
|
|
81
83
|
*/
|
|
82
84
|
export function turnAnswer(
|
|
83
85
|
messages: readonly Message[],
|
|
84
86
|
steered: boolean,
|
|
87
|
+
attended: boolean,
|
|
85
88
|
): TurnResult {
|
|
86
89
|
const last = lastAssistant(messages);
|
|
87
90
|
if (!last.ok) return { ok: false, error: new AgentRunError(last.error) };
|
|
@@ -97,6 +100,7 @@ export function turnAnswer(
|
|
|
97
100
|
.join("\n\n")
|
|
98
101
|
: textOf(last.message.content)
|
|
99
102
|
).trim();
|
|
103
|
+
if (!text && !hasReplyFiles() && !attended) return { ok: true, text: "" };
|
|
100
104
|
if (!text && !hasReplyFiles())
|
|
101
105
|
return {
|
|
102
106
|
ok: false,
|
|
@@ -35,7 +35,8 @@ export function timeoutMessage(timeout: TurnTimeout): string {
|
|
|
35
35
|
/**
|
|
36
36
|
* The time limit of a turn of the built-in runtime; see `runtimeTurnBudget`. `abort` aborts the
|
|
37
37
|
* turn. In a long-running conversation (`options.modes`) a limit extends the turn instead of
|
|
38
|
-
* asking or aborting, until the turn has worked `options.autonomy.longRunningMaxMs`.
|
|
38
|
+
* asking or aborting, until the turn has worked `options.autonomy.longRunningMaxMs`. A
|
|
39
|
+
* `timeLimitMs` replaces the turn's limit and its cap, both held under that same ceiling.
|
|
39
40
|
*/
|
|
40
41
|
export function turnLimits(
|
|
41
42
|
options: Parameters<typeof runtimeTurnBudget>[0] &
|
|
@@ -43,15 +44,24 @@ export function turnLimits(
|
|
|
43
44
|
slot: Pick<PromptSlot, "waitedMs" | "cards">,
|
|
44
45
|
channel: ChannelKey,
|
|
45
46
|
abort: () => void,
|
|
47
|
+
timeLimitMs?: number,
|
|
46
48
|
): TurnLimits {
|
|
47
49
|
const { longRunningMaxMs } = options.autonomy ?? DEFAULT_AUTONOMY;
|
|
50
|
+
const limited =
|
|
51
|
+
timeLimitMs === undefined
|
|
52
|
+
? options
|
|
53
|
+
: {
|
|
54
|
+
...options,
|
|
55
|
+
turnTimeoutMs: Math.min(timeLimitMs, longRunningMaxMs),
|
|
56
|
+
turnMaxMs: Math.min(timeLimitMs, longRunningMaxMs),
|
|
57
|
+
};
|
|
48
58
|
const keepGoing = (workedMs: number) =>
|
|
49
59
|
workedMs < longRunningMaxMs &&
|
|
50
60
|
options.modes?.peek(channel).longRunning === true;
|
|
51
61
|
const activity = activityTracker();
|
|
52
62
|
let timedOut: TurnTimeout | undefined;
|
|
53
63
|
const budget = runtimeTurnBudget(
|
|
54
|
-
|
|
64
|
+
limited,
|
|
55
65
|
slot,
|
|
56
66
|
channel,
|
|
57
67
|
(timeout) => {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HeldCall } from "../domain/conversation.ts";
|
|
2
|
+
import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
|
|
3
|
+
import type { Tier } from "../speakers.ts";
|
|
4
|
+
import { type ToolTiers, toolsForTier } from "../tool-tiers.ts";
|
|
5
|
+
|
|
6
|
+
const MEMORY = new Set<string>(MEMORY_TOOLS);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The tools a turn is given: its selection, and the tools of the held calls an approving turn
|
|
10
|
+
* releases, limited to the speaker's tier; a speaker kept out of memory gets none of its tools.
|
|
11
|
+
*/
|
|
12
|
+
export function turnTools(
|
|
13
|
+
turn: {
|
|
14
|
+
selected: readonly string[];
|
|
15
|
+
held: readonly Pick<HeldCall, "tool">[];
|
|
16
|
+
tier: Tier;
|
|
17
|
+
withheld: boolean;
|
|
18
|
+
},
|
|
19
|
+
tiers: ToolTiers,
|
|
20
|
+
): string[] {
|
|
21
|
+
return toolsForTier(
|
|
22
|
+
[...new Set([...turn.selected, ...turn.held.map((call) => call.tool)])],
|
|
23
|
+
turn.tier,
|
|
24
|
+
tiers,
|
|
25
|
+
).filter((name) => !turn.withheld || !MEMORY.has(name));
|
|
26
|
+
}
|
|
@@ -13,6 +13,9 @@ export const SCHEDULE_TOOLS = Object.freeze([
|
|
|
13
13
|
] as const);
|
|
14
14
|
export type ScheduleToolName = (typeof SCHEDULE_TOOLS)[number];
|
|
15
15
|
|
|
16
|
+
/** The most minutes a schedule's own time limit may name: the four hours a long-running turn may work. */
|
|
17
|
+
export const MAX_SCHEDULE_TIME_LIMIT_MINUTES = 240;
|
|
18
|
+
|
|
16
19
|
export function isScheduleTool(value: string): value is ScheduleToolName {
|
|
17
20
|
return (SCHEDULE_TOOLS as readonly string[]).includes(value);
|
|
18
21
|
}
|
|
@@ -62,6 +65,8 @@ const timing = (zone: string) => ({
|
|
|
62
65
|
),
|
|
63
66
|
});
|
|
64
67
|
|
|
68
|
+
const TIME_LIMIT = `How long, in minutes, each run may work before it is stopped (at most ${MAX_SCHEDULE_TIME_LIMIT_MINUTES}); without it a run is stopped after the host's default of 10 minutes. Set it for a task that works longer.`;
|
|
69
|
+
|
|
65
70
|
export interface ScheduleToolSpec {
|
|
66
71
|
name: ScheduleToolName;
|
|
67
72
|
label: string;
|
|
@@ -118,6 +123,13 @@ export function scheduleToolSpecs(
|
|
|
118
123
|
"The name of a precheck the host runs first, from schedule_list; you are woken only when it finds something.",
|
|
119
124
|
}),
|
|
120
125
|
),
|
|
126
|
+
time_limit_minutes: Type.Optional(
|
|
127
|
+
Type.Integer({
|
|
128
|
+
minimum: 1,
|
|
129
|
+
maximum: MAX_SCHEDULE_TIME_LIMIT_MINUTES,
|
|
130
|
+
description: TIME_LIMIT,
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
121
133
|
...script(false),
|
|
122
134
|
}),
|
|
123
135
|
},
|
|
@@ -133,7 +145,7 @@ export function scheduleToolSpecs(
|
|
|
133
145
|
name: "schedule_update",
|
|
134
146
|
label: "Update schedule",
|
|
135
147
|
description:
|
|
136
|
-
"Change one of this channel's schedules: its title, its prompt, its timing (timing fields replace the old timing), or its
|
|
148
|
+
"Change one of this channel's schedules: its title, its prompt, its timing (timing fields replace the old timing), its precheck, or its time limit. A run can use this to keep its own prompt current, such as adding what it already reported.",
|
|
137
149
|
parameters: Type.Object({
|
|
138
150
|
id: Type.Integer({ description: "Schedule id." }),
|
|
139
151
|
title: Type.Optional(Type.String()),
|
|
@@ -147,6 +159,18 @@ export function scheduleToolSpecs(
|
|
|
147
159
|
"A precheck's name from schedule_list to run first, or null to remove the schedule's precheck.",
|
|
148
160
|
}),
|
|
149
161
|
),
|
|
162
|
+
time_limit_minutes: Type.Optional(
|
|
163
|
+
Type.Union(
|
|
164
|
+
[
|
|
165
|
+
Type.Integer({
|
|
166
|
+
minimum: 1,
|
|
167
|
+
maximum: MAX_SCHEDULE_TIME_LIMIT_MINUTES,
|
|
168
|
+
}),
|
|
169
|
+
Type.Null(),
|
|
170
|
+
],
|
|
171
|
+
{ description: `${TIME_LIMIT} null returns it to the default.` },
|
|
172
|
+
),
|
|
173
|
+
),
|
|
150
174
|
...script(true),
|
|
151
175
|
}),
|
|
152
176
|
},
|