pi-roundtable 0.15.2 → 0.15.4

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,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.15.4] - 2026-10-15
9
+
10
+ ### Changed
11
+
12
+ - With `discord.agentSpeakers: "owners"`, a schedule whose turn would run below the owner tier no longer starts its precheck or its container: the scheduler asks the channel's claim before the precheck, and the run is recorded `skipped:` with the reason the turn would have been skipped with (`the agent server answers owners only (speakers: "owners")`). A new optional `ChannelClaim.backgroundRefusal(turn)` answers that for a claim, with the turn and the speaker the router checked; `ConversationPort.runsAs` and `ChannelRouter.runsAs` now include it, and the agent server's claim uses it for its own `background` skips (the owner target only, no groups, owners only). A claim without it is unaffected.
13
+
14
+ ## [0.15.3] - 2026-10-15
15
+
16
+ ### Added
17
+
18
+ - `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.
19
+
8
20
  ## [0.15.2] - 2026-10-15
9
21
 
10
22
  ### 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.
@@ -1238,7 +1238,7 @@ pi-roundtable-sandbox's `precheckScriptRunner` runs each script in a sealed cont
1238
1238
  - `schedule_create` and `schedule_update` take `precheck_script`, a JavaScript module of at most `PRECHECK_SCRIPT_CHARS` (8,000) characters with a default export; it is parsed, never run, when it is set. A schedule has a `precheck` or a `precheck_script`; setting one removes the other, and `null` removes either.
1239
1239
  - The runner's `run(script, context)` gets the `PrecheckScriptContext`: the schedule, `firedAt`, `tier`, `signal`, the host's `timeZone`, and `today`, the date there. `tier` is the tier the run is for: the schedule's, capped at what its creator holds now, as its turn would run. Its answer is checked like a named precheck's, and its finding is named `script`.
1240
1240
  - `describe({ channel, target, tier })` (a `PrecheckScope`) tells the model how to write one and what it may call there; `schedule_list` shows it, waiting at most 10 seconds. `tier` is the asker's there and the run's `tier` when it runs, so a runner may grant lower tiers less.
1241
- - Before a schedule's precheck, named or a script, the scheduler checks who its turn would run as, as `ConversationPort.runsAs` does: a creator who may not run it now, such as one disabled, runs no precheck, and the run is recorded `skipped:` with the reason.
1241
+ - Before a schedule's precheck, named or a script, the scheduler checks who its turn would run as, as `ConversationPort.runsAs` does: a creator who may not run it now, such as one disabled, runs no precheck, and the run is recorded `skipped:` with the reason. The claim that owns the channel is asked too (`ChannelClaim.backgroundRefusal`): with `discord.agentSpeakers: "owners"`, a schedule whose turn would run below the owner tier starts no precheck and no container, and is recorded with the same reason its turn would have been skipped with.
1242
1242
  - When the host stops, the scheduler aborts running scripts and waits up to 15 seconds for the runner to clean up; a precheck that ends then starts no turn.
1243
1243
  - Without a runner, the tools neither take nor mention `precheck_script`, a script is refused with the registered names, and a schedule that already has one wakes with `### Precheck failed: script`, never a silent skip.
1244
1244
 
@@ -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.4",
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",
@@ -65,7 +65,7 @@
65
65
  "canvas": "3.2.3",
66
66
  "discord.js": "14.27.0",
67
67
  "pi-jev-compaction": "1.0.0",
68
- "pi-mcp-adapter": "5.0.0",
68
+ "pi-mcp-adapter": "5.2.0",
69
69
  "pino": "10.4.0",
70
70
  "typebox": "1.3.34",
71
71
  "unpdf": "1.8.1"
@@ -78,6 +78,6 @@
78
78
  "@biomejs/biome": "2.5.15",
79
79
  "@types/bun": "1.4.2",
80
80
  "pi-self-compact": "0.2.0",
81
- "pi-web-access": "0.35.0"
81
+ "pi-web-access": "0.39.0"
82
82
  }
83
83
  }
@@ -7,6 +7,7 @@ import {
7
7
  import type {
8
8
  Admission,
9
9
  BackgroundTarget,
10
+ BackgroundTurn,
10
11
  ChannelClaim,
11
12
  InboundMessage,
12
13
  } from "../contract/channels.ts";
@@ -19,7 +20,7 @@ import { PERSONAL_TARGET } from "../modules/background/personal-target.ts";
19
20
  import { withReference } from "../routing/message-text.ts";
20
21
  import { outcome } from "../routing/settle-turn.ts";
21
22
  import { systemTurn } from "../routing/system-turns.ts";
22
- import { addressee, attributed } from "../speakers.ts";
23
+ import { addressee, attributed, type Speaker } from "../speakers.ts";
23
24
  import type { DiscordAgentTeam } from "./agent-team.ts";
24
25
  import { discordIdOf } from "./team-keys.ts";
25
26
 
@@ -104,6 +105,19 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
104
105
  }
105
106
  };
106
107
 
108
+ /** Why the agent server would skip a background turn for its checked speaker; the turn's own skips. */
109
+ const backgroundRefusal = (
110
+ turn: BackgroundTurn & { speaker: Speaker },
111
+ ): string | undefined => {
112
+ // Fail closed: a turn for any target but the owner's never reaches the owner's tools.
113
+ if (turn.target !== PERSONAL_TARGET.name)
114
+ return `the agent server answers only "${PERSONAL_TARGET.name}" background turns`;
115
+ if (team.owns(turn.channel) === "group") return "groups have no schedules";
116
+ if (options.speakers === "owners" && turn.speaker.tier !== "owner")
117
+ return 'the agent server answers owners only (speakers: "owners")';
118
+ return undefined;
119
+ };
120
+
107
121
  const webhookReport = (message: InboundMessage): Admission | undefined => {
108
122
  const text = message.text.trim();
109
123
  if (!text) return undefined;
@@ -201,16 +215,8 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
201
215
  failure: "agent message handling failed",
202
216
  };
203
217
  },
218
+ backgroundRefusal,
204
219
  background: async (turn) => {
205
- // Fail closed: a turn for any target but the owner's never reaches the owner's tools.
206
- if (turn.target !== PERSONAL_TARGET.name)
207
- return {
208
- status: "skipped",
209
- reason: `the agent server answers only "${PERSONAL_TARGET.name}" background turns`,
210
- };
211
- const owned = team.owns(turn.channel);
212
- if (owned === "group")
213
- return { status: "skipped", reason: "groups have no schedules" };
214
220
  // Who the turn runs as, checked by the router: its author's principal, at a tier they hold.
215
221
  const { speaker } = turn;
216
222
  if (!speaker)
@@ -219,11 +225,8 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
219
225
  reason:
220
226
  "a background turn reaches the agent server through the router, which says whom it runs as",
221
227
  };
222
- if (options.speakers === "owners" && speaker.tier !== "owner")
223
- return {
224
- status: "skipped",
225
- reason: 'the agent server answers owners only (speakers: "owners")',
226
- };
228
+ const reason = backgroundRefusal({ ...turn, speaker });
229
+ if (reason !== undefined) return { status: "skipped", reason };
227
230
  return outcome(
228
231
  await team.answerBackground(
229
232
  turn.channel,
@@ -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")
@@ -197,6 +197,15 @@ export interface ChannelClaim {
197
197
  admit(message: InboundMessage): Admission | undefined;
198
198
  /** A background turn in an owned channel; a claim without one skips them. */
199
199
  background?(turn: BackgroundTurn): Promise<ScheduledOutcome>;
200
+ /**
201
+ * Why `background` would skip the turn for its checked speaker, without running it; undefined
202
+ * when it would not. The router asks it in `runsAs`, so work that must not happen for a turn
203
+ * the claim will skip, such as a schedule's precheck, does not start. It decides from the turn
204
+ * and its speaker alone, and its reasons are the ones `background` skips with.
205
+ */
206
+ backgroundRefusal?(
207
+ turn: BackgroundTurn & { speaker: Speaker },
208
+ ): string | undefined;
200
209
  /** Starts the channel's conversation over, saying whose it was. */
201
210
  startFresh(channel: ChannelKey): Promise<string>;
202
211
  /**
@@ -230,7 +239,8 @@ export interface ConversationPort {
230
239
  background(turn: BackgroundTurn): Promise<ScheduledOutcome>;
231
240
  /**
232
241
  * Who a background turn would run as now, checked as `background` checks it, without running
233
- * it: for work that must not happen for an author who may not run the turn, such as a
242
+ * it: for work that must not happen for an author who may not run the turn, or one the channel's
243
+ * claim would skip for that speaker (an agent server that answers owners only), such as a
234
244
  * schedule's precheck. Throws when the identity service cannot tell.
235
245
  */
236
246
  runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs>;
@@ -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
  }
@@ -336,10 +336,23 @@ export class ChannelRouter implements ConversationPort {
336
336
  /**
337
337
  * Who a background turn runs as: the system principal for the host's own turn, at its tier;
338
338
  * otherwise its author's principal, which must exist, be enabled, and hold a tier now, at the
339
- * lower of the turn's tier and theirs. Why not, when it may not run; throws when the identity
339
+ * lower of the turn's tier and theirs; and the claim that owns the channel must not refuse it
340
+ * for that speaker (`backgroundRefusal`). Why not, when it may not run; throws when the identity
340
341
  * service cannot tell.
341
342
  */
342
343
  async runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
344
+ const runs = await this.#speakerOf(turn);
345
+ if ("skipped" in runs) return runs;
346
+ // The claim that owns the channel has the last word, so work that must not start for a turn it
347
+ // would skip, such as a schedule's precheck, does not.
348
+ const refusal = this.#owner(turn.channel)?.backgroundRefusal?.({
349
+ ...turn,
350
+ speaker: runs.speaker,
351
+ });
352
+ return refusal === undefined ? runs : { skipped: refusal };
353
+ }
354
+
355
+ async #speakerOf(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
343
356
  const { author, tier } = turn;
344
357
  if (!author?.principalId || !tier)
345
358
  return {
@@ -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";