pi-roundtable 0.26.1 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/docs/plugins.md +12 -7
  3. package/package.json +1 -1
  4. package/src/core/agents/agent-claim.ts +1 -0
  5. package/src/core/agents/agent-conversations.ts +94 -0
  6. package/src/core/agents/agent-messages.ts +247 -56
  7. package/src/core/agents/agent-outbox.ts +91 -0
  8. package/src/core/agents/agent-prompt.ts +40 -1
  9. package/src/core/agents/agent-schema.ts +6 -0
  10. package/src/core/agents/agent-store.ts +22 -3
  11. package/src/core/agents/agent-team-fixture.ts +81 -88
  12. package/src/core/agents/agent-team-seeds.ts +29 -0
  13. package/src/core/agents/agent-team.ts +17 -14
  14. package/src/core/agents/agent-tools.ts +34 -5
  15. package/src/core/agents/group-turns.ts +1 -1
  16. package/src/core/agents/message-target.ts +79 -0
  17. package/src/core/agents/team-editing.ts +30 -2
  18. package/src/core/agents/team-keys.ts +7 -0
  19. package/src/core/agents/team-options.ts +3 -0
  20. package/src/core/agents/team-text.ts +4 -0
  21. package/src/core/agents/team-turn-types.ts +28 -0
  22. package/src/core/agents/team-turns.ts +36 -25
  23. package/src/core/assets/prompts/shared-guest.md +1 -3
  24. package/src/core/assets/prompts/shared.md +1 -3
  25. package/src/core/builtin/agent-server.ts +5 -0
  26. package/src/core/builtin/modules.ts +6 -0
  27. package/src/core/contract/channels.ts +5 -0
  28. package/src/core/contract/surface.ts +5 -0
  29. package/src/core/discord/channel-context.ts +3 -1
  30. package/src/core/discord/embed-text.ts +45 -0
  31. package/src/core/discord/inbound-message.ts +7 -2
  32. package/src/core/domain/ports.ts +17 -0
  33. package/src/core/domain/turn-timeout.ts +2 -0
  34. package/src/core/i18n/turn-timeout.ts +8 -2
  35. package/src/core/modules/background/background-turns.ts +3 -0
  36. package/src/core/modules/schedules/precheck-catalog.ts +49 -0
  37. package/src/core/modules/schedules/schedule-store.ts +27 -3
  38. package/src/core/modules/schedules/schedule-tools.ts +43 -86
  39. package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
  40. package/src/core/modules/schedules/schedules.ts +26 -2
  41. package/src/core/modules/schedules/time-limit.ts +27 -0
  42. package/src/core/presentation/reply-body.ts +8 -0
  43. package/src/core/routing/conversation-turns.ts +21 -2
  44. package/src/core/runtime/extensions/ask-user.ts +1 -1
  45. package/src/core/runtime/extensions/confirmation-gate.ts +31 -27
  46. package/src/core/runtime/extensions/hold-lookups.ts +21 -0
  47. package/src/core/runtime/extensions/late-releases.ts +76 -0
  48. package/src/core/runtime/pi-agent-runtime.ts +21 -19
  49. package/src/core/runtime/turn-answer.ts +5 -1
  50. package/src/core/runtime/turn-budget.ts +1 -0
  51. package/src/core/runtime/turn-limits.ts +12 -2
  52. package/src/core/runtime/turn-tools.ts +26 -0
  53. package/src/core/shared/schedule-tools.ts +25 -1
@@ -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, toolsForTier, toolTiers } from "../tool-tiers.ts";
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 = toolsForTier(
187
- [
188
- ...new Set([
189
- ...this.#factory.toolsFor(request.selection),
190
- ...(request.confirmed && pending
191
- ? pending.calls.map((call) => call.tool)
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
- ).filter((name) => !withheld || !MEMORY.has(name));
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(this.#options, slot, request.channel, () =>
244
- guard.abort(),
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 limits.failure() ?? turnAnswer(turnMessages, running.steered);
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,
@@ -167,6 +167,7 @@ export function runtimeTurnBudget(
167
167
  ...seams.activity.now(clock.now()),
168
168
  graceMs,
169
169
  end,
170
+ ...(slot.cards ? {} : { unasked: true as const }),
170
171
  });
171
172
  const stop = (timeout: TurnTimeout) => {
172
173
  logger.warn(
@@ -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
- options,
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 precheck. A run can use this to keep its own prompt current, such as adding what it already reported.",
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
  },