@bermudi/pi-delegate 0.1.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/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # pi-delegate
2
+
3
+ Repository: https://github.com/bermudi/pi-delegate
4
+
5
+ Delegate tool for the [Pi coding agent](https://github.com/earendil-works/pi) — spawn
6
+ subagents to run tasks in parallel, with async ticketing, session pooling, retries,
7
+ and per-model concurrency limits.
8
+
9
+ Extracted from [`bermudi/agent-extensions`](https://github.com/bermudi/agent-extensions)
10
+ as a standalone repo (full history preserved).
11
+
12
+ ## Install
13
+
14
+ Symlink the **bundled** output into Pi's global extensions dir:
15
+
16
+ ```bash
17
+ ln -s "$PWD/delegate.bundle.ts" ~/.pi/agent/extensions/delegate.ts
18
+ ```
19
+
20
+ Then `/reload` in Pi.
21
+
22
+ ### Token accounting
23
+
24
+ Sync delegate calls report aggregate subagent `Usage` on the tool result, so Pi
25
+ (0.81+) folds those tokens **and cost** into the parent footer and session
26
+ total automatically — no manual addition needed.
27
+
28
+ Async tickets can't be auto-counted: their results arrive as a follow-up message,
29
+ which has no usage slot. The per-call aggregate (`Nk tokens`) is still shown in
30
+ the delegate header for both modes. Use sync delegation when totals must roll
31
+ into the session.
32
+
33
+ ### Stall detection and cancellation
34
+
35
+ `stallTimeoutMs` is an inactivity watchdog, not a hard execution deadline. When
36
+ an active subagent emits no model or tool activity for the configured interval,
37
+ delegate reports that a stall was detected and asks Pi's `AgentSession.abort()`
38
+ to cancel it, along with any active compaction or branch summary. Cancellation
39
+ is cooperative: delegate waits for the session to become idle and non-compacting
40
+ before returning a failed task, because returning while a provider, tool, or
41
+ extension can still run would let a supposedly finished agent keep mutating
42
+ state. An operation that ignores cancellation can therefore delay the final
43
+ task result. Set `stallTimeoutMs` to `0` to disable the watchdog.
44
+
45
+ ## Develop
46
+
47
+ ```bash
48
+ bun install
49
+ bun run build # regenerate delegate.bundle.ts
50
+ bun run typecheck
51
+ bun test
52
+ ```
53
+
54
+ The unbundled entry is `delegate.ts`; `extension.ts` holds the tool implementation.
55
+ `delegate.bundle.ts` is generated by `bun run build` — do not edit by hand.
56
+
57
+ ## Glossary
58
+
59
+ - **Delegate task** — One item in `delegate({ tasks: [...] })`. This is the
60
+ core unit of work: a prompt plus optional overrides such as `agent`, `tools`,
61
+ `systemPrompt`, `thinking`, `cwd`, `context`, `sessionId`, or `resumeFrom`.
62
+ `model` is also accepted but should be rare — subagents inherit the parent
63
+ model by default.
64
+ - **Custom agent** — A subagent profile defined by the parent, either inline in
65
+ a delegate task (`systemPrompt`, `tools`, and `thinking`) or persisted as a
66
+ Markdown file. The subagent inherits the parent model by default; `model` is a
67
+ rare override. Markdown agents are examples of custom agents.
68
+ - **Named agent** / **Markdown agent** — A reusable custom agent persisted as a
69
+ Markdown file in `.pi/agents/*.md` or `~/.pi/agent/agents/*.md`. The frontmatter
70
+ defines its name, description, model, tools, thinking level, and skills; the
71
+ Markdown body is its system prompt.
72
+ - **Ad-hoc subagent** — A subagent created from inline task fields instead of a
73
+ named Markdown agent profile. In current output this is labeled `ad-hoc`.
74
+ - **Inline task** — The task object itself when its configuration is supplied
75
+ directly in the delegate call. Prefer this term over “inline agent” when
76
+ talking about the API shape.
77
+ - **Subagent run** — The actual execution of a resolved delegate task. Multiple
78
+ runs using the same named agent are independent unless they share a
79
+ `sessionId` or resume from the same session file.
80
+ - **Pooled subagent** / **persistent session** — A live subagent kept in memory
81
+ under a `sessionId`. The first call creates it; later calls with the same
82
+ `sessionId` continue the same conversation until explicitly closed or the parent Pi session ends.
83
+ - **Resumed subagent** — A subagent rehydrated from a previous session `.jsonl`
84
+ via `resumeFrom`. It can also be pooled by providing a `sessionId`.
85
+ - **Async ticket** — A background execution handle returned when top-level
86
+ `async: true` is used. Poll or cancel tickets with top-level `action: "poll"`
87
+ or `action: "cancel"`.
88
+ - **Skill** — A `SKILL.md` instruction bundle injected into the subagent system
89
+ prompt. Skills are text instructions only; they do not unlock additional
90
+ tools.
91
+ - **AGENTS.md context** — Project and global guidance files automatically
92
+ appended to subagent system prompts, separate from named agent definitions.
package/agents.ts ADDED
@@ -0,0 +1,347 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type {
5
+ AgentMessage,
6
+ ThinkingLevel,
7
+ } from "@earendil-works/pi-agent-core";
8
+ import { parseFrontmatter as parsePiFrontmatter } from "@earendil-works/pi-coding-agent";
9
+ import { DEFAULT_TOOLS, VALID_THINKING } from "./constants.ts";
10
+ import { resolveToolGroups } from "./tools.ts";
11
+ import type { AgentConfig } from "./types.ts";
12
+
13
+ // Frontmatter fence: `---\n … \n---\n body`. CRLF-tolerant. Captures the
14
+ // YAML block (group 1) and the body (group 2). The YAML itself is parsed by
15
+ // pi-coding-agent's `parseFrontmatter` (built on the `yaml` package, which is
16
+ // a guaranteed dependency of the host pi) — we only use this regex to split
17
+ // the fence from the body and to detect the no-frontmatter case.
18
+ const FRONTMATTER_FENCE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
19
+
20
+ /** Coerce a parsed YAML frontmatter object into the flat
21
+ * `Record<string, string>` shape the rest of the loader expects. Arrays
22
+ * (e.g. `tools: [read, write]`) are joined with ", " so the downstream
23
+ * comma-split in `resolveFrontmatterTools` still works. Nested maps are
24
+ * JSON-stringified rather than `String()`-ified (which would emit the
25
+ * useless "[object Object]"). `null`/`undefined` are dropped. The agent
26
+ * frontmatter schema is flat by convention, so the object branch only fires
27
+ * on malformed input and keeps it debuggable instead of silently garbage. */
28
+ function frontmatterToData(
29
+ fm: Record<string, unknown>,
30
+ ): Record<string, string> {
31
+ const data: Record<string, string> = {};
32
+ for (const [k, v] of Object.entries(fm)) {
33
+ if (v === null || v === undefined) continue;
34
+ if (Array.isArray(v)) data[k] = v.map(String).join(", ");
35
+ else if (typeof v === "object") data[k] = JSON.stringify(v);
36
+ else data[k] = String(v);
37
+ }
38
+ return data;
39
+ }
40
+
41
+ /** Quote ambiguous YAML scalar values before handing them to Pi's parser. */
42
+ function sanitizeYamlScalars(yaml: string): string {
43
+ return yaml
44
+ .split("\n")
45
+ .map((line) => {
46
+ const m = line.match(/^(\s*)([\w-]+)(\s*:\s*)(.*)$/);
47
+ if (!m) return line;
48
+ const [, leading, key, sep, rawValue] = m;
49
+ const value = rawValue.trim();
50
+ if (!value) return line;
51
+ if (/^["'|>\[{]/.test(value)) return line;
52
+ if (!/:\s/.test(value)) return line;
53
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
54
+ return `${leading}${key}${sep}"${escaped}"`;
55
+ })
56
+ .join("\n");
57
+ }
58
+
59
+ /** Parse an agent Markdown frontmatter fence and return its body. */
60
+ export function parseFrontmatter(
61
+ content: string,
62
+ filePath?: string,
63
+ ): {
64
+ data: Record<string, string>;
65
+ body: string;
66
+ } {
67
+ const m = content.match(FRONTMATTER_FENCE);
68
+ if (!m) return { data: {}, body: content.trim() };
69
+ const yamlString = m[1]!;
70
+ const body = m[2]!.trim();
71
+
72
+ // A bare `*` is a YAML alias indicator and is invalid as a scalar, so
73
+ // `tools: *` (the full-agent shorthand) would throw. Quote any value that is
74
+ // exactly `*` so it parses as the string "*", which resolveFrontmatterTools
75
+ // then expands via TOOL_GROUPS. (A `*` mid-scalar, e.g. `use * here`, is a
76
+ // legal plain scalar and needs no quoting.)
77
+ const sanitized = sanitizeYamlScalars(
78
+ yamlString.replace(/^(\s*[\w-]+):\s*\*(?=\s*$)/gm, '$1: "*"'),
79
+ );
80
+
81
+ try {
82
+ // Re-wrap and let pi's parser (yaml under the hood) do the real work.
83
+ const { frontmatter, body: parsedBody } = parsePiFrontmatter(
84
+ `---\n${sanitized}\n---\n${body}`,
85
+ );
86
+ return {
87
+ data: frontmatterToData((frontmatter ?? {}) as Record<string, unknown>),
88
+ body: parsedBody,
89
+ };
90
+ } catch (e) {
91
+ // Malformed frontmatter is a user error, not a crash. Log a clear,
92
+ // actionable message (with the file path when available) and return empty
93
+ // data so the caller's `!data.name || !data.description` check skips the
94
+ // file rather than importing a half-parsed agent.
95
+ const where = filePath ? ` (${filePath})` : "";
96
+ console.warn(
97
+ `[delegate] malformed agent frontmatter${where}: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`,
98
+ );
99
+ return { data: {}, body: content.trim() };
100
+ }
101
+ }
102
+
103
+ // ── Agent Discovery ───────────────────────────────────────────────────────
104
+
105
+ /** Find the nearest ancestor containing project-scoped agent files. */
106
+ export function findProjectRoot(cwd: string): string | null {
107
+ let dir = cwd;
108
+ while (true) {
109
+ // A project root is any dir hosting a pi-native or Claude agent dir.
110
+ // Recognizing .claude/agents here means a project using only Claude Code's
111
+ // convention still resolves its project-scoped agents.
112
+ if (
113
+ fs.existsSync(path.join(dir, ".pi", "agents")) ||
114
+ fs.existsSync(path.join(dir, ".claude", "agents"))
115
+ )
116
+ return dir;
117
+ const parent = path.dirname(dir);
118
+ if (parent === dir) return null;
119
+ dir = parent;
120
+ }
121
+ }
122
+
123
+ /** Map Claude Code's capitalized tool names to delegate's lowercase set.
124
+ * Unmappable tools (WebSearch, WebFetch, TodoWrite, …) are dropped — they are
125
+ * not delegate tools, and warning per imported agent would be noise. The map
126
+ * is exported for tests; do not call it for native pi frontmatter. */
127
+ const CLAUDE_TOOL_ALIASES: Record<string, string> = {
128
+ read: "read",
129
+ write: "write",
130
+ edit: "edit",
131
+ bash: "bash",
132
+ glob: "find",
133
+ grep: "grep",
134
+ ls: "ls",
135
+ };
136
+
137
+ /** Parse a `tools:` frontmatter value into a resolved tool list.
138
+ * Omitted or blank → inherit the full agent set (`*`), matching CC/OpenCode/
139
+ * Devin convention. This is the only caller-owned knob — both inline tasks
140
+ * and named agents now inherit-all when `tools:` is absent. */
141
+ function resolveFrontmatterTools(
142
+ raw: string | undefined,
143
+ aliasMap?: Record<string, string>,
144
+ ): string[] {
145
+ if (!raw) return DEFAULT_TOOLS; // omitted/blank → inherit *
146
+ const names = raw
147
+ .split(",")
148
+ .map((s) => s.trim())
149
+ .filter(Boolean);
150
+ if (!names.length) return DEFAULT_TOOLS;
151
+ const mapped = aliasMap
152
+ ? names
153
+ .map((n) => aliasMap[n.toLowerCase()] ?? null)
154
+ .filter((n): n is string => n !== null)
155
+ : names;
156
+ // Empty after aliasing (e.g. a Claude agent listing only WebSearch) → inherit.
157
+ return mapped.length ? resolveToolGroups(mapped) : DEFAULT_TOOLS;
158
+ }
159
+
160
+ /** Parse and alias a comma-separated Claude tool list into delegate tool names,
161
+ * WITHOUT inheriting on empty. Returns the mapped names only (may be empty).
162
+ * Used when the result will be subtracted from a base set (disallowedTools). */
163
+ function mapClaudeToolNames(raw: string | undefined): string[] {
164
+ if (!raw) return [];
165
+ return raw
166
+ .split(",")
167
+ .map((s) => s.trim())
168
+ .filter(Boolean)
169
+ .map((n) => CLAUDE_TOOL_ALIASES[n.toLowerCase()] ?? null)
170
+ .filter((n): n is string => n !== null);
171
+ }
172
+
173
+ /** Load a native Pi agent Markdown file, or null when it is invalid. */
174
+ export function loadAgentFile(filePath: string): AgentConfig | null {
175
+ let content: string;
176
+ try {
177
+ content = fs.readFileSync(filePath, "utf-8");
178
+ } catch {
179
+ return null;
180
+ }
181
+ const { data, body } = parseFrontmatter(content, filePath);
182
+ if (!data.name || !data.description) return null;
183
+ return {
184
+ name: data.name,
185
+ description: data.description,
186
+ model: data.model,
187
+ thinking: VALID_THINKING.has(data.thinking ?? "")
188
+ ? (data.thinking as ThinkingLevel)
189
+ : "off",
190
+ // Omitted/blank `tools:` → inherit the full agent set (`*`), matching
191
+ // CC/OpenCode/Devin. A previous version rejected empty tools; that was
192
+ // stricter than every comparable tool and surprised anyone porting a
193
+ // profile. Inline tasks still get `[]` as a deliberate escape hatch —
194
+ // this code path only governs named-agent file loading.
195
+ tools: resolveFrontmatterTools(data.tools),
196
+ systemPrompt: body,
197
+ };
198
+ }
199
+
200
+ /** Variant for `.claude/agents/*.md` files. Two Claude-specific adaptations:
201
+ * - Maps capitalized tool names (Read/Glob/…) to delegate tools, dropping
202
+ * unmappable ones (WebSearch, TodoWrite, …). Omitted `tools` inherits `*`.
203
+ * - Honors `disallowedTools` as a denylist layered on top of the resolved
204
+ * set (Claude semantics: denylist applies whether or not an allowlist is
205
+ * set). Since delegate has no runtime denylist, we bake it into `tools` at
206
+ * import time. A reviewer with `disallowedTools: Write, Edit` and no
207
+ * `tools` becomes `read, bash, grep, find, ls` — it does NOT silently
208
+ * inherit full tools.
209
+ * - `model: inherit` (Claude's default) is mapped to "omit" so the agent
210
+ * inherits the parent model; passing it through verbatim would crash
211
+ * resolveModel() with "model 'inherit' is not available". */
212
+ export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
213
+ let content: string;
214
+ try {
215
+ content = fs.readFileSync(filePath, "utf-8");
216
+ } catch {
217
+ return null;
218
+ }
219
+ const { data, body } = parseFrontmatter(content, filePath);
220
+ if (!data.name || !data.description) return null;
221
+
222
+ let tools = resolveFrontmatterTools(data.tools, CLAUDE_TOOL_ALIASES);
223
+ // disallowedTools is a denylist applied after the allowlist resolves.
224
+ // Empty-after-mapping denylist (e.g. only unmappable names) → no-op.
225
+ const denied = new Set(mapClaudeToolNames(data.disallowedTools));
226
+ if (denied.size) tools = tools.filter((t) => !denied.has(t));
227
+
228
+ return {
229
+ name: data.name,
230
+ description: data.description,
231
+ // `inherit` is Claude's "use parent" default — drop it so we fall through
232
+ // to parent-model inheritance. Any other value passes through verbatim.
233
+ model:
234
+ data.model && data.model.toLowerCase() === "inherit"
235
+ ? undefined
236
+ : data.model,
237
+ thinking: VALID_THINKING.has(data.thinking ?? "")
238
+ ? (data.thinking as ThinkingLevel)
239
+ : "off",
240
+ tools,
241
+ systemPrompt: body,
242
+ };
243
+ }
244
+
245
+ /** Discover native and Claude-compatible agents in priority order. */
246
+ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
247
+ // Discovery order for persisted Markdown agents (first definition wins;
248
+ // later dirs cannot overwrite):
249
+ // 1. project .pi/agents (highest priority)
250
+ // 2. global ~/.pi/agent/agents
251
+ // 3. global ~/.agents (legacy)
252
+ // 4. project .claude/agents (Claude Code interchange)
253
+ // 5. global ~/.claude/agents
254
+ //
255
+ // Custom agents are defined by the parent either inline in a task or as a
256
+ // Markdown file in one of the directories above. Markdown agents are
257
+ // examples of custom agents; the parent model can shape the subagent it
258
+ // needs on each call.
259
+ const projectRoot = findProjectRoot(cwd);
260
+ const nativeDirs: { dir: string; scope: "project" | "global" }[] = [];
261
+ if (projectRoot)
262
+ nativeDirs.push({
263
+ dir: path.join(projectRoot, ".pi", "agents"),
264
+ scope: "project",
265
+ });
266
+ // Global user agents — same convention as skills, AGENTS.md, and pi-subagents
267
+ nativeDirs.push({
268
+ dir: path.join(os.homedir(), ".pi", "agent", "agents"),
269
+ scope: "global",
270
+ });
271
+ nativeDirs.push({ dir: path.join(os.homedir(), ".agents"), scope: "global" }); // legacy
272
+
273
+ const claudeDirs: { dir: string; scope: "claude" }[] = [];
274
+ if (projectRoot)
275
+ claudeDirs.push({
276
+ dir: path.join(projectRoot, ".claude", "agents"),
277
+ scope: "claude",
278
+ });
279
+ claudeDirs.push({
280
+ dir: path.join(os.homedir(), ".claude", "agents"),
281
+ scope: "claude",
282
+ });
283
+
284
+ const agents = new Map<string, AgentConfig>();
285
+ const loadDir = (
286
+ { dir, scope }: { dir: string; scope: AgentConfig["scope"] },
287
+ loader: (fp: string) => AgentConfig | null,
288
+ ) => {
289
+ let entries: fs.Dirent[];
290
+ try {
291
+ entries = fs.readdirSync(dir, { withFileTypes: true });
292
+ } catch {
293
+ return;
294
+ }
295
+ for (const e of entries) {
296
+ if (!e.name.endsWith(".md") || e.name.endsWith(".chain.md")) continue;
297
+ const cfg = loader(path.join(dir, e.name));
298
+ if (cfg && !agents.has(cfg.name)) {
299
+ cfg.scope = scope;
300
+ agents.set(cfg.name, cfg);
301
+ }
302
+ }
303
+ };
304
+
305
+ for (const d of nativeDirs) loadDir(d, loadAgentFile);
306
+ for (const d of claudeDirs) loadDir(d, loadClaudeAgentFile);
307
+
308
+ return agents;
309
+ }
310
+
311
+ // ── Subagent Prompt Assembly ──────────────────────────────────────────────
312
+
313
+ export const DEFAULT_SUBAGENT_SYSTEM_PROMPT =
314
+ "You are a helpful coding assistant.";
315
+
316
+ function firstNonBlank(
317
+ ...values: Array<string | undefined>
318
+ ): string | undefined {
319
+ return values.find(
320
+ (v): v is string => typeof v === "string" && v.trim().length > 0,
321
+ );
322
+ }
323
+
324
+ /** Select the frozen, task, agent, or parent prompt for a subagent. */
325
+ export function buildSubagentSystemPrompt(options: {
326
+ taskSystemPrompt?: string;
327
+ agentSystemPrompt?: string;
328
+ parentSystemPrompt?: string;
329
+ pooledSystemPrompt?: string;
330
+ }): string {
331
+ // Pooled agents already have a frozen prompt baked into their session state.
332
+ // Return it unchanged so repeated sessionId calls do not re-resolve.
333
+ if (options.pooledSystemPrompt?.trim()) return options.pooledSystemPrompt;
334
+
335
+ // Return only the base prompt. AgentSession constructs the full system prompt
336
+ // from this custom prompt + its own resource-loader discovery (skills,
337
+ // AGENTS.md, active-tool snippets). We previously appended skills/AGENTS.md
338
+ // here; that duplicated AgentSession's work.
339
+ const base =
340
+ firstNonBlank(
341
+ options.taskSystemPrompt,
342
+ options.agentSystemPrompt,
343
+ options.parentSystemPrompt,
344
+ ) ?? DEFAULT_SUBAGENT_SYSTEM_PROMPT;
345
+
346
+ return base;
347
+ }
package/concurrency.ts ADDED
@@ -0,0 +1,126 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import { getMaxConcurrent } from "./config.ts";
3
+
4
+ // ── Module-level global concurrency cap ───────────────────────────────────
5
+ //
6
+ // `maxConcurrent` is a hard ceiling on the *total* number of subagent tasks
7
+ // that may run at once across the entire extension, not per `delegate` call.
8
+ // A single shared semaphore makes that guarantee real: multiple concurrent
9
+ // sync/async `delegate` invocations contend for the same pool of slots.
10
+
11
+ let globalConcurrencyLimit = Math.max(1, getMaxConcurrent());
12
+ let globalConcurrencyRunning = 0;
13
+ const globalConcurrencyWaiters: Array<() => void> = [];
14
+
15
+ function acquireGlobal(): Promise<void> {
16
+ if (globalConcurrencyRunning < globalConcurrencyLimit) {
17
+ globalConcurrencyRunning++;
18
+ return Promise.resolve();
19
+ }
20
+ return new Promise<void>((r) => globalConcurrencyWaiters.push(r));
21
+ }
22
+
23
+ function releaseGlobal(): void {
24
+ globalConcurrencyRunning--;
25
+ if (globalConcurrencyWaiters.length > 0) {
26
+ globalConcurrencyRunning++;
27
+ globalConcurrencyWaiters.shift()!();
28
+ }
29
+ }
30
+
31
+ /** Test-only hook: override the global concurrency cap. */
32
+ export function _setGlobalConcurrencyLimitForTesting(limit: number): void {
33
+ globalConcurrencyLimit = Math.max(1, limit);
34
+ }
35
+
36
+ /** Test-only hook: reset the global semaphore to the configured cap. */
37
+ export function _resetGlobalConcurrencyForTesting(): void {
38
+ globalConcurrencyLimit = Math.max(1, getMaxConcurrent());
39
+ globalConcurrencyRunning = 0;
40
+ globalConcurrencyWaiters.length = 0;
41
+ }
42
+
43
+ async function mapConcurrent<T, R>(
44
+ items: T[],
45
+ concurrency: number,
46
+ fn: (item: T, index: number) => Promise<R>,
47
+ signal?: AbortSignal,
48
+ ): Promise<R[]> {
49
+ if (items.length === 0) return [];
50
+ const limit = Math.max(1, Math.min(concurrency, items.length));
51
+ const results: R[] = new Array(items.length);
52
+ let next = 0;
53
+ const worker = async () => {
54
+ while (true) {
55
+ const i = next++;
56
+ if (i >= items.length) return;
57
+ // Deliberately do NOT check signal?.aborted here — the caller
58
+ // (runResolvedTask) handles abort at entry and returns a proper
59
+ // TaskResult. Early-returning here would leave results[i] as
60
+ // undefined, causing a crash in the sync result-dereference path.
61
+ results[i] = await fn(items[i]!, i);
62
+ }
63
+ };
64
+ await Promise.all(Array.from({ length: limit }, () => worker()));
65
+ return results;
66
+ }
67
+
68
+ /** Extract a model key string for concurrency grouping. Falls back to "_no_model" for actions without a model. */
69
+ export function getModelKey(model: Model<Api> | undefined): string {
70
+ // provider/id — e.g. "openrouter/deepseek/deepseek-v4-pro"
71
+ return model ? `${model.provider}/${model.id}` : "_no_model";
72
+ }
73
+
74
+ /**
75
+ * Like mapConcurrent but with per-model concurrency limits.
76
+ * Groups items by model key, runs each group with its own limit.
77
+ * All groups run in parallel (Promise.all across groups).
78
+ *
79
+ * The total number of concurrently running tasks is also capped by the
80
+ * configured `maxConcurrent` value, shared across all `delegate` invocations.
81
+ */
82
+ export async function mapConcurrentByModel<T, R>(
83
+ items: T[],
84
+ getModelKey: (item: T, index: number) => string,
85
+ getConcurrency: (modelKey: string) => number,
86
+ fn: (item: T, index: number) => Promise<R>,
87
+ signal?: AbortSignal,
88
+ ): Promise<R[]> {
89
+ if (items.length === 0) return [];
90
+ const results: R[] = new Array(items.length);
91
+
92
+ // Group items by model key, preserving original indices
93
+ const groups = new Map<string, { indices: number[]; limit: number }>();
94
+ for (let i = 0; i < items.length; i++) {
95
+ const key = getModelKey(items[i]!, i);
96
+ let group = groups.get(key);
97
+ if (!group) {
98
+ group = { indices: [], limit: getConcurrency(key) };
99
+ groups.set(key, group);
100
+ }
101
+ group.indices.push(i);
102
+ }
103
+
104
+ // Run all groups in parallel, each with its own concurrency limit + global cap
105
+ await Promise.all(
106
+ [...groups.entries()].map(([, group]) => {
107
+ const groupItems = group.indices.map((i) => items[i]!);
108
+ return mapConcurrent(
109
+ groupItems,
110
+ group.limit,
111
+ async (_item, localIdx) => {
112
+ await acquireGlobal();
113
+ try {
114
+ const globalIdx = group.indices[localIdx]!;
115
+ results[globalIdx] = await fn(_item, globalIdx);
116
+ return results[globalIdx];
117
+ } finally {
118
+ releaseGlobal();
119
+ }
120
+ },
121
+ signal,
122
+ );
123
+ }),
124
+ );
125
+ return results;
126
+ }