@oh-my-pi/pi-coding-agent 16.2.4 → 16.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/dist/cli.js +3211 -3231
- package/dist/types/cli/bench-cli.d.ts +6 -1
- package/dist/types/commands/bench.d.ts +8 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/edit/index.d.ts +1 -0
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/edit/snapshot-details.d.ts +33 -0
- package/dist/types/mcp/oauth-flow.d.ts +4 -6
- package/dist/types/modes/components/status-line/types.d.ts +10 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +11 -0
- package/dist/types/session/session-manager.d.ts +3 -4
- package/dist/types/task/provider-concurrency.d.ts +40 -0
- package/dist/types/utils/git.d.ts +11 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +12 -12
- package/src/cli/args.ts +32 -1
- package/src/cli/bench-cli.ts +89 -21
- package/src/cli/web-search-cli.ts +6 -1
- package/src/cli/worktree-cli.ts +8 -5
- package/src/commands/bench.ts +10 -1
- package/src/config/mcp-schema.json +1 -1
- package/src/config/model-discovery.ts +98 -20
- package/src/config/model-registry.ts +13 -6
- package/src/config/settings-schema.ts +5 -2
- package/src/edit/hashline/execute.ts +15 -9
- package/src/edit/index.ts +19 -6
- package/src/edit/modes/patch.ts +3 -2
- package/src/edit/modes/replace.ts +3 -2
- package/src/edit/renderer.ts +4 -0
- package/src/edit/snapshot-details.ts +77 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/oauth-flow.ts +10 -8
- package/src/mcp/transports/stdio.ts +9 -17
- package/src/modes/components/status-line/component.ts +29 -2
- package/src/modes/components/status-line/segments.ts +22 -8
- package/src/modes/components/status-line/types.ts +7 -0
- package/src/modes/controllers/event-controller.ts +17 -8
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/tool-args-reveal.ts +100 -22
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/bench.md +4 -10
- package/src/prompts/tools/irc.md +19 -29
- package/src/prompts/tools/job.md +8 -14
- package/src/prompts/tools/lsp.md +19 -30
- package/src/prompts/tools/task.md +42 -62
- package/src/sdk.ts +14 -5
- package/src/session/agent-session.ts +107 -13
- package/src/session/session-listing.ts +9 -8
- package/src/session/session-loader.ts +98 -3
- package/src/session/session-manager.ts +34 -4
- package/src/subprocess/worker-client.ts +12 -4
- package/src/task/executor.ts +4 -62
- package/src/task/provider-concurrency.ts +100 -0
- package/src/task/worktree.ts +13 -4
- package/src/task/yield-assembly.ts +27 -39
- package/src/tools/path-utils.ts +4 -2
- package/src/tools/read.ts +11 -1
- package/src/utils/git.ts +13 -0
- package/src/web/search/index.ts +14 -8
- package/src/web/search/providers/duckduckgo.ts +136 -78
- package/src/web/search/providers/gemini.ts +268 -185
- package/src/web/search/types.ts +1 -1
package/src/cli/bench-cli.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
Context,
|
|
9
9
|
Effort,
|
|
10
10
|
Model,
|
|
11
|
+
ProviderSessionState,
|
|
12
|
+
ServiceTier,
|
|
11
13
|
SimpleStreamOptions,
|
|
12
14
|
} from "@oh-my-pi/pi-ai";
|
|
13
15
|
import { streamSimple } from "@oh-my-pi/pi-ai";
|
|
@@ -23,12 +25,14 @@ import {
|
|
|
23
25
|
getModelMatchPreferences,
|
|
24
26
|
resolveCliModel,
|
|
25
27
|
} from "../config/model-resolver";
|
|
28
|
+
import { resolveServiceTierSetting } from "../config/service-tier";
|
|
26
29
|
import { Settings } from "../config/settings";
|
|
27
30
|
import benchPrompt from "../prompts/bench.md" with { type: "text" };
|
|
28
31
|
import { discoverAuthStorage, loadCliExtensionProviders } from "../sdk";
|
|
29
32
|
import { resolveThinkingLevelForModel, shouldDisableReasoning, toReasoningEffort } from "../thinking";
|
|
30
33
|
|
|
31
|
-
const DEFAULT_RUNS =
|
|
34
|
+
const DEFAULT_RUNS = 10;
|
|
35
|
+
const DEFAULT_PAR = 4;
|
|
32
36
|
const DEFAULT_MAX_TOKENS = 512;
|
|
33
37
|
const ERROR_WIDTH = 110;
|
|
34
38
|
const BENCH_PROMPT = benchPrompt.trim();
|
|
@@ -39,7 +43,10 @@ export interface BenchCommandArgs {
|
|
|
39
43
|
runs?: number;
|
|
40
44
|
maxTokens?: number;
|
|
41
45
|
prompt?: string;
|
|
46
|
+
/** Service-tier setting value (`none` omits); overrides the configured `serviceTier` setting. */
|
|
47
|
+
serviceTier?: string;
|
|
42
48
|
json?: boolean;
|
|
49
|
+
par?: number;
|
|
43
50
|
};
|
|
44
51
|
}
|
|
45
52
|
|
|
@@ -99,6 +106,8 @@ export interface BenchSummary {
|
|
|
99
106
|
maxTokens: number;
|
|
100
107
|
models: BenchModelReport[];
|
|
101
108
|
failures: number;
|
|
109
|
+
/** Requested service tier passed to every request; absent when none was requested. Scoped tiers (`openai-only`/`claude-only`) may be dropped per-provider downstream. */
|
|
110
|
+
serviceTier?: ServiceTier;
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
type BenchStreamSimple = (
|
|
@@ -131,6 +140,13 @@ function normalizePositiveInteger(name: string, value: number | undefined, fallb
|
|
|
131
140
|
return value;
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
function closeProviderSessionStates(providerSessionState: Map<string, ProviderSessionState>): void {
|
|
144
|
+
for (const state of providerSessionState.values()) {
|
|
145
|
+
state.close();
|
|
146
|
+
}
|
|
147
|
+
providerSessionState.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
134
150
|
function isFirstTokenEvent(event: AssistantMessageEvent): boolean {
|
|
135
151
|
switch (event.type) {
|
|
136
152
|
case "text_delta":
|
|
@@ -188,6 +204,8 @@ interface BenchRequestOptions {
|
|
|
188
204
|
reasoning?: Effort;
|
|
189
205
|
/** Only set for an explicit `:off` suffix — some endpoints reject disablement. */
|
|
190
206
|
disableReasoning?: boolean;
|
|
207
|
+
/** Requested service tier passed to `streamSimple`; absent omits the option. The provider layer applies scope/support gating before it reaches the wire. */
|
|
208
|
+
serviceTier?: ServiceTier;
|
|
191
209
|
}
|
|
192
210
|
|
|
193
211
|
async function runBenchRequest(
|
|
@@ -198,6 +216,7 @@ async function runBenchRequest(
|
|
|
198
216
|
): Promise<BenchRunResult> {
|
|
199
217
|
const startedAt = now();
|
|
200
218
|
let firstTokenAt: number | undefined;
|
|
219
|
+
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
201
220
|
try {
|
|
202
221
|
const context: Context = {
|
|
203
222
|
// Codex's Responses endpoint 400s with "Instructions are required" when no
|
|
@@ -214,6 +233,9 @@ async function runBenchRequest(
|
|
|
214
233
|
: options.maxTokens,
|
|
215
234
|
reasoning: options.reasoning,
|
|
216
235
|
disableReasoning: options.disableReasoning,
|
|
236
|
+
serviceTier: options.serviceTier,
|
|
237
|
+
providerSessionState,
|
|
238
|
+
preferWebsockets: true,
|
|
217
239
|
// pi-ai opts every OpenRouter request into response caching (1h TTL).
|
|
218
240
|
// Bench sends a byte-identical request each run, so within the TTL
|
|
219
241
|
// OpenRouter replays the cached generation with zeroed usage — the run
|
|
@@ -270,6 +292,8 @@ async function runBenchRequest(
|
|
|
270
292
|
};
|
|
271
293
|
} catch (error) {
|
|
272
294
|
return { ok: false, error: getErrorMessage(error) };
|
|
295
|
+
} finally {
|
|
296
|
+
closeProviderSessionStates(providerSessionState);
|
|
273
297
|
}
|
|
274
298
|
}
|
|
275
299
|
|
|
@@ -469,10 +493,11 @@ function resolveBenchModels(
|
|
|
469
493
|
}
|
|
470
494
|
return resolved;
|
|
471
495
|
}
|
|
472
|
-
|
|
473
496
|
export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDependencies = {}): Promise<BenchSummary> {
|
|
474
497
|
const runs = normalizePositiveInteger("runs", command.flags.runs, DEFAULT_RUNS);
|
|
475
498
|
const maxTokens = normalizePositiveInteger("max-tokens", command.flags.maxTokens, DEFAULT_MAX_TOKENS);
|
|
499
|
+
const par =
|
|
500
|
+
command.flags.par !== undefined ? normalizePositiveInteger("par", command.flags.par, DEFAULT_PAR) : DEFAULT_PAR;
|
|
476
501
|
const prompt = command.flags.prompt?.trim() || BENCH_PROMPT;
|
|
477
502
|
const json = command.flags.json === true;
|
|
478
503
|
const randomSessionId = deps.randomSessionId ?? (() => Bun.randomUUIDv7());
|
|
@@ -493,6 +518,12 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
|
|
|
493
518
|
const runtime = await (deps.createRuntime ?? createDefaultRuntime)();
|
|
494
519
|
try {
|
|
495
520
|
const targets = resolveBenchModels(command.models, runtime.modelRegistry, runtime.settings, writeStderr);
|
|
521
|
+
// Explicit `--service-tier` wins; otherwise fall back to the configured
|
|
522
|
+
// `serviceTier` setting (`none`/unset omits the wire field). Scope-aware
|
|
523
|
+
// gating to the model's provider happens downstream in the provider layer.
|
|
524
|
+
const serviceTierValue = command.flags.serviceTier ?? runtime.settings?.get("serviceTier");
|
|
525
|
+
const serviceTier = serviceTierValue ? resolveServiceTierSetting(serviceTierValue, undefined) : undefined;
|
|
526
|
+
if (!json && serviceTier) writeStdout(`${chalk.dim(`service tier: ${serviceTier}`)}\n`);
|
|
496
527
|
const reports: BenchModelReport[] = [];
|
|
497
528
|
for (const { selector, model, thinking } of targets) {
|
|
498
529
|
if (!json) {
|
|
@@ -501,21 +532,29 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
|
|
|
501
532
|
writeStdout(`${chalk.bold(resolvedModel)}${resolvedNote}\n`);
|
|
502
533
|
}
|
|
503
534
|
const results: BenchRunResult[] = [];
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (!json
|
|
517
|
-
|
|
518
|
-
|
|
535
|
+
|
|
536
|
+
// Preflight check: let's verify credentials before starting any runs.
|
|
537
|
+
// This matches the old sequential break behavior exactly and avoids launching/printing
|
|
538
|
+
// multiple failures.
|
|
539
|
+
const testSessionId = randomSessionId();
|
|
540
|
+
const preflightKey = await runtime.modelRegistry.getApiKey(model, testSessionId);
|
|
541
|
+
if (!preflightKey) {
|
|
542
|
+
const failure: BenchRunFailure = {
|
|
543
|
+
ok: false,
|
|
544
|
+
error: `No credentials for provider "${model.provider}". Run \`omp\` and use /login, or set the provider API key.`,
|
|
545
|
+
};
|
|
546
|
+
results.push(failure);
|
|
547
|
+
if (!json) writeStdout(`${formatRunLine(failure, 0, runs)}\n`);
|
|
548
|
+
reports.push(buildModelReport(selector, model, thinking, results));
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// We will launch up to `par` workers/requests concurrently.
|
|
553
|
+
// To keep output clean, non-JSON output emits entries in correct index order.
|
|
554
|
+
let nextToPrint = 0;
|
|
555
|
+
|
|
556
|
+
const runWorker = async (index: number) => {
|
|
557
|
+
const sessionId = index === 0 ? testSessionId : randomSessionId();
|
|
519
558
|
const result = await runBenchRequest(
|
|
520
559
|
model,
|
|
521
560
|
{
|
|
@@ -525,20 +564,49 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
|
|
|
525
564
|
maxTokens,
|
|
526
565
|
reasoning: toReasoningEffort(thinking),
|
|
527
566
|
disableReasoning: shouldDisableReasoning(thinking) ? true : undefined,
|
|
567
|
+
serviceTier,
|
|
528
568
|
},
|
|
529
569
|
streamFn,
|
|
530
570
|
now,
|
|
531
571
|
);
|
|
532
|
-
results
|
|
572
|
+
results[index] = result;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// Concurrency-limited running pool
|
|
576
|
+
const queue = Array.from({ length: runs }, (_, i) => i);
|
|
577
|
+
const activeWorkers: Promise<void>[] = [];
|
|
578
|
+
|
|
579
|
+
const processNext = async (): Promise<void> => {
|
|
580
|
+
if (queue.length === 0) return;
|
|
581
|
+
const index = queue.shift()!;
|
|
582
|
+
|
|
583
|
+
// Pre-print a status update if requested and interactive
|
|
584
|
+
if (!json && interactive) {
|
|
585
|
+
writeStdout(chalk.dim(` … run ${index + 1}/${runs} streaming\n`));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
await runWorker(index);
|
|
589
|
+
|
|
590
|
+
// Attempt to print completed results that are in-order
|
|
533
591
|
if (!json) {
|
|
534
|
-
|
|
535
|
-
|
|
592
|
+
while (nextToPrint < runs && results[nextToPrint] !== undefined) {
|
|
593
|
+
const res = results[nextToPrint];
|
|
594
|
+
writeStdout(`${formatRunLine(res, nextToPrint, runs)}\n`);
|
|
595
|
+
nextToPrint++;
|
|
596
|
+
}
|
|
536
597
|
}
|
|
598
|
+
|
|
599
|
+
await processNext();
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
for (let w = 0; w < Math.min(par, runs); w++) {
|
|
603
|
+
activeWorkers.push(processNext());
|
|
537
604
|
}
|
|
605
|
+
await Promise.all(activeWorkers);
|
|
538
606
|
reports.push(buildModelReport(selector, model, thinking, results));
|
|
539
607
|
}
|
|
540
608
|
const failures = reports.reduce((sum, report) => sum + report.results.filter(result => !result.ok).length, 0);
|
|
541
|
-
const summary: BenchSummary = { runs, maxTokens, models: reports, failures };
|
|
609
|
+
const summary: BenchSummary = { runs, maxTokens, models: reports, failures, serviceTier };
|
|
542
610
|
if (json) {
|
|
543
611
|
writeStdout(`${JSON.stringify(summary, null, 2)}\n`);
|
|
544
612
|
} else if (reports.length > 1 || runs > 1) {
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* Handles `omp q`/`omp web-search` subcommands for testing web search providers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { APP_NAME } from "@oh-my-pi/pi-utils";
|
|
7
|
+
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import chalk from "chalk";
|
|
9
|
+
import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
|
|
10
|
+
import { Settings } from "../config/settings";
|
|
9
11
|
import { initTheme, theme } from "../modes/theme/theme";
|
|
10
12
|
import { runSearchQuery, type SearchQueryParams } from "../web/search/index";
|
|
11
13
|
import { SEARCH_PROVIDER_ORDER } from "../web/search/provider";
|
|
@@ -85,6 +87,9 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
|
|
|
85
87
|
process.exit(1);
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
const settings = await Settings.init({ cwd: getProjectDir() });
|
|
91
|
+
applyProviderGlobalsFromSettings(settings);
|
|
92
|
+
|
|
88
93
|
await initTheme();
|
|
89
94
|
|
|
90
95
|
const params: SearchQueryParams = {
|
package/src/cli/worktree-cli.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* containing a `.git` *file* that points back at
|
|
8
8
|
* `<parent-repo>/.git/worktrees/<name>/`.
|
|
9
9
|
* - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* compact `m` subdir mounted/cloned by `natives.isoStart`. Legacy `merged`
|
|
11
|
+
* subdirs are still recognized. These are ephemeral; `ensureIsolation`
|
|
12
|
+
* removes the base before re-creating it, so leftovers are crashed runs.
|
|
13
13
|
*
|
|
14
14
|
* Legacy entries from before the encoding change keep working because git still
|
|
15
15
|
* tracks them by branch name. This command exists to GC them on demand.
|
|
@@ -22,6 +22,8 @@ import * as git from "../utils/git";
|
|
|
22
22
|
|
|
23
23
|
type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
|
|
24
24
|
|
|
25
|
+
const TASK_ISOLATION_MOUNT_DIRS = ["m", "merged"] as const;
|
|
26
|
+
|
|
25
27
|
export interface WorktreeEntry {
|
|
26
28
|
/** Absolute path to the worktree dir (or stray container) under `~/.omp/wt/`. */
|
|
27
29
|
path: string;
|
|
@@ -209,8 +211,9 @@ async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
|
|
|
209
211
|
if (gitStat?.isFile()) {
|
|
210
212
|
return classifyPrCheckout(dir, gitEntry);
|
|
211
213
|
}
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
+
for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
|
|
215
|
+
const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
|
|
216
|
+
if (!mountStat?.isDirectory()) continue;
|
|
214
217
|
return {
|
|
215
218
|
path: dir,
|
|
216
219
|
kind: "task-isolation",
|
package/src/commands/bench.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
|
2
2
|
import { runBenchCommand } from "../cli/bench-cli";
|
|
3
|
+
import { SERVICE_TIER_SETTING_VALUES } from "../config/service-tier";
|
|
3
4
|
|
|
4
5
|
export default class Bench extends Command {
|
|
5
6
|
static description =
|
|
@@ -14,16 +15,22 @@ export default class Bench extends Command {
|
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
static flags = {
|
|
17
|
-
runs: Flags.integer({ description: "Requests per model (results are averaged)", default:
|
|
18
|
+
runs: Flags.integer({ description: "Requests per model (results are averaged)", default: 10 }),
|
|
18
19
|
"max-tokens": Flags.integer({ description: "Max output tokens per request", default: 512 }),
|
|
19
20
|
prompt: Flags.string({ description: "Custom prompt text (default: bundled bench prompt)" }),
|
|
21
|
+
"service-tier": Flags.string({
|
|
22
|
+
description: "Service tier hint (default: configured `serviceTier` setting; `none` omits it)",
|
|
23
|
+
options: SERVICE_TIER_SETTING_VALUES,
|
|
24
|
+
}),
|
|
20
25
|
json: Flags.boolean({ description: "Output JSON" }),
|
|
26
|
+
par: Flags.integer({ description: "Execute runs with N parallel queries/requests", default: 4 }),
|
|
21
27
|
};
|
|
22
28
|
|
|
23
29
|
static examples = [
|
|
24
30
|
"# Compare two models\n omp bench anthropic/claude-opus-4-5 openai/gpt-5.2",
|
|
25
31
|
"# Fuzzy selectors work\n omp bench opus sonnet",
|
|
26
32
|
"# Average over 3 runs each\n omp bench opus gpt-5.2 --runs 3",
|
|
33
|
+
"# Force priority serving tier\n omp bench openai-codex/gpt-5.5:low --runs 10 --service-tier priority",
|
|
27
34
|
"# Machine-readable output\n omp bench opus --json",
|
|
28
35
|
];
|
|
29
36
|
|
|
@@ -35,7 +42,9 @@ export default class Bench extends Command {
|
|
|
35
42
|
runs: flags.runs,
|
|
36
43
|
maxTokens: flags["max-tokens"],
|
|
37
44
|
prompt: flags.prompt,
|
|
45
|
+
serviceTier: flags["service-tier"],
|
|
38
46
|
json: flags.json,
|
|
47
|
+
par: flags.par,
|
|
39
48
|
},
|
|
40
49
|
});
|
|
41
50
|
}
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
},
|
|
93
93
|
"prompt": {
|
|
94
94
|
"type": "string",
|
|
95
|
-
"description": "OAuth `prompt` parameter sent during authorization (default:
|
|
95
|
+
"description": "OAuth `prompt` parameter sent during authorization (default: omit unless the requested scope contains `offline_access`, which sends \"consent\"; set to \"\" to always omit)."
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"description": "Explicit OAuth client settings for servers that need them during /mcp reauth or initial connect."
|
|
@@ -122,11 +122,18 @@ type OllamaDiscoveredModelMetadata = {
|
|
|
122
122
|
type LlamaCppDiscoveredServerMetadata = {
|
|
123
123
|
contextWindow?: number;
|
|
124
124
|
input?: ("text" | "image")[];
|
|
125
|
+
maxTokens?: "contextWindow";
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type LlamaCppDiscoveredModelRuntimeMetadata = {
|
|
129
|
+
contextWindow: number;
|
|
130
|
+
maxTokens: number;
|
|
125
131
|
};
|
|
126
132
|
|
|
127
133
|
type LlamaCppModelListEntry = {
|
|
128
134
|
id: string;
|
|
129
|
-
|
|
135
|
+
runtimeContextWindow?: number;
|
|
136
|
+
trainingContextWindow?: number;
|
|
130
137
|
};
|
|
131
138
|
|
|
132
139
|
function toPositiveNumberOrUndefined(value: unknown): number | undefined {
|
|
@@ -142,7 +149,59 @@ function toPositiveNumberOrUndefined(value: unknown): number | undefined {
|
|
|
142
149
|
return undefined;
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
function isLlamaCppUnlimitedSentinel(value: unknown): boolean {
|
|
153
|
+
if (typeof value === "number") {
|
|
154
|
+
return value === -1;
|
|
155
|
+
}
|
|
156
|
+
if (typeof value === "string" && value.trim()) {
|
|
157
|
+
return Number(value) === -1;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* llama.cpp `/props.default_generation_settings.params.{max_tokens,n_predict}`
|
|
164
|
+
* are per-request defaults the server applies when a client omits the field —
|
|
165
|
+
* clients can still raise them per call. Positive values therefore are NOT
|
|
166
|
+
* hard model caps; only the `-1` unlimited sentinel reliably tells us the
|
|
167
|
+
* server bounds generation by the runtime context window. Anything else
|
|
168
|
+
* leaves the discovery default in place.
|
|
169
|
+
*/
|
|
170
|
+
function extractLlamaCppMaxTokens(payload: Record<string, unknown>): "contextWindow" | undefined {
|
|
171
|
+
const generationSettings = payload.default_generation_settings;
|
|
172
|
+
const params = isRecord(generationSettings) ? generationSettings.params : undefined;
|
|
173
|
+
const candidates = [
|
|
174
|
+
isRecord(params) ? params.max_tokens : undefined,
|
|
175
|
+
isRecord(params) ? params.n_predict : undefined,
|
|
176
|
+
isRecord(generationSettings) ? generationSettings.max_tokens : undefined,
|
|
177
|
+
isRecord(generationSettings) ? generationSettings.n_predict : undefined,
|
|
178
|
+
payload.max_tokens,
|
|
179
|
+
payload.n_predict,
|
|
180
|
+
];
|
|
181
|
+
return candidates.some(isLlamaCppUnlimitedSentinel) ? "contextWindow" : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveLlamaCppMaxTokens(contextWindow: number, maxTokens: "contextWindow" | undefined): number {
|
|
185
|
+
return maxTokens === "contextWindow"
|
|
186
|
+
? contextWindow
|
|
187
|
+
: Math.min(contextWindow, maxTokens ?? DISCOVERY_DEFAULT_MAX_TOKENS);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function extractOllamaRuntimeContextWindow(payload: Record<string, unknown>): number | undefined {
|
|
191
|
+
const parameters = payload.parameters;
|
|
192
|
+
if (typeof parameters !== "string") {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
|
|
196
|
+
return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
145
199
|
function extractOllamaContextWindow(payload: Record<string, unknown>): number | undefined {
|
|
200
|
+
const runtimeContextWindow = extractOllamaRuntimeContextWindow(payload);
|
|
201
|
+
if (runtimeContextWindow !== undefined) {
|
|
202
|
+
return runtimeContextWindow;
|
|
203
|
+
}
|
|
204
|
+
|
|
146
205
|
const modelInfo = payload.model_info;
|
|
147
206
|
if (isRecord(modelInfo)) {
|
|
148
207
|
for (const [key, value] of Object.entries(modelInfo)) {
|
|
@@ -155,12 +214,7 @@ function extractOllamaContextWindow(payload: Record<string, unknown>): number |
|
|
|
155
214
|
}
|
|
156
215
|
}
|
|
157
216
|
|
|
158
|
-
|
|
159
|
-
if (typeof parameters !== "string") {
|
|
160
|
-
return undefined;
|
|
161
|
-
}
|
|
162
|
-
const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
|
|
163
|
-
return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
|
|
217
|
+
return undefined;
|
|
164
218
|
}
|
|
165
219
|
|
|
166
220
|
function extractLlamaCppContextWindow(payload: Record<string, unknown>): number | undefined {
|
|
@@ -174,12 +228,17 @@ function extractLlamaCppContextWindow(payload: Record<string, unknown>): number
|
|
|
174
228
|
return toPositiveNumberOrUndefined(payload.n_ctx);
|
|
175
229
|
}
|
|
176
230
|
|
|
177
|
-
function
|
|
231
|
+
function extractLlamaCppModelContextWindows(
|
|
232
|
+
item: Record<string, unknown>,
|
|
233
|
+
): Pick<LlamaCppModelListEntry, "runtimeContextWindow" | "trainingContextWindow"> {
|
|
178
234
|
const meta = item.meta;
|
|
179
235
|
if (!isRecord(meta)) {
|
|
180
|
-
return
|
|
236
|
+
return {};
|
|
181
237
|
}
|
|
182
|
-
return
|
|
238
|
+
return {
|
|
239
|
+
runtimeContextWindow: toPositiveNumberOrUndefined(meta.n_ctx),
|
|
240
|
+
trainingContextWindow: toPositiveNumberOrUndefined(meta.n_ctx_train),
|
|
241
|
+
};
|
|
183
242
|
}
|
|
184
243
|
|
|
185
244
|
function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
|
|
@@ -190,7 +249,7 @@ function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
|
|
|
190
249
|
if (!isRecord(item) || typeof item.id !== "string" || !item.id) {
|
|
191
250
|
return [];
|
|
192
251
|
}
|
|
193
|
-
return [{ id: item.id,
|
|
252
|
+
return [{ id: item.id, ...extractLlamaCppModelContextWindows(item) }];
|
|
194
253
|
});
|
|
195
254
|
}
|
|
196
255
|
|
|
@@ -338,6 +397,7 @@ async function discoverLlamaCppServerMetadata(
|
|
|
338
397
|
}
|
|
339
398
|
return {
|
|
340
399
|
contextWindow: extractLlamaCppContextWindow(payload),
|
|
400
|
+
maxTokens: extractLlamaCppMaxTokens(payload),
|
|
341
401
|
input: extractLlamaCppInputCapabilities(payload),
|
|
342
402
|
};
|
|
343
403
|
} catch {
|
|
@@ -378,7 +438,11 @@ export async function discoverLlamaCppModels(
|
|
|
378
438
|
for (const item of models) {
|
|
379
439
|
const { id } = item;
|
|
380
440
|
if (!id) continue;
|
|
381
|
-
const contextWindow =
|
|
441
|
+
const contextWindow =
|
|
442
|
+
item.runtimeContextWindow ??
|
|
443
|
+
serverMetadata?.contextWindow ??
|
|
444
|
+
item.trainingContextWindow ??
|
|
445
|
+
DISCOVERY_DEFAULT_CONTEXT_WINDOW;
|
|
382
446
|
discovered.push(
|
|
383
447
|
buildModel({
|
|
384
448
|
id,
|
|
@@ -391,7 +455,7 @@ export async function discoverLlamaCppModels(
|
|
|
391
455
|
imageInputDecoder: "stb",
|
|
392
456
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
393
457
|
contextWindow,
|
|
394
|
-
maxTokens:
|
|
458
|
+
maxTokens: resolveLlamaCppMaxTokens(contextWindow, serverMetadata?.maxTokens),
|
|
395
459
|
headers,
|
|
396
460
|
compat: {
|
|
397
461
|
supportsStore: false,
|
|
@@ -404,23 +468,37 @@ export async function discoverLlamaCppModels(
|
|
|
404
468
|
return discovered;
|
|
405
469
|
}
|
|
406
470
|
|
|
407
|
-
export async function
|
|
471
|
+
export async function discoverLlamaCppModelRuntimeMetadata(
|
|
408
472
|
model: Pick<Model<Api>, "provider" | "id" | "baseUrl" | "headers">,
|
|
409
473
|
ctx: DiscoveryContext,
|
|
410
|
-
): Promise<
|
|
474
|
+
): Promise<LlamaCppDiscoveredModelRuntimeMetadata | undefined> {
|
|
411
475
|
const baseUrl = normalizeLlamaCppBaseUrl(model.baseUrl);
|
|
412
476
|
const modelsUrl = `${baseUrl}/models`;
|
|
413
477
|
const baseHeaders: Record<string, string> = { ...(model.headers ?? {}) };
|
|
414
478
|
const attempt = async (headers: Record<string, string>) => {
|
|
415
|
-
const response = await
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
479
|
+
const [response, serverMetadata] = await Promise.all([
|
|
480
|
+
ctx.fetch(modelsUrl, {
|
|
481
|
+
headers,
|
|
482
|
+
signal: AbortSignal.timeout(250),
|
|
483
|
+
}),
|
|
484
|
+
discoverLlamaCppServerMetadata(ctx, baseUrl, headers),
|
|
485
|
+
]);
|
|
419
486
|
if (!response.ok) {
|
|
420
487
|
return undefined;
|
|
421
488
|
}
|
|
422
489
|
const entries = parseLlamaCppModelList(await response.json());
|
|
423
|
-
|
|
490
|
+
const entry = entries.find(entry => entry.id === model.id);
|
|
491
|
+
if (!entry) {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
const contextWindow = entry.runtimeContextWindow ?? serverMetadata?.contextWindow ?? entry.trainingContextWindow;
|
|
495
|
+
if (contextWindow === undefined) {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
contextWindow,
|
|
500
|
+
maxTokens: resolveLlamaCppMaxTokens(contextWindow, serverMetadata?.maxTokens),
|
|
501
|
+
};
|
|
424
502
|
};
|
|
425
503
|
try {
|
|
426
504
|
const apiKey = await ctx.getBearerApiKeyResolver(model.provider);
|
|
@@ -75,7 +75,7 @@ import {
|
|
|
75
75
|
DISCOVERY_DEFAULT_MAX_TOKENS,
|
|
76
76
|
type DiscoveryContext,
|
|
77
77
|
type DiscoveryProviderConfig,
|
|
78
|
-
|
|
78
|
+
discoverLlamaCppModelRuntimeMetadata,
|
|
79
79
|
discoverModelsByProviderType,
|
|
80
80
|
getImplicitOllamaBaseUrl,
|
|
81
81
|
getOllamaContextLengthOverride,
|
|
@@ -873,10 +873,11 @@ export class ModelRegistry {
|
|
|
873
873
|
if (!isLlamaCppDiscovery) {
|
|
874
874
|
return model;
|
|
875
875
|
}
|
|
876
|
-
const
|
|
877
|
-
if (
|
|
876
|
+
const runtimeMetadata = await discoverLlamaCppModelRuntimeMetadata(model, this.#nonResolvingDiscoveryContext());
|
|
877
|
+
if (runtimeMetadata === undefined) {
|
|
878
878
|
return this.find(model.provider, model.id) ?? model;
|
|
879
879
|
}
|
|
880
|
+
const { contextWindow, maxTokens } = runtimeMetadata;
|
|
880
881
|
const current = this.find(model.provider, model.id) ?? model;
|
|
881
882
|
const override = this.#resolveLiveModelOverride(current);
|
|
882
883
|
const customModel = this.#resolveLiveCustomModelOverlay(current);
|
|
@@ -888,13 +889,19 @@ export class ModelRegistry {
|
|
|
888
889
|
) {
|
|
889
890
|
patch.contextWindow = contextWindow;
|
|
890
891
|
}
|
|
891
|
-
const
|
|
892
|
+
const effectiveContextWindow =
|
|
893
|
+
override?.contextWindow ??
|
|
894
|
+
customModel?.contextWindow ??
|
|
895
|
+
patch.contextWindow ??
|
|
896
|
+
current.contextWindow ??
|
|
897
|
+
contextWindow;
|
|
898
|
+
const effectiveMaxTokens = Math.min(maxTokens, effectiveContextWindow);
|
|
892
899
|
if (
|
|
893
900
|
override?.maxTokens === undefined &&
|
|
894
901
|
customModel?.maxTokens === undefined &&
|
|
895
|
-
current.maxTokens !==
|
|
902
|
+
current.maxTokens !== effectiveMaxTokens
|
|
896
903
|
) {
|
|
897
|
-
patch.maxTokens =
|
|
904
|
+
patch.maxTokens = effectiveMaxTokens;
|
|
898
905
|
}
|
|
899
906
|
if (patch.contextWindow === undefined && patch.maxTokens === undefined) {
|
|
900
907
|
return current;
|
|
@@ -317,8 +317,11 @@ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
|
|
|
317
317
|
// `>` must sit outside quoted regions (so `echo "a -> b"` passes) and be
|
|
318
318
|
// followed by a plausible filename — including `$VAR` targets; `>|`
|
|
319
319
|
// (clobber) counts as a redirect; `>&2`/`2>&1` style fd duplication is
|
|
320
|
-
// not matched.
|
|
321
|
-
|
|
320
|
+
// not matched. Allowed device sinks are consumed while looking for later
|
|
321
|
+
// real file redirects because the write tool cannot replace shell
|
|
322
|
+
// output/discard targets.
|
|
323
|
+
pattern:
|
|
324
|
+
"^\\s*(echo|printf|cat\\s*<<)\\s+(?:(?:[^\"'>]|\"[^\"]*\"|'[^']*')|(?<!\\|)>{1,2}\\|?\\s*(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))*(?<!\\|)>{1,2}\\|?\\s*(?!(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))[$\\w./~\"'-]",
|
|
322
325
|
tool: "write",
|
|
323
326
|
message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
|
|
324
327
|
},
|
|
@@ -27,6 +27,7 @@ import { ToolError } from "../../tools/tool-errors";
|
|
|
27
27
|
import { generateDiffString } from "../diff";
|
|
28
28
|
import { getFileSnapshotStore } from "../file-snapshot-store";
|
|
29
29
|
import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
|
|
30
|
+
import { pruneOversizedEditSnapshots } from "../snapshot-details";
|
|
30
31
|
import { nativeBlockResolver } from "./block-resolver";
|
|
31
32
|
import { HashlineFilesystem } from "./filesystem";
|
|
32
33
|
import { hashPatchInput, NOOP_HARD_LIMIT, recordNoopEdit, resetNoopEdit } from "./noop-loop-guard";
|
|
@@ -114,17 +115,22 @@ function renderSection(
|
|
|
114
115
|
if (result.op === "delete") {
|
|
115
116
|
const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
|
|
116
117
|
content: [{ type: "text", text: `Deleted ${result.path}` }],
|
|
117
|
-
details: {
|
|
118
|
+
details: pruneOversizedEditSnapshots({
|
|
118
119
|
diff: "",
|
|
119
120
|
op: "delete",
|
|
120
121
|
path: result.path,
|
|
121
122
|
oldText: result.before,
|
|
122
123
|
meta: outputMeta().get(),
|
|
123
|
-
},
|
|
124
|
+
}),
|
|
124
125
|
};
|
|
125
126
|
return {
|
|
126
127
|
toolResult,
|
|
127
|
-
perFileResult: {
|
|
128
|
+
perFileResult: pruneOversizedEditSnapshots({
|
|
129
|
+
path: result.path,
|
|
130
|
+
diff: "",
|
|
131
|
+
op: "delete",
|
|
132
|
+
oldText: result.before,
|
|
133
|
+
}),
|
|
128
134
|
};
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -161,7 +167,7 @@ function renderSection(
|
|
|
161
167
|
text: `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`,
|
|
162
168
|
},
|
|
163
169
|
],
|
|
164
|
-
details: {
|
|
170
|
+
details: pruneOversizedEditSnapshots({
|
|
165
171
|
diff: diff.diff,
|
|
166
172
|
firstChangedLine,
|
|
167
173
|
diagnostics,
|
|
@@ -172,9 +178,9 @@ function renderSection(
|
|
|
172
178
|
oldText: result.before,
|
|
173
179
|
newText: result.after,
|
|
174
180
|
meta,
|
|
175
|
-
},
|
|
181
|
+
}),
|
|
176
182
|
},
|
|
177
|
-
perFileResult: {
|
|
183
|
+
perFileResult: pruneOversizedEditSnapshots({
|
|
178
184
|
path: result.moveDest ?? result.path,
|
|
179
185
|
diff: diff.diff,
|
|
180
186
|
firstChangedLine,
|
|
@@ -184,7 +190,7 @@ function renderSection(
|
|
|
184
190
|
sourcePath: result.moveDest ? sourcePath : undefined,
|
|
185
191
|
oldText: result.before,
|
|
186
192
|
newText: result.after,
|
|
187
|
-
},
|
|
193
|
+
}),
|
|
188
194
|
};
|
|
189
195
|
}
|
|
190
196
|
|
|
@@ -263,10 +269,10 @@ export async function executeHashlineSingle(
|
|
|
263
269
|
.join("\n\n"),
|
|
264
270
|
},
|
|
265
271
|
],
|
|
266
|
-
details: {
|
|
272
|
+
details: pruneOversizedEditSnapshots({
|
|
267
273
|
diff: rendered.map(r => r.toolResult.details?.diff ?? "").join("\n"),
|
|
268
274
|
perFileResults: rendered.map(r => r.perFileResult),
|
|
269
|
-
},
|
|
275
|
+
}),
|
|
270
276
|
};
|
|
271
277
|
}
|
|
272
278
|
|