@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.
- package/dist/actions/auth.js +1 -1
- package/dist/actions/review-usage.js +4 -4
- package/dist/actions/review.js +32 -21
- package/dist/config.js +59 -13
- package/dist/index.js +8 -2
- package/dist/terminal.js +5 -0
- package/package.json +4 -4
- package/sandbox/dist/core/datadog-metrics.js +9 -2
- package/sandbox/dist/core/label-parsing.js +1 -22
- package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
- package/sandbox/dist/core/pi-progress-logging.js +4 -0
- package/sandbox/dist/core/pi.js +290 -67
- package/sandbox/dist/core/repository-map.js +1 -220
- package/sandbox/dist/core/repository-resolver.js +1 -166
- package/sandbox/dist/core/tracing.js +1 -1
- package/sandbox/dist/providers/config.js +1 -1
- package/sandbox/dist/providers/index.js +1 -0
- package/sandbox/dist/providers/pi-review.js +53 -3
- package/sandbox/dist/tasks/chat/chat.js +95 -45
- package/sandbox/dist/tasks/chat/prompt.js +5 -0
- package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -0
- package/sandbox/dist/tasks/issue-summarize/model.js +1 -1
- package/sandbox/dist/tasks/issue-triage/context.js +2 -0
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -1
- package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +4 -7
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +65 -41
- package/sandbox/dist/tasks/issue-triage/output.js +1 -1
- package/sandbox/dist/tasks/issue-triage/resolution.js +1 -0
- package/sandbox/dist/tasks/issue-triage/shared.js +2 -12
- package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
- package/sandbox/dist/tasks/review/engines/multi-focus.js +14 -0
- package/sandbox/dist/tasks/review/engines/shared.js +2 -0
- package/sandbox/dist/tasks/review/review.js +5 -3
- package/sandbox/dist/tasks/review/summary-model.js +4 -3
- package/sandbox/dist/tasks/review/thinking-level.js +2 -0
package/dist/actions/auth.js
CHANGED
|
@@ -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,
|
|
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',
|
package/dist/actions/review.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
215
|
+
apiKeys: configuredApiKeys,
|
|
216
|
+
missingModels: missingRequiredProviders.includes('OpenAI') ? ['openai'] : [],
|
|
217
|
+
missingProviders: missingRequiredProviders,
|
|
206
218
|
};
|
|
207
219
|
}
|
|
208
|
-
function getMissingAuthProviderFailures(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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,
|
|
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(
|
|
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 } = {
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
40
|
-
const storedOpenRouterApiKey = envApiKeys.openrouter
|
|
41
|
-
? undefined
|
|
42
|
-
:
|
|
43
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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 },
|
|
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
|
-
|
|
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.
|
|
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.
|
|
46
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
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.
|
|
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
|
|
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;
|