@doist/doistbot-cli 1.0.9 → 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 (35) hide show
  1. package/dist/actions/auth.js +1 -1
  2. package/dist/actions/review-usage.js +4 -4
  3. package/dist/actions/review.js +32 -21
  4. package/dist/config.js +59 -13
  5. package/dist/index.js +8 -2
  6. package/dist/terminal.js +5 -0
  7. package/package.json +4 -4
  8. package/sandbox/dist/core/datadog-metrics.js +9 -2
  9. package/sandbox/dist/core/label-parsing.js +1 -22
  10. package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
  11. package/sandbox/dist/core/pi-progress-logging.js +4 -0
  12. package/sandbox/dist/core/pi.js +290 -67
  13. package/sandbox/dist/core/repository-map.js +1 -220
  14. package/sandbox/dist/core/repository-resolver.js +1 -166
  15. package/sandbox/dist/core/tracing.js +1 -1
  16. package/sandbox/dist/providers/config.js +1 -1
  17. package/sandbox/dist/providers/index.js +1 -0
  18. package/sandbox/dist/providers/pi-review.js +53 -3
  19. package/sandbox/dist/tasks/chat/chat.js +95 -45
  20. package/sandbox/dist/tasks/chat/prompt.js +5 -0
  21. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -0
  22. package/sandbox/dist/tasks/issue-summarize/model.js +1 -1
  23. package/sandbox/dist/tasks/issue-triage/context.js +2 -0
  24. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -1
  25. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +4 -7
  26. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +65 -41
  27. package/sandbox/dist/tasks/issue-triage/output.js +1 -1
  28. package/sandbox/dist/tasks/issue-triage/resolution.js +1 -0
  29. package/sandbox/dist/tasks/issue-triage/shared.js +2 -12
  30. package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
  31. package/sandbox/dist/tasks/review/engines/multi-focus.js +14 -0
  32. package/sandbox/dist/tasks/review/engines/shared.js +2 -0
  33. package/sandbox/dist/tasks/review/review.js +5 -3
  34. package/sandbox/dist/tasks/review/summary-model.js +4 -3
  35. package/sandbox/dist/tasks/review/thinking-level.js +2 -0
@@ -138,7 +138,7 @@ export async function runAuthSetup(dependencies = {}) {
138
138
  deps.writeLine('OpenRouter not configured. Skipping optional OpenRouter provider.');
139
139
  return;
140
140
  }
141
- deps.writeLine(`Doistbot can use OpenRouter for DeepSeek, Z.ai, and other open models. Paste an OpenRouter API key, or ${formatSkipInstruction()}.`);
141
+ deps.writeLine(`Doistbot can use OpenRouter for DeepSeek, Grok, and other open models. Paste an OpenRouter API key, or ${formatSkipInstruction()}.`);
142
142
  const openRouterApiKey = (await deps.promptHiddenInput(API_KEY_PROMPT)).trim();
143
143
  if (!openRouterApiKey) {
144
144
  deps.writeLine();
@@ -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',
@@ -6,6 +6,7 @@ const JSON_SCHEMA_VERSION = 1;
6
6
  const PROVIDER_DISPLAY_NAMES = {
7
7
  deepseek: 'DeepSeek',
8
8
  gemini: 'Gemini',
9
+ grok: 'Grok',
9
10
  zai: 'Z.ai',
10
11
  openai: 'OpenAI',
11
12
  };
@@ -198,24 +199,31 @@ function formatReviewForJson(result) {
198
199
  providerFailures: getReadableProviderFailureEntries(result.reviewResult.providerFailures),
199
200
  };
200
201
  }
201
- async function getReviewAuthCheck() {
202
- 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);
203
214
  return {
204
- apiKeys,
205
- missingProviders: getMissingRequiredCliReviewAuthProviders(apiKeys),
215
+ apiKeys: configuredApiKeys,
216
+ missingModels: missingRequiredProviders.includes('OpenAI') ? ['openai'] : [],
217
+ missingProviders: missingRequiredProviders,
206
218
  };
207
219
  }
208
- function getMissingAuthProviderFailures(missingProviders) {
209
- const failures = [];
210
- if (missingProviders.includes('OpenAI')) {
211
- failures.push({
212
- model: 'openai',
213
- message: 'OpenAI authentication unavailable; run `doistbot auth login`',
214
- });
215
- }
216
- 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
+ }));
217
225
  }
218
- function formatMissingAuthForJson({ source, missingProviders, }) {
226
+ function formatMissingAuthForJson({ source, missingModels, }) {
219
227
  return {
220
228
  schemaVersion: JSON_SCHEMA_VERSION,
221
229
  status: 'unverified',
@@ -232,27 +240,29 @@ function formatMissingAuthForJson({ source, missingProviders, }) {
232
240
  count: 0,
233
241
  findings: [],
234
242
  },
235
- providerFailures: getMissingAuthProviderFailures(missingProviders),
243
+ providerFailures: getMissingAuthProviderFailures(missingModels),
236
244
  };
237
245
  }
238
- async function runLocalReviewForCli({ source, apiKeys, onProgress, }) {
246
+ async function runLocalReviewForCli({ source, apiKeys, models, onProgress, }) {
239
247
  const reviewModule = await loadSandboxReviewModule();
240
248
  return await reviewModule.runLocalReview({
241
249
  workspaceDir: getRepositoryRoot(),
242
250
  source,
243
251
  apiKeys,
244
- ...getCliReviewRouting(apiKeys),
252
+ ...getCliReviewRouting(apiKeys, models),
245
253
  suppressLogs: true,
246
254
  onProgress,
247
255
  });
248
256
  }
249
- export async function runReview({ source, json = false } = { source: 'base' }) {
250
- const authCheck = await getReviewAuthCheck();
257
+ export async function runReview({ source, json = false, models } = {
258
+ source: 'base',
259
+ }) {
260
+ const authCheck = await getReviewAuthCheck(models);
251
261
  if (authCheck.missingProviders.length > 0) {
252
262
  if (json) {
253
263
  writeLine(JSON.stringify(formatMissingAuthForJson({
254
264
  source,
255
- missingProviders: authCheck.missingProviders,
265
+ missingModels: authCheck.missingModels,
256
266
  }), null, 2));
257
267
  process.exitCode = 1;
258
268
  return;
@@ -260,7 +270,7 @@ export async function runReview({ source, json = false } = { source: 'base' }) {
260
270
  throw new Error(formatMissingRequiredCliAuthProviders(authCheck.missingProviders));
261
271
  }
262
272
  if (json) {
263
- const result = await runLocalReviewForCli({ source, apiKeys: authCheck.apiKeys });
273
+ const result = await runLocalReviewForCli({ source, apiKeys: authCheck.apiKeys, models });
264
274
  const output = formatReviewForJson(result);
265
275
  writeLine(JSON.stringify(output, null, 2));
266
276
  if (output.status === 'unverified') {
@@ -273,6 +283,7 @@ export async function runReview({ source, json = false } = { source: 'base' }) {
273
283
  : 'Reviewing local changes...', async (status) => await runLocalReviewForCli({
274
284
  source,
275
285
  apiKeys: authCheck.apiKeys,
286
+ models,
276
287
  onProgress: status.enabled
277
288
  ? (event) => status.update(formatReviewProgress(event))
278
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',
@@ -32,24 +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.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
+ }
53
83
  }
54
84
  if (openAICodexAuthState === 'ready') {
55
85
  apiKeys.openai = PI_AUTH_API_KEY;
@@ -74,7 +104,23 @@ export function getMissingRequiredCliReviewAuthProviders(apiKeys) {
74
104
  export function formatMissingRequiredCliAuthProviders(missingProviders) {
75
105
  return `${missingProviders.join(' and ')} authentication unavailable (not configured, or the session could not be refreshed); run \`doistbot auth login\``;
76
106
  }
77
- 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
+ }
78
124
  const hasGemini = Boolean(apiKeys.gemini);
79
125
  const hasOpenRouter = Boolean(apiKeys.deepseek && apiKeys.zai);
80
126
  if (hasGemini && hasOpenRouter) {
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
@@ -116,6 +116,7 @@ export async function withSpinner(message, task, terminalConfig = getDefaultTerm
116
116
  const PROVIDER_DISPLAY_NAMES = {
117
117
  deepseek: 'DeepSeek',
118
118
  gemini: 'Gemini',
119
+ grok: 'Grok',
119
120
  zai: 'Z.ai',
120
121
  openai: 'OpenAI',
121
122
  };
@@ -140,6 +141,10 @@ export function formatReviewProgress(event) {
140
141
  return `${PROVIDER_DISPLAY_NAMES[event.provider]} is reasoning through the changes`;
141
142
  case 'responding':
142
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
+ }
143
148
  case 'tool_start':
144
149
  return event.toolName
145
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.9",
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,15 +42,15 @@
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",
50
50
  "commander": "14.0.3",
51
51
  "js-yaml": "4.3.1",
52
52
  "octokit": "5.0.5",
53
- "undici": "8.9.0",
53
+ "undici": "8.10.0",
54
54
  "yocto-spinner": "1.2.0"
55
55
  },
56
56
  "devDependencies": {
@@ -11,6 +11,8 @@ function toTaskMetricBaseModel(model) {
11
11
  case 'gemini':
12
12
  case 'google':
13
13
  return 'google';
14
+ case 'grok':
15
+ return 'grok';
14
16
  case 'zai':
15
17
  return 'zai';
16
18
  case 'openai':
@@ -29,13 +31,16 @@ function normalizeMetricRepository(repository) {
29
31
  const normalized = repository.trim().toLowerCase();
30
32
  return normalized || 'unknown';
31
33
  }
32
- function buildTaskMetricTags({ type, repository, model, outcome, reason, }) {
34
+ function buildTaskMetricTags({ type, repository, model, outcome, reason, sandboxVariant, }) {
33
35
  const tags = [
34
36
  `type:${type}`,
35
37
  `repo:${normalizeMetricRepository(repository)}`,
36
38
  `model:${model}`,
37
39
  `status:${outcome}`,
38
40
  ];
41
+ if (sandboxVariant) {
42
+ tags.push(`sandbox_variant:${sandboxVariant}`);
43
+ }
39
44
  if (reason) {
40
45
  tags.push(`reason:${reason}`);
41
46
  }
@@ -44,7 +49,7 @@ function buildTaskMetricTags({ type, repository, model, outcome, reason, }) {
44
49
  }
45
50
  return tags;
46
51
  }
47
- export async function recordTaskMetrics({ type, repository, model, outcome, durationMs, reason, }) {
52
+ export async function recordTaskMetrics({ type, repository, model, outcome, durationMs, reason, sandboxVariant, }) {
48
53
  const apiKey = resolveDatadogApiKey();
49
54
  if (!apiKey) {
50
55
  if (!hasWarnedAboutMissingDatadogApiKey) {
@@ -54,6 +59,7 @@ export async function recordTaskMetrics({ type, repository, model, outcome, dura
54
59
  repository: normalizeMetricRepository(repository),
55
60
  model: toTaskMetricModel(model),
56
61
  outcome,
62
+ ...(sandboxVariant ? { sandboxVariant } : {}),
57
63
  ...(reason ? { reason } : {}),
58
64
  });
59
65
  }
@@ -68,6 +74,7 @@ export async function recordTaskMetrics({ type, repository, model, outcome, dura
68
74
  model: metricModel,
69
75
  outcome,
70
76
  reason,
77
+ sandboxVariant,
71
78
  });
72
79
  await Promise.allSettled([
73
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;