@oh-my-pi/pi-coding-agent 15.10.11 → 15.10.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/CHANGELOG.md +44 -0
- package/dist/cli.js +5349 -5328
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli-commands.d.ts +12 -0
- package/dist/types/commands/launch.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +18 -0
- package/dist/types/config/settings-schema.d.ts +29 -1
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
- package/dist/types/eval/py/executor.d.ts +5 -0
- package/dist/types/eval/py/kernel.d.ts +6 -1
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/memory-backend/index.d.ts +1 -0
- package/dist/types/memory-backend/runtime.d.ts +4 -0
- package/dist/types/memory-backend/types.d.ts +66 -1
- package/dist/types/modes/index.d.ts +3 -3
- package/dist/types/modes/interactive-mode.d.ts +7 -2
- package/dist/types/modes/oauth-manual-input.d.ts +7 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
- package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
- package/dist/types/modes/setup-wizard/index.d.ts +5 -1
- package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/secrets/index.d.ts +1 -1
- package/dist/types/secrets/obfuscator.d.ts +8 -2
- package/dist/types/session/agent-session.d.ts +14 -2
- package/dist/types/session/streaming-output.d.ts +23 -0
- package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
- package/dist/types/slash-commands/types.d.ts +1 -1
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/index.d.ts +2 -2
- package/dist/types/task/types.d.ts +8 -0
- package/dist/types/thinking.d.ts +4 -0
- package/dist/types/tiny/title-client.d.ts +11 -0
- package/dist/types/tiny/title-protocol.d.ts +1 -0
- package/dist/types/tools/index.d.ts +6 -0
- package/dist/types/utils/git.d.ts +15 -2
- package/dist/types/utils/title-generator.d.ts +3 -2
- package/package.json +10 -10
- package/src/auto-thinking/classifier.ts +1 -0
- package/src/cli/args.ts +3 -0
- package/src/cli-commands.ts +29 -0
- package/src/cli.ts +8 -9
- package/src/commands/launch.ts +4 -0
- package/src/commit/model-selection.ts +3 -2
- package/src/config/api-key-resolver.ts +8 -6
- package/src/config/model-registry.ts +97 -30
- package/src/config/model-resolver.ts +60 -0
- package/src/config/settings-schema.ts +43 -15
- package/src/config/settings.ts +61 -3
- package/src/edit/hashline/execute.ts +39 -2
- package/src/edit/hashline/noop-loop-guard.ts +99 -0
- package/src/eval/completion-bridge.ts +1 -0
- package/src/eval/py/executor.ts +29 -7
- package/src/eval/py/index.ts +6 -1
- package/src/eval/py/kernel.ts +31 -11
- package/src/eval/py/runtime.ts +37 -0
- package/src/exec/bash-executor.ts +82 -3
- package/src/extensibility/extensions/get-commands-handler.ts +2 -1
- package/src/extensibility/extensions/runner.ts +6 -1
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/hindsight/bank.ts +17 -2
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/main.ts +18 -6
- package/src/memories/index.ts +2 -0
- package/src/memory-backend/index.ts +1 -0
- package/src/memory-backend/local-backend.ts +9 -0
- package/src/memory-backend/off-backend.ts +9 -0
- package/src/memory-backend/runtime.ts +66 -0
- package/src/memory-backend/types.ts +81 -1
- package/src/mnemopi/backend.ts +151 -4
- package/src/modes/acp/acp-agent.ts +119 -11
- package/src/modes/components/assistant-message.ts +19 -21
- package/src/modes/components/footer.ts +3 -1
- package/src/modes/components/status-line/component.ts +118 -34
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +1 -0
- package/src/modes/controllers/mcp-command-controller.ts +38 -3
- package/src/modes/index.ts +3 -21
- package/src/modes/interactive-mode.ts +39 -9
- package/src/modes/oauth-manual-input.ts +30 -3
- package/src/modes/rpc/rpc-client.ts +154 -3
- package/src/modes/rpc/rpc-mode.ts +97 -12
- package/src/modes/rpc/rpc-subagents.ts +265 -0
- package/src/modes/rpc/rpc-types.ts +81 -1
- package/src/modes/setup-wizard/index.ts +12 -2
- package/src/modes/setup-wizard/lazy.ts +16 -0
- package/src/modes/types.ts +2 -0
- package/src/sdk.ts +8 -1
- package/src/secrets/index.ts +8 -1
- package/src/secrets/obfuscator.ts +39 -18
- package/src/session/agent-session.ts +179 -54
- package/src/session/streaming-output.ts +166 -10
- package/src/slash-commands/acp-builtins.ts +24 -0
- package/src/slash-commands/builtin-registry.ts +20 -0
- package/src/slash-commands/types.ts +1 -1
- package/src/system-prompt.ts +14 -0
- package/src/task/executor.ts +13 -12
- package/src/task/index.ts +9 -8
- package/src/task/render.ts +18 -3
- package/src/task/types.ts +9 -0
- package/src/thinking.ts +7 -0
- package/src/tiny/title-client.ts +34 -5
- package/src/tiny/title-protocol.ts +1 -1
- package/src/tiny/worker.ts +6 -4
- package/src/tools/bash.ts +46 -5
- package/src/tools/image-gen.ts +11 -4
- package/src/tools/index.ts +13 -1
- package/src/tools/inspect-image.ts +1 -0
- package/src/utils/commit-message-generator.ts +1 -0
- package/src/utils/git.ts +267 -13
- package/src/utils/title-generator.ts +24 -5
package/src/config/settings.ts
CHANGED
|
@@ -64,6 +64,8 @@ export interface SettingsOptions {
|
|
|
64
64
|
inMemory?: boolean;
|
|
65
65
|
/** Initial overrides */
|
|
66
66
|
overrides?: Partial<Record<SettingPath, unknown>>;
|
|
67
|
+
/** Extra config.yml-style overlays loaded after global/project settings */
|
|
68
|
+
configFiles?: string[];
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -116,10 +118,12 @@ type PathScopedStringArrayEntry = {
|
|
|
116
118
|
providers?: unknown;
|
|
117
119
|
};
|
|
118
120
|
|
|
121
|
+
function expandTilde(p: string): string {
|
|
122
|
+
return p === "~" ? os.homedir() : p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
|
|
123
|
+
}
|
|
124
|
+
|
|
119
125
|
function normalizePathPrefix(prefix: string): string {
|
|
120
|
-
|
|
121
|
-
prefix === "~" ? os.homedir() : prefix.startsWith("~/") ? path.join(os.homedir(), prefix.slice(2)) : prefix;
|
|
122
|
-
return path.resolve(expanded);
|
|
126
|
+
return path.resolve(expandTilde(prefix));
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
function pathMatchesPrefix(cwd: string, prefix: string): boolean {
|
|
@@ -193,10 +197,13 @@ export class Settings {
|
|
|
193
197
|
#agentDir: string;
|
|
194
198
|
#storage: AgentStorage | null = null;
|
|
195
199
|
|
|
200
|
+
#configFiles: string[] = [];
|
|
196
201
|
/** Global settings from config.yml */
|
|
197
202
|
#global: RawSettings = {};
|
|
198
203
|
/** Project settings from .claude/settings.yml etc */
|
|
199
204
|
#project: RawSettings = {};
|
|
205
|
+
/** Extra config.yml-style overlays passed by CLI */
|
|
206
|
+
#configOverlay: RawSettings = {};
|
|
200
207
|
/** Runtime overrides (not persisted) */
|
|
201
208
|
#overrides: RawSettings = {};
|
|
202
209
|
/** Merged view (global + project + overrides) */
|
|
@@ -221,6 +228,7 @@ export class Settings {
|
|
|
221
228
|
this.#cwd = path.normalize(options.cwd ?? getProjectDir());
|
|
222
229
|
this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
|
|
223
230
|
this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml");
|
|
231
|
+
this.#configFiles = options.configFiles?.map(file => path.resolve(this.#cwd, expandTilde(file))) ?? [];
|
|
224
232
|
this.#persist = !options.inMemory;
|
|
225
233
|
|
|
226
234
|
if (options.overrides) {
|
|
@@ -256,6 +264,7 @@ export class Settings {
|
|
|
256
264
|
},
|
|
257
265
|
error => {
|
|
258
266
|
globalInstance = null;
|
|
267
|
+
globalInstancePromise = null;
|
|
259
268
|
clearBoundSettingsMethods();
|
|
260
269
|
throw error;
|
|
261
270
|
},
|
|
@@ -303,6 +312,14 @@ export class Settings {
|
|
|
303
312
|
return resolved as SettingValue<P>;
|
|
304
313
|
}
|
|
305
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Whether `path` has an explicitly configured value (global config, project
|
|
317
|
+
* config, or runtime override) rather than falling back to the schema default.
|
|
318
|
+
*/
|
|
319
|
+
isConfigured(path: SettingPath): boolean {
|
|
320
|
+
return getByPath(this.#merged, SETTING_PATH_SEGMENTS[path]) !== undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
306
323
|
/**
|
|
307
324
|
* Set a setting value (sync).
|
|
308
325
|
* Updates global settings and queues a background save.
|
|
@@ -386,6 +403,8 @@ export class Settings {
|
|
|
386
403
|
cloned.#storage = this.#storage;
|
|
387
404
|
cloned.#global = structuredClone(this.#global);
|
|
388
405
|
cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
|
|
406
|
+
cloned.#configFiles = [...this.#configFiles];
|
|
407
|
+
cloned.#configOverlay = structuredClone(this.#configOverlay);
|
|
389
408
|
cloned.#overrides = structuredClone(this.#overrides);
|
|
390
409
|
cloned.#rebuildMerged();
|
|
391
410
|
cloned.#fireAllHooks();
|
|
@@ -557,6 +576,7 @@ export class Settings {
|
|
|
557
576
|
}
|
|
558
577
|
|
|
559
578
|
this.#project = await projectPromise;
|
|
579
|
+
this.#configOverlay = await this.#loadConfigOverlays();
|
|
560
580
|
|
|
561
581
|
// Build merged view (global → project → overrides; project wins over global)
|
|
562
582
|
this.#rebuildMerged();
|
|
@@ -594,6 +614,43 @@ export class Settings {
|
|
|
594
614
|
}
|
|
595
615
|
}
|
|
596
616
|
|
|
617
|
+
async #loadConfigOverlays(): Promise<RawSettings> {
|
|
618
|
+
let merged: RawSettings = {};
|
|
619
|
+
for (const filePath of this.#configFiles) {
|
|
620
|
+
merged = this.#deepMerge(merged, await this.#loadOverlayYaml(filePath));
|
|
621
|
+
}
|
|
622
|
+
return merged;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Strict loader for explicit `--config` overlays: unlike `#loadYaml`,
|
|
627
|
+
* missing or malformed files are hard errors so a typo'd path cannot
|
|
628
|
+
* silently fall back to the persistent settings.
|
|
629
|
+
*/
|
|
630
|
+
async #loadOverlayYaml(filePath: string): Promise<RawSettings> {
|
|
631
|
+
let content: string;
|
|
632
|
+
try {
|
|
633
|
+
content = await Bun.file(filePath).text();
|
|
634
|
+
} catch (error) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
isEnoent(error)
|
|
637
|
+
? `Config overlay not found: ${filePath}`
|
|
638
|
+
: `Failed to read config overlay ${filePath}: ${String(error)}`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
let parsed: unknown;
|
|
642
|
+
try {
|
|
643
|
+
parsed = YAML.parse(content);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
throw new Error(`Failed to parse config overlay ${filePath}: ${String(error)}`);
|
|
646
|
+
}
|
|
647
|
+
if (parsed === null || parsed === undefined) return {};
|
|
648
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
649
|
+
throw new Error(`Config overlay must be a YAML mapping: ${filePath}`);
|
|
650
|
+
}
|
|
651
|
+
return this.#migrateRawSettings(parsed as RawSettings);
|
|
652
|
+
}
|
|
653
|
+
|
|
597
654
|
async #migrateFromLegacy(): Promise<void> {
|
|
598
655
|
if (!this.#configPath) return;
|
|
599
656
|
|
|
@@ -898,6 +955,7 @@ export class Settings {
|
|
|
898
955
|
|
|
899
956
|
#rebuildMerged(): void {
|
|
900
957
|
this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project);
|
|
958
|
+
this.#merged = this.#deepMerge(this.#merged, this.#configOverlay);
|
|
901
959
|
this.#merged = this.#deepMerge(this.#merged, this.#overrides);
|
|
902
960
|
this.#resolvedCache.clear();
|
|
903
961
|
}
|
|
@@ -23,11 +23,13 @@ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
|
23
23
|
import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
24
24
|
import type { ToolSession } from "../../tools";
|
|
25
25
|
import { outputMeta } from "../../tools/output-meta";
|
|
26
|
+
import { ToolError } from "../../tools/tool-errors";
|
|
26
27
|
import { generateDiffString } from "../diff";
|
|
27
28
|
import { getFileSnapshotStore } from "../file-snapshot-store";
|
|
28
29
|
import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
|
|
29
30
|
import { nativeBlockResolver } from "./block-resolver";
|
|
30
31
|
import { HashlineFilesystem } from "./filesystem";
|
|
32
|
+
import { hashPatchInput, NOOP_HARD_LIMIT, recordNoopEdit, resetNoopEdit } from "./noop-loop-guard";
|
|
31
33
|
import { type HashlineParams, hashlineEditParamsSchema } from "./params";
|
|
32
34
|
|
|
33
35
|
export interface ExecuteHashlineSingleOptions {
|
|
@@ -54,6 +56,24 @@ function noChangeDiagnostic(path: string): string {
|
|
|
54
56
|
);
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Escalated diagnostic surfaced once the same payload has no-op'd
|
|
61
|
+
* {@link NOOP_HARD_LIMIT} times in a row on the same canonical path. Thrown as
|
|
62
|
+
* a {@link ToolError} so the agent loop sees a tool *failure* — empirically
|
|
63
|
+
* far more effective at breaking a no-op edit loop than the soft hint alone
|
|
64
|
+
* (issue #2081 saw 182 byte-identical no-op results in 205 calls before the
|
|
65
|
+
* user aborted).
|
|
66
|
+
*/
|
|
67
|
+
function noChangeLoopDiagnostic(path: string, count: number): string {
|
|
68
|
+
return (
|
|
69
|
+
`STOP. Edits to ${path} have been a byte-identical no-op ${count} times in a row — ` +
|
|
70
|
+
`the patch body matches the file at the targeted lines and the soft hint did not break the cycle. ` +
|
|
71
|
+
`Cease re-issuing this payload. Either the intended change is already on disk (move on), ` +
|
|
72
|
+
`or your anchor is wrong (re-read the file with \`read\` to observe the current line numbers and ` +
|
|
73
|
+
`tag, then author a different edit). This exact payload will keep being rejected until it changes.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
|
|
58
78
|
const seen = new Map<string, string>();
|
|
59
79
|
for (const entry of prepared) {
|
|
@@ -156,13 +176,19 @@ export async function executeHashlineSingle(
|
|
|
156
176
|
const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver });
|
|
157
177
|
|
|
158
178
|
// Single-section fast path: prepare, commit, render.
|
|
179
|
+
const inputHash = hashPatchInput(options.input);
|
|
159
180
|
if (patch.sections.length === 1) {
|
|
160
181
|
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true));
|
|
161
182
|
const prepared = await patcher.prepare(patch.sections[0]);
|
|
162
183
|
const sectionResult = await patcher.commit(prepared);
|
|
163
184
|
if (sectionResult.op === "noop") {
|
|
185
|
+
const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
|
|
186
|
+
if (escalate) {
|
|
187
|
+
throw new ToolError(noChangeLoopDiagnostic(sectionResult.path, count));
|
|
188
|
+
}
|
|
164
189
|
return renderSection(sectionResult, undefined).toolResult;
|
|
165
190
|
}
|
|
191
|
+
resetNoopEdit(options.session, sectionResult.canonicalPath);
|
|
166
192
|
return renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path)).toolResult;
|
|
167
193
|
}
|
|
168
194
|
|
|
@@ -172,7 +198,12 @@ export async function executeHashlineSingle(
|
|
|
172
198
|
for (const section of patch.sections) prepared.push(await patcher.prepare(section));
|
|
173
199
|
assertUniqueCanonicalPaths(prepared);
|
|
174
200
|
for (const entry of prepared) {
|
|
175
|
-
if (entry.isNoop)
|
|
201
|
+
if (entry.isNoop) {
|
|
202
|
+
const { count, escalate } = recordNoopEdit(options.session, entry.canonicalPath, inputHash);
|
|
203
|
+
throw escalate
|
|
204
|
+
? new ToolError(noChangeLoopDiagnostic(entry.section.path, count))
|
|
205
|
+
: new ToolError(noChangeDiagnostic(entry.section.path));
|
|
206
|
+
}
|
|
176
207
|
}
|
|
177
208
|
// Then commit each one, narrowing the LSP batch flush flag to the final
|
|
178
209
|
// section only. A no-op apply mid-batch is treated as a hard failure —
|
|
@@ -182,7 +213,13 @@ export async function executeHashlineSingle(
|
|
|
182
213
|
const isLast = i === prepared.length - 1;
|
|
183
214
|
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, isLast));
|
|
184
215
|
const sectionResult = await patcher.commit(prepared[i]);
|
|
185
|
-
if (sectionResult.op === "noop")
|
|
216
|
+
if (sectionResult.op === "noop") {
|
|
217
|
+
const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
|
|
218
|
+
throw escalate
|
|
219
|
+
? new ToolError(noChangeLoopDiagnostic(sectionResult.path, count))
|
|
220
|
+
: new ToolError(noChangeDiagnostic(sectionResult.path));
|
|
221
|
+
}
|
|
222
|
+
resetNoopEdit(options.session, sectionResult.canonicalPath);
|
|
186
223
|
rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path)));
|
|
187
224
|
}
|
|
188
225
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session guard against subagents looping on byte-identical no-op edits.
|
|
3
|
+
*
|
|
4
|
+
* A hashline patch can apply cleanly yet produce no change when the body rows
|
|
5
|
+
* are already byte-identical to the targeted lines. {@link executeHashlineSingle}
|
|
6
|
+
* surfaces a soft hint ("re-read the file before issuing another edit"), but in
|
|
7
|
+
* the wild some models ignore the hint and keep re-issuing the same bytes
|
|
8
|
+
* (issue #2081 captured 182 such repeats in 205 calls before the user aborted).
|
|
9
|
+
*
|
|
10
|
+
* This module tracks consecutive byte-identical no-op edits per canonical file
|
|
11
|
+
* path within a single session. Once the same payload no-ops {@link NOOP_HARD_LIMIT}
|
|
12
|
+
* times in a row the caller is expected to escalate from a soft text result to
|
|
13
|
+
* a thrown {@link ToolError} so the agent loop sees a tool *failure* — empirically
|
|
14
|
+
* far more effective at breaking the cycle than the soft hint alone.
|
|
15
|
+
*
|
|
16
|
+
* A successful (non-noop) commit for a path resets that path's counter; a
|
|
17
|
+
* different payload on the same path also resets it because the body hash
|
|
18
|
+
* changed, which is a sign of model progress and deserves another soft hint.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface NoopLoopEntry {
|
|
22
|
+
/** Hash of the most recent input that no-op'd on this canonical path. */
|
|
23
|
+
hash: string;
|
|
24
|
+
/** Consecutive no-op count for the same `hash` on this path. */
|
|
25
|
+
count: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Cross-session-safe state slot held on the `ToolSession`. */
|
|
29
|
+
export interface NoopLoopGuard {
|
|
30
|
+
entries: Map<string, NoopLoopEntry>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* After this many consecutive byte-identical no-op edits on the same path,
|
|
35
|
+
* {@link recordNoopEdit} returns `escalate: true`. Picked deliberately small
|
|
36
|
+
* so the soft hint still fires once or twice before we escalate — the model
|
|
37
|
+
* deserves a chance to recover, but a tight bound is what actually breaks
|
|
38
|
+
* loops in practice.
|
|
39
|
+
*/
|
|
40
|
+
export const NOOP_HARD_LIMIT = 3;
|
|
41
|
+
|
|
42
|
+
interface NoopLoopGuardOwner {
|
|
43
|
+
noopLoopGuard?: NoopLoopGuard;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Lazily create the per-session guard, mirroring `getFileSnapshotStore`. */
|
|
47
|
+
export function getNoopLoopGuard(session: NoopLoopGuardOwner): NoopLoopGuard {
|
|
48
|
+
if (!session.noopLoopGuard) session.noopLoopGuard = { entries: new Map() };
|
|
49
|
+
return session.noopLoopGuard;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Result of recording one no-op against the guard. */
|
|
53
|
+
export interface NoopRecordResult {
|
|
54
|
+
/** Consecutive identical no-op count, including the current one. */
|
|
55
|
+
count: number;
|
|
56
|
+
/** True once `count >= NOOP_HARD_LIMIT` and the caller MUST escalate. */
|
|
57
|
+
escalate: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Record a no-op edit for `canonicalPath` keyed by `inputHash` (a stable hash
|
|
62
|
+
* of the raw patch input bytes). Returns the running consecutive-no-op count
|
|
63
|
+
* and whether the caller should escalate from a soft text result to a thrown
|
|
64
|
+
* error.
|
|
65
|
+
*
|
|
66
|
+
* `inputHash` is intentionally derived from the raw model-authored bytes
|
|
67
|
+
* rather than from file content: when the model emits a different payload
|
|
68
|
+
* (even whitespace-only) that's progress and earns a fresh soft hint, but
|
|
69
|
+
* re-issuing the same bytes after being warned is what we want to break.
|
|
70
|
+
*/
|
|
71
|
+
export function recordNoopEdit(
|
|
72
|
+
session: NoopLoopGuardOwner,
|
|
73
|
+
canonicalPath: string,
|
|
74
|
+
inputHash: string,
|
|
75
|
+
): NoopRecordResult {
|
|
76
|
+
const guard = getNoopLoopGuard(session);
|
|
77
|
+
const prev = guard.entries.get(canonicalPath);
|
|
78
|
+
const count = prev && prev.hash === inputHash ? prev.count + 1 : 1;
|
|
79
|
+
guard.entries.set(canonicalPath, { hash: inputHash, count });
|
|
80
|
+
return { count, escalate: count >= NOOP_HARD_LIMIT };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Clear the no-op counter for `canonicalPath`. Call after a non-noop commit
|
|
85
|
+
* for the same path so a future no-op starts fresh from the soft hint.
|
|
86
|
+
*/
|
|
87
|
+
export function resetNoopEdit(session: NoopLoopGuardOwner, canonicalPath: string): void {
|
|
88
|
+
const guard = session.noopLoopGuard;
|
|
89
|
+
if (!guard) return;
|
|
90
|
+
guard.entries.delete(canonicalPath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Stable hash of the raw patch input. Bun's `Bun.hash` is xxHash64 — fast,
|
|
95
|
+
* non-cryptographic, more than adequate for "is this the same payload?".
|
|
96
|
+
*/
|
|
97
|
+
export function hashPatchInput(input: string): string {
|
|
98
|
+
return Bun.hash(input).toString(16);
|
|
99
|
+
}
|
|
@@ -163,6 +163,7 @@ export async function runEvalCompletion(
|
|
|
163
163
|
apiKey: registry.resolver(model.provider, {
|
|
164
164
|
sessionId: options.session.getSessionId?.() ?? undefined,
|
|
165
165
|
baseUrl: model.baseUrl,
|
|
166
|
+
modelId: model.id,
|
|
166
167
|
}),
|
|
167
168
|
signal: options.signal,
|
|
168
169
|
reasoning: reasoningForTier(tier, model),
|
package/src/eval/py/executor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
1
2
|
import * as path from "node:path";
|
|
2
3
|
|
|
3
4
|
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
type KernelRuntimeEnv,
|
|
16
17
|
PythonKernel,
|
|
17
18
|
} from "./kernel";
|
|
19
|
+
import { resolveExplicitPythonRuntime } from "./runtime";
|
|
18
20
|
import { ensurePyToolBridge, registerPyToolBridge } from "./tool-bridge";
|
|
19
21
|
|
|
20
22
|
export type PythonKernelMode = "session" | "per-call";
|
|
@@ -42,6 +44,11 @@ export interface PythonExecutorOptions {
|
|
|
42
44
|
kernelOwnerId?: string;
|
|
43
45
|
/** Kernel mode (session reuse vs per-call) */
|
|
44
46
|
kernelMode?: PythonKernelMode;
|
|
47
|
+
/**
|
|
48
|
+
* Explicit interpreter path (`python.interpreter` resolved from the
|
|
49
|
+
* session's settings). Skips automatic runtime discovery when set.
|
|
50
|
+
*/
|
|
51
|
+
interpreter?: string;
|
|
45
52
|
/** Restart the kernel before executing */
|
|
46
53
|
reset?: boolean;
|
|
47
54
|
/** Session file path for accessing task outputs */
|
|
@@ -116,9 +123,9 @@ export interface PythonResult {
|
|
|
116
123
|
// ---------------------------------------------------------------------------
|
|
117
124
|
// Session bookkeeping
|
|
118
125
|
//
|
|
119
|
-
// One PythonKernel subprocess per (session id, cwd) tuple. The
|
|
120
|
-
// process-global cwd/sys.path during execution, so cross-directory
|
|
121
|
-
// never share a live kernel. Multiple agent owners can still register against
|
|
126
|
+
// One PythonKernel subprocess per (session id, cwd, interpreter) tuple. The
|
|
127
|
+
// runner mutates process-global cwd/sys.path during execution, so cross-directory
|
|
128
|
+
// work must never share a live kernel. Multiple agent owners can still register against
|
|
122
129
|
// the same tuple; the kernel stays alive until the last owner detaches.
|
|
123
130
|
// ---------------------------------------------------------------------------
|
|
124
131
|
|
|
@@ -139,8 +146,19 @@ function normalizeSessionCwd(cwd: string): string {
|
|
|
139
146
|
return path.resolve(cwd);
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
function
|
|
143
|
-
|
|
149
|
+
function normalizeExplicitInterpreter(cwd: string, interpreter: string | undefined): string {
|
|
150
|
+
if (interpreter === undefined) return "";
|
|
151
|
+
const resolved = resolveExplicitPythonRuntime(interpreter, cwd, {}).pythonPath;
|
|
152
|
+
try {
|
|
153
|
+
return fs.realpathSync.native(resolved);
|
|
154
|
+
} catch {
|
|
155
|
+
return resolved;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildSessionKey(sessionId: string, cwd: string, interpreter: string | undefined): string {
|
|
160
|
+
const normalizedCwd = normalizeSessionCwd(cwd);
|
|
161
|
+
return `${sessionId}\0${normalizedCwd}\0${normalizeExplicitInterpreter(normalizedCwd, interpreter)}`;
|
|
144
162
|
}
|
|
145
163
|
|
|
146
164
|
// ---------------------------------------------------------------------------
|
|
@@ -326,6 +344,7 @@ async function startKernel(cwd: string, options: PythonExecutorOptions): Promise
|
|
|
326
344
|
env: buildKernelEnv(options),
|
|
327
345
|
signal: options.signal,
|
|
328
346
|
deadlineMs: options.deadlineMs,
|
|
347
|
+
interpreter: options.interpreter,
|
|
329
348
|
});
|
|
330
349
|
}
|
|
331
350
|
|
|
@@ -587,7 +606,10 @@ async function executeWithKernel(
|
|
|
587
606
|
}
|
|
588
607
|
|
|
589
608
|
async function ensureKernelAvailable(cwd: string, options: PythonExecutorOptions): Promise<void> {
|
|
590
|
-
const availability = await waitForPromiseWithCancellation(
|
|
609
|
+
const availability = await waitForPromiseWithCancellation(
|
|
610
|
+
checkPythonKernelAvailability(cwd, options.interpreter),
|
|
611
|
+
options,
|
|
612
|
+
);
|
|
591
613
|
if (!availability.ok) {
|
|
592
614
|
throw new Error(availability.reason ?? "Python kernel unavailable");
|
|
593
615
|
}
|
|
@@ -618,7 +640,7 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
|
|
|
618
640
|
|
|
619
641
|
async function executeOnSession(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
|
|
620
642
|
const sessionId = options.sessionId ?? `session:${cwd}`;
|
|
621
|
-
const sessionKey = buildSessionKey(sessionId, cwd);
|
|
643
|
+
const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter);
|
|
622
644
|
if (options.bridge && !options.bridgeSessionId) {
|
|
623
645
|
options.bridgeSessionId = sessionId;
|
|
624
646
|
}
|
package/src/eval/py/index.ts
CHANGED
|
@@ -19,13 +19,17 @@ function readSetting<T>(session: ToolSession, key: string): T | undefined {
|
|
|
19
19
|
return settings?.get?.(key);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function readInterpreterSetting(session: ToolSession): string | undefined {
|
|
23
|
+
return readSetting<string>(session, "python.interpreter")?.trim() || undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
22
26
|
export default {
|
|
23
27
|
id: "python",
|
|
24
28
|
label: "Python",
|
|
25
29
|
highlightLang: "python",
|
|
26
30
|
|
|
27
31
|
async isAvailable(session: ToolSession): Promise<boolean> {
|
|
28
|
-
const availability = await checkPythonKernelAvailability(session.cwd);
|
|
32
|
+
const availability = await checkPythonKernelAvailability(session.cwd, readInterpreterSetting(session));
|
|
29
33
|
return availability.ok;
|
|
30
34
|
},
|
|
31
35
|
|
|
@@ -37,6 +41,7 @@ export default {
|
|
|
37
41
|
signal: opts.signal,
|
|
38
42
|
sessionId: namespaceSessionId(opts.sessionId),
|
|
39
43
|
kernelMode,
|
|
44
|
+
interpreter: readInterpreterSetting(opts.session),
|
|
40
45
|
sessionFile: opts.sessionFile,
|
|
41
46
|
artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
|
|
42
47
|
localRoots: resolveEvalUrlRoots(opts.session),
|
package/src/eval/py/kernel.ts
CHANGED
|
@@ -17,7 +17,13 @@ import { Settings } from "../../config/settings";
|
|
|
17
17
|
import { type KernelDisplayOutput, renderKernelDisplay } from "./display";
|
|
18
18
|
import { PYTHON_PRELUDE } from "./prelude";
|
|
19
19
|
import RUNNER_SCRIPT from "./runner.py" with { type: "text" };
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
enumeratePythonRuntimes,
|
|
22
|
+
filterEnv,
|
|
23
|
+
type PythonRuntime,
|
|
24
|
+
resolveExplicitPythonRuntime,
|
|
25
|
+
resolvePythonRuntime,
|
|
26
|
+
} from "./runtime";
|
|
21
27
|
import { hostHasInheritableConsole, shouldHideKernelWindow } from "./spawn-options";
|
|
22
28
|
|
|
23
29
|
export type { KernelDisplayOutput, PythonStatusEvent } from "./display";
|
|
@@ -96,6 +102,11 @@ interface KernelLifecycleOptions {
|
|
|
96
102
|
interface KernelStartOptions extends KernelLifecycleOptions {
|
|
97
103
|
cwd: string;
|
|
98
104
|
env?: Record<string, string | undefined>;
|
|
105
|
+
/**
|
|
106
|
+
* Explicit interpreter path (`python.interpreter` from the session's
|
|
107
|
+
* settings). When set, runtime discovery is skipped entirely.
|
|
108
|
+
*/
|
|
109
|
+
interpreter?: string;
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
interface KernelShutdownOptions {
|
|
@@ -129,20 +140,24 @@ function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string)
|
|
|
129
140
|
throw createAbortError("AbortError", typeof reason === "string" ? reason : fallbackReason);
|
|
130
141
|
}
|
|
131
142
|
|
|
132
|
-
// Cache successful probes per resolved cwd: every cell
|
|
133
|
-
// two — backend.isAvailable + ensureKernelAvailable)
|
|
134
|
-
// when the kernel is already hot. Failures are not
|
|
135
|
-
// Python mid-session is picked up on the next attempt.
|
|
143
|
+
// Cache successful probes per resolved cwd + explicit interpreter: every cell
|
|
144
|
+
// otherwise pays one (or two — backend.isAvailable + ensureKernelAvailable)
|
|
145
|
+
// interpreter spawns even when the kernel is already hot. Failures are not
|
|
146
|
+
// cached so installing a Python mid-session is picked up on the next attempt.
|
|
136
147
|
const availabilityCache = new Map<string, Promise<PythonKernelAvailability>>();
|
|
137
148
|
|
|
138
|
-
export async function checkPythonKernelAvailability(
|
|
149
|
+
export async function checkPythonKernelAvailability(
|
|
150
|
+
cwd: string,
|
|
151
|
+
interpreter?: string,
|
|
152
|
+
): Promise<PythonKernelAvailability> {
|
|
139
153
|
if (isBunTestRuntime() || $flag("PI_PYTHON_SKIP_CHECK")) {
|
|
140
154
|
return { ok: true };
|
|
141
155
|
}
|
|
142
|
-
const
|
|
156
|
+
const resolvedCwd = path.resolve(cwd);
|
|
157
|
+
const key = `${resolvedCwd}\0${interpreter ?? ""}`;
|
|
143
158
|
const cached = availabilityCache.get(key);
|
|
144
159
|
if (cached) return await cached;
|
|
145
|
-
const probe = probePythonKernelAvailability(
|
|
160
|
+
const probe = probePythonKernelAvailability(resolvedCwd, interpreter);
|
|
146
161
|
availabilityCache.set(key, probe);
|
|
147
162
|
const result = await probe;
|
|
148
163
|
if (!result.ok && availabilityCache.get(key) === probe) {
|
|
@@ -151,12 +166,14 @@ export async function checkPythonKernelAvailability(cwd: string): Promise<Python
|
|
|
151
166
|
return result;
|
|
152
167
|
}
|
|
153
168
|
|
|
154
|
-
async function probePythonKernelAvailability(cwd: string): Promise<PythonKernelAvailability> {
|
|
169
|
+
async function probePythonKernelAvailability(cwd: string, interpreter?: string): Promise<PythonKernelAvailability> {
|
|
155
170
|
try {
|
|
156
171
|
const settings = await Settings.init();
|
|
157
172
|
const { env } = settings.getShellConfig();
|
|
158
173
|
const baseEnv = filterEnv(env);
|
|
159
|
-
const runtimes =
|
|
174
|
+
const runtimes = interpreter
|
|
175
|
+
? [resolveExplicitPythonRuntime(interpreter, cwd, baseEnv)]
|
|
176
|
+
: enumeratePythonRuntimes(cwd, baseEnv);
|
|
160
177
|
if (runtimes.length === 0) {
|
|
161
178
|
return { ok: false, reason: "Python executable not found on PATH" };
|
|
162
179
|
}
|
|
@@ -239,6 +256,7 @@ export class PythonKernel {
|
|
|
239
256
|
"PythonKernel.start:availabilityCheck",
|
|
240
257
|
checkPythonKernelAvailability,
|
|
241
258
|
options.cwd,
|
|
259
|
+
options.interpreter,
|
|
242
260
|
);
|
|
243
261
|
if (!availability.ok) {
|
|
244
262
|
throw new Error(availability.reason ?? "Python kernel unavailable");
|
|
@@ -251,7 +269,9 @@ export class PythonKernel {
|
|
|
251
269
|
let runtime = availability.runtime;
|
|
252
270
|
if (!runtime) {
|
|
253
271
|
const { env: shellEnv } = (await Settings.init()).getShellConfig();
|
|
254
|
-
runtime =
|
|
272
|
+
runtime = options.interpreter
|
|
273
|
+
? resolveExplicitPythonRuntime(options.interpreter, options.cwd, filterEnv(shellEnv))
|
|
274
|
+
: resolvePythonRuntime(options.cwd, filterEnv(shellEnv));
|
|
255
275
|
}
|
|
256
276
|
const spawnEnv: Record<string, string> = {};
|
|
257
277
|
for (const [key, value] of Object.entries(runtime.env)) {
|
package/src/eval/py/runtime.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* for both the shared gateway and local kernel spawning.
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
8
9
|
import * as path from "node:path";
|
|
9
10
|
import { $env, $which, getPythonEnvDir } from "@oh-my-pi/pi-utils";
|
|
10
11
|
|
|
@@ -182,6 +183,42 @@ function venvBinDir(venvPath: string): string {
|
|
|
182
183
|
return process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin");
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
function detectExplicitVenv(pythonPath: string): { venvPath: string; binDir: string } | undefined {
|
|
187
|
+
const binDir = path.dirname(pythonPath);
|
|
188
|
+
const venvPath = path.dirname(binDir);
|
|
189
|
+
if (fs.existsSync(path.join(venvPath, "pyvenv.cfg"))) {
|
|
190
|
+
return { venvPath, binDir };
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve an explicitly configured interpreter (`python.interpreter`) into a
|
|
197
|
+
* runtime, bypassing discovery. Does not probe or validate the executable —
|
|
198
|
+
* callers must check it actually runs. `~` expands to the home directory and
|
|
199
|
+
* relative paths resolve against `cwd`. When the interpreter sits inside a
|
|
200
|
+
* virtualenv (a `pyvenv.cfg` above its bin dir), the venv activation env is
|
|
201
|
+
* applied so subprocesses and `pip` resolve consistently.
|
|
202
|
+
*/
|
|
203
|
+
export function resolveExplicitPythonRuntime(
|
|
204
|
+
interpreter: string,
|
|
205
|
+
cwd: string,
|
|
206
|
+
baseEnv: Record<string, string | undefined>,
|
|
207
|
+
): PythonRuntime {
|
|
208
|
+
const expanded =
|
|
209
|
+
interpreter === "~"
|
|
210
|
+
? os.homedir()
|
|
211
|
+
: interpreter.startsWith("~/")
|
|
212
|
+
? path.join(os.homedir(), interpreter.slice(2))
|
|
213
|
+
: interpreter;
|
|
214
|
+
const pythonPath = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
|
|
215
|
+
const venv = detectExplicitVenv(pythonPath);
|
|
216
|
+
if (venv) {
|
|
217
|
+
return { pythonPath, env: applyVenvEnv(baseEnv, venv.venvPath, venv.binDir), venvPath: venv.venvPath };
|
|
218
|
+
}
|
|
219
|
+
return { pythonPath, env: { ...baseEnv } };
|
|
220
|
+
}
|
|
221
|
+
|
|
185
222
|
/**
|
|
186
223
|
* Enumerate candidate Python runtimes in priority order: an active/project venv,
|
|
187
224
|
* the managed `~/.omp/python-env`, then the system interpreter on PATH. Every
|