pi-roundtable 0.15.2 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.15.3] - 2026-10-15
9
+
10
+ ### Added
11
+
12
+ - `effortPolicy` in the configuration (`EffortPolicy`, `EffortRequest` and `EffortRule` in `pi-roundtable/kit`): a host chooses, per turn that thinks at the judge's choice, how the level is decided. The policy gets the turn's `channel`, `kind`, `agent` and `origin` and answers `{ fixed: level }` to run the turn at that level without asking the judge, `{ requiredMass: [0, medium, high, xhigh] }` to run the highest level whose share of the judge's answer at or above it meets its entry (the previous turn's level does not carry over, and a judge that fails gives low), or `undefined` for the default. The `kind` of an agent-server agent's turn is `"agent"`. A `requiredMass` that is not four finite numbers in [0, 1], or a policy that throws, fails the turn with a clear error. Without a policy nothing changes.
13
+
8
14
  ## [0.15.2] - 2026-10-15
9
15
 
10
16
  ### Fixed
package/docs/plugins.md CHANGED
@@ -80,7 +80,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
80
80
  Two adapters log each skip as `Jev leaves the compaction to Pi's summary` with `reason`, `detail` and `tokensBefore` through the `logger` they take: `jevCompactionExtension({ logger, ...options })`, the `compaction` session tool's extension, placed as `session.compaction.wrap(jevCompactionExtension({ logger }))` under the engine `JEV_COMPACTION_ENGINE` (`pi-jev-compaction`), and `jevCompactor({ logger, ...options })`, a `JevCompactor` for pi-roundtable-sandbox's `compaction` option that returns the compaction or `undefined` and logs with the `channel`.
81
81
  A `no_key` skip is logged once per adapter, as `Jev is not configured, so compaction uses Pi's summary`, and then not again; every other skip is logged each time.
82
82
  The types are `JevCompactInput`, `JevCompactOptions`, `JevCompactOutcome`, `JevCompactor`, `JevCompactRequest`, `JevExtensionOptions` and `JevSkipReason`.
83
- - Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`).
83
+ - Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`). A host that wants other turns decided differently sets `effortPolicy` (an `EffortPolicy`) in its configuration: it gets the turn's `channel`, `kind`, `agent` and `origin` and answers `{ fixed: level }` (no judge call), `{ requiredMass: [0, medium, high, xhigh] }` (the highest level whose share of the judge's answer at or above it meets its entry; the previous level does not carry over and a failed judge gives low), or `undefined` for the default. The `kind` of an agent-server agent's turn is `"agent"`; a policy that throws, or a `requiredMass` that is not four finite numbers in [0, 1], fails the turn.
84
84
  - Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, and `searchTerms` for memory search.
85
85
 
86
86
  The [coding package source][coding-source] shows these helpers in an out-of-process Pi worker.
@@ -3847,6 +3847,9 @@ Import from the entries listed below; source area files are internal.
3847
3847
  | `EffortJudgeOptions` | `pi-roundtable/kit` | type |
3848
3848
  | `EffortLevel` | `pi-roundtable/kit` | type |
3849
3849
  | `EffortPicker` | `pi-roundtable/kit` | type |
3850
+ | `EffortPolicy` | `pi-roundtable/kit` | type |
3851
+ | `EffortRequest` | `pi-roundtable/kit` | type |
3852
+ | `EffortRule` | `pi-roundtable/kit` | type |
3850
3853
  | `GroupMessage` | `pi-roundtable/kit` | type |
3851
3854
  | `JUDGE_WORK` | `pi-roundtable/kit` | value |
3852
3855
  | `McpEndpoint` | `pi-roundtable/kit` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,6 +8,7 @@ import { isLocale, type Locale } from "../i18n/index.ts";
8
8
  import type { AccessRules } from "../identity/access-policy.ts";
9
9
  import type { OwnerIdentity } from "../identity.ts";
10
10
  import {
11
+ type EffortPolicy,
11
12
  type ModelRef,
12
13
  parseModelRef,
13
14
  THINKING_LEVELS,
@@ -109,6 +110,8 @@ export interface RoundtableConfig {
109
110
  thinking?: ThinkingLevel;
110
111
  /** The model that judges small questions and the delegated tasks' model; default the agents' model. */
111
112
  judge?: { model?: string; threshold?: number };
113
+ /** How each turn that thinks at the judge's choice is decided, instead of the default; see `EffortPolicy`. */
114
+ effortPolicy?: EffortPolicy;
112
115
  delegation?: { model?: string; thinking?: ThinkingLevel };
113
116
  /** The language of what the bot shows in Discord; default "en". */
114
117
  locale?: Locale;
@@ -200,6 +203,8 @@ const tierMembers = shape({
200
203
  everyone: optional(bool),
201
204
  });
202
205
 
206
+ const isPolicy = (value: unknown): value is EffortPolicy =>
207
+ typeof value === "function";
203
208
  const isPlugin = (value: unknown): value is RoundtablePlugin =>
204
209
  typeof value === "object" &&
205
210
  value !== null &&
@@ -225,6 +230,9 @@ const schema = shape({
225
230
  judge: optional(
226
231
  shape({ model: optional(text), threshold: optional(number(0, 1)) }),
227
232
  ),
233
+ effortPolicy: optional(
234
+ guarded("a function from a turn to its effort rule", isPolicy),
235
+ ),
228
236
  delegation: optional(
229
237
  shape({
230
238
  model: optional(text),
@@ -320,6 +328,7 @@ export interface ResolvedConfig {
320
328
  model: ModelRef;
321
329
  thinking: ThinkingLevel;
322
330
  judge: { model: ModelRef; threshold: number };
331
+ effortPolicy?: EffortPolicy;
323
332
  delegation: { model: ModelRef; thinking: ThinkingLevel };
324
333
  locale: Locale;
325
334
  timeZone: string;
@@ -443,6 +452,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
443
452
  : model,
444
453
  threshold: config.judge?.threshold ?? 0.6,
445
454
  },
455
+ ...(config.effortPolicy ? { effortPolicy: config.effortPolicy } : {}),
446
456
  delegation: {
447
457
  model: config.delegation?.model
448
458
  ? modelOf(config.delegation.model, "delegation.model")
@@ -329,6 +329,7 @@ export async function defineRoundtable(
329
329
  model: config.model,
330
330
  thinking: config.thinking,
331
331
  judgeThreshold: config.judge.threshold,
332
+ ...(config.effortPolicy ? { effortPolicy: config.effortPolicy } : {}),
332
333
  agents: agentSessions,
333
334
  interimText: config.interimText,
334
335
  interimPrimaryChars: config.interimPrimaryChars,
@@ -72,6 +72,7 @@ export interface EffortPicker<Fallback extends ThinkingLevel = ThinkingLevel> {
72
72
  judge(
73
73
  message: string,
74
74
  previous?: PreviousTurn<EffortLevel | Fallback>,
75
+ rule?: { requiredMass: readonly [number, number, number, number] },
75
76
  ): Promise<EffortLevel | Fallback>;
76
77
  }
77
78
 
@@ -96,6 +97,7 @@ export class EffortJudge<Fallback extends ThinkingLevel = ThinkingLevel>
96
97
  async judge(
97
98
  message: string,
98
99
  previous: PreviousTurn<EffortLevel | Fallback> = {},
100
+ rule?: { requiredMass: readonly [number, number, number, number] },
99
101
  ): Promise<EffortLevel | Fallback> {
100
102
  const { judge, brief, fallback, threshold, logger } = this.#options;
101
103
  try {
@@ -113,10 +115,13 @@ export class EffortJudge<Fallback extends ThinkingLevel = ThinkingLevel>
113
115
  criteria: brief.criteria,
114
116
  },
115
117
  );
116
- const from = EFFORT_LEVELS.indexOf(previous.level as EffortLevel);
118
+ // A rule picks each turn on its own: the previous turn's level does not carry over.
119
+ const from = rule
120
+ ? -1
121
+ : EFFORT_LEVELS.indexOf(previous.level as EffortLevel);
117
122
  const index =
118
123
  from === -1
119
- ? levelIndex(probabilities)
124
+ ? levelIndex(probabilities, rule?.requiredMass)
120
125
  : moveIndex(from, probabilities, threshold);
121
126
  const level =
122
127
  index === from && previous.level
@@ -128,7 +133,7 @@ export class EffortJudge<Fallback extends ThinkingLevel = ThinkingLevel>
128
133
  );
129
134
  return level;
130
135
  } catch (error) {
131
- const level = previous.level ?? fallback;
136
+ const level = rule ? "low" : (previous.level ?? fallback);
132
137
  logger.warn({ err: error, level }, "effort request failed");
133
138
  return level;
134
139
  }
@@ -140,11 +145,14 @@ export class EffortJudge<Fallback extends ThinkingLevel = ThinkingLevel>
140
145
  * meets REQUIRED_MASS. Below the top level that is the median of an ordinal answer, which
141
146
  * unlike the mean never lands on a middle level nothing voted for.
142
147
  */
143
- export function levelIndex(probabilities: readonly number[]): number {
148
+ export function levelIndex(
149
+ probabilities: readonly number[],
150
+ requiredMass: readonly number[] = REQUIRED_MASS,
151
+ ): number {
144
152
  let reached = 0;
145
153
  for (let index = probabilities.length - 1; index > 0; index--) {
146
154
  reached += probabilities[index] ?? 0;
147
- if (reached >= (REQUIRED_MASS[index] ?? 0.5)) return index;
155
+ if (reached >= (requiredMass[index] ?? 0.5)) return index;
148
156
  }
149
157
  return 0;
150
158
  }
@@ -1,4 +1,5 @@
1
1
  import { messages } from "./i18n/index.ts";
2
+ import type { ChannelKey } from "./sessions.ts";
2
3
  /** Models and thinking levels as Pi names them, and the settings built on them. */
3
4
 
4
5
  export type ThinkingLevel =
@@ -43,10 +44,40 @@ export function parseModelRef(text: string): ModelRef | undefined {
43
44
  export const formatModelRef = (model: ModelRef): string =>
44
45
  `${model.provider}/${model.id}`;
45
46
 
47
+ /** What a host's effort policy is told about a turn that thinks at the judge's choice. */
48
+ export interface EffortRequest {
49
+ /** Where the turn's message arrived, as `<surface>:<channel id>`. */
50
+ channel: ChannelKey;
51
+ /** The conversation's kind, as a claim's `startFresh` set it; "agent" for an agent-server agent's turn; absent otherwise (an owner's turn). */
52
+ kind?: string;
53
+ /** The agent-server agent's name, for an agent's turn. */
54
+ agent?: string;
55
+ /** Where the request came from, as the transport knows it; see `TurnRequest.origin`. */
56
+ origin?: string;
57
+ }
58
+
59
+ /**
60
+ * How one turn's level is chosen, instead of the default. `fixed` runs the turn at that level
61
+ * without asking the judge. `requiredMass` asks the judge and runs the highest level whose share
62
+ * of the answer at or above it meets its entry (index 0 is low and never read; 1 medium, 2 high,
63
+ * 3 xhigh, each a finite number in [0, 1], else the turn fails); the previous turn's level does not
64
+ * carry over, and a judge that fails gives low.
65
+ */
66
+ export type EffortRule =
67
+ | { fixed: ThinkingLevel }
68
+ | { requiredMass: readonly [number, number, number, number] };
69
+
70
+ /**
71
+ * Picks the rule of a turn, or undefined for the default (sticky judging with the host's threshold).
72
+ * A policy that throws, or returns an invalid `requiredMass`, fails that turn.
73
+ */
74
+ export type EffortPolicy = (request: EffortRequest) => EffortRule | undefined;
75
+
46
76
  /** Picks one turn's thinking level from its message and the previous turn's reply and level. */
47
77
  export interface ThinkingPicker {
48
78
  judge(
49
79
  message: string,
50
80
  previous?: { reply?: string; level?: ThinkingLevel },
81
+ rule?: Exclude<EffortRule, { fixed: ThinkingLevel }>,
51
82
  ): Promise<ThinkingLevel>;
52
83
  }
@@ -151,6 +151,7 @@ export class PiAgentRuntime implements AgentRuntime {
151
151
  {
152
152
  modelRuntime: this.#modelRuntime,
153
153
  effort: this.#options.effort,
154
+ policy: this.#options.effortPolicy,
154
155
  judged: this.#sessions.judged,
155
156
  },
156
157
  key,
@@ -12,6 +12,7 @@ import type { OwnerIdentity } from "../identity.ts";
12
12
  import type { PromptScope } from "../interactions/prompts.ts";
13
13
  import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
14
14
  import {
15
+ type EffortPolicy,
15
16
  formatModelRef,
16
17
  type ModelRef,
17
18
  type ThinkingLevel,
@@ -69,6 +70,8 @@ export interface RuntimePluginOptions {
69
70
  thinking: ThinkingLevel;
70
71
  /** How sure the effort judge must be before its answer is used. */
71
72
  judgeThreshold: number;
73
+ /** Picks a rule per turn instead of the judge's default; see `EffortPolicy`. */
74
+ effortPolicy?: EffortPolicy;
72
75
  /** The agent server's settings, bound when it sets up; without a slot no agent turn runs. */
73
76
  agents?: AgentSessionsSlot;
74
77
  /** Whether turns post the text they write before their final answer as they go; default "on". */
@@ -159,6 +162,9 @@ export function runtimePlugin(
159
162
  dataDir: options.dataDir,
160
163
  model: options.model,
161
164
  thinking: options.thinking,
165
+ ...(options.effortPolicy
166
+ ? { effortPolicy: options.effortPolicy }
167
+ : {}),
162
168
  effort: new EffortJudge({
163
169
  judge: providers.judge,
164
170
  brief: AGENT_BRIEF,
@@ -13,7 +13,7 @@ import type { Principal } from "../identity/principal-store.ts";
13
13
  import type { OwnerIdentity } from "../identity.ts";
14
14
  import type { PromptScope, Prompts } from "../interactions/prompts.ts";
15
15
  import type { Logger } from "../log.ts";
16
- import type { ThinkingLevel, ThinkingPicker } from "../models.ts";
16
+ import type { EffortPolicy, ThinkingLevel, ThinkingPicker } from "../models.ts";
17
17
  import type { LinkedSessions } from "../plugin.ts";
18
18
  import {
19
19
  planOrder,
@@ -60,6 +60,8 @@ export interface PiAgentRuntimeOptions {
60
60
  thinking: ThinkingLevel;
61
61
  /** Picks the level of the assistant's turns and of agents whose thinking is `auto`. */
62
62
  effort: ThinkingPicker;
63
+ /** Chooses a turn's rule for the picker, or a fixed level; absent = the judge decides alike for every turn. */
64
+ effortPolicy?: EffortPolicy;
63
65
  /** Held actions survive a restart here, so a confirmation after one still runs. */
64
66
  confirmations: Pick<PendingConfirmationStore, "load" | "save">;
65
67
  logger: Logger;
@@ -4,7 +4,11 @@ import type {
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import type { ChannelKey } from "../domain/conversation.ts";
6
6
  import type { TurnRequest } from "../domain/ports.ts";
7
- import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
7
+ import {
8
+ AUTO_THINKING,
9
+ type EffortPolicy,
10
+ type ThinkingLevel,
11
+ } from "../models.ts";
8
12
  import {
9
13
  type AgentModelSettings,
10
14
  useChosenAgentModel,
@@ -12,6 +16,20 @@ import {
12
16
  import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
13
17
  import { lastReply } from "./turn-answer.ts";
14
18
 
19
+ function checkRequiredMass(mass: readonly number[]): void {
20
+ if (
21
+ !Array.isArray(mass) ||
22
+ mass.length !== 4 ||
23
+ !mass.every(
24
+ (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1,
25
+ )
26
+ ) {
27
+ throw new Error(
28
+ `effortPolicy requiredMass must be four finite numbers in [0, 1], got ${JSON.stringify(mass)}`,
29
+ );
30
+ }
31
+ }
32
+
15
33
  /** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
16
34
  export async function thinkingLevel(
17
35
  parts: {
@@ -19,6 +37,8 @@ export async function thinkingLevel(
19
37
  effort: PiAgentRuntimeOptions["effort"];
20
38
  /** The level last judged for each conversation. */
21
39
  judged: Map<ChannelKey, ThinkingLevel>;
40
+ /** The host's rule for a turn that thinks at the judge's choice. */
41
+ policy?: EffortPolicy | undefined;
22
42
  },
23
43
  key: ChannelKey,
24
44
  session: AgentSession,
@@ -38,10 +58,23 @@ export async function thinkingLevel(
38
58
  judged.delete(key);
39
59
  return setting;
40
60
  }
41
- const level = await effort.judge(request.text, {
42
- reply: lastReply(session.messages),
43
- level: judged.get(key),
61
+ const kind = request.kind ?? (request.agent ? "agent" : undefined);
62
+ const rule = parts.policy?.({
63
+ channel: request.channel,
64
+ ...(kind !== undefined ? { kind } : {}),
65
+ ...(request.agent ? { agent: request.agent.name } : {}),
66
+ ...(request.origin !== undefined ? { origin: request.origin } : {}),
44
67
  });
68
+ if (rule && "requiredMass" in rule) checkRequiredMass(rule.requiredMass);
69
+ if (rule && "fixed" in rule) {
70
+ judged.delete(key);
71
+ return rule.fixed;
72
+ }
73
+ const level = await effort.judge(
74
+ request.text,
75
+ { reply: lastReply(session.messages), level: judged.get(key) },
76
+ rule,
77
+ );
45
78
  judged.set(key, level);
46
79
  return level;
47
80
  }
package/src/kit/index.ts CHANGED
@@ -133,6 +133,9 @@ export type {
133
133
  ChannelMessage,
134
134
  DashboardBoard,
135
135
  DelegationWorker,
136
+ EffortPolicy,
137
+ EffortRequest,
138
+ EffortRule,
136
139
  GroupMessage,
137
140
  Notifier,
138
141
  OwnerNotifier,
@@ -14,7 +14,12 @@ export type { Backlog, GroupMessage } from "../core/agents/agent-rules.ts";
14
14
  export type { AgentOps } from "../core/agents/agent-tools.ts";
15
15
  export type { ThreadHost } from "../core/discord/dispatch-threads.ts";
16
16
  export type { Notifier, OwnerNotifier } from "../core/domain/ports.ts";
17
- export type { ThinkingPicker } from "../core/models.ts";
17
+ export type {
18
+ EffortPolicy,
19
+ EffortRequest,
20
+ EffortRule,
21
+ ThinkingPicker,
22
+ } from "../core/models.ts";
18
23
  export type { DelegationWorker } from "../core/modules/delegation/delegator.ts";
19
24
  export type { AgentChannelLookup } from "../core/modules/schedules/schedules.ts";
20
25
  export type { AssistantLike } from "../core/shared/session-messages.ts";