@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.0
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 +146 -198
- package/dist/types/async/job-manager.d.ts +3 -3
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/claude-trace-cli.d.ts +54 -0
- package/dist/types/cli/session-picker.d.ts +10 -3
- package/dist/types/cli/update-cli.d.ts +17 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +2 -2
- package/dist/types/config/settings.d.ts +13 -0
- package/dist/types/eval/concurrency-bridge.d.ts +26 -0
- package/dist/types/eval/js/tool-bridge.d.ts +2 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
- package/dist/types/main.d.ts +5 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/hook-selector.d.ts +3 -0
- package/dist/types/modes/components/session-selector.d.ts +32 -5
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +9 -2
- package/dist/types/modes/types.d.ts +4 -2
- package/dist/types/registry/agent-registry.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +4 -2
- package/dist/types/session/history-storage.d.ts +16 -1
- package/dist/types/session/session-manager.d.ts +4 -0
- package/dist/types/task/output-manager.d.ts +6 -15
- package/dist/types/tools/find.d.ts +0 -9
- package/dist/types/tools/index.d.ts +1 -1
- package/dist/types/tools/path-utils.d.ts +16 -0
- package/dist/types/tools/sqlite-reader.d.ts +25 -8
- package/dist/types/utils/clipboard.d.ts +4 -0
- package/dist/types/web/kagi.d.ts +76 -0
- package/dist/types/web/search/providers/exa.d.ts +7 -1
- package/dist/types/web/search/providers/kagi.d.ts +1 -0
- package/package.json +9 -9
- package/src/async/job-manager.ts +3 -3
- package/src/cli/args.ts +6 -2
- package/src/cli/claude-trace-cli.ts +783 -0
- package/src/cli/session-picker.ts +36 -10
- package/src/cli/update-cli.ts +35 -2
- package/src/commands/launch.ts +3 -0
- package/src/config/keybindings.ts +6 -0
- package/src/config/settings-schema.ts +2 -2
- package/src/config/settings.ts +23 -0
- package/src/discovery/claude-plugins.ts +7 -9
- package/src/eval/__tests__/agent-bridge.test.ts +58 -4
- package/src/eval/concurrency-bridge.ts +34 -0
- package/src/eval/js/shared/prelude.txt +20 -17
- package/src/eval/js/tool-bridge.ts +5 -0
- package/src/eval/py/prelude.py +23 -15
- package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
- package/src/extensibility/skills.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/main.ts +92 -24
- package/src/modes/acp/acp-event-mapper.ts +54 -4
- package/src/modes/components/custom-editor.ts +10 -0
- package/src/modes/components/hook-selector.ts +89 -31
- package/src/modes/components/oauth-selector.ts +12 -6
- package/src/modes/components/session-selector.ts +179 -24
- package/src/modes/components/tool-execution.ts +16 -3
- package/src/modes/controllers/command-controller.ts +2 -11
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +19 -1
- package/src/modes/controllers/selector-controller.ts +61 -21
- package/src/modes/interactive-mode.ts +125 -15
- package/src/modes/types.ts +5 -2
- package/src/prompts/system/orchestrate-notice.md +5 -3
- package/src/prompts/system/workflow-notice.md +2 -2
- package/src/prompts/tools/eval.md +5 -5
- package/src/prompts/tools/find.md +1 -1
- package/src/prompts/tools/irc.md +6 -6
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/task.md +1 -1
- package/src/registry/agent-registry.ts +1 -1
- package/src/sdk.ts +85 -31
- package/src/session/agent-session.ts +62 -46
- package/src/session/history-storage.ts +56 -12
- package/src/session/session-manager.ts +34 -0
- package/src/task/output-manager.ts +40 -48
- package/src/task/render.ts +3 -8
- package/src/tools/browser/tab-worker.ts +8 -5
- package/src/tools/find.ts +5 -29
- package/src/tools/index.ts +1 -1
- package/src/tools/path-utils.ts +144 -1
- package/src/tools/read.ts +47 -0
- package/src/tools/search.ts +2 -27
- package/src/tools/sqlite-reader.ts +92 -9
- package/src/utils/clipboard.ts +38 -1
- package/src/utils/open.ts +37 -2
- package/src/web/kagi.ts +168 -49
- package/src/web/search/providers/anthropic.ts +1 -1
- package/src/web/search/providers/exa.ts +20 -86
- package/src/web/search/providers/kagi.ts +4 -0
|
@@ -1,23 +1,43 @@
|
|
|
1
1
|
import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import { SessionSelectorComponent } from "../modes/components/session-selector";
|
|
3
|
-
import
|
|
4
|
+
import { HistoryStorage } from "../session/history-storage";
|
|
5
|
+
import { type SessionInfo, SessionManager } from "../session/session-manager";
|
|
4
6
|
import { FileSessionStorage } from "../session/session-storage";
|
|
5
7
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Show the TUI session selector and return the selected session, or null if
|
|
10
|
+
* cancelled. Tab toggles between current-folder and all-projects scope; the
|
|
11
|
+
* all-projects list is loaded lazily via `SessionManager.listAll`.
|
|
12
|
+
*/
|
|
13
|
+
export async function selectSession(
|
|
14
|
+
sessions: SessionInfo[],
|
|
15
|
+
options?: { allSessions?: SessionInfo[]; startInAllScope?: boolean },
|
|
16
|
+
): Promise<SessionInfo | null> {
|
|
17
|
+
const { promise, resolve } = Promise.withResolvers<SessionInfo | null>();
|
|
9
18
|
const ui = new TUI(new ProcessTerminal());
|
|
10
19
|
let resolved = false;
|
|
11
20
|
const storage = new FileSessionStorage();
|
|
12
21
|
|
|
22
|
+
// Rank sessions with prompt-history matches too, recovering prompts the 4KB
|
|
23
|
+
// session-list prefix never sees. Best-effort: a missing/locked history.db
|
|
24
|
+
// must not break the picker.
|
|
25
|
+
let historyMatcher: ((query: string) => string[]) | undefined;
|
|
26
|
+
try {
|
|
27
|
+
const history = HistoryStorage.open();
|
|
28
|
+
historyMatcher = (query: string) => history.matchingSessionIds(query);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
logger.warn("History storage unavailable for session ranking", { error: String(error) });
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
const showSelector = () => {
|
|
14
34
|
const selector = new SessionSelectorComponent(
|
|
15
35
|
sessions,
|
|
16
|
-
(
|
|
36
|
+
(session: SessionInfo) => {
|
|
17
37
|
if (!resolved) {
|
|
18
38
|
resolved = true;
|
|
19
39
|
ui.stop();
|
|
20
|
-
resolve(
|
|
40
|
+
resolve(session);
|
|
21
41
|
}
|
|
22
42
|
},
|
|
23
43
|
() => {
|
|
@@ -34,10 +54,16 @@ export async function selectSession(sessions: SessionInfo[]): Promise<string | n
|
|
|
34
54
|
process.exit(0);
|
|
35
55
|
}
|
|
36
56
|
},
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
{
|
|
58
|
+
onDelete: async (session: SessionInfo) => {
|
|
59
|
+
// Delete handler - SessionList will show confirmation internally
|
|
60
|
+
await storage.deleteSessionWithArtifacts(session.path);
|
|
61
|
+
return true;
|
|
62
|
+
},
|
|
63
|
+
historyMatcher,
|
|
64
|
+
loadAllSessions: () => SessionManager.listAll(storage),
|
|
65
|
+
allSessions: options?.allSessions,
|
|
66
|
+
startInAllScope: options?.startInAllScope,
|
|
41
67
|
},
|
|
42
68
|
);
|
|
43
69
|
return selector;
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -14,6 +14,18 @@ import { theme } from "../modes/theme/theme";
|
|
|
14
14
|
|
|
15
15
|
const REPO = "can1357/oh-my-pi";
|
|
16
16
|
const PACKAGE = "@oh-my-pi/pi-coding-agent";
|
|
17
|
+
/**
|
|
18
|
+
* Official npm registry origin.
|
|
19
|
+
*
|
|
20
|
+
* Pinned across both the version check and the bun install step so the two
|
|
21
|
+
* agree on which catalog they are talking to. A user's bun may be pointed at
|
|
22
|
+
* an unofficial mirror (corporate proxy, Taobao, etc.) that lags the upstream
|
|
23
|
+
* registry by minutes-to-hours, in which case `getLatestRelease` would resolve
|
|
24
|
+
* a version the mirror has not yet replicated and the install would fail with
|
|
25
|
+
* `No version matching "X" found for specifier "<pkg>" (but package exists)`.
|
|
26
|
+
* See #1686.
|
|
27
|
+
*/
|
|
28
|
+
const NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
17
29
|
|
|
18
30
|
interface ReleaseInfo {
|
|
19
31
|
tag: string;
|
|
@@ -130,7 +142,7 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
|
|
|
130
142
|
* Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
|
|
131
143
|
*/
|
|
132
144
|
async function getLatestRelease(): Promise<ReleaseInfo> {
|
|
133
|
-
const response = await fetch(
|
|
145
|
+
const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`);
|
|
134
146
|
if (!response.ok) {
|
|
135
147
|
throw new Error(`Failed to fetch release info: ${response.statusText}`);
|
|
136
148
|
}
|
|
@@ -292,12 +304,33 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
|
|
|
292
304
|
}
|
|
293
305
|
}
|
|
294
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Build the bun argv used to globally install a specific omp version.
|
|
309
|
+
*
|
|
310
|
+
* The version is selected by hitting {@link NPM_REGISTRY} directly in
|
|
311
|
+
* {@link getLatestRelease}, so the install MUST observe the same catalog:
|
|
312
|
+
*
|
|
313
|
+
* - `--registry=${NPM_REGISTRY}` pins the install to the official registry
|
|
314
|
+
* regardless of the user's bunfig/`.npmrc`. A mirror (corporate proxy,
|
|
315
|
+
* Taobao, …) that hasn't yet replicated the release would otherwise reject
|
|
316
|
+
* a version the upstream registry already advertises.
|
|
317
|
+
* - `--no-cache` tells bun to ignore its on-disk manifest snapshot so it
|
|
318
|
+
* re-fetches metadata from that registry on every invocation.
|
|
319
|
+
*
|
|
320
|
+
* Together these two flags make `omp update` produce exactly the registry
|
|
321
|
+
* lookup the version check just performed. See #1686.
|
|
322
|
+
*/
|
|
323
|
+
export function buildBunInstallArgs(expectedVersion: string): string[] {
|
|
324
|
+
return ["install", "-g", "--no-cache", `--registry=${NPM_REGISTRY}`, `${PACKAGE}@${expectedVersion}`];
|
|
325
|
+
}
|
|
326
|
+
|
|
295
327
|
/**
|
|
296
328
|
* Update via bun package manager.
|
|
297
329
|
*/
|
|
298
330
|
async function updateViaBun(expectedVersion: string): Promise<void> {
|
|
299
331
|
console.log(chalk.dim("Updating via bun..."));
|
|
300
|
-
const
|
|
332
|
+
const args = buildBunInstallArgs(expectedVersion);
|
|
333
|
+
const result = await $`bun ${args}`.nothrow();
|
|
301
334
|
if (result.exitCode !== 0) {
|
|
302
335
|
throw new Error(`bun install failed with exit code ${result.exitCode}`);
|
|
303
336
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -90,6 +90,9 @@ export default class Index extends Command {
|
|
|
90
90
|
description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
|
|
91
91
|
options: [...THINKING_EFFORTS],
|
|
92
92
|
}),
|
|
93
|
+
"hide-thinking": Flags.boolean({
|
|
94
|
+
description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
|
|
95
|
+
}),
|
|
93
96
|
hook: Flags.string({
|
|
94
97
|
description: "Load a hook/extension file (can be used multiple times)",
|
|
95
98
|
multiple: true,
|
|
@@ -32,6 +32,7 @@ interface AppKeybindings {
|
|
|
32
32
|
"app.message.followUp": true;
|
|
33
33
|
"app.message.dequeue": true;
|
|
34
34
|
"app.clipboard.pasteImage": true;
|
|
35
|
+
"app.clipboard.pasteTextRaw": true;
|
|
35
36
|
"app.clipboard.copyLine": true;
|
|
36
37
|
"app.clipboard.copyPrompt": true;
|
|
37
38
|
"app.session.new": true;
|
|
@@ -122,6 +123,10 @@ export const KEYBINDINGS = {
|
|
|
122
123
|
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
|
|
123
124
|
description: "Paste image from clipboard",
|
|
124
125
|
},
|
|
126
|
+
"app.clipboard.pasteTextRaw": {
|
|
127
|
+
defaultKeys: ["ctrl+shift+v", "alt+shift+v"],
|
|
128
|
+
description: "Paste text from clipboard as raw text (no collapse)",
|
|
129
|
+
},
|
|
125
130
|
"app.clipboard.copyLine": {
|
|
126
131
|
defaultKeys: "alt+shift+l",
|
|
127
132
|
description: "Copy current line",
|
|
@@ -214,6 +219,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
|
|
|
214
219
|
followUp: "app.message.followUp",
|
|
215
220
|
dequeue: "app.message.dequeue",
|
|
216
221
|
pasteImage: "app.clipboard.pasteImage",
|
|
222
|
+
pasteTextRaw: "app.clipboard.pasteTextRaw",
|
|
217
223
|
copyLine: "app.clipboard.copyLine",
|
|
218
224
|
copyPrompt: "app.clipboard.copyPrompt",
|
|
219
225
|
newSession: "app.session.new",
|
|
@@ -2894,7 +2894,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
2894
2894
|
label: "Auto",
|
|
2895
2895
|
description: "Preferred web-search provider",
|
|
2896
2896
|
},
|
|
2897
|
-
{ value: "exa", label: "Exa", description: "
|
|
2897
|
+
{ value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
|
|
2898
2898
|
{ value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
|
|
2899
2899
|
{ value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
|
|
2900
2900
|
{ value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
|
|
@@ -2920,7 +2920,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
2920
2920
|
},
|
|
2921
2921
|
{ value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
|
|
2922
2922
|
{ value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" },
|
|
2923
|
-
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY
|
|
2923
|
+
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY (Kagi V1 Search API)" },
|
|
2924
2924
|
{ value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
|
|
2925
2925
|
{ value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
|
|
2926
2926
|
{ value: "searxng", label: "SearXNG", description: "Requires SEARXNG_ENDPOINT or searxng.endpoint" },
|
package/src/config/settings.ts
CHANGED
|
@@ -365,6 +365,29 @@ export class Settings {
|
|
|
365
365
|
return cloned;
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Re-scope this instance to a new working directory *in place*: reload the
|
|
370
|
+
* project layer (`.claude/settings.yml` etc.) from `cwd`, re-resolve
|
|
371
|
+
* path-scoped settings against it, and re-fire side-effect hooks (theme,
|
|
372
|
+
* symbols, tab width, …). Global settings and runtime overrides are preserved.
|
|
373
|
+
*
|
|
374
|
+
* Unlike {@link cloneForCwd}, this mutates the live instance, so every holder
|
|
375
|
+
* (the `settings` proxy, the active session, controllers) observes the new
|
|
376
|
+
* project scope without swapping references — used when the process changes
|
|
377
|
+
* directory mid-run (`/move`, cross-project resume). No-op when `cwd` is
|
|
378
|
+
* already the current scope.
|
|
379
|
+
*/
|
|
380
|
+
async reloadForCwd(cwd: string): Promise<void> {
|
|
381
|
+
const normalized = path.normalize(cwd);
|
|
382
|
+
if (normalized === this.#cwd) return;
|
|
383
|
+
this.#cwd = normalized;
|
|
384
|
+
if (this.#persist) {
|
|
385
|
+
this.#project = await this.#loadProjectSettings();
|
|
386
|
+
}
|
|
387
|
+
this.#rebuildMerged();
|
|
388
|
+
this.#fireAllHooks();
|
|
389
|
+
}
|
|
390
|
+
|
|
368
391
|
// ─────────────────────────────────────────────────────────────────────────
|
|
369
392
|
// Accessors
|
|
370
393
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -99,10 +99,8 @@ async function resolvePluginDir(
|
|
|
99
99
|
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
100
100
|
const items: Skill[] = [];
|
|
101
101
|
const warnings: string[] = [];
|
|
102
|
-
|
|
103
102
|
const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
|
|
104
103
|
warnings.push(...rootWarnings);
|
|
105
|
-
|
|
106
104
|
const results = await Promise.all(
|
|
107
105
|
roots.map(async root => {
|
|
108
106
|
const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
|
|
@@ -114,16 +112,16 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
114
112
|
return { root, result, warning };
|
|
115
113
|
}),
|
|
116
114
|
);
|
|
117
|
-
|
|
118
|
-
for (const { root, result, warning } of results) {
|
|
115
|
+
for (const { result, warning } of results) {
|
|
119
116
|
if (warning) warnings.push(warning);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
// Intentionally do NOT prefix skill names with `root.plugin`.
|
|
118
|
+
// The `plugin:name` format breaks skill:// URL parsing (colons are
|
|
119
|
+
// ambiguous with port separators) and is unintuitive for callers.
|
|
120
|
+
// Dedup-by-key in the capability layer already handles name collisions
|
|
121
|
+
// across providers using priority ordering.
|
|
122
|
+
items.push(...result.items);
|
|
124
123
|
if (result.warnings) warnings.push(...result.warnings);
|
|
125
124
|
}
|
|
126
|
-
|
|
127
125
|
return { items, warnings };
|
|
128
126
|
}
|
|
129
127
|
|
|
@@ -106,6 +106,7 @@ function singleResult(options: ExecutorOptions, overrides: Partial<SingleResult>
|
|
|
106
106
|
function makeEvalSession(
|
|
107
107
|
tempDir: TempDir,
|
|
108
108
|
prefix: string,
|
|
109
|
+
settings?: Settings,
|
|
109
110
|
): { session: ToolSession; sessionFile: string; sessionId: string } {
|
|
110
111
|
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
|
111
112
|
const artifactsDir = sessionFile.slice(0, -6);
|
|
@@ -113,6 +114,7 @@ function makeEvalSession(
|
|
|
113
114
|
cwd: tempDir.path(),
|
|
114
115
|
sessionFile,
|
|
115
116
|
artifactsDir,
|
|
117
|
+
settings,
|
|
116
118
|
outputManager: new AgentOutputManager(() => artifactsDir),
|
|
117
119
|
});
|
|
118
120
|
return { session, sessionFile, sessionId: `${prefix}:${crypto.randomUUID()}` };
|
|
@@ -261,9 +263,15 @@ describe("agent() through eval runtimes", () => {
|
|
|
261
263
|
expect(JSON.parse(result.output.trim())).toEqual(["hello from agent", { ok: true, n: 3 }]);
|
|
262
264
|
});
|
|
263
265
|
|
|
264
|
-
it("
|
|
266
|
+
it("bounds JavaScript parallel() by the task.maxConcurrency setting while preserving order", async () => {
|
|
265
267
|
using tempDir = TempDir.createSync("@omp-eval-agent-js-parallel-");
|
|
266
|
-
const
|
|
268
|
+
const settings = Settings.isolated({
|
|
269
|
+
"async.enabled": false,
|
|
270
|
+
"task.isolation.mode": "none",
|
|
271
|
+
"task.enableLsp": true,
|
|
272
|
+
"task.maxConcurrency": 2,
|
|
273
|
+
});
|
|
274
|
+
const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel", settings);
|
|
267
275
|
mockAgents();
|
|
268
276
|
let inFlight = 0;
|
|
269
277
|
let maxInFlight = 0;
|
|
@@ -279,7 +287,7 @@ describe("agent() through eval runtimes", () => {
|
|
|
279
287
|
});
|
|
280
288
|
|
|
281
289
|
const result = await executeJs(
|
|
282
|
-
'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name))
|
|
290
|
+
'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name))); return JSON.stringify(values);',
|
|
283
291
|
{ cwd: tempDir.path(), sessionId, session, sessionFile },
|
|
284
292
|
);
|
|
285
293
|
|
|
@@ -300,7 +308,7 @@ describe("agent() through eval runtimes", () => {
|
|
|
300
308
|
return singleResult(options, { output: options.assignment ?? "" });
|
|
301
309
|
});
|
|
302
310
|
|
|
303
|
-
const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")]
|
|
311
|
+
const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")]);', {
|
|
304
312
|
cwd: tempDir.path(),
|
|
305
313
|
sessionId,
|
|
306
314
|
session,
|
|
@@ -343,6 +351,52 @@ describe("agent() through eval runtimes", () => {
|
|
|
343
351
|
expect(result.output.trim()).toBe("hello from python");
|
|
344
352
|
});
|
|
345
353
|
|
|
354
|
+
it("bounds Python parallel() by the task.maxConcurrency setting while preserving order", async () => {
|
|
355
|
+
using tempDir = TempDir.createSync("@omp-eval-agent-py-parallel-");
|
|
356
|
+
const settings = Settings.isolated({
|
|
357
|
+
"async.enabled": false,
|
|
358
|
+
"task.isolation.mode": "none",
|
|
359
|
+
"task.enableLsp": true,
|
|
360
|
+
"task.maxConcurrency": 2,
|
|
361
|
+
});
|
|
362
|
+
const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-parallel", settings);
|
|
363
|
+
mockAgents();
|
|
364
|
+
let inFlight = 0;
|
|
365
|
+
let maxInFlight = 0;
|
|
366
|
+
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
|
367
|
+
inFlight++;
|
|
368
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
369
|
+
try {
|
|
370
|
+
await Bun.sleep(options.assignment === "a" ? 30 : 10);
|
|
371
|
+
return singleResult(options, { output: options.assignment ?? "" });
|
|
372
|
+
} finally {
|
|
373
|
+
inFlight--;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const probe = await executePython('print("probe")', {
|
|
378
|
+
cwd: tempDir.path(),
|
|
379
|
+
sessionId: `${sessionId}:probe`,
|
|
380
|
+
sessionFile,
|
|
381
|
+
kernelMode: "per-call",
|
|
382
|
+
});
|
|
383
|
+
if (probe.exitCode === undefined && probe.cancelled) {
|
|
384
|
+
expect(probe.output).toBe("");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
expect(probe.exitCode).toBe(0);
|
|
388
|
+
|
|
389
|
+
const result = await executePython(
|
|
390
|
+
'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))',
|
|
391
|
+
{ cwd: tempDir.path(), sessionId, sessionFile, kernelMode: "per-call", toolSession: session },
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
expect(result.exitCode).toBe(0);
|
|
395
|
+
expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
|
|
396
|
+
expect(maxInFlight).toBeGreaterThan(1);
|
|
397
|
+
expect(maxInFlight).toBeLessThanOrEqual(2);
|
|
398
|
+
});
|
|
399
|
+
|
|
346
400
|
it("streams enriched agent progress through onStatus before the cell finishes", async () => {
|
|
347
401
|
using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
|
|
348
402
|
const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side handler for the eval `parallel()` / `pipeline()` worker pool.
|
|
3
|
+
*
|
|
4
|
+
* The pool ceiling is not a kernel-side knob: it tracks the `task.maxConcurrency`
|
|
5
|
+
* setting so an eval fan-out runs as wide as a `task` tool batch would. `0` means
|
|
6
|
+
* unbounded — run every item at once, exactly like `task.maxConcurrency = 0`.
|
|
7
|
+
*/
|
|
8
|
+
import type { ToolSession } from "../tools";
|
|
9
|
+
import type { JsStatusEvent } from "./js/shared/types";
|
|
10
|
+
|
|
11
|
+
/** Synthetic bridge name reserved for the parallel-pool ceiling across both runtimes. */
|
|
12
|
+
export const EVAL_CONCURRENCY_BRIDGE_NAME = "__concurrency__";
|
|
13
|
+
|
|
14
|
+
export interface EvalConcurrencyBridgeOptions {
|
|
15
|
+
session: ToolSession;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
emitStatus?: (event: JsStatusEvent) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EvalConcurrencyResult {
|
|
21
|
+
/** Worker-pool ceiling; `0` means unbounded (run every item at once). */
|
|
22
|
+
limit: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the worker-pool ceiling for an eval cell's `parallel()`/`pipeline()`
|
|
27
|
+
* helpers from the live `task.maxConcurrency` setting. Negative/non-finite
|
|
28
|
+
* values collapse to `0` (unbounded), matching the `task` tool's own handling.
|
|
29
|
+
*/
|
|
30
|
+
export function runEvalConcurrency(_args: unknown, options: EvalConcurrencyBridgeOptions): EvalConcurrencyResult {
|
|
31
|
+
const raw = options.session.settings.get("task.maxConcurrency");
|
|
32
|
+
const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
33
|
+
return { limit: limit > 0 ? limit : 0 };
|
|
34
|
+
}
|
|
@@ -55,15 +55,24 @@ if (!globalThis.__omp_js_prelude_loaded__) {
|
|
|
55
55
|
return hasOwn(o, "schema") ? JSON.parse(text) : text;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// Pool ceiling mirrors the task tool's `task.maxConcurrency` setting so an
|
|
59
|
+
// eval fan-out runs as wide as a `task` batch would. 0 (and any non-finite
|
|
60
|
+
// reply) means unbounded — run every item at once.
|
|
61
|
+
const __concurrencyLimit = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const r = await globalThis.__omp_call_tool__("__concurrency__", {});
|
|
64
|
+
const n = Math.trunc(Number(r && typeof r === "object" ? r.limit : r));
|
|
65
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
66
|
+
} catch {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
62
69
|
};
|
|
63
70
|
|
|
64
|
-
const __pool = async (items,
|
|
71
|
+
const __pool = async (items, fn) => {
|
|
65
72
|
const list = Array.from(items ?? []);
|
|
66
|
-
|
|
73
|
+
if (list.length === 0) return [];
|
|
74
|
+
const limit = await __concurrencyLimit();
|
|
75
|
+
const concurrency = limit > 0 ? Math.min(limit, list.length) : list.length;
|
|
67
76
|
const results = new Array(list.length);
|
|
68
77
|
let next = 0;
|
|
69
78
|
const worker = async () => {
|
|
@@ -77,23 +86,17 @@ if (!globalThis.__omp_js_prelude_loaded__) {
|
|
|
77
86
|
return results;
|
|
78
87
|
};
|
|
79
88
|
|
|
80
|
-
const parallel = async
|
|
81
|
-
__pool(thunks,
|
|
89
|
+
const parallel = async thunks =>
|
|
90
|
+
__pool(thunks, (thunk, index) => {
|
|
82
91
|
if (typeof thunk !== "function") throw new TypeError("parallel() expects an iterable of functions");
|
|
83
92
|
return thunk(index);
|
|
84
93
|
});
|
|
85
94
|
|
|
86
|
-
const pipeline = async (items, ...
|
|
87
|
-
let opts = {};
|
|
88
|
-
const last = stagesAndOptions.at(-1);
|
|
89
|
-
if (last && typeof last === "object" && !Array.isArray(last)) {
|
|
90
|
-
opts = last;
|
|
91
|
-
stagesAndOptions = stagesAndOptions.slice(0, -1);
|
|
92
|
-
}
|
|
95
|
+
const pipeline = async (items, ...stages) => {
|
|
93
96
|
let current = Array.from(items ?? []);
|
|
94
|
-
for (const stage of
|
|
97
|
+
for (const stage of stages) {
|
|
95
98
|
if (typeof stage !== "function") throw new TypeError("pipeline() stages must be functions");
|
|
96
|
-
current = await __pool(current,
|
|
99
|
+
current = await __pool(current, stage);
|
|
97
100
|
}
|
|
98
101
|
return current;
|
|
99
102
|
};
|
|
@@ -3,6 +3,7 @@ import type { ToolSession } from "../../tools";
|
|
|
3
3
|
import { ToolError } from "../../tools/tool-errors";
|
|
4
4
|
import { EVAL_AGENT_BRIDGE_NAME, runEvalAgent } from "../agent-bridge";
|
|
5
5
|
import { EVAL_BUDGET_BRIDGE_NAME, type EvalBudgetResult, runEvalBudget } from "../budget-bridge";
|
|
6
|
+
import { EVAL_CONCURRENCY_BRIDGE_NAME, type EvalConcurrencyResult, runEvalConcurrency } from "../concurrency-bridge";
|
|
6
7
|
import { EVAL_LLM_BRIDGE_NAME, runEvalLlm } from "../llm-bridge";
|
|
7
8
|
import type { JsStatusEvent } from "./shared/types";
|
|
8
9
|
|
|
@@ -17,6 +18,7 @@ interface ToolBridgeOptions {
|
|
|
17
18
|
type ToolValue =
|
|
18
19
|
| string
|
|
19
20
|
| EvalBudgetResult
|
|
21
|
+
| EvalConcurrencyResult
|
|
20
22
|
| {
|
|
21
23
|
text: string;
|
|
22
24
|
details?: unknown;
|
|
@@ -114,6 +116,9 @@ export async function callSessionTool(name: string, args: unknown, options: Tool
|
|
|
114
116
|
if (name === EVAL_BUDGET_BRIDGE_NAME) {
|
|
115
117
|
return await runEvalBudget(args, options);
|
|
116
118
|
}
|
|
119
|
+
if (name === EVAL_CONCURRENCY_BRIDGE_NAME) {
|
|
120
|
+
return runEvalConcurrency(args, options);
|
|
121
|
+
}
|
|
117
122
|
const tool = getTool(options.session, name);
|
|
118
123
|
const normalizedArgs = normalizeArgs(args);
|
|
119
124
|
const toolCallId = `js-${name}-${crypto.randomUUID()}`;
|
package/src/eval/py/prelude.py
CHANGED
|
@@ -503,27 +503,35 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
503
503
|
text = res.get("text") if isinstance(res, dict) else res
|
|
504
504
|
return json.loads(text) if schema is not None else text
|
|
505
505
|
|
|
506
|
-
def
|
|
507
|
-
"""
|
|
506
|
+
def _concurrency_limit():
|
|
507
|
+
"""Worker-pool ceiling from the host ``task.maxConcurrency`` setting.
|
|
508
|
+
|
|
509
|
+
An eval fan-out runs as wide as a ``task`` batch would. Returns ``0`` for
|
|
510
|
+
unbounded (run every item at once); falls back to ``0`` if the host
|
|
511
|
+
bridge is unreachable.
|
|
512
|
+
"""
|
|
508
513
|
try:
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
514
|
+
snap = _bridge_call("__concurrency__", {}) or {}
|
|
515
|
+
n = int(snap.get("limit") or 0)
|
|
516
|
+
except Exception:
|
|
517
|
+
return 0
|
|
518
|
+
return n if n > 0 else 0
|
|
513
519
|
|
|
514
|
-
def _pool_map(items, fn
|
|
520
|
+
def _pool_map(items, fn):
|
|
515
521
|
"""Run ``fn`` over ``items`` through a bounded thread pool.
|
|
516
522
|
|
|
517
523
|
Preserves input order, barriers until every task settles, and raises the
|
|
518
524
|
lowest-index exception if any task failed. Each task runs inside a copy
|
|
519
525
|
of the submitting thread's context so the ``_CURRENT_RID`` ContextVar
|
|
520
|
-
propagates and bridge calls (agent(), tool.*, etc.) keep working.
|
|
526
|
+
propagates and bridge calls (agent(), tool.*, etc.) keep working. The
|
|
527
|
+
pool width tracks ``task.maxConcurrency`` (0 = run every item at once).
|
|
521
528
|
"""
|
|
522
529
|
import concurrent.futures, contextvars
|
|
523
530
|
items = list(items)
|
|
524
531
|
if not items:
|
|
525
532
|
return []
|
|
526
|
-
|
|
533
|
+
limit = _concurrency_limit()
|
|
534
|
+
workers = min(limit, len(items)) if limit > 0 else len(items)
|
|
527
535
|
results = [None] * len(items)
|
|
528
536
|
errors = {}
|
|
529
537
|
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
|
|
@@ -541,30 +549,30 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
541
549
|
raise errors[min(errors)]
|
|
542
550
|
return results
|
|
543
551
|
|
|
544
|
-
def parallel(thunks
|
|
552
|
+
def parallel(thunks):
|
|
545
553
|
"""Run zero-arg callables through a bounded pool, preserving input order.
|
|
546
554
|
|
|
547
555
|
Barriers until all finish; re-raises the lowest-index exception if any
|
|
548
|
-
thunk raised.
|
|
556
|
+
thunk raised. Pool width tracks the task tool's ``task.maxConcurrency``.
|
|
549
557
|
"""
|
|
550
558
|
thunks = list(thunks)
|
|
551
559
|
for t in thunks:
|
|
552
560
|
if not callable(t):
|
|
553
561
|
raise TypeError("parallel() expects an iterable of zero-arg callables")
|
|
554
|
-
return _pool_map(thunks, lambda t: t()
|
|
562
|
+
return _pool_map(thunks, lambda t: t())
|
|
555
563
|
|
|
556
|
-
def pipeline(items, *stages
|
|
564
|
+
def pipeline(items, *stages):
|
|
557
565
|
"""Map items left-to-right through one-arg stage callables.
|
|
558
566
|
|
|
559
567
|
Every item clears stage N before any item enters stage N+1 (barrier per
|
|
560
568
|
stage). Stage 1 receives the original item; later stages receive the
|
|
561
|
-
previous stage's result.
|
|
569
|
+
previous stage's result. Pool width tracks ``task.maxConcurrency``.
|
|
562
570
|
"""
|
|
563
571
|
current = list(items)
|
|
564
572
|
for stage in stages:
|
|
565
573
|
if not callable(stage):
|
|
566
574
|
raise TypeError("pipeline() stages must be callables")
|
|
567
|
-
current = _pool_map(current, stage
|
|
575
|
+
current = _pool_map(current, stage)
|
|
568
576
|
return current
|
|
569
577
|
|
|
570
578
|
def log(message):
|