pi-roundtable 0.25.0 → 0.26.1

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 (62) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/docs/plugins.md +156 -6
  3. package/package.json +1 -1
  4. package/src/cli/checks/agent-tools.ts +23 -0
  5. package/src/cli/doctor.ts +6 -0
  6. package/src/cli/principal.ts +9 -2
  7. package/src/core/agents/agent-prompt.ts +6 -1
  8. package/src/core/agents/agent-team.ts +2 -0
  9. package/src/core/agents/team-editing.ts +4 -0
  10. package/src/core/agents/team-options.ts +3 -1
  11. package/src/core/agents/team-selection.ts +6 -2
  12. package/src/core/builtin/agent-server.ts +1 -0
  13. package/src/core/builtin/channel-context.ts +21 -0
  14. package/src/core/builtin/discord.ts +4 -1
  15. package/src/core/config/config-schema.ts +22 -6
  16. package/src/core/config/config.ts +38 -2
  17. package/src/core/config/conversations.ts +40 -0
  18. package/src/core/config/profiles.ts +53 -0
  19. package/src/core/contract/channel-context.ts +106 -0
  20. package/src/core/contract/surface.ts +16 -0
  21. package/src/core/conversations/conversation-registry.ts +6 -0
  22. package/src/core/conversations/conversation-retention.ts +123 -0
  23. package/src/core/conversations/conversation-store.ts +6 -0
  24. package/src/core/conversations/conversations-plugin.ts +25 -3
  25. package/src/core/define-roundtable.ts +13 -2
  26. package/src/core/discord/channel-context.ts +47 -78
  27. package/src/core/discord/discord-surface.ts +16 -0
  28. package/src/core/events.ts +1 -0
  29. package/src/core/holds.ts +9 -0
  30. package/src/core/host-registration.ts +4 -0
  31. package/src/core/host.ts +3 -1
  32. package/src/core/identity/actor-facts.ts +7 -0
  33. package/src/core/identity/identity-plugin.ts +27 -5
  34. package/src/core/identity/identity-port.ts +62 -0
  35. package/src/core/identity/identity-schema.ts +2 -0
  36. package/src/core/identity/identity-service.ts +45 -52
  37. package/src/core/identity/identity-view.ts +1 -0
  38. package/src/core/identity/person-profiles.ts +275 -0
  39. package/src/core/identity/principal-tables.ts +10 -0
  40. package/src/core/identity/profile-choices.ts +45 -0
  41. package/src/core/identity/profile-memory.ts +65 -0
  42. package/src/core/identity/profile-picture.ts +117 -0
  43. package/src/core/identity/profiles-view.ts +20 -0
  44. package/src/core/identity/profiles.ts +82 -0
  45. package/src/core/modules/host-shell/shell-policy.ts +18 -3
  46. package/src/core/plugin.ts +40 -0
  47. package/src/core/registry/contributions.ts +8 -1
  48. package/src/core/runtime/chat-only.ts +32 -0
  49. package/src/core/runtime/conversation-sessions.ts +2 -1
  50. package/src/core/runtime/extensions/confirmation-gate.ts +5 -9
  51. package/src/core/runtime/extensions/gate-refusals.ts +32 -0
  52. package/src/core/runtime/extensions/hold-lookups.ts +10 -2
  53. package/src/core/runtime/extensions/late-answer.ts +5 -2
  54. package/src/core/runtime/runtime-plugin.ts +1 -0
  55. package/src/core/runtime/runtime-types.ts +3 -1
  56. package/src/core/runtime/session-factory.ts +19 -30
  57. package/src/core/runtime/session-helpers.ts +27 -0
  58. package/src/core/services.ts +9 -0
  59. package/src/index.ts +18 -0
  60. package/src/kit/index.ts +1 -0
  61. package/src/kit/oidc.ts +16 -0
  62. package/src/testing.ts +1 -0
@@ -0,0 +1,20 @@
1
+ import type { PersonProfiles } from "./profiles.ts";
2
+
3
+ /**
4
+ * The directory as plugins get it: the `PersonProfiles` methods and nothing else, so no plugin
5
+ * reaches the name the core gives agents. Each answer is a frozen copy.
6
+ */
7
+ export function profilesView(directory: PersonProfiles): PersonProfiles {
8
+ return Object.freeze({
9
+ register: (source) => directory.register(source),
10
+ sources: () =>
11
+ Object.freeze(directory.sources().map((s) => Object.freeze({ ...s }))),
12
+ observed: (principalId, provider, name) =>
13
+ directory.observed(principalId, provider, name),
14
+ sourceOf: (principalId) => directory.sourceOf(principalId),
15
+ choose: (principalId, provider) => directory.choose(principalId, provider),
16
+ of: async (principalId) =>
17
+ Object.freeze({ ...(await directory.of(principalId)) }),
18
+ picture: (principalId) => directory.picture(principalId),
19
+ } satisfies PersonProfiles);
20
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * How the core names a person. A person's accounts each carry a name and maybe a picture; the
3
+ * person picks which account represents them (or the host's `profiles.prefer` order does), and
4
+ * the core shows that name where it names the person: the web app, transcripts, and, when the
5
+ * host lists the provider in `profiles.modelVisible`, the `Speaker.name` the agents read.
6
+ */
7
+
8
+ /**
9
+ * Where a provider's names and pictures come from. Example: a support inbox registers a
10
+ * `github` source that reads a user's public profile.
11
+ */
12
+ export interface ProfileSource {
13
+ /** The identity provider the source reads, an open string such as `line`, `discord` or `github`. */
14
+ provider: string;
15
+ /** What the person sees when choosing, such as "LINE". */
16
+ label: string;
17
+ /**
18
+ * The source's own default. It never widens anything on its own: the names reach
19
+ * `Speaker.name` only for a provider the host lists in `profiles.modelVisible`.
20
+ */
21
+ modelVisible: boolean;
22
+ /** The https hosts its pictures come from; the picture proxy fetches only these. */
23
+ pictureHosts: readonly string[];
24
+ /**
25
+ * The name and picture address of one account, or undefined when the provider knows none. It
26
+ * has two seconds; a miss, a failure or a timeout is remembered for ten minutes.
27
+ */
28
+ fetch(
29
+ subject: string,
30
+ signal: AbortSignal,
31
+ ): Promise<{ name?: string; pictureUrl?: string } | undefined>;
32
+ }
33
+
34
+ /** How one person is shown. */
35
+ export interface PersonDisplay {
36
+ name: string;
37
+ /** The provider the name comes from; absent when it is the principal's own display name. */
38
+ source?: string;
39
+ /** Whether `picture` has one to give. */
40
+ picture: boolean;
41
+ }
42
+
43
+ /**
44
+ * The names and pictures of the people the host serves, provided as `PROFILES` by the `identity`
45
+ * plugin. Fetched and observed names and picture addresses live in memory for at most an hour and
46
+ * are never stored; only a person's choice of source is.
47
+ */
48
+ export interface PersonProfiles {
49
+ /** Adds a source; call it from a plugin's `setup`. A second source for one provider throws. */
50
+ register(source: ProfileSource): void;
51
+ /** The registered sources. */
52
+ sources(): readonly { provider: string; label: string }[];
53
+ /**
54
+ * A name a signed-in session reported for one of the person's linked identities, such as an
55
+ * OIDC `name` claim. It is held in memory for an hour, renewed by each sign-in, and never
56
+ * stored, so an identity provider becomes a source without a fetch.
57
+ */
58
+ observed(principalId: string, provider: string, name: string): void;
59
+ /**
60
+ * The provider that names the person: their stored choice while it stays linked, else the
61
+ * first `profiles.prefer` provider they have linked, else undefined.
62
+ */
63
+ sourceOf(principalId: string): Promise<string | undefined>;
64
+ /** Stores the person's choice; the provider must be linked to them. `null` returns to the default. */
65
+ choose(principalId: string, provider: string | null): Promise<void>;
66
+ /** How the person is shown; their display name when no source names them. */
67
+ of(principalId: string): Promise<PersonDisplay>;
68
+ /**
69
+ * The person's picture, fetched through the source's allowed hosts, or undefined: an address
70
+ * on another host, a redirect, anything but PNG, JPEG, GIF or WebP, and a body over 2 MiB are
71
+ * all refused. The answer is `nosniff` and cacheable for an hour by the one browser.
72
+ */
73
+ picture(principalId: string): Promise<Response | undefined>;
74
+ }
75
+
76
+ /** The `profiles` key of the host's configuration, filled. */
77
+ export interface ProfileRules {
78
+ /** Providers in the order that names a person who has not chosen, such as `["line", "oidc:<base64url issuer>"]`. */
79
+ prefer: readonly string[];
80
+ /** Providers whose names may become `Speaker.name`, which agents read. */
81
+ modelVisible: readonly string[];
82
+ }
@@ -33,7 +33,22 @@ export type { PushPolicy } from "./git-push.ts";
33
33
  export { simpleCommands } from "./shell-lexer.ts";
34
34
  export { YOLO_CARDED_SHELL_CLASSES } from "./shell-programs.ts";
35
35
 
36
- const MAX_SHOWN_COMMAND = 600;
36
+ const MAX_SHOWN_COMMAND = 120;
37
+
38
+ /**
39
+ * The command as a card's sentence shows it: its first line, cut to a length, in one code span.
40
+ * The card shows the whole command beside it; a second line or a backtick here would break the
41
+ * sentence's Markdown (a `# comment` line reads as a heading).
42
+ */
43
+ function shownCommand(command: string): string {
44
+ const [line = ""] = command.trim().split("\n", 1);
45
+ const cut = line.length > MAX_SHOWN_COMMAND;
46
+ const more = cut || line.length < command.trim().length;
47
+ const shown = (cut ? line.slice(0, MAX_SHOWN_COMMAND) : line)
48
+ .replaceAll("`", "'")
49
+ .trimEnd();
50
+ return more ? `${shown} …` : shown;
51
+ }
37
52
 
38
53
  /** Pi's built-in shell and file tools, which agent sessions activate. */
39
54
  export const SHELL_TOOLS = Object.freeze([
@@ -382,7 +397,7 @@ function shellHold(
382
397
  let detail = `the ${toolName} call`;
383
398
  try {
384
399
  if (toolName === "bash" && typeof input.command === "string")
385
- detail = `the shell command \`${input.command.slice(0, MAX_SHOWN_COMMAND)}\``;
400
+ detail = `the shell command \`${shownCommand(input.command)}\``;
386
401
  } catch {
387
402
  // The input itself cannot be read; the call is held without showing it.
388
403
  }
@@ -410,7 +425,7 @@ function judge(
410
425
  const [first] = found;
411
426
  return first
412
427
  ? {
413
- action: `run the shell command \`${command.slice(0, MAX_SHOWN_COMMAND)}\` on the host (${first.reason})`,
428
+ action: `run the shell command \`${shownCommand(command)}\` on the host (${first.reason})`,
414
429
  classes: [...new Set(found.map((held) => held.class))],
415
430
  }
416
431
  : undefined;
@@ -107,10 +107,30 @@ export interface EventHandlers {
107
107
  turnProgress?(event: TurnProgressEvent): Promise<void> | void;
108
108
  /** The team changed: an agent or group was created, edited, arranged, archived, or started over. */
109
109
  changed?(): Promise<void> | void;
110
+ /**
111
+ * Something a person did through a plugin's surface, reported by any plugin through
112
+ * `context.events.action`: for example a support inbox's "assign", "reply" or "close". It names
113
+ * the kind, who did it and whether it worked, never the content.
114
+ */
115
+ action?(event: ActionEvent): Promise<void> | void;
110
116
  /** The shutdown drain ended, before any service stops; `left` is the work it gave up on. */
111
117
  shutdown?(left: readonly string[]): Promise<void> | void;
112
118
  }
113
119
 
120
+ /** One thing a person did through a plugin's surface, as a handler hears of it. */
121
+ export interface ActionEvent {
122
+ /** What was done, in the reporting plugin's words, such as "assign" or "close". */
123
+ kind: string;
124
+ /** The `Speaker.id` of who did it. */
125
+ actor: string;
126
+ principalId: string;
127
+ /** The conversation or page it concerns, when there is one. */
128
+ channel?: ChannelKey | `page:${string}`;
129
+ /** Whether it worked. */
130
+ ok: boolean;
131
+ at: Date;
132
+ }
133
+
114
134
  /** What the core reports to the plugins' handlers. */
115
135
  export interface EventSink {
116
136
  turnStarted(turn: TurnEvent): void;
@@ -118,6 +138,8 @@ export interface EventSink {
118
138
  /** Optional, so a sink of your own written before it still fits. */
119
139
  turnProgress?(event: TurnProgressEvent): void;
120
140
  changed(): void;
141
+ /** Optional, as `turnProgress` is: a sink of your own written before it still fits. */
142
+ action?(event: ActionEvent): void;
121
143
  }
122
144
 
123
145
  /** What a prompt section is built for: one agent's turn. */
@@ -265,6 +287,12 @@ export interface LinkedSessions {
265
287
  agentSelection(): ToolSelection;
266
288
  }
267
289
 
290
+ /**
291
+ * What the agents' turns may use of the machine: "full" gives them the shell and file tools, and
292
+ * "chat-only" none of them, as `agentServer: { tools: "chat-only" }` configures.
293
+ */
294
+ export type AgentTools = "full" | "chat-only";
295
+
268
296
  /** The host's own locale and time zone, as its plugins read them. */
269
297
  export interface HostEnv {
270
298
  readonly locale: Locale;
@@ -284,6 +312,12 @@ export interface HostEnv {
284
312
  * agent wears the same ones.
285
313
  */
286
314
  readonly characterSkins: readonly Hex[];
315
+ /**
316
+ * Whether the agents' turns carry the shell and file tools. A plugin whose tools reach the host
317
+ * checks it before it offers them to agents; under "chat-only" the host refuses to start with a
318
+ * plugin that declares `hostAccess`.
319
+ */
320
+ readonly agentTools: AgentTools;
287
321
  }
288
322
 
289
323
  /** What the host gives every plugin. */
@@ -391,6 +425,12 @@ export interface RoundtablePlugin {
391
425
  * dropped plugin provides a service this one does not replace.
392
426
  */
393
427
  replaces?: readonly ServiceKey<unknown>[];
428
+ /**
429
+ * Marks a plugin whose tools reach the host's machine, such as one that runs shells or edits
430
+ * its files for the agents. A host that configures `agentServer: { tools: "chat-only" }`
431
+ * refuses to start with one registered, with a ConfigError that names the plugin.
432
+ */
433
+ hostAccess?: true;
394
434
  setup(context: PluginContext): Promise<Contribution> | Contribution;
395
435
  /**
396
436
  * Runs once every plugin is set up and linked, before any service starts; a failure stops the
@@ -25,6 +25,7 @@ import {
25
25
  type DirectChannelProvider,
26
26
  directChannelsPort,
27
27
  } from "../presence/direct-channels.ts";
28
+ import { refuseShellTools } from "../runtime/chat-only.ts";
28
29
  import {
29
30
  compileSessionPlan,
30
31
  type SessionTool,
@@ -451,8 +452,14 @@ export async function collectContributions(
451
452
  registry.holdRules.push(tool.hold);
452
453
  }
453
454
  }
454
- if (contribution.agentSelection)
455
+ if (contribution.agentSelection) {
456
+ if (context.env.agentTools === "chat-only")
457
+ refuseShellTools(
458
+ contribution.agentSelection().tools,
459
+ `plugin ${plugin.name}: agentSelection`,
460
+ );
455
461
  registry.agentSelections.push(contribution.agentSelection);
462
+ }
456
463
  if (events) registry.handlers.push({ plugin: plugin.name, events });
457
464
  registry.routes.push(...http);
458
465
  registry.holdRules.push(...holdRules);
@@ -0,0 +1,32 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import { SHELL_TOOLS } from "../modules/host-shell/shell-policy.ts";
3
+ import type { RoundtablePlugin } from "../plugin.ts";
4
+
5
+ const CHAT_ONLY = 'agentServer: { tools: "chat-only" }';
6
+
7
+ /** The shell and file tools among these names. */
8
+ export function shellToolsIn(names: Iterable<string>): string[] {
9
+ const shell: readonly string[] = SHELL_TOOLS;
10
+ return [...new Set(names)].filter((name) => shell.includes(name));
11
+ }
12
+
13
+ /**
14
+ * Refuses names that carry the machine's shell and file tools: the one place a chat-only host
15
+ * checks, where a selection turns into active tools. `where` says whose selection it is.
16
+ */
17
+ export function refuseShellTools(names: Iterable<string>, where: string): void {
18
+ const found = shellToolsIn(names);
19
+ if (found.length > 0)
20
+ throw new ConfigError(
21
+ `${where} names ${found.join(", ")}, which ${CHAT_ONLY} withholds from every turn. Take the tool out of the selection, or remove tools: "chat-only" from agentServer to give the agents the shell.`,
22
+ );
23
+ }
24
+
25
+ /** Refuses, at start, a plugin whose tools reach the host: an agent of a chat-only host must not. */
26
+ export function refuseHostAccess(plugins: readonly RoundtablePlugin[]): void {
27
+ const reaching = plugins.find((plugin) => plugin.hostAccess === true);
28
+ if (reaching)
29
+ throw new ConfigError(
30
+ `plugin ${reaching.name} declares hostAccess: its tools reach the host's machine, and the configuration says ${CHAT_ONLY}. Remove the plugin, or remove tools: "chat-only" from agentServer.`,
31
+ );
32
+ }
@@ -102,7 +102,8 @@ export class ConversationSessions {
102
102
  this.#factory.link().holds,
103
103
  this.#options.owner,
104
104
  await this.#options.confirmations.load(key),
105
- agents
105
+ // A chat-only host's agents have no shell, so their gate judges no workspace.
106
+ agents && this.#options.agentTools !== "chat-only"
106
107
  ? {
107
108
  workspace: agents.workDir,
108
109
  ...(agents.scratchDir ? { scratchDir: agents.scratchDir } : {}),
@@ -20,7 +20,7 @@ import {
20
20
  approverOf,
21
21
  askSpeaker,
22
22
  awaitingText,
23
- declinedText,
23
+ declinedRefusal,
24
24
  type GateDecision,
25
25
  type Needing,
26
26
  namesOf,
@@ -337,7 +337,7 @@ export class ConfirmationGate {
337
337
  wait: {
338
338
  // A late answer on the card: the text of the turn that tells the model, and the release it earned.
339
339
  late: (late) => {
340
- const outcome = lateOutcome(call, late, watch);
340
+ const outcome = lateOutcome(call, late, watch, needing.onDeclined);
341
341
  if (outcome?.release) this.#lateApproved.push(outcome.release);
342
342
  return outcome?.text;
343
343
  },
@@ -372,13 +372,8 @@ export class ConfirmationGate {
372
372
  reason: awaitingText(this.#addressee, call),
373
373
  refused: "pending",
374
374
  };
375
- if (answer === "declined") {
376
- watch?.end();
377
- return {
378
- reason: declinedText(this.#addressee, call),
379
- refused: "declined",
380
- };
381
- }
375
+ if (answer === "declined")
376
+ return declinedRefusal(this.#addressee, call, watch, needing.onDeclined);
382
377
  if (answer === "cancelled") {
383
378
  watch?.end();
384
379
  return { reason: "The turn was stopped." };
@@ -449,6 +444,7 @@ export class ConfirmationGate {
449
444
  ...(judged.copy ? { copy: judged.copy } : {}),
450
445
  ...(judged.supersede ? { supersede: judged.supersede } : {}),
451
446
  ...(judged.speakerOnly ? { speakerOnly: judged.speakerOnly } : {}),
447
+ ...(judged.onDeclined ? { onDeclined: judged.onDeclined } : {}),
452
448
  };
453
449
  }
454
450
 
@@ -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);
@@ -36,6 +36,8 @@ export interface Judgment {
36
36
  speakerOnly?: true;
37
37
  /** The hold classes of the call; see `HoldRule.holdClasses`. Absent when the holds gave none. */
38
38
  classes?: readonly string[] | undefined;
39
+ /** Called when the call's card is declined; see `HoldDescription`. */
40
+ onDeclined?: () => void | Promise<void>;
39
41
  }
40
42
 
41
43
  export type Judged = Judgment | undefined | typeof STOPPED;
@@ -59,11 +61,14 @@ function isSignal(value: unknown): value is AbortSignal {
59
61
  export function described(
60
62
  answer: HoldDescription,
61
63
  tool: string,
62
- ): Pick<Judgment, "action" | "copy" | "supersede" | "speakerOnly" | "classes"> {
64
+ ): Pick<
65
+ Judgment,
66
+ "action" | "copy" | "supersede" | "speakerOnly" | "classes" | "onDeclined"
67
+ > {
63
68
  if (typeof answer === "string") return { action: answer };
64
69
  if (typeof answer !== "object" || answer === null)
65
70
  return { action: messages().holdGeneric(tool) };
66
- const { text, copy, supersede, approval, classes } = answer;
71
+ const { text, copy, supersede, approval, classes, onDeclined } = answer;
67
72
  if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
68
73
  const named = Array.isArray(classes)
69
74
  ? classes.filter((id): id is string => typeof id === "string" && id !== "")
@@ -72,6 +77,7 @@ export function described(
72
77
  ...(isSignal(supersede) ? { supersede } : {}),
73
78
  ...(approval === "speaker" ? { speakerOnly: true as const } : {}),
74
79
  ...(named.length > 0 ? { classes: named } : {}),
80
+ ...(typeof onDeclined === "function" ? { onDeclined } : {}),
75
81
  };
76
82
  if (
77
83
  !copy ||
@@ -142,6 +148,7 @@ export function joinApprovers(
142
148
  supersede,
143
149
  speakerOnly,
144
150
  classes,
151
+ onDeclined,
145
152
  } = described(action, tool);
146
153
  const joined = (approvers: readonly string[] | undefined): Judgment => ({
147
154
  action: text,
@@ -149,6 +156,7 @@ export function joinApprovers(
149
156
  ...(supersede ? { supersede } : {}),
150
157
  ...(speakerOnly ? { speakerOnly } : {}),
151
158
  ...(classes ? { classes } : {}),
159
+ ...(onDeclined ? { onDeclined } : {}),
152
160
  ...(approvers ? { approvers } : {}),
153
161
  });
154
162
  const controller = new AbortController();
@@ -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 { Release } from "./gate-refusals.ts";
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
  };
@@ -199,6 +199,7 @@ export function runtimePlugin(
199
199
  },
200
200
  ...turnPolicy(options),
201
201
  sessions: context.sessions,
202
+ agentTools: context.env.agentTools,
202
203
  agentDir: options.agentDir,
203
204
  modelRuntime: options.modelRuntime,
204
205
  dataDir: options.dataDir,
@@ -16,7 +16,7 @@ import type { Logger } from "../log.ts";
16
16
  import type { EffortPolicy, ThinkingLevel, ThinkingPicker } from "../models.ts";
17
17
  import type { Autonomy } from "../modes/autonomy.ts";
18
18
  import type { ConversationModes } from "../modes/conversation-modes.ts";
19
- import type { LinkedSessions } from "../plugin.ts";
19
+ import type { AgentTools, LinkedSessions } from "../plugin.ts";
20
20
  import {
21
21
  planOrder,
22
22
  type SessionContext,
@@ -75,6 +75,8 @@ export interface PiAgentRuntimeOptions {
75
75
  sessions: () => LinkedSessions;
76
76
  /** The agent server's sessions: their shared workspace, model, and skills. */
77
77
  agents?: AgentSessions;
78
+ /** "chat-only": no turn carries the shell and file tools, and agent sessions have no workspace; default "full". */
79
+ agentTools?: AgentTools;
78
80
  /**
79
81
  * The prompts of a channel for an interactive turn's scope, made from its speaker and its
80
82
  * conversation's visibility; undefined for a channel that cannot show them. Without it every
@@ -3,14 +3,11 @@ import { join } from "node:path";
3
3
  import {
4
4
  type AgentSession,
5
5
  createAgentSession,
6
- createBashToolDefinition,
7
6
  DefaultResourceLoader,
8
7
  type ModelRuntime,
9
8
  type SessionManager,
10
- type ToolDefinition,
11
9
  } from "@earendil-works/pi-coding-agent";
12
10
  import { channelSegment } from "../attachments/attachment-dir.ts";
13
- import type { ImageDrawer } from "../contract/providers.ts";
14
11
  import type { ClientPort } from "../domain/client.ts";
15
12
  import type { ChannelKey } from "../domain/conversation.ts";
16
13
  import { ConfigError } from "../domain/errors.ts";
@@ -29,16 +26,13 @@ import {
29
26
  ATTACH_FILE_TOOL,
30
27
  attachFileExtension,
31
28
  } from "../shared/attach-file-tool.ts";
32
- import {
33
- GENERATE_IMAGE_TOOL,
34
- generateImageExtension,
35
- } from "../shared/generate-image-tool.ts";
29
+ import { GENERATE_IMAGE_TOOL } from "../shared/generate-image-tool.ts";
36
30
  import { packageDir } from "../shared/package-dir.ts";
37
31
  import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
38
- import type { WorkspaceRoots } from "../shared/workspace-files.ts";
39
32
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
40
33
  import { currentToolNames } from "../tool-tiers.ts";
41
34
  import { toolTurn } from "../tool-turn.ts";
35
+ import { refuseShellTools } from "./chat-only.ts";
42
36
  import {
43
37
  CompactionTiers,
44
38
  compactionEngine,
@@ -64,7 +58,6 @@ import type { PromptSlot } from "./prompt-slot.ts";
64
58
  import type {
65
59
  ChannelSession,
66
60
  CompactionEnd,
67
- CoreExtensions,
68
61
  PiAgentRuntimeOptions,
69
62
  } from "./runtime-types.ts";
70
63
  import {
@@ -78,6 +71,9 @@ import {
78
71
  sessionAddressee,
79
72
  turnAddressee,
80
73
  } from "./session-conversation.ts";
74
+ import { imageTools, scratchBash } from "./session-helpers.ts";
75
+
76
+ export { imageTools, scratchBash };
81
77
 
82
78
  /** What a session factory asks of the runtime that owns the turns. */
83
79
  export interface SessionFactoryDeps {
@@ -139,11 +135,22 @@ export class SessionFactory {
139
135
  );
140
136
  }
141
137
 
138
+ /** Whether the host's agents carry no shell or file tools. */
139
+ get chatOnly(): boolean {
140
+ return this.#options.agentTools === "chat-only";
141
+ }
142
+
142
143
  get plan(): SessionPlan {
143
144
  return this.link().plan;
144
145
  }
145
146
 
147
+ /**
148
+ * The tools a selection turns into. A chat-only host refuses a selection that names a shell or
149
+ * file tool, whatever built it (the agent team, `context.turns`, a plugin), so the turn fails
150
+ * closed before any session runs it.
151
+ */
146
152
  toolsFor(selection: ToolSelection): string[] {
153
+ if (this.chatOnly) refuseShellTools(selection.tools, "a tool selection");
147
154
  const groups = planOrder(this.plan).flatMap(
148
155
  (tool) => tool.snapshot().groups ?? [],
149
156
  );
@@ -360,7 +367,8 @@ export class SessionFactory {
360
367
  },
361
368
  conversation,
362
369
  addressee,
363
- ...(agents
370
+ // A chat-only host's agents work in no workspace.
371
+ ...(agents && !this.chatOnly
364
372
  ? {
365
373
  workspace: {
366
374
  workspace: cwd,
@@ -450,7 +458,7 @@ export class SessionFactory {
450
458
  cwd,
451
459
  agentDir,
452
460
  // Pi's bash under its own name, run with TMPDIR at the agents' scratch dir.
453
- ...(agents?.scratchDir
461
+ ...(agents?.scratchDir && !this.chatOnly
454
462
  ? { customTools: [scratchBash(cwd, agents.scratchDir)] }
455
463
  : {}),
456
464
  thinkingLevel: thinking,
@@ -479,22 +487,3 @@ export class SessionFactory {
479
487
  return Object.assign(state, { session });
480
488
  }
481
489
  }
482
-
483
- /** `generate_image` for a session of a host with an image provider; nothing without one. */
484
- export function imageTools(
485
- images: ImageDrawer | undefined,
486
- roots: WorkspaceRoots,
487
- ): Pick<CoreExtensions, "generateImage"> {
488
- return images ? { generateImage: generateImageExtension(images, roots) } : {};
489
- }
490
-
491
- /** Pi's bash tool with TMPDIR set to `scratchDir`, so tools that read TMPDIR and GNU `mktemp` write there (BSD `mktemp` needs `-p "$TMPDIR"`). */
492
- export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
493
- // SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
494
- return createBashToolDefinition(cwd, {
495
- spawnHook: (context) => ({
496
- ...context,
497
- env: { ...context.env, TMPDIR: scratchDir },
498
- }),
499
- }) as unknown as ToolDefinition;
500
- }
@@ -0,0 +1,27 @@
1
+ import {
2
+ createBashToolDefinition,
3
+ type ToolDefinition,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { ImageDrawer } from "../contract/providers.ts";
6
+ import { generateImageExtension } from "../shared/generate-image-tool.ts";
7
+ import type { WorkspaceRoots } from "../shared/workspace-files.ts";
8
+ import type { CoreExtensions } from "./runtime-types.ts";
9
+
10
+ /** `generate_image` for a session of a host with an image provider; nothing without one. */
11
+ export function imageTools(
12
+ images: ImageDrawer | undefined,
13
+ roots: WorkspaceRoots,
14
+ ): Pick<CoreExtensions, "generateImage"> {
15
+ return images ? { generateImage: generateImageExtension(images, roots) } : {};
16
+ }
17
+
18
+ /** Pi's bash tool with TMPDIR set to `scratchDir`, so tools that read TMPDIR and GNU `mktemp` write there (BSD `mktemp` needs `-p "$TMPDIR"`). */
19
+ export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
20
+ // SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
21
+ return createBashToolDefinition(cwd, {
22
+ spawnHook: (context) => ({
23
+ ...context,
24
+ env: { ...context.env, TMPDIR: scratchDir },
25
+ }),
26
+ }) as unknown as ToolDefinition;
27
+ }
@@ -3,6 +3,7 @@ import type { CharacterInput, CharacterSpec } from "../characters/index.ts";
3
3
  import type { Agent, AgentGroup } from "./agents/agent-rules.ts";
4
4
  import type { AvatarMode } from "./agents/agent-tools.ts";
5
5
  import type { TeamStatus } from "./agents/team-status.ts";
6
+ import type { ChannelContexts } from "./contract/channel-context.ts";
6
7
  import type {
7
8
  BackgroundRunsAs,
8
9
  ScheduledOutcome,
@@ -14,6 +15,7 @@ import type { PendingConfirmation } from "./domain/conversation.ts";
14
15
  import type { HttpRoute } from "./http/listeners.ts";
15
16
  import type { IdentityLinks } from "./identity/identity-links.ts";
16
17
  import type { IdentityService } from "./identity/identity-service.ts";
18
+ import type { PersonProfiles } from "./identity/profiles.ts";
17
19
  import type { ThinkingLevel, ThinkingSetting } from "./models.ts";
18
20
  import type { ConversationModes } from "./modes/conversation-modes.ts";
19
21
  import type {
@@ -53,6 +55,10 @@ import type { DispatchThreads } from "./threads/dispatch-threads.ts";
53
55
  */
54
56
  export const IDENTITY: ServiceKey<IdentityService> =
55
57
  serviceKey<IdentityService>("roundtable.identity");
58
+ /** How the core names a person (linked accounts' names and pictures, the chosen one, plugin sources); from the `identity` plugin. */
59
+ export const PROFILES: ServiceKey<PersonProfiles> = serviceKey<PersonProfiles>(
60
+ "roundtable.profiles",
61
+ );
56
62
  /**
57
63
  * Links a person's own account to their principal, which `IDENTITY` cannot. Provided by the
58
64
  * `identity` plugin only when the configuration lists providers in `access.selfLink`; read it with
@@ -92,6 +98,9 @@ export const MODES: ServiceKey<ConversationModes> =
92
98
  */
93
99
  export const THREADS: ServiceKey<DispatchThreads> =
94
100
  serviceKey<DispatchThreads>("roundtable.threads");
101
+ /** The channel context of any surface, chosen by the message's channel key as `THREADS` is; provided by the `channel-context` plugin, which every host registers. */
102
+ export const CHANNEL_CONTEXT: ServiceKey<ChannelContexts> =
103
+ serviceKey<ChannelContexts>("roundtable.channel-context");
95
104
  /** The agent server: its team, its directory, the runtime that runs every agent turn. Provided by the agent-server plugin. */
96
105
  export const AGENTS: ServiceKey<AgentServer> = serviceKey<AgentServer>(
97
106
  "roundtable.agents",