pi-roundtable 0.27.1 → 0.28.0

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 (49) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/README.zh-TW.md +2 -0
  4. package/docs/plugins.md +29 -13
  5. package/package.json +1 -1
  6. package/src/characters/compose.ts +11 -0
  7. package/src/characters/parts-face.ts +23 -2
  8. package/src/core/agents/agent-messages.ts +32 -85
  9. package/src/core/agents/agent-ports.ts +3 -0
  10. package/src/core/agents/agent-prompt.ts +2 -16
  11. package/src/core/agents/agent-tools.ts +1 -1
  12. package/src/core/agents/helper-create.ts +3 -24
  13. package/src/core/agents/helper-modes.ts +64 -0
  14. package/src/core/agents/team-turn-types.ts +7 -13
  15. package/src/core/agents/team-turns.ts +7 -2
  16. package/src/core/builtin/agent-server.ts +3 -4
  17. package/src/core/builtin/discord.ts +35 -8
  18. package/src/core/config/adapters.ts +2 -1
  19. package/src/core/config/limits.ts +35 -2
  20. package/src/core/discord/agent-discord.ts +12 -1
  21. package/src/core/discord/card-copies.ts +7 -2
  22. package/src/core/discord/discord-surface.ts +13 -30
  23. package/src/core/discord/intercom-cards.ts +121 -0
  24. package/src/core/discord/owner-card-asks.ts +9 -0
  25. package/src/core/discord/owner-card-types.ts +2 -0
  26. package/src/core/discord/owner-cards.ts +2 -1
  27. package/src/core/discord/stop-button.ts +35 -0
  28. package/src/core/domain/conversation.ts +18 -0
  29. package/src/core/domain/interim.ts +7 -0
  30. package/src/core/domain/turn-timeout.ts +5 -0
  31. package/src/core/i18n/conversation.ts +16 -4
  32. package/src/core/i18n/turn-timeout.ts +10 -2
  33. package/src/core/interactions/prompts.ts +5 -0
  34. package/src/core/modes/autonomy.ts +22 -1
  35. package/src/core/modes/conversation-modes.ts +135 -23
  36. package/src/core/modes/mode-inheritance.ts +78 -0
  37. package/src/core/modes/modes-plugin.ts +1 -1
  38. package/src/core/modules/skills/skill-contract.ts +2 -0
  39. package/src/core/presentation/intercom.ts +44 -0
  40. package/src/core/runtime/ask-questions.ts +21 -4
  41. package/src/core/runtime/extensions/ask-user-text.ts +36 -3
  42. package/src/core/runtime/extensions/ask-user.ts +21 -1
  43. package/src/core/runtime/interim-text.ts +7 -4
  44. package/src/core/runtime/pi-agent-runtime.ts +2 -2
  45. package/src/core/runtime/prompt-slot.ts +11 -1
  46. package/src/core/runtime/session-factory.ts +11 -6
  47. package/src/core/runtime/turn-budget.ts +34 -3
  48. package/src/core/runtime/turn-limits.ts +13 -3
  49. package/src/index.ts +1 -0
@@ -0,0 +1,78 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+ import type { ConversationMode } from "./conversation-modes.ts";
3
+
4
+ /**
5
+ * What the agent server says of a conversation that follows another's modes, given to
6
+ * `ConversationModes.trackOrigins`.
7
+ */
8
+ export interface ModeOrigins {
9
+ /**
10
+ * Whether `channel` still follows `origin`, as a temp knight's conversation follows the one it
11
+ * was made from, and the origin's name to show: undefined when it no longer does (its agent is
12
+ * gone, or the name is another agent's now), so its own modes count again; `alive: false` when
13
+ * the origin conversation is gone, so both modes read off.
14
+ */
15
+ originOf(
16
+ channel: ChannelKey,
17
+ origin: ChannelKey,
18
+ ): { name: string; alive: boolean } | undefined;
19
+ }
20
+
21
+ /** A conversation's stored modes, and the conversation it follows when it follows one. */
22
+ export interface StoredMode {
23
+ own: ConversationMode;
24
+ follows?: ChannelKey;
25
+ }
26
+
27
+ const OFF: ConversationMode = Object.freeze({
28
+ yolo: false,
29
+ longRunning: false,
30
+ });
31
+
32
+ /**
33
+ * A conversation's modes as they apply now: its own, or, for one that follows another, the
34
+ * origin's own modes with `inheritedFrom`, both off when the origin is gone. One level only: an
35
+ * origin's modes are its own, whatever it follows. Without `origins` an origin counts as alive.
36
+ */
37
+ export function resolveMode(
38
+ channel: ChannelKey,
39
+ stored: (channel: ChannelKey) => StoredMode | undefined,
40
+ origins: ModeOrigins | undefined,
41
+ ): ConversationMode {
42
+ const mine = stored(channel);
43
+ if (!mine) return OFF;
44
+ const follows = mine.follows;
45
+ if (follows === undefined) return mine.own;
46
+ const origin = origins
47
+ ? origins.originOf(channel, follows)
48
+ : { name: undefined, alive: true };
49
+ if (!origin) return mine.own;
50
+ const from = origin.alive ? (stored(follows)?.own ?? OFF) : OFF;
51
+ return Object.freeze({
52
+ yolo: from.yolo,
53
+ longRunning: from.longRunning,
54
+ ...(from.changedBy === undefined ? {} : { changedBy: from.changedBy }),
55
+ ...(from.changedAt === undefined ? {} : { changedAt: from.changedAt }),
56
+ inheritedFrom: {
57
+ channel: follows,
58
+ ...(origin.name === undefined ? {} : { name: origin.name }),
59
+ ...(origin.alive ? {} : { gone: true as const }),
60
+ },
61
+ });
62
+ }
63
+
64
+ /** The conversations that follow `origin` now, from every stored one. */
65
+ export function followersOf(
66
+ origin: ChannelKey,
67
+ all: Iterable<[ChannelKey, StoredMode]>,
68
+ origins: ModeOrigins | undefined,
69
+ ): ChannelKey[] {
70
+ const found: ChannelKey[] = [];
71
+ for (const [channel, mode] of all)
72
+ if (
73
+ mode.follows === origin &&
74
+ (!origins || origins.originOf(channel, origin) !== undefined)
75
+ )
76
+ found.push(channel);
77
+ return found;
78
+ }
@@ -13,7 +13,7 @@ export const MODES_PLUGIN = "modes";
13
13
  export function modesPlugin(): RoundtablePlugin {
14
14
  return {
15
15
  name: MODES_PLUGIN,
16
- migrations: [PgConversationModes.migration],
16
+ migrations: [...PgConversationModes.migrations],
17
17
  provides: [MODES],
18
18
  setup: ({ database, logger, services }) => {
19
19
  const modes = PgConversationModes.attach(database(), logger);
@@ -41,6 +41,8 @@ export interface SkillRegistry {
41
41
  ): Promise<SkillDocument>;
42
42
  /** Makes a skill from a whole SKILL.md, as `skill_create` does; it is attached to no one. Throws AgentError for a taken or refused name or text. */
43
43
  createDocument(text: string, by: string): Promise<SkillDocument>;
44
+ /** Deletes an agent-written skill and detaches it from every agent and group, as `skill_delete` does; returns what it was detached from. Throws AgentError for any other skill. */
45
+ delete(name: string): Promise<string>;
44
46
  /** The saved versions of an agent-written skill, newest first; none for any other skill. */
45
47
  documentHistory(name: string): Promise<SkillVersion[]>;
46
48
  /** Adds and removes an agent's registered skills together, or changes nothing; returns what it carries after. */
@@ -0,0 +1,44 @@
1
+ import type { IntercomCard } from "../domain/conversation.ts";
2
+ import { messages } from "../i18n/index.ts";
3
+
4
+ /** The most characters of a card's summary line. */
5
+ export const INTERCOM_SUMMARY_CHARS = 120;
6
+
7
+ /**
8
+ * The first line of the text that has words in it, without a Markdown heading's marks, cut to
9
+ * `max` characters with an ellipsis and never through a surrogate pair.
10
+ */
11
+ export function intercomSummary(
12
+ text: string,
13
+ max = INTERCOM_SUMMARY_CHARS,
14
+ ): string {
15
+ const line =
16
+ text
17
+ .split("\n")
18
+ .map((one) => one.trim())
19
+ .find((one) => one !== "") ?? "";
20
+ const plain = line.replace(/^#{1,6}\s+/, "");
21
+ if (plain.length <= max) return plain;
22
+ const last = plain.charCodeAt(max - 2);
23
+ const cut = last >= 0xd800 && last <= 0xdbff ? max - 2 : max - 1;
24
+ return `${plain.slice(0, cut)}…`;
25
+ }
26
+
27
+ /** Whether the summary leaves part of the text out, so the card offers to open to the rest. */
28
+ export function intercomFolds(card: IntercomCard): boolean {
29
+ return intercomSummary(card.text) !== card.text.trim();
30
+ }
31
+
32
+ /** The card's heading: whom the request went to, or whom the answer came from. */
33
+ export function intercomHeading(card: IntercomCard): string {
34
+ const text = messages();
35
+ const name = card.peer.displayName;
36
+ if (card.kind === "sent") return text.intercomSent(name);
37
+ if (card.kind === "answered") return text.intercomAnswered(name);
38
+ return text.intercomFailed(name);
39
+ }
40
+
41
+ /** The card as plain text, for a surface that shows no cards: its heading, then the whole text. */
42
+ export function intercomPlain(card: IntercomCard): string {
43
+ return `${intercomHeading(card)}\n${card.text}`;
44
+ }
@@ -12,6 +12,7 @@ import {
12
12
  lateAnswerText,
13
13
  lateQuestionnaireText,
14
14
  questionnaireText,
15
+ restartingText,
15
16
  sequentialText,
16
17
  } from "./extensions/ask-user-text.ts";
17
18
 
@@ -19,7 +20,11 @@ import {
19
20
  * Asks a questionnaire on `prompts` and returns the tool's result text. A set the core refuses
20
21
  * posts no card. One question without previews, and a surface without `questionnaire` or with
21
22
  * room for fewer questions, go through `ask`: one question exactly as before 0.20, several one
22
- * at a time, stopping at the first that does not come back answered.
23
+ * at a time, stopping at the first that does not come back answered. While the host stops
24
+ * (`stopping`), no card is posted, and one left waiting is reported lost: open cards do not
25
+ * survive the restart. With `yoloGraceMs`, the turn's conversation is in yolo mode: each card
26
+ * holds the turn that long at most, and one left unanswered tells it to go on with its own best
27
+ * choice instead of ending its turn; the restart still wins over that.
23
28
  */
24
29
  export async function askQuestions(
25
30
  prompts: Prompts,
@@ -27,9 +32,16 @@ export async function askQuestions(
27
32
  questionnaire: Questionnaire,
28
33
  owner: OwnerIdentity,
29
34
  signal: AbortSignal | undefined,
35
+ stopping?: () => boolean,
36
+ yoloGraceMs?: number,
30
37
  ): Promise<string> {
38
+ const yolo = yoloGraceMs !== undefined;
39
+ const grace = yolo ? { graceMs: yoloGraceMs } : {};
31
40
  const refused = validateQuestionnaire(questionnaire);
32
41
  if (refused !== undefined) return refused;
42
+ if (stopping?.()) return restartingText(owner, false);
43
+ const lost = (answer: unknown) =>
44
+ answer === "pending" && stopping?.() === true;
33
45
  const title = messages().askTitle(asker);
34
46
  const n = questionnaire.questions.length;
35
47
  const support = prompts.asks;
@@ -47,6 +59,7 @@ export async function askQuestions(
47
59
  shapeFor(questionnaire, support),
48
60
  signal,
49
61
  {
62
+ ...grace,
50
63
  late: (late) =>
51
64
  late.kind === "questionnaire"
52
65
  ? n === 1
@@ -59,33 +72,37 @@ export async function askQuestions(
59
72
  : undefined,
60
73
  },
61
74
  );
75
+ if (lost(answer)) return restartingText(owner, true);
62
76
  if (n === 1)
63
77
  return answerText(
64
78
  answer === "pending" || answer === "declined" || answer === undefined
65
79
  ? answer
66
80
  : (answer.answers[0] ?? { choices: [] }),
67
81
  owner,
82
+ yolo,
68
83
  );
69
- return questionnaireText(answer, questionnaire, owner);
84
+ return questionnaireText(answer, questionnaire, owner, yolo);
70
85
  }
71
86
  const shaped = shapeFor(questionnaire, SINGLE_QUESTION);
72
87
  const answers: OwnerAnswer[] = [];
73
88
  for (const [i, question] of shaped.questions.entries()) {
74
89
  const text = questionnaire.questions[i]?.question ?? question.question;
75
90
  const answer = await prompts.ask(title, question, signal, {
91
+ ...grace,
76
92
  late: (late) =>
77
93
  late.kind === "question"
78
94
  ? lateAnswerText(text, late.answer, late.by)
79
95
  : undefined,
80
96
  });
81
- if (n === 1) return answerText(answer, owner);
97
+ if (lost(answer)) return restartingText(owner, true);
98
+ if (n === 1) return answerText(answer, owner, yolo);
82
99
  if (answer === "pending" || answer === "declined" || answer === undefined)
83
100
  return sequentialText(
84
101
  questionnaire,
85
102
  answers,
86
103
  answer ?? "unanswered",
87
104
  owner,
88
- answerText(answer, owner),
105
+ answerText(answer, owner, yolo),
89
106
  );
90
107
  answers.push(answer);
91
108
  }
@@ -9,14 +9,37 @@ import type {
9
9
  /** The longest a question's name is when it has no header, in characters. */
10
10
  const NAME_CHARS = 120;
11
11
 
12
- /** The tool's result: what the owner chose and wrote. */
12
+ /**
13
+ * When a question truly blocks a yolo turn: the one case it may end waiting for the answer. Shared
14
+ * by the unanswered result and the yolo note, so the two never say it differently.
15
+ */
16
+ function trulyBlocks(o: ReturnType<typeof ownerWords>): string {
17
+ return `Only a question that truly blocks may end your turn: one with no safe choice without the answer, such as an irreversible or outward-facing step whose direction only ${o.he} can pick. Then first finish every part of the work the answer does not affect, and end your turn saying what you are waiting for.`;
18
+ }
19
+
20
+ /**
21
+ * The note a yolo turn's system prompt carries: work on without waiting for the owner, and end the
22
+ * turn with a question only when it truly blocks.
23
+ */
24
+ export function yoloAskNote(owner: OwnerIdentity): string {
25
+ const o = ownerWords(owner);
26
+ return `Yolo mode is on in this conversation: keep working without waiting for ${o.name}, and do not end your turn with a question. When an ask_user card goes unanswered, go on with your recommended option, or your best judgment where you offered none, and say in your reply what you assumed; ${o.his} later answer comes in a new turn to correct course. ${trulyBlocks(o)}`;
27
+ }
28
+
29
+ /**
30
+ * The tool's result: what the owner chose and wrote. `yolo`: the turn's conversation is in yolo
31
+ * mode, so a question not answered in its short wait does not end the turn.
32
+ */
13
33
  export function answerText(
14
34
  answer: OwnerAnswer | "pending" | "declined" | undefined,
15
35
  owner: OwnerIdentity,
36
+ yolo = false,
16
37
  ): string {
17
38
  const o = ownerWords(owner);
18
39
  if (answer === "declined")
19
40
  return `${o.name} cancelled the question on its card without answering. Do not ask the same question again. Continue without the answer, or, if you cannot go on without it, end your turn saying so.`;
41
+ if (answer === "pending" && yolo)
42
+ return `${o.name} has not answered yet, and yolo mode is on in this conversation, so keep working: do not end your turn and do not ask again. Go on with your recommended option, or your best judgment where you offered none, and say in your reply what you assumed. The card stays open; if ${o.he} answers later, a new turn brings you the answer and you correct course from there. ${trulyBlocks(o)}`;
20
43
  if (answer === "pending")
21
44
  return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
22
45
  if (!answer)
@@ -30,6 +53,15 @@ export function answerText(
30
53
  return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
31
54
  }
32
55
 
56
+ /**
57
+ * The tool's result while the server restarts: open cards live in its memory and are gone after
58
+ * the restart, so a new card is not posted (`opened` false) and one still waiting will be lost.
59
+ */
60
+ export function restartingText(owner: OwnerIdentity, opened: boolean): string {
61
+ const o = ownerWords(owner);
62
+ return `${opened ? `${o.name} has not answered yet, and the server is restarting: the card closes with it, so ${o.his} answer cannot reach you through it.` : `The server is restarting, so no card was posted: a card would not survive the restart.`} Do not call ask_user again in this turn. 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.`;
63
+ }
64
+
33
65
  /** The turn a late answer starts: the question it answers and what was chosen and written. */
34
66
  export function lateAnswerText(
35
67
  question: string,
@@ -72,14 +104,15 @@ function listOf(
72
104
  ];
73
105
  }
74
106
 
75
- /** The tool's result for a questionnaire: each question's answer, and the note. */
107
+ /** The tool's result for a questionnaire: each question's answer, and the note; `yolo` as for `answerText`. */
76
108
  export function questionnaireText(
77
109
  answer: QuestionnaireAnswer | "pending" | "declined" | undefined,
78
110
  questionnaire: Questionnaire,
79
111
  owner: OwnerIdentity,
112
+ yolo = false,
80
113
  ): string {
81
114
  if (answer === "pending" || answer === "declined" || !answer)
82
- return answerText(answer, owner);
115
+ return answerText(answer, owner, yolo);
83
116
  return [
84
117
  `${owner.name} answered your questions on the card:`,
85
118
  ...listOf(questionnaire, answer),
@@ -3,24 +3,42 @@ import { type Static, Type } from "typebox";
3
3
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
4
4
  import type { OwnerQuestion } from "../../interactions/prompts.ts";
5
5
  import { QUESTIONNAIRE_LIMITS as L } from "../../interactions/questionnaire.ts";
6
+ import { DEFAULT_YOLO_QUESTION_GRACE_MS } from "../../modes/autonomy.ts";
6
7
  import { toolText } from "../../shared/tool-result.ts";
7
8
  import { askQuestions } from "../ask-questions.ts";
8
9
  import type { PromptSlot } from "../prompt-slot.ts";
9
10
  import { foldAskArguments } from "./ask-user-arguments.ts";
11
+ import { yoloAskNote } from "./ask-user-text.ts";
10
12
 
11
13
  export { answerText, lateAnswerText } from "./ask-user-text.ts";
12
14
 
13
15
  export const ASK_USER_TOOL = "ask_user";
14
16
 
15
- /** Registers ask_user, which asks the owner on a card in the turn's channel and waits. */
17
+ /**
18
+ * Registers ask_user, which asks the owner on a card in the turn's channel and waits. In a turn
19
+ * whose conversation is in yolo mode (`slot.yolo`), a card holds the turn `yoloGraceMs` at most and
20
+ * an unanswered one does not end it, and the system prompt carries the yolo note.
21
+ */
16
22
  export function askUserExtension(
17
23
  slot: PromptSlot,
18
24
  owner: OwnerIdentity,
19
25
  /** Whom the running turn is for, when not the owner; the answer's words name them. */
20
26
  addressee?: () => OwnerIdentity,
27
+ /** Whether the host is shutting down: no card is posted then, as none survives the restart. */
28
+ stopping?: () => boolean,
29
+ /** How long a card holds a yolo turn, in ms; default `DEFAULT_YOLO_QUESTION_GRACE_MS`. */
30
+ yoloGraceMs: number = DEFAULT_YOLO_QUESTION_GRACE_MS,
21
31
  ): ExtensionFactory {
22
32
  const o = ownerWords(owner);
23
33
  return (pi) => {
34
+ pi.on("before_agent_start", (event) => {
35
+ if (!slot.yolo) return;
36
+ const note = yoloAskNote(addressee?.() ?? owner);
37
+ const appended = event.systemPromptOptions.appendSystemPrompt;
38
+ event.systemPromptOptions.appendSystemPrompt = appended
39
+ ? `${appended}\n\n${note}`
40
+ : note;
41
+ });
24
42
  const parameters = Type.Object(
25
43
  {
26
44
  questions: Type.Array(
@@ -113,6 +131,8 @@ export function askUserExtension(
113
131
  },
114
132
  addressee?.() ?? owner,
115
133
  signal,
134
+ stopping,
135
+ slot.yolo ? yoloGraceMs : undefined,
116
136
  ),
117
137
  );
118
138
  },
@@ -86,8 +86,9 @@ export interface InterimPosterOptions {
86
86
  /**
87
87
  * Posts a turn's intermediate text as it goes. A tool-calling assistant message whose text is
88
88
  * primary is posted as ordinary messages; shorter text joins the run's progress message with
89
- * the tools called, edited in place. A primary post or a card starts a new run. Posts go out
90
- * in order, and a failed one is logged, never thrown.
89
+ * the tools called, edited in place, or without them where the posts say `tools: false`. A
90
+ * primary post or a card starts a new run. Posts go out in order, and a failed one is logged,
91
+ * never thrown.
91
92
  */
92
93
  export class InterimPoster {
93
94
  readonly #posts: InterimPosts;
@@ -163,9 +164,11 @@ export class InterimPoster {
163
164
  }
164
165
 
165
166
  #render(run: Run): void {
166
- if (run.lines.length === 0 && run.tools.size === 0) return;
167
+ // A surface that shows the tools elsewhere gets no tool line.
168
+ const tools = this.#posts.tools === false ? new Map() : run.tools;
169
+ if (run.lines.length === 0 && tools.size === 0) return;
167
170
  run.lastAt = now();
168
- const text = progressText(run.lines, run.tools);
171
+ const text = progressText(run.lines, tools);
169
172
  this.#enqueue(async () => {
170
173
  if (text === run.shown) return;
171
174
  if (run.message) await run.message.edit(text);
@@ -266,6 +266,7 @@ export class PiAgentRuntime implements AgentRuntime {
266
266
  : undefined,
267
267
  request.agent?.name ?? assistantName(),
268
268
  interim ? () => interim.flush() : undefined,
269
+ () => this.#options.modes?.peek(request.channel).yolo === true,
269
270
  );
270
271
  // A model that ignores the abort must not hold the turn: its session is disposed of instead.
271
272
  const guard = new AbortGuard(session, {
@@ -421,8 +422,7 @@ export class PiAgentRuntime implements AgentRuntime {
421
422
  const timer = setTimeout(abort, task.timeoutMs);
422
423
  try {
423
424
  const registered = new Set(session.getAllTools().map((t) => t.name));
424
- // A worker that loaded the reader's memory may report it: the call that started it, and the
425
- // calls that one runs within, then record their results as the reader's.
425
+ // A worker that loaded the reader's memory: its starting call and the calls within it report as the reader's.
426
426
  if (memoryReader(conversation, turn.speaker) && loadsMemory(registered)) {
427
427
  parent.draws.drawn(runningCalls());
428
428
  this.#memoryTurns.get(scope.turn)?.();
@@ -18,6 +18,7 @@ import { turnBudget } from "./turn-budget.ts";
18
18
  export class PromptSlot {
19
19
  #prompts: Prompts | undefined;
20
20
  #beforeCard: (() => Promise<void>) | undefined;
21
+ #yolo: (() => boolean) | undefined;
21
22
  #asker = assistantName();
22
23
  #open = 0;
23
24
  #openSince = 0;
@@ -26,15 +27,18 @@ export class PromptSlot {
26
27
 
27
28
  /**
28
29
  * Binds the turn's prompts; `asker` names who asks, the assistant or an agent. `beforeCard`
29
- * runs before each card opens, such as posting the text the turn wrote before it.
30
+ * runs before each card opens, such as posting the text the turn wrote before it. `yolo` says
31
+ * whether the turn's conversation is in yolo mode now, read each time a card asks.
30
32
  */
31
33
  bind(
32
34
  prompts: Prompts | undefined,
33
35
  asker: string,
34
36
  beforeCard?: () => Promise<void>,
37
+ yolo?: () => boolean,
35
38
  ): void {
36
39
  this.#prompts = prompts;
37
40
  this.#beforeCard = beforeCard;
41
+ this.#yolo = yolo;
38
42
  this.#asker = asker;
39
43
  this.#open = 0;
40
44
  this.#waited = 0;
@@ -56,12 +60,18 @@ export class PromptSlot {
56
60
  unbind(): void {
57
61
  this.#prompts = undefined;
58
62
  this.#beforeCard = undefined;
63
+ this.#yolo = undefined;
59
64
  }
60
65
 
61
66
  get asker(): string {
62
67
  return this.#asker;
63
68
  }
64
69
 
70
+ /** Whether the bound turn's conversation is in yolo mode now; false with none bound. */
71
+ get yolo(): boolean {
72
+ return this.#yolo?.() === true;
73
+ }
74
+
65
75
  /** The turn's prompts, each open card counted as waiting; undefined when none is bound. */
66
76
  get prompts(): Prompts | undefined {
67
77
  const prompts = this.#prompts;
@@ -425,12 +425,17 @@ export class SessionFactory {
425
425
  confirmationGate: confirmationGateExtension(gate, slot, (signal) =>
426
426
  toolTurn(context, signal),
427
427
  ),
428
- askUser: askUserExtension(slot, addressee, () =>
429
- turnAddressee(
430
- this.#deps.speaker(turnKey),
431
- state,
432
- this.#options.owner,
433
- ),
428
+ askUser: askUserExtension(
429
+ slot,
430
+ addressee,
431
+ () =>
432
+ turnAddressee(
433
+ this.#deps.speaker(turnKey),
434
+ state,
435
+ this.#options.owner,
436
+ ),
437
+ this.#options.stopping,
438
+ this.#options.autonomy?.yolo.questionGraceMs,
434
439
  ),
435
440
  selfCompactGuard: selfCompactGuardExtension(),
436
441
  // Each request carries no one's memory but the running turn's reader's.
@@ -5,6 +5,7 @@ import type {
5
5
  } from "../domain/turn-timeout.ts";
6
6
  import { messages } from "../i18n/index.ts";
7
7
  import type { Approval } from "../interactions/prompts.ts";
8
+ import { DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS } from "../modes/autonomy.ts";
8
9
  import type { ChannelKey } from "../sessions.ts";
9
10
  import type { PromptSlot } from "./prompt-slot.ts";
10
11
  import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
@@ -129,6 +130,14 @@ export interface TurnBudgetSeams {
129
130
  * fires at it, as the turn's cap, so a long time limit cannot overshoot the long-running cap.
130
131
  */
131
132
  ceilingMs?: number;
133
+ /**
134
+ * Whether the turn's conversation is in yolo mode, asked when a limit fires: its timeout card
135
+ * then waits `yoloCardMs` and, unanswered, extends the turn as Continue would, up to
136
+ * `ceilingMs` of work. Past that, or without `ceilingMs`, the card is the usual one.
137
+ */
138
+ yolo?: () => boolean;
139
+ /** How long a yolo turn's timeout card waits, in ms; default `DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS`. */
140
+ yoloCardMs?: number;
132
141
  }
133
142
 
134
143
  /**
@@ -139,6 +148,8 @@ export interface TurnBudgetSeams {
139
148
  * no answer within `turnTimeoutCardMs` (default 5 minutes, wall time) aborts. The card closes
140
149
  * when the turn ends first. Without a card the turn is aborted at once. `abort` is told why.
141
150
  * When `seams.keepGoing` says so, the limit is extended server-side first and neither happens.
151
+ * In a yolo conversation (`seams.yolo`) short of `seams.ceilingMs`, the card waits
152
+ * `seams.yoloCardMs` instead, and no answer extends the turn as Continue would, up to the ceiling.
142
153
  */
143
154
  export function runtimeTurnBudget(
144
155
  options: Pick<
@@ -160,15 +171,24 @@ export function runtimeTurnBudget(
160
171
  limit: TurnLimit,
161
172
  limitMs: number,
162
173
  end: TurnTimeoutEnd,
174
+ auto = false,
163
175
  ): TurnTimeout => ({
164
176
  limit,
165
177
  limitMs,
166
178
  workedMs: budget.workedMs(),
167
179
  ...seams.activity.now(clock.now()),
168
- graceMs,
180
+ graceMs: auto
181
+ ? (seams.yoloCardMs ?? DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS)
182
+ : graceMs,
169
183
  end,
184
+ ...(auto ? { autoContinue: true as const } : {}),
170
185
  ...(slot.cards ? {} : { unasked: true as const }),
171
186
  });
187
+ /** Whether an unanswered card goes on by itself: a yolo conversation short of the ceiling. */
188
+ const autoContinues = () =>
189
+ seams.ceilingMs !== undefined &&
190
+ budget.workedMs() < seams.ceilingMs &&
191
+ seams.yolo?.() === true;
172
192
  const stop = (timeout: TurnTimeout) => {
173
193
  logger.warn(
174
194
  {
@@ -202,13 +222,15 @@ export function runtimeTurnBudget(
202
222
  const prompts = slot.cards;
203
223
  if (!prompts) return stop(reason(limit, limitMs, "timeout"));
204
224
  const m = messages();
205
- const asked = reason(limit, limitMs, "timeout");
225
+ const auto = autoContinues();
226
+ const asked = reason(limit, limitMs, "timeout", auto);
206
227
  logger.info(
207
228
  {
208
229
  channel,
209
230
  limit,
210
231
  workedMs: asked.workedMs,
211
232
  running: asked.running,
233
+ ...(auto ? { yolo: true } : {}),
212
234
  },
213
235
  "turn reached its time limit; asking the owner",
214
236
  );
@@ -223,10 +245,19 @@ export function runtimeTurnBudget(
223
245
  };
224
246
  const cancelGrace = clock.schedule(() => {
225
247
  if (!settle()) return;
248
+ if (auto) {
249
+ controller.abort(m.turnTimeoutAutoContinued(asked));
250
+ budget.extend(limit === "turnMax", seams.ceilingMs);
251
+ logger.info(
252
+ { channel, limit, workedMs: budget.workedMs() },
253
+ "yolo mode continued the turn; the timeout card went unanswered",
254
+ );
255
+ return;
256
+ }
226
257
  const timeout = reason(limit, limitMs, "unanswered");
227
258
  controller.abort(m.turnTimeoutUnanswered(timeout));
228
259
  stop(timeout);
229
- }, graceMs);
260
+ }, asked.graceMs);
230
261
  closeCard = (footer) => {
231
262
  if (settle()) controller.abort(footer);
232
263
  };
@@ -35,7 +35,9 @@ 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`. A
38
+ * asking or aborting, until the turn has worked `options.autonomy.longRunningMaxMs`; in a yolo
39
+ * conversation the card waits `autonomy.yolo.timeoutCardGraceMs` and, unanswered, goes on up to
40
+ * that same cap. A
39
41
  * `timeLimitMs` replaces the turn's limit and its cap, both held under that same ceiling.
40
42
  */
41
43
  export function turnLimits(
@@ -46,7 +48,7 @@ export function turnLimits(
46
48
  abort: () => void,
47
49
  timeLimitMs?: number,
48
50
  ): TurnLimits {
49
- const { longRunningMaxMs } = options.autonomy ?? DEFAULT_AUTONOMY;
51
+ const { longRunningMaxMs, yolo } = options.autonomy ?? DEFAULT_AUTONOMY;
50
52
  const limited =
51
53
  timeLimitMs === undefined
52
54
  ? options
@@ -68,7 +70,15 @@ export function turnLimits(
68
70
  timedOut = timeout;
69
71
  abort();
70
72
  },
71
- { activity, keepGoing, ceilingMs: longRunningMaxMs },
73
+ {
74
+ activity,
75
+ keepGoing,
76
+ ceilingMs: longRunningMaxMs,
77
+ yolo: () => options.modes?.peek(channel).yolo === true,
78
+ ...(yolo.timeoutCardGraceMs === undefined
79
+ ? {}
80
+ : { yoloCardMs: yolo.timeoutCardGraceMs }),
81
+ },
72
82
  );
73
83
  return {
74
84
  budget,
package/src/index.ts CHANGED
@@ -153,6 +153,7 @@ export type {
153
153
  } from "./core/domain/client.ts";
154
154
  export type {
155
155
  HeldCall,
156
+ IntercomCard,
156
157
  OutboundReply,
157
158
  PendingConfirmation,
158
159
  PostAuthor,