@doist/doistbot-cli 1.0.10 → 1.0.12
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/review-usage.js +4 -4
- package/dist/actions/review.js +31 -21
- package/dist/config.js +61 -16
- package/dist/index.js +8 -2
- package/dist/terminal.js +4 -0
- package/package.json +10 -9
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
- package/sandbox/dist/core/datadog-metrics.js +7 -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 +286 -67
- package/sandbox/dist/core/repository-map.js +1 -220
- package/sandbox/dist/core/repository-resolver.js +1 -166
- package/sandbox/dist/providers/config.js +1 -1
- package/sandbox/dist/providers/helpers.js +3 -0
- package/sandbox/dist/providers/pi-review.js +53 -3
- package/sandbox/dist/providers/schemas.js +7 -0
- package/sandbox/dist/providers/types.js +4 -1
- 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 +4 -4
- 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/diff.js +1 -1
- package/sandbox/dist/tasks/review/engines/multi-focus.js +47 -12
- package/sandbox/dist/tasks/review/local-git.js +62 -8
- package/sandbox/dist/tasks/review/prompt.js +2 -0
- package/sandbox/dist/tasks/review/review-summary.js +1 -1
- package/sandbox/dist/tasks/review/summary-model.js +2 -2
- package/sandbox/dist/tasks/review/translation-review-policy.js +37 -0
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
- package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
- package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +18 -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',
|
package/dist/actions/review.js
CHANGED
|
@@ -199,24 +199,31 @@ function formatReviewForJson(result) {
|
|
|
199
199
|
providerFailures: getReadableProviderFailureEntries(result.reviewResult.providerFailures),
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
|
-
async function getReviewAuthCheck() {
|
|
203
|
-
|
|
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
|
-
|
|
215
|
+
apiKeys: configuredApiKeys,
|
|
216
|
+
missingModels: missingRequiredProviders.includes('OpenAI') ? ['openai'] : [],
|
|
217
|
+
missingProviders: missingRequiredProviders,
|
|
207
218
|
};
|
|
208
219
|
}
|
|
209
|
-
function getMissingAuthProviderFailures(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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,
|
|
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(
|
|
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 } = {
|
|
251
|
-
|
|
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
|
-
|
|
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', '
|
|
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
|
-
:
|
|
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
|
-
|
|
53
|
-
|
|
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.
|
|
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 },
|
|
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
|
@@ -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.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Local self-review CLI for Doistbot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -40,20 +40,21 @@
|
|
|
40
40
|
"test": "vitest run src scripts --config ../../config/vitest.base.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@
|
|
44
|
-
"@
|
|
45
|
-
"@
|
|
46
|
-
"@
|
|
43
|
+
"@earendil-works/pi-ai": "0.85.1",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
45
|
+
"@inquirer/checkbox": "5.2.4",
|
|
46
|
+
"@inquirer/password": "5.2.1",
|
|
47
47
|
"@napi-rs/keyring": "1.3.0",
|
|
48
|
+
"@opentelemetry/api": "1.9.1",
|
|
48
49
|
"ajv": "8.20.0",
|
|
49
50
|
"chalk": "4.1.2",
|
|
50
51
|
"commander": "14.0.3",
|
|
51
|
-
"js-yaml": "4.3.
|
|
52
|
+
"js-yaml": "4.3.2",
|
|
52
53
|
"octokit": "5.0.5",
|
|
53
|
-
"undici": "8.10.
|
|
54
|
-
"yocto-spinner": "1.2.
|
|
54
|
+
"undici": "8.10.2",
|
|
55
|
+
"yocto-spinner": "1.2.2"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
|
-
"vitest": "4.1.
|
|
58
|
+
"vitest": "4.1.11"
|
|
58
59
|
}
|
|
59
60
|
}
|
|
@@ -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", "
|
|
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', '
|
|
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
|
|
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;
|