pi-roundtable 0.7.19 → 0.8.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 (72) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +44 -11
  3. package/README.zh-TW.md +44 -9
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/plugins.md +267 -23
  6. package/examples/discord-adapter.test.ts +42 -0
  7. package/examples/discord-adapter.ts +26 -0
  8. package/examples/echo-runtime.ts +1 -1
  9. package/examples/headless.test.ts +72 -0
  10. package/examples/headless.ts +31 -0
  11. package/examples/http.test.ts +31 -1
  12. package/examples/http.ts +39 -0
  13. package/package.json +3 -2
  14. package/src/cli/cli.ts +15 -4
  15. package/src/cli/doctor.ts +21 -5
  16. package/src/cli/init.ts +9 -1
  17. package/src/cli/project.ts +28 -2
  18. package/src/cli/templates.ts +46 -14
  19. package/src/core/agents/agent-team-fixture.ts +6 -0
  20. package/src/core/agents/team-turns.ts +6 -1
  21. package/src/core/builtin/agent-server.ts +30 -95
  22. package/src/core/builtin/modules.ts +133 -34
  23. package/src/core/config/adapters.ts +57 -0
  24. package/src/core/config/config.ts +174 -77
  25. package/src/core/contract/channels.ts +4 -0
  26. package/src/core/contract/providers.ts +3 -2
  27. package/src/core/contract/runtime.ts +8 -4
  28. package/src/core/contract/surface.ts +13 -0
  29. package/src/core/conversations/conversation-registry.ts +42 -0
  30. package/src/core/conversations/conversation-store.ts +113 -0
  31. package/src/core/conversations/conversations-plugin.ts +29 -0
  32. package/src/core/define-roundtable.ts +124 -54
  33. package/src/core/discord/discord-adapter.ts +9 -0
  34. package/src/core/discord/owner-cards.ts +19 -8
  35. package/src/core/domain/conversation.ts +5 -0
  36. package/src/core/domain/owner-prompts.ts +1 -1
  37. package/src/core/domain/ports.ts +6 -0
  38. package/src/core/domain/progress.ts +10 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host.ts +6 -3
  41. package/src/core/http/listeners.ts +174 -21
  42. package/src/core/http/websocket.ts +316 -0
  43. package/src/core/i18n/cards.ts +5 -7
  44. package/src/core/ops/error-reporter.ts +24 -12
  45. package/src/core/plugin.ts +14 -0
  46. package/src/core/registry/providers.ts +1 -1
  47. package/src/core/routing/channel-router.ts +8 -0
  48. package/src/core/routing/conversation-turns.ts +55 -1
  49. package/src/core/routing/surface-port.ts +3 -0
  50. package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
  51. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  52. package/src/core/runtime/pending-confirmation-store.ts +42 -4
  53. package/src/core/runtime/pi-agent-runtime.ts +13 -6
  54. package/src/core/runtime/prompt-slot.ts +2 -2
  55. package/src/core/runtime/runtime-plugin.ts +160 -0
  56. package/src/core/runtime/turn-progress.ts +116 -0
  57. package/src/core/services.ts +15 -1
  58. package/src/core/shared/unix-server.ts +35 -5
  59. package/src/core/testing/modules.ts +47 -23
  60. package/src/core/testing/surface-contract.ts +383 -0
  61. package/src/core/testing/test-host.ts +27 -9
  62. package/src/discord/index.ts +2 -0
  63. package/src/index.ts +18 -0
  64. package/src/testing.ts +41 -5
  65. package/templates/adapters/web/.env.example +24 -0
  66. package/templates/adapters/web/README.md +15 -0
  67. package/templates/adapters/web/package.json.tmpl +26 -0
  68. package/templates/adapters/web/persona/assistant.md +2 -0
  69. package/templates/adapters/web/roundtable.config.ts +55 -0
  70. package/templates/package.json.tmpl +1 -0
  71. package/templates/plugins/self-compact.ts +11 -0
  72. package/templates/roundtable.config.ts +2 -1
@@ -19,20 +19,28 @@ const OWN_KEYS = new Set(["level", "time", "pid", "hostname", "msg", "err"]);
19
19
  const HEAD_KEYS = ["app", "plugin", "module"];
20
20
  const APP_FRAME = /((?:src|shared|worker)\/[^\s():]+\.ts):(\d+)/;
21
21
 
22
- /** Where the reports go, known once the Discord surface and the agent team are up. */
22
+ /** Who investigates the errors: an agent, in its channel, or the claim of one conversation. */
23
+ export type ErrorReportDestination =
24
+ | { agent: string }
25
+ | { conversation: ChannelKey };
26
+
27
+ /** How the reports are delivered, known once the surfaces, and for an agent the team, are up. */
23
28
  export interface ErrorReportDelivery {
24
- /** The agent's channel while it is active; undefined for an archived or unknown agent. */
25
- channelOf(agent: string): ChannelKey | undefined;
26
- /** The visible message in the agent's channel. */
29
+ /**
30
+ * The agent's channel while it is active; undefined for an archived or unknown agent. Asked
31
+ * only for an agent's reports.
32
+ */
33
+ channelOf?(agent: string): ChannelKey | undefined;
34
+ /** The visible message in the channel. */
27
35
  post(channel: ChannelKey, text: string): Promise<void>;
28
- /** The agent's report turn; resolves when it has run. */
36
+ /** The report turn; resolves when it has run. */
29
37
  turn(channel: ChannelKey, text: string): Promise<ScheduledOutcome>;
30
38
  logger: Logger;
31
39
  }
32
40
 
33
41
  export interface ErrorReporterOptions {
34
- /** The agent that investigates the errors, reported in its channel. */
35
- opsAgent: string;
42
+ /** Who investigates the errors. */
43
+ destination: ErrorReportDestination;
36
44
  /** The name the reports give the process, such as "Roundtable". */
37
45
  app: string;
38
46
  ignored?: readonly RegExp[];
@@ -43,14 +51,14 @@ export interface ErrorReporterOptions {
43
51
  }
44
52
 
45
53
  /**
46
- * The process's own `error` and `fatal` log entries, each reported to the ops agent as a visible
47
- * message and a report turn in its channel. The same error goes at most once an hour, and at
54
+ * The process's own `error` and `fatal` log entries, each reported to the ops agent or the ops
55
+ * conversation as a visible message and a report turn in its channel. The same error goes at most once an hour, and at
48
56
  * most five reports an hour overall; the rest are counted and summed up in one report when the
49
57
  * hour allows. Errors from the reporting itself are only logged; errors logged elsewhere while
50
58
  * an error-report turn runs are counted and summed up once it ends.
51
59
  */
52
60
  export class ErrorReporter {
53
- readonly #opsAgent: string;
61
+ readonly destination: ErrorReportDestination;
54
62
  readonly #app: string;
55
63
  readonly #ignored: readonly RegExp[];
56
64
  readonly #now: () => number;
@@ -68,7 +76,7 @@ export class ErrorReporter {
68
76
  #summaryDue = false;
69
77
 
70
78
  constructor(options: ErrorReporterOptions) {
71
- this.#opsAgent = options.opsAgent;
79
+ this.destination = options.destination;
72
80
  this.#app = options.app;
73
81
  this.#ignored = options.ignored ?? IGNORED_ERRORS;
74
82
  this.#now = options.now ?? Date.now;
@@ -177,7 +185,11 @@ export class ErrorReporter {
177
185
  const delivery = this.#delivery;
178
186
  if (!delivery) return Promise.resolve();
179
187
  return this.#reporting.run(true, async () => {
180
- const channel = delivery.channelOf(this.#opsAgent);
188
+ const { destination } = this;
189
+ const channel =
190
+ "conversation" in destination
191
+ ? destination.conversation
192
+ : delivery.channelOf?.(destination.agent);
181
193
  if (!channel) return;
182
194
  try {
183
195
  await delivery.post(channel, text);
@@ -11,6 +11,7 @@ import type { ServiceKey, Services } from "./contract/services.ts";
11
11
  import type { ChatSurface, SurfacePort } from "./contract/surface.ts";
12
12
  import type { Migration } from "./db/migrations.ts";
13
13
  import type { ToolContribution } from "./define.ts";
14
+ import type { TurnProgress } from "./domain/progress.ts";
14
15
  import { PluginError } from "./errors.ts";
15
16
  import type { HoldCheck, HoldRule } from "./holds.ts";
16
17
  import type { HttpRoute } from "./http/listeners.ts";
@@ -75,6 +76,11 @@ export interface TurnEndEvent extends TurnEvent {
75
76
  result: "ok" | "failed" | "stopped";
76
77
  }
77
78
 
79
+ /** What a running turn wrote or ran, as a handler hears of it, between its start and its end. */
80
+ export interface TurnProgressEvent extends TurnEvent {
81
+ progress: TurnProgress;
82
+ }
83
+
78
84
  /** What a plugin may react to; a handler that throws is logged and never stops the others. */
79
85
  export interface EventHandlers {
80
86
  /**
@@ -87,6 +93,12 @@ export interface EventHandlers {
87
93
  turnStarted?(turn: TurnEvent): Promise<void> | void;
88
94
  /** A turn that began ended, however it ended. */
89
95
  turnEnded?(turn: TurnEndEvent): Promise<void> | void;
96
+ /**
97
+ * A turn run through `context.turns` wrote text or ran a tool, as it goes: text joined over a
98
+ * short interval, and each tool's name with a short preview of its arguments. Turns on a runtime
99
+ * without live progress report none.
100
+ */
101
+ turnProgress?(event: TurnProgressEvent): Promise<void> | void;
90
102
  /** The team changed: an agent or group was created, edited, arranged, archived, or started over. */
91
103
  changed?(): Promise<void> | void;
92
104
  /** The shutdown drain ended, before any service stops; `left` is the work it gave up on. */
@@ -97,6 +109,8 @@ export interface EventHandlers {
97
109
  export interface EventSink {
98
110
  turnStarted(turn: TurnEvent): void;
99
111
  turnEnded(turn: TurnEndEvent): void;
112
+ /** Optional, so a sink of your own written before it still fits. */
113
+ turnProgress?(event: TurnProgressEvent): void;
100
114
  changed(): void;
101
115
  }
102
116
 
@@ -35,7 +35,7 @@ function defaults(judgeModel: JudgeModel | undefined): Providers {
35
35
  },
36
36
  runtime: () => {
37
37
  throw new ProviderError(
38
- "no runtime provider is configured: the agent server builds the Pi runtime when no plugin fills the runtime slot",
38
+ "no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)",
39
39
  );
40
40
  },
41
41
  };
@@ -168,4 +168,12 @@ export class ChannelRouter implements ConversationPort {
168
168
  postsInPlace(channel: ChannelKey): boolean {
169
169
  return this.#owner(channel)?.postsInPlace === true;
170
170
  }
171
+
172
+ owns(channel: ChannelKey): boolean {
173
+ return this.#owner(channel) !== undefined;
174
+ }
175
+
176
+ takesBackground(channel: ChannelKey): boolean {
177
+ return this.#owner(channel)?.background !== undefined;
178
+ }
171
179
  }
@@ -1,7 +1,13 @@
1
1
  import type { AgentRuntime } from "../contract/runtime.ts";
2
2
  import type { SurfacePort } from "../contract/surface.ts";
3
+ import type {
4
+ ConversationRegistry,
5
+ ConversationVisibility,
6
+ } from "../conversations/conversation-registry.ts";
3
7
  import type { TurnAttachments } from "../domain/attachment.ts";
4
8
  import type { TurnResult } from "../domain/conversation.ts";
9
+ import type { TurnProgress } from "../domain/progress.ts";
10
+ import { PluginError } from "../errors.ts";
5
11
  import { messages } from "../i18n/index.ts";
6
12
  import type { Logger } from "../log.ts";
7
13
  import type { EventSink } from "../plugin.ts";
@@ -36,6 +42,11 @@ export interface ConversationTurnInput {
36
42
  * notice); the claim formats its own. A throw is logged, never rethrown.
37
43
  */
38
44
  reply?(result: TurnResult): Promise<void>;
45
+ /**
46
+ * How the conversation is recorded at its first turn: `private` to the speaker, or `shared`
47
+ * (the default). Later turns keep what the first recorded, and give a title only then.
48
+ */
49
+ conversation?: { visibility: ConversationVisibility; title?: string };
39
50
  }
40
51
 
41
52
  /**
@@ -48,7 +59,11 @@ export interface ConversationTurns {
48
59
  * task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
49
60
  * `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
50
61
  * posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
51
- * It rejects during setup (NotLinkedError) and when the host has no runtime to run the turn on.
62
+ * It rejects during setup (NotLinkedError), when the host has no runtime to run the turn on,
63
+ * and when the conversation cannot be recorded in the host's registry (a database error, or a
64
+ * `private` conversation without a speaker). Each of these rejects before the turn starts:
65
+ * nothing is shown, no event is emitted, and `reply` is not called, so a claim that answers
66
+ * its person tells them itself.
52
67
  */
53
68
  run(input: ConversationTurnInput): Promise<TurnResult>;
54
69
  }
@@ -58,6 +73,8 @@ export interface ConversationTurnsOptions {
58
73
  linked: () => void;
59
74
  /** The runtime that runs the turn; throws PluginError when the host has none yet. */
60
75
  runtime: () => AgentRuntime;
76
+ /** Where each turn records its conversation; absent, or undefined, records none. */
77
+ registry?: () => Pick<ConversationRegistry, "register"> | undefined;
61
78
  surfaces: SurfacePort;
62
79
  events: EventSink;
63
80
  /** The plugins' agent selection, read at each turn that gives none of its own. */
@@ -68,6 +85,27 @@ export interface ConversationTurnsOptions {
68
85
  /** The selection of a turn that gives none: the plugins' `agentSelection`. */
69
86
  const DEFAULT_SELECTION = "turns";
70
87
 
88
+ /** Records the conversation of a turn: private to the speaker when asked, else shared. */
89
+ async function record(
90
+ registry: Pick<ConversationRegistry, "register"> | undefined,
91
+ input: ConversationTurnInput,
92
+ ): Promise<void> {
93
+ if (!registry) return;
94
+ const visibility = input.conversation?.visibility ?? "shared";
95
+ if (visibility === "private" && !input.speaker)
96
+ throw new PluginError(
97
+ `a private conversation needs the speaker it belongs to; ${input.channel} was run without one.`,
98
+ );
99
+ const title = input.conversation?.title;
100
+ await registry.register({
101
+ key: input.channel,
102
+ kind: input.kind,
103
+ visibility,
104
+ ...(visibility === "private" ? { principalId: input.speaker.id } : {}),
105
+ ...(title === undefined ? {} : { title }),
106
+ });
107
+ }
108
+
71
109
  export function conversationTurns(
72
110
  options: ConversationTurnsOptions,
73
111
  ): ConversationTurns {
@@ -78,12 +116,26 @@ export function conversationTurns(
78
116
  options.linked();
79
117
  // A host without a runtime is a setup mistake, so it is refused before anything is shown.
80
118
  const runtime = options.runtime();
119
+ // A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
120
+ await record(options.registry?.(), input);
81
121
  const stopTyping = surfaces.startTyping(channel);
82
122
  const hideStop = surfaces.showStop(channel);
83
123
  const turn = { kind, channel, speaker };
84
124
  events.turnStarted(turn);
85
125
  // A claim that posts its own reply formats its own text, so only the default reply posts as it goes.
86
126
  const interim = input.reply ? undefined : surfaces.interim(channel);
127
+ // What the runtime reports as it goes reaches the surface and the handlers until the turn ends.
128
+ let live = true;
129
+ const progress = (event: TurnProgress) => {
130
+ if (!live) return;
131
+ events.turnProgress?.({ ...turn, progress: event });
132
+ // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
133
+ surfaces
134
+ .progress?.(channel, event)
135
+ ?.catch((error: unknown) =>
136
+ logger.warn({ channel, kind, err: error }, "progress not shown"),
137
+ );
138
+ };
87
139
  let result: TurnResult;
88
140
  try {
89
141
  result = await settleTurn(
@@ -105,11 +157,13 @@ export function conversationTurns(
105
157
  ...(input.steerable ? { steerable: true } : {}),
106
158
  ...(input.interactive ? { interactive: true } : {}),
107
159
  ...(interim ? { interim } : {}),
160
+ progress,
108
161
  }),
109
162
  ),
110
163
  "conversation turn",
111
164
  );
112
165
  } finally {
166
+ live = false;
113
167
  hideStop();
114
168
  }
115
169
  events.turnEnded({
@@ -42,5 +42,8 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
42
42
  of(channel)?.unreact?.(channel, messageId, emoji),
43
43
  prompts: (channel, speaker) => of(channel)?.prompts?.(channel, speaker),
44
44
  interim: (channel) => of(channel)?.interim?.(channel),
45
+ progress: async (channel, event) => {
46
+ await of(channel)?.progress?.(channel, event);
47
+ },
45
48
  };
46
49
  }
@@ -54,6 +54,8 @@ function byCodeUnit(a: string, b: string): number {
54
54
  // pi-lens-ignore: large-class — one gate per conversation; its state is the held calls and their approvals
55
55
  export class ConfirmationGate {
56
56
  #selectionId: string | undefined;
57
+ /** The running turn's speaker, whom the actions it holds belong to. */
58
+ #speakerId: string | undefined;
57
59
  #pending: PendingConfirmation | undefined;
58
60
  #approved: HeldCall[] = [];
59
61
  readonly #holds: HoldCheck;
@@ -91,13 +93,18 @@ export class ConfirmationGate {
91
93
  : pending;
92
94
  }
93
95
 
94
- /** `addressee` is who the turn is for; without one, the owner. */
96
+ /**
97
+ * `addressee` is who the turn is for; without one, the owner. `speakerId` is who spoke the turn,
98
+ * recorded with what it holds; without one, only the owner may approve them.
99
+ */
95
100
  beginTurn(
96
101
  selectionId: string,
97
102
  confirmed: boolean,
98
103
  addressee?: OwnerIdentity,
104
+ speakerId?: string,
99
105
  ): void {
100
106
  this.#selectionId = selectionId;
107
+ this.#speakerId = speakerId;
101
108
  this.#addressee = addressee ?? this.#owner;
102
109
  this.#approved = confirmed ? [...(this.pending()?.calls ?? [])] : [];
103
110
  this.#pending = undefined;
@@ -180,10 +187,11 @@ export class ConfirmationGate {
180
187
  const selectionId = this.#selectionId;
181
188
  if (selectionId === undefined)
182
189
  throw new Error("a call was held outside a turn");
183
- const pending = this.#pending ?? {
190
+ const pending: PendingConfirmation = this.#pending ?? {
184
191
  selectionId,
185
192
  heldAt: new Date(),
186
193
  calls: [],
194
+ ...(this.#speakerId === undefined ? {} : { speakerId: this.#speakerId }),
187
195
  };
188
196
  if (
189
197
  !pending.calls.some((c) => c.tool === call.tool && c.input === call.input)
@@ -1,8 +1,19 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { ConfigError } from "../../domain/errors.ts";
2
3
 
3
4
  /** pi-self-compact's tool, active in every session. */
4
5
  export const COMPACT_TOOL = "compact_session";
5
6
 
7
+ /** The preflight's refusal of a session without the tools it requires, saying where compaction's comes from. */
8
+ export function missingToolsError(missing: readonly string[]): ConfigError {
9
+ const source = missing.includes(COMPACT_TOOL)
10
+ ? ` ${COMPACT_TOOL} comes from the Pi package pi-self-compact: install it (bun add pi-self-compact) and load it from a plugin with piPackages: ["pi-self-compact"], as the plugins/self-compact.ts of a project roundtable init creates does.`
11
+ : "";
12
+ return new ConfigError(
13
+ `required tools are not registered: ${missing.join(", ")}.${source}`,
14
+ );
15
+ }
16
+
6
17
  const RESUME_REFUSED =
7
18
  "compact_session cannot take resume here: a resumed run would start outside this conversation and its reply would reach no one. Finish this turn's work, then call compact_session again without resume; the next message starts from the compacted context.";
8
19
 
@@ -10,6 +10,7 @@ interface Row {
10
10
  selection_id: string;
11
11
  held_at: Date;
12
12
  calls: string;
13
+ speaker_id: string | null;
13
14
  }
14
15
 
15
16
  /** Each owner channel's held actions, in the database, so a restart keeps them. */
@@ -34,14 +35,46 @@ export class PendingConfirmationStore {
34
35
  },
35
36
  };
36
37
 
38
+ /** Who spoke the turn that held the actions, so only they and the owner approve them. */
39
+ static readonly speakerMigration: Migration = {
40
+ name: "held-actions-speaker",
41
+ up: async (sql) => {
42
+ await sql`ALTER TABLE held_actions ADD COLUMN IF NOT EXISTS speaker_id text`;
43
+ },
44
+ };
45
+
46
+ /**
47
+ * The hold the speaker belongs to, by its `held_at`. 0.7 replaces a row's held actions and
48
+ * leaves `speaker_id` as it was, so after a rollback and an upgrade a speaker counts only while
49
+ * the row still holds the actions it was written with.
50
+ */
51
+ static readonly speakerHoldMigration: Migration = {
52
+ name: "held-actions-speaker-hold",
53
+ up: async (sql) => {
54
+ await sql`ALTER TABLE held_actions ADD COLUMN IF NOT EXISTS speaker_held_at timestamptz`;
55
+ },
56
+ };
57
+
58
+ /** The store's migrations in order. */
59
+ static migrations(): Migration[] {
60
+ return [
61
+ PendingConfirmationStore.migration,
62
+ PendingConfirmationStore.speakerMigration,
63
+ PendingConfirmationStore.speakerHoldMigration,
64
+ ];
65
+ }
66
+
37
67
  /** The store over the host's migrated pool. */
38
68
  static async attach(sql: SQL): Promise<PendingConfirmationStore> {
39
69
  return new PendingConfirmationStore(sql);
40
70
  }
41
71
 
42
72
  async load(channel: ChannelKey): Promise<PendingConfirmation | undefined> {
73
+ // A speaker written with other held actions than the row's, or without its hold, is none.
43
74
  const rows: Row[] = await this.#sql`
44
- SELECT selection_id, held_at, calls FROM held_actions
75
+ SELECT selection_id, held_at, calls,
76
+ CASE WHEN speaker_held_at = held_at THEN speaker_id END AS speaker_id
77
+ FROM held_actions
45
78
  WHERE channel_key = ${channel}`;
46
79
  const row = rows[0];
47
80
  return row
@@ -50,6 +83,7 @@ export class PendingConfirmationStore {
50
83
  heldAt: row.held_at,
51
84
  // pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
52
85
  calls: JSON.parse(row.calls) as HeldCall[],
86
+ ...(row.speaker_id === null ? {} : { speakerId: row.speaker_id }),
53
87
  }
54
88
  : undefined;
55
89
  }
@@ -63,11 +97,15 @@ export class PendingConfirmationStore {
63
97
  await this.#sql`DELETE FROM held_actions WHERE channel_key = ${channel}`;
64
98
  return;
65
99
  }
100
+ const speaker = pending.speakerId ?? null;
66
101
  await this.#sql`
67
- INSERT INTO held_actions (channel_key, selection_id, held_at, calls)
102
+ INSERT INTO held_actions
103
+ (channel_key, selection_id, held_at, calls, speaker_id, speaker_held_at)
68
104
  VALUES (${channel}, ${pending.selectionId}, ${pending.heldAt},
69
- ${JSON.stringify(pending.calls)})
105
+ ${JSON.stringify(pending.calls)}, ${speaker},
106
+ ${speaker === null ? null : pending.heldAt})
70
107
  ON CONFLICT (channel_key) DO UPDATE SET selection_id = EXCLUDED.selection_id,
71
- held_at = EXCLUDED.held_at, calls = EXCLUDED.calls`;
108
+ held_at = EXCLUDED.held_at, calls = EXCLUDED.calls,
109
+ speaker_id = EXCLUDED.speaker_id, speaker_held_at = EXCLUDED.speaker_held_at`;
72
110
  }
73
111
  }
@@ -35,7 +35,10 @@ import {
35
35
  ConfirmationGate,
36
36
  confirmedTurnText,
37
37
  } from "./extensions/confirmation-gate.ts";
38
- import { COMPACT_TOOL } from "./extensions/self-compact-guard.ts";
38
+ import {
39
+ COMPACT_TOOL,
40
+ missingToolsError,
41
+ } from "./extensions/self-compact-guard.ts";
39
42
  import { interimPoster } from "./interim-text.ts";
40
43
  import { PromptSlot, workTimeout } from "./prompt-slot.ts";
41
44
  import {
@@ -47,6 +50,7 @@ import { archiveSessions } from "./session-archive.ts";
47
50
  import { SessionFactory } from "./session-factory.ts";
48
51
  import { type PromptImages, SteerableRun } from "./steerable-run.ts";
49
52
  import { lastReply, turnAnswer } from "./turn-answer.ts";
53
+ import { progressReporter } from "./turn-progress.ts";
50
54
  import { workerReport } from "./worker-task.ts";
51
55
 
52
56
  export {
@@ -117,11 +121,7 @@ export class PiAgentRuntime implements AgentRuntime {
117
121
  ),
118
122
  ];
119
123
  const missing = expected.filter((name) => !registered.has(name));
120
- if (missing.length > 0) {
121
- throw new ConfigError(
122
- `required tools are not registered: ${missing.join(", ")}`,
123
- );
124
- }
124
+ if (missing.length > 0) throw missingToolsError(missing);
125
125
  }
126
126
 
127
127
  // pi-lens-ignore: high-complexity, high-fan-out, mixed-async-styles — one turn's lifecycle end to end; batch 4 splits it with the channel router
@@ -168,6 +168,7 @@ export class PiAgentRuntime implements AgentRuntime {
168
168
  request.selection.id,
169
169
  request.confirmed === true,
170
170
  addressee(request.speaker, this.#options.owner),
171
+ request.speaker?.id,
171
172
  );
172
173
  if (request.confirmed && pending) {
173
174
  logger.info(
@@ -184,7 +185,12 @@ export class PiAgentRuntime implements AgentRuntime {
184
185
  const toolCalls: string[] = [];
185
186
  // Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
186
187
  const interim = interimPoster(request, this.#options);
188
+ // The caller's live view: the text as it streams and each tool, never the thinking.
189
+ const progress = request.progress
190
+ ? progressReporter(request.progress)
191
+ : undefined;
187
192
  const unsubscribe = session.subscribe((event) => {
193
+ progress?.observe(event);
188
194
  if (event.type === "tool_execution_start") {
189
195
  toolCalls.push(event.toolName);
190
196
  interim?.toolStart(event.toolName);
@@ -247,6 +253,7 @@ export class PiAgentRuntime implements AgentRuntime {
247
253
  cancelTimeout();
248
254
  slot.unbind();
249
255
  unsubscribe();
256
+ progress?.close();
250
257
  await interim?.flush();
251
258
  gate.endTurn();
252
259
  const usage = session.getContextUsage();
@@ -46,8 +46,8 @@ export class PromptSlot {
46
46
  const prompts = this.#prompts;
47
47
  if (!prompts) return undefined;
48
48
  return {
49
- confirm: (title, message, signal) =>
50
- this.#waiting(() => prompts.confirm(title, message, signal)),
49
+ confirm: (title, message, signal, minTier) =>
50
+ this.#waiting(() => prompts.confirm(title, message, signal, minTier)),
51
51
  ask: (title, question: OwnerQuestion, signal) =>
52
52
  this.#waiting(() => prompts.ask(title, question, signal)),
53
53
  };
@@ -0,0 +1,160 @@
1
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AgentRuntime,
4
+ AgentSessions,
5
+ HeldActionStore,
6
+ RuntimeDeps,
7
+ } from "../contract/runtime.ts";
8
+ import type { ChannelKey } from "../domain/conversation.ts";
9
+ import type { InterimTextMode } from "../domain/interim.ts";
10
+ import type { OwnerIdentity } from "../identity.ts";
11
+ import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
12
+ import type { ModelRef, ThinkingLevel } from "../models.ts";
13
+ import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
14
+ import { RUNTIME } from "../services.ts";
15
+ import type { Speaker } from "../speakers.ts";
16
+ import { PendingConfirmationStore } from "./pending-confirmation-store.ts";
17
+ import { PiAgentRuntime } from "./pi-agent-runtime.ts";
18
+
19
+ /** The name of the runtime plugin, as `serviceStarted` events and the migration ledger name it. */
20
+ export const RUNTIME_PLUGIN = "runtime";
21
+
22
+ /**
23
+ * Where the agent server hands the runtime its per-agent settings. The runtime is built before the
24
+ * agent server sets up, so it reads them at each turn; on a host without the agent server they stay
25
+ * unbound and no agent turn can run.
26
+ */
27
+ export interface AgentSessionsSlot {
28
+ /** The agent server's settings, or undefined while none are bound. */
29
+ current(): AgentSessions | undefined;
30
+ /** Called once, by the agent server's setup. */
31
+ bind(sessions: AgentSessions): void;
32
+ }
33
+
34
+ export function agentSessionsSlot(): AgentSessionsSlot {
35
+ let bound: AgentSessions | undefined;
36
+ return {
37
+ current: () => bound,
38
+ bind: (sessions) => {
39
+ if (bound)
40
+ throw new Error(
41
+ "the agent sessions are bound once, by the agent server",
42
+ );
43
+ bound = sessions;
44
+ },
45
+ };
46
+ }
47
+
48
+ export interface RuntimePluginOptions {
49
+ /** Who the conversations serve, as prompts and tool results name them. */
50
+ owner: OwnerIdentity & { id: string };
51
+ /** Shared with the rest of the host, so logins refresh in one place. */
52
+ modelRuntime: ModelRuntime;
53
+ agentDir: string;
54
+ dataDir: string;
55
+ /** The assistant's model, and the thinking level the effort judge falls back to. */
56
+ model: ModelRef;
57
+ thinking: ThinkingLevel;
58
+ /** How sure the effort judge must be before its answer is used. */
59
+ judgeThreshold: number;
60
+ /** The agent server's settings, bound when it sets up; without a slot no agent turn runs. */
61
+ agents?: AgentSessionsSlot;
62
+ /** Whether turns post the text they write before their final answer as they go; default "on". */
63
+ interimText?: InterimTextMode;
64
+ /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
65
+ interimPrimaryChars?: number;
66
+ }
67
+
68
+ /**
69
+ * The runtime every conversation turn runs on, built once at setup and provided as `RUNTIME`: the
70
+ * `runtime` provider slot's when a plugin fills it, Pi's otherwise. It keeps the held actions'
71
+ * table, so a host without the agent server still holds and restores actions across a restart.
72
+ */
73
+ export function runtimePlugin(
74
+ options: RuntimePluginOptions,
75
+ /** Opens the held actions' store; a test hands a stand-in, so the plugin sets up without a database. */
76
+ openHeldActions: (
77
+ context: Pick<PluginContext, "database">,
78
+ ) => Promise<HeldActionStore> = (context) =>
79
+ PendingConfirmationStore.attach(context.database()),
80
+ ): RoundtablePlugin {
81
+ let runtime: AgentRuntime | undefined;
82
+ return {
83
+ name: RUNTIME_PLUGIN,
84
+ migrations: PendingConfirmationStore.migrations(),
85
+ provides: [RUNTIME],
86
+ preflight: async () => {
87
+ await runtime?.preflight?.();
88
+ },
89
+ setup: async (context) => {
90
+ const { logger, providers } = context;
91
+ const heldActions = await openHeldActions(context);
92
+ const prompts = (channel: ChannelKey, speaker?: Speaker) =>
93
+ context.surfaces.prompts(channel, speaker);
94
+ const agents = () => options.agents?.current();
95
+ const running: AgentRuntime = providers.filled.has("runtime")
96
+ ? providers.runtime(
97
+ runtimeDeps({
98
+ logger,
99
+ env: context.env,
100
+ owner: { id: options.owner.id, name: options.owner.name },
101
+ sessions: context.sessions,
102
+ toolTiers: context.toolTiers,
103
+ prompts,
104
+ agents,
105
+ confirmations: heldActions,
106
+ judge: providers.judge,
107
+ }),
108
+ )
109
+ : new PiAgentRuntime({
110
+ owner: options.owner,
111
+ sessions: context.sessions,
112
+ agentDir: options.agentDir,
113
+ modelRuntime: options.modelRuntime,
114
+ dataDir: options.dataDir,
115
+ model: options.model,
116
+ thinking: options.thinking,
117
+ effort: new EffortJudge({
118
+ judge: providers.judge,
119
+ brief: AGENT_BRIEF,
120
+ fallback: options.thinking,
121
+ threshold: options.judgeThreshold,
122
+ logger,
123
+ }),
124
+ confirmations: heldActions,
125
+ toolTiers: context.toolTiers,
126
+ get agents() {
127
+ return agents();
128
+ },
129
+ prompts,
130
+ logger,
131
+ ...(options.interimText
132
+ ? { interimText: options.interimText }
133
+ : {}),
134
+ ...(options.interimPrimaryChars
135
+ ? { interimPrimaryChars: options.interimPrimaryChars }
136
+ : {}),
137
+ });
138
+ runtime = running;
139
+ context.services.provide(RUNTIME, running);
140
+ return {
141
+ services: [{ name: "runtime", stop: () => running.dispose?.() }],
142
+ };
143
+ },
144
+ };
145
+ }
146
+
147
+ /** The deps a runtime provider gets, with `agents` read when asked, after the agent server bound it. */
148
+ function runtimeDeps(
149
+ deps: Omit<RuntimeDeps, "agents"> & {
150
+ agents: () => AgentSessions | undefined;
151
+ },
152
+ ): RuntimeDeps {
153
+ const { agents, ...rest } = deps;
154
+ return {
155
+ ...rest,
156
+ get agents() {
157
+ return agents();
158
+ },
159
+ };
160
+ }