@oh-my-pi/pi-coding-agent 16.1.18 → 16.1.20
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 +24 -0
- package/dist/cli.js +15452 -15142
- package/dist/types/cli/gallery-cli.d.ts +6 -0
- package/dist/types/commands/gallery.d.ts +1 -1
- package/dist/types/config/service-tier.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +36 -33
- package/dist/types/edit/hashline/filesystem.d.ts +1 -18
- package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +4 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +12 -0
- package/dist/types/internal-urls/skill-protocol.d.ts +2 -2
- package/dist/types/internal-urls/types.d.ts +3 -0
- package/dist/types/modes/components/custom-editor.d.ts +0 -2
- package/dist/types/modes/controllers/input-controller.d.ts +0 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/task/parallel.d.ts +17 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/tools/path-utils.d.ts +3 -0
- package/package.json +13 -13
- package/scripts/build-binary.ts +20 -0
- package/scripts/generate-legacy-pi-bundled-registry.ts +404 -0
- package/src/cli/gallery-cli.ts +31 -2
- package/src/cli/gallery-fixtures/agentic.ts +13 -4
- package/src/commands/gallery.ts +11 -3
- package/src/config/service-tier.ts +87 -0
- package/src/config/settings-schema.ts +48 -23
- package/src/edit/hashline/filesystem.ts +14 -0
- package/src/eval/agent-bridge.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +987 -0
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +3330 -18
- package/src/extensibility/plugins/legacy-pi-compat.ts +29 -14
- package/src/internal-urls/local-protocol.ts +116 -9
- package/src/internal-urls/skill-protocol.ts +3 -3
- package/src/internal-urls/types.ts +3 -0
- package/src/main.ts +1 -0
- package/src/mcp/transports/stdio.ts +5 -0
- package/src/modes/components/custom-editor.test.ts +7 -5
- package/src/modes/components/custom-editor.ts +6 -16
- package/src/modes/controllers/input-controller.ts +143 -137
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +72 -15
- package/src/session/messages.ts +70 -47
- package/src/session/session-history-format.ts +21 -4
- package/src/task/executor.ts +79 -2
- package/src/task/index.ts +11 -6
- package/src/task/parallel.ts +59 -7
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/find.ts +1 -0
- package/src/tools/index.ts +3 -1
- package/src/tools/irc.ts +16 -2
- package/src/tools/path-utils.ts +4 -0
- package/src/tools/read.ts +43 -17
- package/src/tools/search.ts +4 -0
- package/src/utils/shell-snapshot-fn-env.sh +60 -0
- package/src/utils/shell-snapshot.ts +77 -20
package/src/session/messages.ts
CHANGED
|
@@ -482,65 +482,88 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
|
|
|
482
482
|
* - Custom extensions and tools
|
|
483
483
|
*/
|
|
484
484
|
export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
485
|
-
return messages
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
485
|
+
return messages.flatMap((m): Message[] => {
|
|
486
|
+
switch (m.role) {
|
|
487
|
+
case "bashExecution":
|
|
488
|
+
if (m.excludeFromContext) {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
return [
|
|
492
|
+
{
|
|
493
493
|
role: "user",
|
|
494
494
|
content: [{ type: "text", text: bashExecutionToText(m) }],
|
|
495
495
|
attribution: "user",
|
|
496
496
|
timestamp: m.timestamp,
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
497
|
+
},
|
|
498
|
+
];
|
|
499
|
+
case "pythonExecution":
|
|
500
|
+
if (m.excludeFromContext) {
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
return [
|
|
504
|
+
{
|
|
503
505
|
role: "user",
|
|
504
506
|
content: [{ type: "text", text: pythonExecutionToText(m) }],
|
|
505
507
|
attribution: "user",
|
|
506
508
|
timestamp: m.timestamp,
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
509
|
+
},
|
|
510
|
+
];
|
|
511
|
+
case "fileMention": {
|
|
512
|
+
// One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image)
|
|
513
|
+
// in the same turn (`generateFileMentionMessages` packs every `@…` into a
|
|
514
|
+
// single message). Splitting by image presence keeps text-only mentions on
|
|
515
|
+
// the higher-priority `developer` slot while routing image attachments
|
|
516
|
+
// through `user`, the only Responses content slot that legitimately accepts
|
|
517
|
+
// `input_image` (Codex chatgpt.com /codex/responses rejects everything else
|
|
518
|
+
// with `Invalid value: 'input_image'`, #3443).
|
|
519
|
+
const wrap = (file: FileMentionMessage["files"][number]): string => {
|
|
520
|
+
const inner = file.content ? `\n${file.content}\n` : "\n";
|
|
521
|
+
return `<file path="${file.path}">${inner}</file>`;
|
|
522
|
+
};
|
|
523
|
+
const textFiles = m.files.filter(file => !file.image);
|
|
524
|
+
const imageFiles = m.files.filter(file => file.image);
|
|
525
|
+
const out: Message[] = [];
|
|
526
|
+
if (textFiles.length > 0) {
|
|
527
|
+
out.push({
|
|
522
528
|
role: "developer",
|
|
529
|
+
content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }],
|
|
530
|
+
attribution: "user",
|
|
531
|
+
timestamp: m.timestamp,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
if (imageFiles.length > 0) {
|
|
535
|
+
const content: (TextContent | ImageContent)[] = [
|
|
536
|
+
{ type: "text" as const, text: imageFiles.map(wrap).join("\n") },
|
|
537
|
+
];
|
|
538
|
+
for (const file of imageFiles) {
|
|
539
|
+
if (file.image) content.push(file.image);
|
|
540
|
+
}
|
|
541
|
+
out.push({
|
|
542
|
+
role: "user",
|
|
523
543
|
content,
|
|
524
544
|
attribution: "user",
|
|
525
545
|
timestamp: m.timestamp,
|
|
526
|
-
};
|
|
546
|
+
});
|
|
527
547
|
}
|
|
528
|
-
|
|
529
|
-
case "hookMessage":
|
|
530
|
-
case "branchSummary":
|
|
531
|
-
case "compactionSummary":
|
|
532
|
-
case "user":
|
|
533
|
-
case "developer":
|
|
534
|
-
case "assistant":
|
|
535
|
-
case "toolResult":
|
|
536
|
-
// Core roles share one transformer with agent-core —
|
|
537
|
-
// duplicating them here is how snapcompact frames once
|
|
538
|
-
// silently fell off the provider request.
|
|
539
|
-
return convertMessageToLlm(m);
|
|
540
|
-
default:
|
|
541
|
-
m satisfies never;
|
|
542
|
-
return undefined;
|
|
548
|
+
return out;
|
|
543
549
|
}
|
|
544
|
-
|
|
545
|
-
|
|
550
|
+
case "custom":
|
|
551
|
+
case "hookMessage":
|
|
552
|
+
case "branchSummary":
|
|
553
|
+
case "compactionSummary":
|
|
554
|
+
case "user":
|
|
555
|
+
case "developer":
|
|
556
|
+
case "assistant":
|
|
557
|
+
case "toolResult": {
|
|
558
|
+
// Core roles share one transformer with agent-core —
|
|
559
|
+
// duplicating them here is how snapcompact frames once
|
|
560
|
+
// silently fell off the provider request.
|
|
561
|
+
const converted = convertMessageToLlm(m);
|
|
562
|
+
return converted ? [converted] : [];
|
|
563
|
+
}
|
|
564
|
+
default:
|
|
565
|
+
m satisfies never;
|
|
566
|
+
return [];
|
|
567
|
+
}
|
|
568
|
+
});
|
|
546
569
|
}
|
|
@@ -86,6 +86,14 @@ function lineCount(text: string): number {
|
|
|
86
86
|
return text.split("\n").length;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function primaryArgValue(value: unknown): string {
|
|
90
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
91
|
+
if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string")) {
|
|
92
|
+
return value.join(", ");
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
/** Pick the most informative scalar argument of a tool call. */
|
|
90
98
|
function primaryArg(name: string, args: Record<string, unknown> | undefined): string {
|
|
91
99
|
if (!args || typeof args !== "object") return "";
|
|
@@ -97,12 +105,21 @@ function primaryArg(name: string, args: Record<string, unknown> | undefined): st
|
|
|
97
105
|
if (note) return oneLine(note);
|
|
98
106
|
if (severity) return oneLine(severity);
|
|
99
107
|
}
|
|
108
|
+
if (name === "search") {
|
|
109
|
+
const pattern = primaryArgValue(args.pattern);
|
|
110
|
+
const paths = primaryArgValue(args.paths);
|
|
111
|
+
if (pattern && paths) return oneLine(`${pattern} @ ${paths}`);
|
|
112
|
+
if (pattern) return oneLine(pattern);
|
|
113
|
+
if (paths) return oneLine(paths);
|
|
114
|
+
}
|
|
115
|
+
if (name === "find") {
|
|
116
|
+
const paths = primaryArgValue(args.paths);
|
|
117
|
+
if (paths) return oneLine(paths);
|
|
118
|
+
}
|
|
100
119
|
for (const key of PRIMARY_ARG_KEYS) {
|
|
101
120
|
const value = args[key];
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
return oneLine(value.join(", "));
|
|
105
|
-
}
|
|
121
|
+
const summary = primaryArgValue(value);
|
|
122
|
+
if (summary) return oneLine(summary);
|
|
106
123
|
}
|
|
107
124
|
// Fallback: first non-intent string arg, then a compact JSON of the args.
|
|
108
125
|
const rest: Record<string, unknown> = {};
|
package/src/task/executor.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
9
9
|
import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
|
|
10
|
-
import type { Api, Model, Usage } from "@oh-my-pi/pi-ai";
|
|
10
|
+
import type { Api, Model, ServiceTier, Usage } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import type { Rule } from "../capability/rule";
|
|
13
13
|
import { ModelRegistry } from "../config/model-registry";
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveModelOverrideWithAuthFallback,
|
|
19
19
|
} from "../config/model-resolver";
|
|
20
20
|
import type { PromptTemplate } from "../config/prompt-templates";
|
|
21
|
+
import { resolveSubagentServiceTier } from "../config/service-tier";
|
|
21
22
|
import { Settings } from "../config/settings";
|
|
22
23
|
import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
|
|
23
24
|
import type { ToolPathWithSource } from "../extensibility/custom-tools";
|
|
@@ -50,12 +51,12 @@ import {
|
|
|
50
51
|
type OutputValidator,
|
|
51
52
|
summarizeValidationFailure,
|
|
52
53
|
} from "../tools/output-schema-validator";
|
|
53
|
-
|
|
54
54
|
import { type ReportFindingDetails, toReviewFinding } from "../tools/review";
|
|
55
55
|
import { ToolAbortError } from "../tools/tool-errors";
|
|
56
56
|
import type { EventBus } from "../utils/event-bus";
|
|
57
57
|
import { buildNamedToolChoice } from "../utils/tool-choice";
|
|
58
58
|
import type { WorkspaceTree } from "../workspace-tree";
|
|
59
|
+
import { Semaphore } from "./parallel";
|
|
59
60
|
import { subprocessToolRegistry } from "./subprocess-tool-registry";
|
|
60
61
|
import {
|
|
61
62
|
type AgentDefinition,
|
|
@@ -194,6 +195,51 @@ function installSubagentRetryFallbackChain(args: {
|
|
|
194
195
|
return role;
|
|
195
196
|
}
|
|
196
197
|
|
|
198
|
+
const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
|
|
199
|
+
"ollama-cloud": "providers.ollama-cloud.maxConcurrency",
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
interface ProviderSemaphoreEntry {
|
|
203
|
+
limit: number;
|
|
204
|
+
semaphore: Semaphore;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve the configured concurrency ceiling for a provider, or `undefined`
|
|
211
|
+
* when the provider has no cap concept at all. A configured value `<= 0` means
|
|
212
|
+
* "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
|
|
213
|
+
* holds a slot and a later finite resize counts work started while unlimited.
|
|
214
|
+
*/
|
|
215
|
+
function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
|
|
216
|
+
const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
|
|
217
|
+
if (!settingPath) return undefined;
|
|
218
|
+
const raw = settings.get(settingPath);
|
|
219
|
+
const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
220
|
+
return limit > 0 ? limit : Number.POSITIVE_INFINITY;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
|
|
224
|
+
const limit = getProviderConcurrencyLimit(settings, provider);
|
|
225
|
+
if (limit === undefined) return undefined;
|
|
226
|
+
// Always hand out (and acquire on) the single shared limiter, even when
|
|
227
|
+
// unlimited (Infinity). Resizing it in place — rather than replacing it —
|
|
228
|
+
// keeps every in-flight slot counted, so a runtime or mixed limit change can
|
|
229
|
+
// never push concurrency past the cap (issue #3464 review feedback).
|
|
230
|
+
const existing = providerSemaphores.get(provider);
|
|
231
|
+
if (existing) {
|
|
232
|
+
if (existing.limit !== limit) {
|
|
233
|
+
existing.limit = limit;
|
|
234
|
+
existing.semaphore.resize(limit);
|
|
235
|
+
}
|
|
236
|
+
return existing.semaphore;
|
|
237
|
+
}
|
|
238
|
+
const semaphore = new Semaphore(limit);
|
|
239
|
+
providerSemaphores.set(provider, { limit, semaphore });
|
|
240
|
+
return semaphore;
|
|
241
|
+
}
|
|
242
|
+
|
|
197
243
|
function renderIrcPeerRoster(selfId: string): string {
|
|
198
244
|
const peers = AgentRegistry.global()
|
|
199
245
|
.list()
|
|
@@ -339,6 +385,13 @@ export interface ExecutorOptions {
|
|
|
339
385
|
authStorage?: AuthStorage;
|
|
340
386
|
modelRegistry?: ModelRegistry;
|
|
341
387
|
settings?: Settings;
|
|
388
|
+
/**
|
|
389
|
+
* Parent session's live effective service tier, the source of truth for a
|
|
390
|
+
* subagent whose `serviceTierSubagent` is `"inherit"`. `null` = the parent
|
|
391
|
+
* explicitly has no tier (e.g. `/fast off`); omitted = no live session, so
|
|
392
|
+
* inherit falls back to the configured `serviceTier` setting.
|
|
393
|
+
*/
|
|
394
|
+
parentServiceTier?: ServiceTier | null;
|
|
342
395
|
/** Override local:// protocol options so subagent shares parent's local:// root */
|
|
343
396
|
localProtocolOptions?: LocalProtocolOptions;
|
|
344
397
|
/**
|
|
@@ -732,11 +785,21 @@ export function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
|
|
|
732
785
|
export function createSubagentSettings(
|
|
733
786
|
baseSettings: Settings,
|
|
734
787
|
overrides?: Partial<Record<SettingPath, unknown>>,
|
|
788
|
+
inheritedServiceTier?: ServiceTier | null,
|
|
735
789
|
): Settings {
|
|
736
790
|
const snapshot: Partial<Record<SettingPath, unknown>> = {};
|
|
737
791
|
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
|
|
738
792
|
snapshot[key] = baseSettings.get(key);
|
|
739
793
|
}
|
|
794
|
+
// Resolve the subagent's service tier from `serviceTierSubagent` ("inherit" =
|
|
795
|
+
// match the parent's live tier when a live session supplied one, else the
|
|
796
|
+
// configured `serviceTier`). The result is stamped back onto the snapshot so
|
|
797
|
+
// createAgentSession's `settings.get("serviceTier")` read picks it up.
|
|
798
|
+
snapshot.serviceTier = resolveSubagentServiceTier(
|
|
799
|
+
baseSettings.get("serviceTierSubagent"),
|
|
800
|
+
baseSettings.get("serviceTier"),
|
|
801
|
+
inheritedServiceTier,
|
|
802
|
+
);
|
|
740
803
|
return Settings.isolated({
|
|
741
804
|
...snapshot,
|
|
742
805
|
"async.enabled": false,
|
|
@@ -1747,6 +1810,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1747
1810
|
const subagentSettings = createSubagentSettings(
|
|
1748
1811
|
settings,
|
|
1749
1812
|
agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
|
|
1813
|
+
options.parentServiceTier,
|
|
1750
1814
|
);
|
|
1751
1815
|
const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
|
|
1752
1816
|
// Tailored specialist identity for this spawn. `subagentRole` is the full
|
|
@@ -1889,6 +1953,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1889
1953
|
let sessionOpenedAt: number | undefined;
|
|
1890
1954
|
let sessionCreatedAt: number | undefined;
|
|
1891
1955
|
let readyAt: number | undefined;
|
|
1956
|
+
let providerSemaphore: Semaphore | undefined;
|
|
1957
|
+
let providerSemaphoreAcquired = false;
|
|
1892
1958
|
|
|
1893
1959
|
try {
|
|
1894
1960
|
checkAbort();
|
|
@@ -1957,6 +2023,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1957
2023
|
? resolvedThinkingLevel
|
|
1958
2024
|
: (thinkingLevel ?? resolvedThinkingLevel);
|
|
1959
2025
|
resolvedAt = performance.now();
|
|
2026
|
+
if (model) {
|
|
2027
|
+
providerSemaphore = getProviderSemaphore(settings, model.provider);
|
|
2028
|
+
if (providerSemaphore) {
|
|
2029
|
+
await providerSemaphore.acquire(abortSignal);
|
|
2030
|
+
providerSemaphoreAcquired = true;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
1960
2033
|
|
|
1961
2034
|
const effectiveCwd = worktree ?? cwd;
|
|
1962
2035
|
const sessionManager = sessionFile
|
|
@@ -2247,6 +2320,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2247
2320
|
}
|
|
2248
2321
|
if (exitCode === 0) exitCode = 1;
|
|
2249
2322
|
}
|
|
2323
|
+
if (providerSemaphoreAcquired) {
|
|
2324
|
+
providerSemaphore?.release();
|
|
2325
|
+
providerSemaphoreAcquired = false;
|
|
2326
|
+
}
|
|
2250
2327
|
sessionAbortController.abort();
|
|
2251
2328
|
if (unsubscribe) {
|
|
2252
2329
|
try {
|
package/src/task/index.ts
CHANGED
|
@@ -798,13 +798,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
798
798
|
onSettled?.(true);
|
|
799
799
|
throw new Error("Aborted before execution");
|
|
800
800
|
}
|
|
801
|
-
markRunning();
|
|
802
|
-
progress.status = "running";
|
|
803
|
-
await reportProgress(
|
|
804
|
-
`Running background task ${agentId}...`,
|
|
805
|
-
buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
|
|
806
|
-
);
|
|
807
801
|
try {
|
|
802
|
+
markRunning();
|
|
803
|
+
progress.status = "running";
|
|
804
|
+
await reportProgress(
|
|
805
|
+
`Running background task ${agentId}...`,
|
|
806
|
+
buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
|
|
807
|
+
);
|
|
808
808
|
const result = await this.#executeSync(
|
|
809
809
|
toolCallId,
|
|
810
810
|
spawnParams,
|
|
@@ -1296,6 +1296,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1296
1296
|
parentTelemetry: this.session.getTelemetry?.(),
|
|
1297
1297
|
parentEvalSessionId,
|
|
1298
1298
|
parentAgentId: this.session.getAgentId?.() ?? MAIN_AGENT_ID,
|
|
1299
|
+
// Live source of truth for `serviceTierSubagent: inherit`. When the
|
|
1300
|
+
// session exposes a tier accessor, pass tier-or-null (null = explicit
|
|
1301
|
+
// none, e.g. /fast off); otherwise leave undefined so inherit falls
|
|
1302
|
+
// back to the configured serviceTier setting.
|
|
1303
|
+
parentServiceTier: this.session.getServiceTier ? (this.session.getServiceTier() ?? null) : undefined,
|
|
1299
1304
|
};
|
|
1300
1305
|
|
|
1301
1306
|
const runTask = async (): Promise<SingleResult> => {
|
package/src/task/parallel.ts
CHANGED
|
@@ -100,22 +100,74 @@ export class Semaphore {
|
|
|
100
100
|
this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Resolves when a slot is available. Pass an `AbortSignal` so callers that
|
|
105
|
+
* stop waiting (parent task cancelled, wall-clock budget elapsed) also stop
|
|
106
|
+
* occupying a queue slot — otherwise a later `release()` would resolve the
|
|
107
|
+
* abandoned waiter, permanently shrinking effective concurrency for the
|
|
108
|
+
* remaining lifetime of the process (issue #3464 review feedback).
|
|
109
|
+
*/
|
|
110
|
+
async acquire(signal?: AbortSignal): Promise<void> {
|
|
111
|
+
if (signal?.aborted) {
|
|
112
|
+
throw semaphoreAbortReason(signal);
|
|
113
|
+
}
|
|
104
114
|
if (this.#current < this.#max) {
|
|
105
115
|
this.#current++;
|
|
106
116
|
return;
|
|
107
117
|
}
|
|
108
|
-
const { promise, resolve } = Promise.withResolvers<void>();
|
|
109
|
-
this.#queue
|
|
118
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
119
|
+
const queue = this.#queue;
|
|
120
|
+
let waiter: () => void = resolve;
|
|
121
|
+
if (signal) {
|
|
122
|
+
const onAbort = () => {
|
|
123
|
+
const index = queue.indexOf(waiter);
|
|
124
|
+
if (index >= 0) queue.splice(index, 1);
|
|
125
|
+
reject(semaphoreAbortReason(signal));
|
|
126
|
+
};
|
|
127
|
+
waiter = () => {
|
|
128
|
+
signal.removeEventListener("abort", onAbort);
|
|
129
|
+
resolve();
|
|
130
|
+
};
|
|
131
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
132
|
+
}
|
|
133
|
+
queue.push(waiter);
|
|
110
134
|
return promise;
|
|
111
135
|
}
|
|
112
136
|
|
|
113
137
|
release(): void {
|
|
114
|
-
|
|
115
|
-
if (
|
|
138
|
+
if (this.#current > 0) this.#current--;
|
|
139
|
+
// Admit the next waiter only if we are under the (possibly just-lowered) ceiling.
|
|
140
|
+
if (this.#current < this.#max) {
|
|
141
|
+
const next = this.#queue.shift();
|
|
142
|
+
if (next) {
|
|
143
|
+
this.#current++;
|
|
144
|
+
next();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Adjust the maximum concurrency in place. Raising the ceiling immediately
|
|
151
|
+
* admits queued waiters that now fit; lowering it lets in-flight holders
|
|
152
|
+
* drain naturally (new acquires keep blocking until `#current` falls below
|
|
153
|
+
* the new max). Resizing the single shared instance — instead of replacing
|
|
154
|
+
* it — keeps in-flight slots counted, so a runtime or mixed limit change can
|
|
155
|
+
* never push concurrency past the cap (issue #3464 review feedback).
|
|
156
|
+
*/
|
|
157
|
+
resize(max: number): void {
|
|
158
|
+
const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
|
|
159
|
+
this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
|
|
160
|
+
while (this.#current < this.#max) {
|
|
161
|
+
const next = this.#queue.shift();
|
|
162
|
+
if (!next) break;
|
|
163
|
+
this.#current++;
|
|
116
164
|
next();
|
|
117
|
-
} else {
|
|
118
|
-
this.#current--;
|
|
119
165
|
}
|
|
120
166
|
}
|
|
121
167
|
}
|
|
168
|
+
|
|
169
|
+
function semaphoreAbortReason(signal: AbortSignal): unknown {
|
|
170
|
+
const reason = signal.reason;
|
|
171
|
+
if (reason !== undefined) return reason;
|
|
172
|
+
return new Error("Semaphore acquire aborted");
|
|
173
|
+
}
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -283,6 +283,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|
|
283
283
|
settings: this.session.settings,
|
|
284
284
|
signal,
|
|
285
285
|
localProtocolOptions: this.session.localProtocolOptions,
|
|
286
|
+
skills: this.session.skills,
|
|
286
287
|
});
|
|
287
288
|
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
|
|
288
289
|
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -185,6 +185,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
185
185
|
settings: this.session.settings,
|
|
186
186
|
signal,
|
|
187
187
|
localProtocolOptions: this.session.localProtocolOptions,
|
|
188
|
+
skills: this.session.skills,
|
|
188
189
|
});
|
|
189
190
|
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
|
|
190
191
|
|
package/src/tools/find.ts
CHANGED
|
@@ -166,6 +166,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
166
166
|
settings: this.session.settings,
|
|
167
167
|
signal,
|
|
168
168
|
localProtocolOptions: this.session.localProtocolOptions,
|
|
169
|
+
skills: this.session.skills,
|
|
169
170
|
});
|
|
170
171
|
if (!resource.sourcePath) {
|
|
171
172
|
throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
|
package/src/tools/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
2
2
|
import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
3
|
-
import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import type { FetchImpl, ImageContent, Model, ServiceTier, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import type { AsyncJobManager } from "../async/job-manager";
|
|
6
6
|
import type { Rule } from "../capability/rule";
|
|
@@ -240,6 +240,8 @@ export interface ToolSession {
|
|
|
240
240
|
getActiveModelString?: () => string | undefined;
|
|
241
241
|
/** Get the current session model object (provider/api capabilities), regardless of how it was chosen. */
|
|
242
242
|
getActiveModel?: () => Model | undefined;
|
|
243
|
+
/** Get the session's live effective service tier (undefined = none). Source of truth for subagent `serviceTierSubagent: inherit`. */
|
|
244
|
+
getServiceTier?: () => ServiceTier | undefined;
|
|
243
245
|
/** Auth storage for passing to subagents (avoids re-discovery) */
|
|
244
246
|
authStorage?: import("../session/auth-storage").AuthStorage;
|
|
245
247
|
/** Model registry for passing to subagents (avoids re-discovery) */
|
package/src/tools/irc.ts
CHANGED
|
@@ -514,6 +514,18 @@ function callMeta(args: IrcRenderArgs | undefined): string[] {
|
|
|
514
514
|
return meta;
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
function renderErrorResult(
|
|
518
|
+
result: { content: Array<{ type: string; text?: string }> },
|
|
519
|
+
args: IrcRenderArgs | undefined,
|
|
520
|
+
theme: Theme,
|
|
521
|
+
): string[] {
|
|
522
|
+
const text = textContent(result) || "IRC call failed.";
|
|
523
|
+
return [
|
|
524
|
+
renderStatusLine({ icon: "error", title: callTitle(args, theme), meta: callMeta(args) }, theme),
|
|
525
|
+
formatErrorDetail(text, theme),
|
|
526
|
+
];
|
|
527
|
+
}
|
|
528
|
+
|
|
517
529
|
/**
|
|
518
530
|
* Display-only transcript card for live IRC traffic: `irc:incoming` DMs
|
|
519
531
|
* delivered to this session, `irc:autoreply` side-channel replies sent on
|
|
@@ -743,9 +755,11 @@ function buildResultLines(
|
|
|
743
755
|
case "wait":
|
|
744
756
|
return renderWaitResult(result, details, args, expanded, theme);
|
|
745
757
|
case "inbox":
|
|
746
|
-
return
|
|
758
|
+
return result.isError
|
|
759
|
+
? renderErrorResult(result, args, theme)
|
|
760
|
+
: renderInboxResult(details, args, expanded, theme);
|
|
747
761
|
case "list":
|
|
748
|
-
return renderListResult(details, expanded, theme);
|
|
762
|
+
return result.isError ? renderErrorResult(result, args, theme) : renderListResult(details, expanded, theme);
|
|
749
763
|
default: {
|
|
750
764
|
const text = textContent(result) || (result.isError ? "IRC call failed." : "Done.");
|
|
751
765
|
return [
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as url from "node:url";
|
|
5
5
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import type { Skill } from "../extensibility/skills";
|
|
6
7
|
import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
|
|
7
8
|
import { ToolError } from "./tool-errors";
|
|
8
9
|
|
|
@@ -986,6 +987,8 @@ export interface ToolScopeOptions {
|
|
|
986
987
|
signal?: AbortSignal;
|
|
987
988
|
/** Calling session's `local://` root mapping — pins resolutions to the calling session. */
|
|
988
989
|
localProtocolOptions?: LocalProtocolOptions;
|
|
990
|
+
/** Calling session's loaded skills — lets skill:// resolve without process-global state. */
|
|
991
|
+
skills?: readonly Skill[];
|
|
989
992
|
}
|
|
990
993
|
|
|
991
994
|
export interface ToolScopeResolution {
|
|
@@ -1042,6 +1045,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
|
|
|
1042
1045
|
settings: opts.settings,
|
|
1043
1046
|
signal: opts.signal,
|
|
1044
1047
|
localProtocolOptions: opts.localProtocolOptions,
|
|
1048
|
+
skills: opts.skills,
|
|
1045
1049
|
});
|
|
1046
1050
|
if (!resource.sourcePath) {
|
|
1047
1051
|
throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
|
package/src/tools/read.ts
CHANGED
|
@@ -2083,7 +2083,24 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2083
2083
|
`Invalid selector ':${internalTarget.sel}' on '${internalTarget.path}'. Use :N, :N-M, :N+K, :N- (open-ended), a comma-separated list of ranges, :raw, or a range combined with raw (e.g. :raw:50-100).`,
|
|
2084
2084
|
);
|
|
2085
2085
|
}
|
|
2086
|
-
|
|
2086
|
+
const urlMeta = parseInternalUrl(internalTarget.path);
|
|
2087
|
+
const scheme = urlMeta.protocol.replace(/:$/, "").toLowerCase();
|
|
2088
|
+
if (scheme === "local") {
|
|
2089
|
+
const localFile = await resolveLocalUrlToFile(urlMeta, {
|
|
2090
|
+
cwd: this.session.cwd,
|
|
2091
|
+
settings: this.session.settings,
|
|
2092
|
+
signal,
|
|
2093
|
+
localProtocolOptions: this.session.localProtocolOptions,
|
|
2094
|
+
skills: this.session.skills,
|
|
2095
|
+
});
|
|
2096
|
+
if (localFile) {
|
|
2097
|
+
readPath = internalTarget.sel === undefined ? localFile.path : `${localFile.path}:${internalTarget.sel}`;
|
|
2098
|
+
} else {
|
|
2099
|
+
return this.#handleInternalUrl(internalTarget.path, parsed, signal);
|
|
2100
|
+
}
|
|
2101
|
+
} else {
|
|
2102
|
+
return this.#handleInternalUrl(internalTarget.path, parsed, signal);
|
|
2103
|
+
}
|
|
2087
2104
|
}
|
|
2088
2105
|
|
|
2089
2106
|
// One suffix-glob memo per read call — archive, sqlite, and plain-path
|
|
@@ -2393,23 +2410,31 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2393
2410
|
// counts in `truncation` keep reflecting the source, not the trimmed
|
|
2394
2411
|
// view — column truncation surfaces separately via `.limits()`.
|
|
2395
2412
|
const rawSelector = isRawSelector(parsed);
|
|
2396
|
-
// Binary sniff: NUL bytes
|
|
2397
|
-
//
|
|
2398
|
-
//
|
|
2399
|
-
//
|
|
2413
|
+
// Binary sniff: NUL bytes mean the file is not displayable text
|
|
2414
|
+
// (binary, or UTF-16 which has NULs in the ASCII range) — emit a
|
|
2415
|
+
// notice instead of mojibake filling the line budget. `:raw`
|
|
2416
|
+
// stays an explicit escape hatch.
|
|
2417
|
+
//
|
|
2418
|
+
// `collectedLines` covers the common case where at least one
|
|
2419
|
+
// physical line terminates within the byte budget. Binary blobs
|
|
2420
|
+
// without newlines (videos, archives, packed JSON) leave it
|
|
2421
|
+
// empty; their bytes only land in `firstLinePreview`, which the
|
|
2422
|
+
// `firstLineExceedsLimit` branch below would otherwise emit
|
|
2423
|
+
// verbatim. Sniffing the preview here keeps the refusal uniform.
|
|
2400
2424
|
if (!rawSelector) {
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
)
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2425
|
+
const hasNul = (text: string): boolean => text.includes("\u0000");
|
|
2426
|
+
const binaryDetected =
|
|
2427
|
+
collectedLines.some(hasNul) || (firstLinePreview !== undefined && hasNul(firstLinePreview.text));
|
|
2428
|
+
if (binaryDetected) {
|
|
2429
|
+
return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
|
|
2430
|
+
.text(
|
|
2431
|
+
prependSuffixResolutionNotice(
|
|
2432
|
+
`[Cannot read binary file '${formatPathRelativeToCwd(absolutePath, this.session.cwd)}' (${formatBytes(fileSize)}); content contains NUL bytes (binary or UTF-16 encoded)]`,
|
|
2433
|
+
suffixResolution,
|
|
2434
|
+
),
|
|
2435
|
+
)
|
|
2436
|
+
.sourcePath(absolutePath)
|
|
2437
|
+
.done();
|
|
2413
2438
|
}
|
|
2414
2439
|
}
|
|
2415
2440
|
const maxColumns = resolveOutputMaxColumns(this.session.settings);
|
|
@@ -2771,6 +2796,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2771
2796
|
settings: this.session.settings,
|
|
2772
2797
|
signal,
|
|
2773
2798
|
localProtocolOptions: this.session.localProtocolOptions,
|
|
2799
|
+
skills: this.session.skills,
|
|
2774
2800
|
});
|
|
2775
2801
|
const details: ReadToolDetails = { resolvedPath: resource.sourcePath, contentType: resource.contentType };
|
|
2776
2802
|
|
package/src/tools/search.ts
CHANGED
|
@@ -579,6 +579,7 @@ async function resolveInternalSearchInputs(opts: {
|
|
|
579
579
|
signal?: AbortSignal;
|
|
580
580
|
archiveDisplayMap: ReadonlyMap<string, string>;
|
|
581
581
|
localProtocolOptions?: LocalProtocolOptions;
|
|
582
|
+
skills?: ResolveContext["skills"];
|
|
582
583
|
}): Promise<InternalSearchInputResolution> {
|
|
583
584
|
const internalRouter = InternalUrlRouter.instance();
|
|
584
585
|
const paths = opts.resolvedPaths.slice();
|
|
@@ -592,6 +593,7 @@ async function resolveInternalSearchInputs(opts: {
|
|
|
592
593
|
settings: opts.settings,
|
|
593
594
|
signal: opts.signal,
|
|
594
595
|
localProtocolOptions: opts.localProtocolOptions,
|
|
596
|
+
skills: opts.skills,
|
|
595
597
|
};
|
|
596
598
|
|
|
597
599
|
for (let idx = 0; idx < paths.length; idx++) {
|
|
@@ -725,6 +727,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
725
727
|
signal,
|
|
726
728
|
archiveDisplayMap,
|
|
727
729
|
localProtocolOptions: this.session.localProtocolOptions,
|
|
730
|
+
skills: this.session.skills,
|
|
728
731
|
});
|
|
729
732
|
const searchablePaths = internalResolution.paths;
|
|
730
733
|
const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
|
|
@@ -797,6 +800,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
797
800
|
settings: this.session.settings,
|
|
798
801
|
signal,
|
|
799
802
|
localProtocolOptions: this.session.localProtocolOptions,
|
|
803
|
+
skills: this.session.skills,
|
|
800
804
|
});
|
|
801
805
|
searchPath = scope.searchPath;
|
|
802
806
|
isDirectory = scope.isDirectory;
|