@doist/doistbot-cli 1.0.10 → 1.0.11

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 (34) hide show
  1. package/dist/actions/review-usage.js +4 -4
  2. package/dist/actions/review.js +31 -21
  3. package/dist/config.js +61 -16
  4. package/dist/index.js +8 -2
  5. package/dist/terminal.js +4 -0
  6. package/package.json +3 -3
  7. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  8. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
  9. package/sandbox/dist/core/datadog-metrics.js +7 -2
  10. package/sandbox/dist/core/label-parsing.js +1 -22
  11. package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
  12. package/sandbox/dist/core/pi-progress-logging.js +4 -0
  13. package/sandbox/dist/core/pi.js +286 -67
  14. package/sandbox/dist/core/repository-map.js +1 -220
  15. package/sandbox/dist/core/repository-resolver.js +1 -166
  16. package/sandbox/dist/providers/config.js +1 -1
  17. package/sandbox/dist/providers/pi-review.js +53 -3
  18. package/sandbox/dist/tasks/chat/chat.js +95 -45
  19. package/sandbox/dist/tasks/chat/prompt.js +5 -0
  20. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -0
  21. package/sandbox/dist/tasks/issue-summarize/model.js +4 -4
  22. package/sandbox/dist/tasks/issue-triage/context.js +2 -0
  23. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -1
  24. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +4 -7
  25. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +65 -41
  26. package/sandbox/dist/tasks/issue-triage/output.js +1 -1
  27. package/sandbox/dist/tasks/issue-triage/resolution.js +1 -0
  28. package/sandbox/dist/tasks/issue-triage/shared.js +2 -12
  29. package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
  30. package/sandbox/dist/tasks/review/engines/multi-focus.js +23 -9
  31. package/sandbox/dist/tasks/review/review-summary.js +1 -1
  32. package/sandbox/dist/tasks/review/summary-model.js +2 -2
  33. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  34. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
@@ -1,8 +1,8 @@
1
1
  const REVIEW_USAGE_COMMANDS = [
2
- 'doistbot review [--base <ref>]',
3
- 'doistbot review staged',
4
- 'db review [--base <ref>]',
5
- 'db review staged',
2
+ 'doistbot review [--base <ref>] [--models <models>]',
3
+ 'doistbot review staged [--models <models>]',
4
+ 'db review [--base <ref>] [--models <models>]',
5
+ 'db review staged [--models <models>]',
6
6
  ];
7
7
  const REVIEW_SETUP_COMMANDS = [
8
8
  'doistbot review',
@@ -199,24 +199,31 @@ function formatReviewForJson(result) {
199
199
  providerFailures: getReadableProviderFailureEntries(result.reviewResult.providerFailures),
200
200
  };
201
201
  }
202
- async function getReviewAuthCheck() {
203
- const apiKeys = await getApiKeys();
202
+ async function getReviewAuthCheck(models) {
203
+ if (models !== undefined) {
204
+ const configuredApiKeys = await getApiKeys(undefined, undefined, models);
205
+ const missingModels = models.filter((model) => configuredApiKeys[model] === undefined);
206
+ return {
207
+ apiKeys: configuredApiKeys,
208
+ missingModels,
209
+ missingProviders: missingModels.map((model) => PROVIDER_DISPLAY_NAMES[model]),
210
+ };
211
+ }
212
+ const configuredApiKeys = await getApiKeys();
213
+ const missingRequiredProviders = getMissingRequiredCliReviewAuthProviders(configuredApiKeys);
204
214
  return {
205
- apiKeys,
206
- missingProviders: getMissingRequiredCliReviewAuthProviders(apiKeys),
215
+ apiKeys: configuredApiKeys,
216
+ missingModels: missingRequiredProviders.includes('OpenAI') ? ['openai'] : [],
217
+ missingProviders: missingRequiredProviders,
207
218
  };
208
219
  }
209
- function getMissingAuthProviderFailures(missingProviders) {
210
- const failures = [];
211
- if (missingProviders.includes('OpenAI')) {
212
- failures.push({
213
- model: 'openai',
214
- message: 'OpenAI authentication unavailable; run `doistbot auth login`',
215
- });
216
- }
217
- return failures;
220
+ function getMissingAuthProviderFailures(missingModels) {
221
+ return missingModels.map((model) => ({
222
+ model,
223
+ message: `${PROVIDER_DISPLAY_NAMES[model]} authentication unavailable; run \`doistbot auth login\``,
224
+ }));
218
225
  }
219
- function formatMissingAuthForJson({ source, missingProviders, }) {
226
+ function formatMissingAuthForJson({ source, missingModels, }) {
220
227
  return {
221
228
  schemaVersion: JSON_SCHEMA_VERSION,
222
229
  status: 'unverified',
@@ -233,27 +240,29 @@ function formatMissingAuthForJson({ source, missingProviders, }) {
233
240
  count: 0,
234
241
  findings: [],
235
242
  },
236
- providerFailures: getMissingAuthProviderFailures(missingProviders),
243
+ providerFailures: getMissingAuthProviderFailures(missingModels),
237
244
  };
238
245
  }
239
- async function runLocalReviewForCli({ source, apiKeys, onProgress, }) {
246
+ async function runLocalReviewForCli({ source, apiKeys, models, onProgress, }) {
240
247
  const reviewModule = await loadSandboxReviewModule();
241
248
  return await reviewModule.runLocalReview({
242
249
  workspaceDir: getRepositoryRoot(),
243
250
  source,
244
251
  apiKeys,
245
- ...getCliReviewRouting(apiKeys),
252
+ ...getCliReviewRouting(apiKeys, models),
246
253
  suppressLogs: true,
247
254
  onProgress,
248
255
  });
249
256
  }
250
- export async function runReview({ source, json = false } = { source: 'base' }) {
251
- const authCheck = await getReviewAuthCheck();
257
+ export async function runReview({ source, json = false, models } = {
258
+ source: 'base',
259
+ }) {
260
+ const authCheck = await getReviewAuthCheck(models);
252
261
  if (authCheck.missingProviders.length > 0) {
253
262
  if (json) {
254
263
  writeLine(JSON.stringify(formatMissingAuthForJson({
255
264
  source,
256
- missingProviders: authCheck.missingProviders,
265
+ missingModels: authCheck.missingModels,
257
266
  }), null, 2));
258
267
  process.exitCode = 1;
259
268
  return;
@@ -261,7 +270,7 @@ export async function runReview({ source, json = false } = { source: 'base' }) {
261
270
  throw new Error(formatMissingRequiredCliAuthProviders(authCheck.missingProviders));
262
271
  }
263
272
  if (json) {
264
- const result = await runLocalReviewForCli({ source, apiKeys: authCheck.apiKeys });
273
+ const result = await runLocalReviewForCli({ source, apiKeys: authCheck.apiKeys, models });
265
274
  const output = formatReviewForJson(result);
266
275
  writeLine(JSON.stringify(output, null, 2));
267
276
  if (output.status === 'unverified') {
@@ -274,6 +283,7 @@ export async function runReview({ source, json = false } = { source: 'base' }) {
274
283
  : 'Reviewing local changes...', async (status) => await runLocalReviewForCli({
275
284
  source,
276
285
  apiKeys: authCheck.apiKeys,
286
+ models,
277
287
  onProgress: status.enabled
278
288
  ? (event) => status.update(formatReviewProgress(event))
279
289
  : undefined,
package/dist/config.js CHANGED
@@ -1,5 +1,23 @@
1
1
  import { getOpenAICodexAuthState, getStoredGeminiApiKey, getStoredOpenRouterApiKey, PI_AUTH_API_KEY, } from './auth.js';
2
2
  import { getEnvApiKeys } from './env.js';
3
+ export const SUPPORTED_CLI_REVIEW_MODELS = [
4
+ 'openai',
5
+ 'deepseek',
6
+ 'gemini',
7
+ 'zai',
8
+ ];
9
+ export function parseCliReviewModels(value) {
10
+ const models = value.split(',').map((model) => model.trim().toLowerCase());
11
+ if (models.some((model) => model.length === 0)) {
12
+ throw new Error(`--models requires a comma-separated list of: ${SUPPORTED_CLI_REVIEW_MODELS.join(', ')}`);
13
+ }
14
+ const supportedModels = new Set(SUPPORTED_CLI_REVIEW_MODELS);
15
+ const unsupportedModels = [...new Set(models.filter((model) => !supportedModels.has(model)))];
16
+ if (unsupportedModels.length > 0) {
17
+ throw new Error(`Unsupported review model(s): ${unsupportedModels.join(', ')}. Choose from: ${SUPPORTED_CLI_REVIEW_MODELS.join(', ')}`);
18
+ }
19
+ return [...new Set(models)];
20
+ }
3
21
  const REQUIRED_CLI_AUTH_PROVIDERS = [{ provider: 'openai', label: 'OpenAI' }];
4
22
  const ALL_REVIEW_FOCUSES = [
5
23
  'general',
@@ -10,7 +28,7 @@ const ALL_REVIEW_FOCUSES = [
10
28
  'tests',
11
29
  'standards',
12
30
  ];
13
- const OPENROUTER_REVIEW_MODELS = ['deepseek', 'grok'];
31
+ const OPENROUTER_REVIEW_MODELS = ['deepseek', 'zai'];
14
32
  const CLI_GEMINI_REVIEW_MODEL = 'gemini-3.7-flash';
15
33
  function createCliGeminiCredential(apiKey) {
16
34
  return {
@@ -32,25 +50,36 @@ export async function getApiKeys(env = process.env, auth = {
32
50
  getStoredGeminiApiKey,
33
51
  getStoredOpenRouterApiKey,
34
52
  getOpenAICodexAuthState,
35
- }) {
53
+ }, providers) {
54
+ const selectedProviders = providers ? new Set(providers) : undefined;
55
+ const needsGemini = !selectedProviders || selectedProviders.has('gemini');
56
+ const needsOpenRouter = !selectedProviders || selectedProviders.has('deepseek') || selectedProviders.has('zai');
57
+ const needsOpenAI = !selectedProviders || selectedProviders.has('openai');
36
58
  const envApiKeys = getEnvApiKeys(env);
37
- const storedGeminiApiKey = envApiKeys.gemini
38
- ? undefined
39
- : await auth.getStoredGeminiApiKey().catch(() => undefined);
40
- const storedOpenRouterApiKey = envApiKeys.openrouter
41
- ? undefined
42
- : await auth.getStoredOpenRouterApiKey().catch(() => undefined);
43
- const openAICodexAuthState = await auth.getOpenAICodexAuthState();
59
+ const storedGeminiApiKey = needsGemini && !envApiKeys.gemini
60
+ ? await auth.getStoredGeminiApiKey().catch(() => undefined)
61
+ : undefined;
62
+ const storedOpenRouterApiKey = needsOpenRouter && !envApiKeys.openrouter
63
+ ? await auth.getStoredOpenRouterApiKey().catch(() => undefined)
64
+ : undefined;
65
+ // Explicit non-OpenAI runs must not refresh an unrelated OAuth session.
66
+ const openAICodexAuthState = needsOpenAI ? await auth.getOpenAICodexAuthState() : 'missing';
44
67
  const openRouterApiKey = envApiKeys.openrouter || storedOpenRouterApiKey;
45
68
  const apiKeys = {};
46
- const geminiApiKey = envApiKeys.gemini || storedGeminiApiKey;
69
+ const geminiApiKey = needsGemini ? envApiKeys.gemini || storedGeminiApiKey : undefined;
47
70
  if (geminiApiKey) {
48
71
  apiKeys.gemini = createCliGeminiCredential(geminiApiKey);
49
72
  }
50
- if (openRouterApiKey) {
51
- apiKeys.deepseek = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
52
- apiKeys.grok = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
53
- apiKeys.zai = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
73
+ if (needsOpenRouter && openRouterApiKey) {
74
+ if (!selectedProviders || selectedProviders.has('deepseek')) {
75
+ apiKeys.deepseek = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
76
+ }
77
+ if (!selectedProviders) {
78
+ apiKeys.grok = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
79
+ }
80
+ if (!selectedProviders || selectedProviders.has('zai')) {
81
+ apiKeys.zai = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
82
+ }
54
83
  }
55
84
  if (openAICodexAuthState === 'ready') {
56
85
  apiKeys.openai = PI_AUTH_API_KEY;
@@ -75,9 +104,25 @@ export function getMissingRequiredCliReviewAuthProviders(apiKeys) {
75
104
  export function formatMissingRequiredCliAuthProviders(missingProviders) {
76
105
  return `${missingProviders.join(' and ')} authentication unavailable (not configured, or the session could not be refreshed); run \`doistbot auth login\``;
77
106
  }
78
- export function getCliReviewRouting(apiKeys) {
107
+ export function getCliReviewRouting(apiKeys, selectedModels) {
108
+ if (selectedModels !== undefined) {
109
+ if (selectedModels.length === 0) {
110
+ throw new Error('At least one review model must be selected.');
111
+ }
112
+ const focusModels = [...selectedModels];
113
+ return {
114
+ reviewModelsEnabled: focusModels,
115
+ reviewModelsByFocus: Object.fromEntries(ALL_REVIEW_FOCUSES.map((focus) => [focus, focusModels])),
116
+ geminiFallbackFocuses: [],
117
+ ...(selectedModels.includes('gemini') &&
118
+ !selectedModels.includes('zai') &&
119
+ apiKeys.gemini
120
+ ? { reviewSummaryModel: createCliGeminiSummaryModel(apiKeys.gemini) }
121
+ : {}),
122
+ };
123
+ }
79
124
  const hasGemini = Boolean(apiKeys.gemini);
80
- const hasOpenRouter = Boolean(apiKeys.deepseek && apiKeys.grok);
125
+ const hasOpenRouter = Boolean(apiKeys.deepseek && apiKeys.zai);
81
126
  if (hasGemini && hasOpenRouter) {
82
127
  return {
83
128
  reviewModelsEnabled: ['openai', ...OPENROUTER_REVIEW_MODELS],
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { runDoctor } from './actions/doctor.js';
8
8
  import { runReview } from './actions/review.js';
9
9
  import { formatReviewUsage } from './actions/review-usage.js';
10
10
  import { runSetup } from './actions/setup.js';
11
+ import { parseCliReviewModels, SUPPORTED_CLI_REVIEW_MODELS } from './config.js';
11
12
  import { writeErrorLine } from './terminal.js';
12
13
  import { notifyAndRefreshCliUpdateCache, notifyIfCliUpdateAvailable } from './update-check.js';
13
14
  export { getSandboxRoot } from './runtime.js';
@@ -33,12 +34,16 @@ function parseReviewInput(sourceArg, options = {}) {
33
34
  if (sourceArg === LOCAL_REVIEW_SOURCE_STAGED && options.base) {
34
35
  throw new Error(formatReviewUsage());
35
36
  }
37
+ const reviewOptions = {
38
+ json: options.json === true,
39
+ ...(options.models !== undefined ? { models: parseCliReviewModels(options.models) } : {}),
40
+ };
36
41
  if (options.base !== undefined) {
37
- return { source: { kind: 'base-ref', ref: options.base }, json: options.json === true };
42
+ return { source: { kind: 'base-ref', ref: options.base }, ...reviewOptions };
38
43
  }
39
44
  return {
40
45
  source: sourceArg === LOCAL_REVIEW_SOURCE_STAGED ? LOCAL_REVIEW_SOURCE_STAGED : 'base',
41
- json: options.json === true,
46
+ ...reviewOptions,
42
47
  };
43
48
  }
44
49
  export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAuthSetup: authSetup = runAuthSetup, runAuthStatus: authStatus = runAuthStatus, runDoctor: doctor = runDoctor, runSetup: setup = runSetup, runReview: review = runReview, runAutomaticUpdateCheck: automaticUpdateCheck = async () => await notifyAndRefreshCliUpdateCache({
@@ -97,6 +102,7 @@ export function createProgram({ runAuthLogout: authLogout = runAuthLogout, runAu
97
102
  .command('review [source]')
98
103
  .description('Run a local self-review. Use `staged` to review only staged changes.')
99
104
  .option('--base <ref>', 'Review local changes against a specific base ref')
105
+ .option('--models <models>', `Use only these review models (${SUPPORTED_CLI_REVIEW_MODELS.join(', ')})`)
100
106
  .addOption(new Option('--json', 'Print machine-readable JSON instead of terminal-formatted markdown').hideHelp())
101
107
  .action(async (source, options) => await review(parseReviewInput(source, options))));
102
108
  program
package/dist/terminal.js CHANGED
@@ -141,6 +141,10 @@ export function formatReviewProgress(event) {
141
141
  return `${PROVIDER_DISPLAY_NAMES[event.provider]} is reasoning through the changes`;
142
142
  case 'responding':
143
143
  return `${PROVIDER_DISPLAY_NAMES[event.provider]} is writing review findings`;
144
+ case 'limit_reached': {
145
+ const limit = event.level === 'soft' ? 'inspection budget' : 'inspection limit';
146
+ return `${PROVIDER_DISPLAY_NAMES[event.provider]} reached its ${limit} and is finalizing the review`;
147
+ }
144
148
  case 'tool_start':
145
149
  return event.toolName
146
150
  ? `${PROVIDER_DISPLAY_NAMES[event.provider]} is inspecting the repo with ${event.toolName}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/doistbot-cli",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Local self-review CLI for Doistbot",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,8 +42,8 @@
42
42
  "dependencies": {
43
43
  "@inquirer/checkbox": "5.2.1",
44
44
  "@inquirer/password": "5.1.1",
45
- "@earendil-works/pi-ai": "0.84.2",
46
- "@earendil-works/pi-coding-agent": "0.84.2",
45
+ "@earendil-works/pi-ai": "0.84.3",
46
+ "@earendil-works/pi-coding-agent": "0.84.3",
47
47
  "@napi-rs/keyring": "1.3.0",
48
48
  "ajv": "8.20.0",
49
49
  "chalk": "4.1.2",
@@ -1,7 +1,7 @@
1
1
  import type { Logger } from 'doistbot-logging';
2
2
  import type { Octokit } from 'octokit';
3
3
  export declare const CONFIG_FILE_PATH = ".github/doistbot.yml";
4
- export declare const DEFAULT_REVIEW_MODELS: readonly ["openai", "deepseek", "grok"];
4
+ export declare const DEFAULT_REVIEW_MODELS: readonly ["openai", "deepseek", "zai"];
5
5
  export type DefaultReviewModel = (typeof DEFAULT_REVIEW_MODELS)[number];
6
6
  export type RepoConfig = {
7
7
  review?: {
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { load as yamlLoad } from 'js-yaml';
4
4
  export const CONFIG_FILE_PATH = '.github/doistbot.yml';
5
- export const DEFAULT_REVIEW_MODELS = ['openai', 'deepseek', 'grok'];
5
+ export const DEFAULT_REVIEW_MODELS = ['openai', 'deepseek', 'zai'];
6
6
  function parseRepoConfigContent(content) {
7
7
  return yamlLoad(content);
8
8
  }
@@ -31,13 +31,16 @@ function normalizeMetricRepository(repository) {
31
31
  const normalized = repository.trim().toLowerCase();
32
32
  return normalized || 'unknown';
33
33
  }
34
- function buildTaskMetricTags({ type, repository, model, outcome, reason, }) {
34
+ function buildTaskMetricTags({ type, repository, model, outcome, reason, sandboxVariant, }) {
35
35
  const tags = [
36
36
  `type:${type}`,
37
37
  `repo:${normalizeMetricRepository(repository)}`,
38
38
  `model:${model}`,
39
39
  `status:${outcome}`,
40
40
  ];
41
+ if (sandboxVariant) {
42
+ tags.push(`sandbox_variant:${sandboxVariant}`);
43
+ }
41
44
  if (reason) {
42
45
  tags.push(`reason:${reason}`);
43
46
  }
@@ -46,7 +49,7 @@ function buildTaskMetricTags({ type, repository, model, outcome, reason, }) {
46
49
  }
47
50
  return tags;
48
51
  }
49
- export async function recordTaskMetrics({ type, repository, model, outcome, durationMs, reason, }) {
52
+ export async function recordTaskMetrics({ type, repository, model, outcome, durationMs, reason, sandboxVariant, }) {
50
53
  const apiKey = resolveDatadogApiKey();
51
54
  if (!apiKey) {
52
55
  if (!hasWarnedAboutMissingDatadogApiKey) {
@@ -56,6 +59,7 @@ export async function recordTaskMetrics({ type, repository, model, outcome, dura
56
59
  repository: normalizeMetricRepository(repository),
57
60
  model: toTaskMetricModel(model),
58
61
  outcome,
62
+ ...(sandboxVariant ? { sandboxVariant } : {}),
59
63
  ...(reason ? { reason } : {}),
60
64
  });
61
65
  }
@@ -70,6 +74,7 @@ export async function recordTaskMetrics({ type, repository, model, outcome, dura
70
74
  model: metricModel,
71
75
  outcome,
72
76
  reason,
77
+ sandboxVariant,
73
78
  });
74
79
  await Promise.allSettled([
75
80
  postDatadogMetric({
@@ -1,22 +1 @@
1
- export function normalizeScopedLabelValue(value) {
2
- return value.trim().toLowerCase().replace(/\s+/g, '-');
3
- }
4
- export function scopedLabelValue(label, scope) {
5
- const prefix = `${scope.toLowerCase()}:`;
6
- const trimmed = label.trim();
7
- if (!trimmed.toLowerCase().startsWith(prefix)) {
8
- return null;
9
- }
10
- const value = trimmed.slice(scope.length + 1).trim();
11
- return value ? value : null;
12
- }
13
- export function extractScopedLabelValues(labels, scope) {
14
- const values = [];
15
- for (const label of labels) {
16
- const value = scopedLabelValue(label, scope);
17
- if (value) {
18
- values.push(value);
19
- }
20
- }
21
- return values;
22
- }
1
+ export { extractScopedLabelValues, normalizeScopedLabelValue, scopedLabelValue, } from 'doistbot-issue-routing';
@@ -0,0 +1,79 @@
1
+ export const PI_EXECUTION_LIMIT_ENV = {
2
+ startedAtMs: 'DOISTBOT_PI_LIMIT_STARTED_AT_MS',
3
+ softTimeMs: 'DOISTBOT_PI_SOFT_TIME_LIMIT_MS',
4
+ softToolCalls: 'DOISTBOT_PI_SOFT_TOOL_LIMIT',
5
+ hardTimeMs: 'DOISTBOT_PI_HARD_TIME_LIMIT_MS',
6
+ hardToolCalls: 'DOISTBOT_PI_HARD_TOOL_LIMIT',
7
+ };
8
+ const SOFT_LIMIT_MESSAGE = 'The inspection budget is nearly complete. Stop gathering new context and return the requested final result now using the evidence you already have.';
9
+ const HARD_LIMIT_MESSAGE = 'The inspection budget is complete. Do not request more tools. Return the requested final result immediately using the evidence you already have.';
10
+ const HARD_TOOL_BLOCK_REASON = 'The inspection budget is complete. No more tool calls are available. Return the final result now.';
11
+ function readPositiveInteger(name) {
12
+ // Keep this parser local. Pi loads this file as a standalone extension, so
13
+ // importing shared application helpers would also load their dependencies.
14
+ const parsed = Number(process.env[name]);
15
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
16
+ }
17
+ // Pi's extension loader requires a default export.
18
+ // eslint-disable-next-line import/no-default-export
19
+ export default function executionLimitsExtension(pi) {
20
+ const startedAtMs = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.startedAtMs) ?? Date.now();
21
+ const softTimeMs = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.softTimeMs);
22
+ const softToolCalls = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.softToolCalls);
23
+ const hardTimeMs = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.hardTimeMs);
24
+ const hardToolCalls = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.hardToolCalls);
25
+ let timersStarted = false;
26
+ let softLimitReached = false;
27
+ let hardLimitReached = false;
28
+ let toolCallCount = 0;
29
+ const timers = [];
30
+ function steer(message) {
31
+ pi.sendUserMessage(message, { deliverAs: 'steer' });
32
+ }
33
+ function reachSoftLimit() {
34
+ if (softLimitReached)
35
+ return;
36
+ softLimitReached = true;
37
+ steer(SOFT_LIMIT_MESSAGE);
38
+ }
39
+ function reachHardLimit() {
40
+ if (hardLimitReached)
41
+ return;
42
+ hardLimitReached = true;
43
+ softLimitReached = true;
44
+ steer(HARD_LIMIT_MESSAGE);
45
+ }
46
+ function scheduleAtElapsed(elapsedMs, callback) {
47
+ const remainingMs = Math.max(0, elapsedMs - (Date.now() - startedAtMs));
48
+ timers.push(setTimeout(callback, remainingMs));
49
+ }
50
+ pi.on('agent_start', () => {
51
+ if (timersStarted)
52
+ return;
53
+ timersStarted = true;
54
+ if (softTimeMs)
55
+ scheduleAtElapsed(softTimeMs, reachSoftLimit);
56
+ if (hardTimeMs)
57
+ scheduleAtElapsed(hardTimeMs, reachHardLimit);
58
+ });
59
+ pi.on('tool_call', () => {
60
+ if (hardLimitReached) {
61
+ return { block: true, reason: HARD_TOOL_BLOCK_REASON };
62
+ }
63
+ toolCallCount += 1;
64
+ if (softToolCalls && toolCallCount >= softToolCalls) {
65
+ reachSoftLimit();
66
+ }
67
+ if (hardToolCalls && toolCallCount >= hardToolCalls) {
68
+ // The call that reaches the hard limit is allowed to finish. All
69
+ // later calls are blocked so the model can submit its current work.
70
+ reachHardLimit();
71
+ }
72
+ return undefined;
73
+ });
74
+ pi.on('agent_settled', () => {
75
+ for (const timer of timers)
76
+ clearTimeout(timer);
77
+ timers.length = 0;
78
+ });
79
+ }
@@ -15,6 +15,10 @@ export function logPiProgressEvent({ event, messages, meta, logResponding = fals
15
15
  logger.info(messages.responding, meta);
16
16
  }
17
17
  return;
18
+ // Callers that opt into execution limits handle this event with
19
+ // task-specific messages, severity, and metadata.
20
+ case 'limit_reached':
21
+ return;
18
22
  case 'tool_start':
19
23
  if (logTools) {
20
24
  const log = toolLogLevel === 'warn' ? logger.warn : logger.info;