@oh-my-pi/pi-coding-agent 16.1.19 → 16.1.21
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 +25 -0
- package/dist/cli.js +3795 -3760
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- 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/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +42 -5
- package/dist/types/modes/components/custom-editor.d.ts +32 -0
- package/dist/types/modes/components/tool-execution.d.ts +5 -5
- package/dist/types/modes/controllers/event-controller.d.ts +10 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -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/utils/clipboard.d.ts +10 -0
- package/package.json +13 -13
- package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
- package/src/advisor/__tests__/advisor.test.ts +44 -0
- package/src/advisor/advise-tool.ts +33 -0
- package/src/autolearn/controller.ts +17 -2
- 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/eval/agent-bridge.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
- package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
- package/src/main.ts +1 -0
- package/src/mcp/manager.ts +12 -3
- package/src/mcp/oauth-flow.ts +121 -7
- package/src/mcp/transports/stdio.ts +5 -0
- package/src/modes/components/chat-transcript-builder.ts +31 -0
- package/src/modes/components/custom-editor.test.ts +80 -0
- package/src/modes/components/custom-editor.ts +86 -6
- package/src/modes/components/tool-execution.ts +50 -25
- package/src/modes/controllers/event-controller.ts +57 -8
- package/src/modes/controllers/input-controller.ts +213 -93
- package/src/modes/controllers/mcp-command-controller.ts +18 -2
- package/src/modes/utils/ui-helpers.ts +40 -0
- package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
- package/src/prompts/system/autolearn-nudge.md +4 -2
- package/src/prompts/tools/todo.md +1 -1
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +76 -15
- 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/index.ts +3 -1
- package/src/tools/irc.ts +16 -2
- package/src/tools/todo.ts +20 -10
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/shell-snapshot-fn-env.sh +60 -0
- package/src/utils/shell-snapshot.ts +77 -20
|
@@ -160,6 +160,7 @@ import {
|
|
|
160
160
|
} from "../config/model-resolver";
|
|
161
161
|
import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
|
|
162
162
|
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
|
|
163
|
+
import { resolveServiceTierSetting } from "../config/service-tier";
|
|
163
164
|
import type { Settings, SkillsSettings } from "../config/settings";
|
|
164
165
|
import { getDefault, onAppendOnlyModeChanged } from "../config/settings";
|
|
165
166
|
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
|
@@ -1163,6 +1164,7 @@ export class AgentSession {
|
|
|
1163
1164
|
#advisorEnabled = false;
|
|
1164
1165
|
/** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
|
|
1165
1166
|
#advisorAgent?: Agent;
|
|
1167
|
+
#advisorAdviseTool?: AdviseTool;
|
|
1166
1168
|
#advisorReadOnlyTools?: AgentTool[];
|
|
1167
1169
|
#advisorWatchdogPrompt?: string;
|
|
1168
1170
|
#advisorYieldQueueUnsubscribe?: () => void;
|
|
@@ -1797,6 +1799,7 @@ export class AgentSession {
|
|
|
1797
1799
|
this.#advisorAgentUnsubscribe?.();
|
|
1798
1800
|
this.#advisorAgentUnsubscribe = undefined;
|
|
1799
1801
|
this.#advisorRuntime?.reset();
|
|
1802
|
+
this.#advisorAdviseTool?.resetDeliveredNotes();
|
|
1800
1803
|
this.#attachAdvisorRecorderFeed();
|
|
1801
1804
|
this.#advisorPrimaryTurnsCompleted = 0;
|
|
1802
1805
|
this.#advisorInterruptImmuneTurnStart = undefined;
|
|
@@ -1877,6 +1880,7 @@ export class AgentSession {
|
|
|
1877
1880
|
};
|
|
1878
1881
|
|
|
1879
1882
|
const adviseTool = new AdviseTool(enqueueAdvice);
|
|
1883
|
+
this.#advisorAdviseTool = adviseTool;
|
|
1880
1884
|
const advisorReadOnlyTools = this.#advisorReadOnlyTools ?? [];
|
|
1881
1885
|
|
|
1882
1886
|
const appendOnlyContext = new AppendOnlyContextManager();
|
|
@@ -1887,6 +1891,16 @@ export class AgentSession {
|
|
|
1887
1891
|
}
|
|
1888
1892
|
const advisorSessionId = this.sessionId ? `${this.sessionId}-advisor` : undefined;
|
|
1889
1893
|
|
|
1894
|
+
// Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
|
|
1895
|
+
// advisor on standard processing; "inherit" tracks the session's live tier
|
|
1896
|
+
// per request (like the main agent, including /fast toggles) via a resolver;
|
|
1897
|
+
// a concrete value pins the advisor to that tier regardless of the session.
|
|
1898
|
+
const advisorTierSetting = this.settings.get("serviceTierAdvisor");
|
|
1899
|
+
const advisorServiceTier =
|
|
1900
|
+
advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
|
|
1901
|
+
const advisorServiceTierResolver =
|
|
1902
|
+
advisorTierSetting === "inherit" ? (model: Model) => this.#effectiveServiceTier(model) : undefined;
|
|
1903
|
+
|
|
1890
1904
|
// Thread the primary's telemetry into the advisor loop so the advisor
|
|
1891
1905
|
// model's GenAI spans + usage/cost hooks fire like every other model call,
|
|
1892
1906
|
// stamped with the advisor's own identity. `conversationId` is cleared so
|
|
@@ -1916,6 +1930,8 @@ export class AgentSession {
|
|
|
1916
1930
|
getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
|
|
1917
1931
|
intentTracing: false,
|
|
1918
1932
|
telemetry: advisorTelemetry,
|
|
1933
|
+
serviceTier: advisorServiceTier,
|
|
1934
|
+
serviceTierResolver: advisorServiceTierResolver,
|
|
1919
1935
|
});
|
|
1920
1936
|
advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
|
|
1921
1937
|
|
|
@@ -1991,6 +2007,7 @@ export class AgentSession {
|
|
|
1991
2007
|
if (this.#advisorAgent) {
|
|
1992
2008
|
this.#advisorAgent = undefined;
|
|
1993
2009
|
}
|
|
2010
|
+
this.#advisorAdviseTool = undefined;
|
|
1994
2011
|
this.#advisorYieldQueueUnsubscribe?.();
|
|
1995
2012
|
this.#advisorYieldQueueUnsubscribe = undefined;
|
|
1996
2013
|
}
|
|
@@ -2797,6 +2814,9 @@ export class AgentSession {
|
|
|
2797
2814
|
return;
|
|
2798
2815
|
}
|
|
2799
2816
|
|
|
2817
|
+
const successfulYieldMessage = this.#findSuccessfulYieldAssistantMessage(settledMessages);
|
|
2818
|
+
const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
|
|
2819
|
+
|
|
2800
2820
|
const maintenanceRoute = (route: string, extra?: Record<string, unknown>) => {
|
|
2801
2821
|
logger.debug("agent_end maintenance routing", {
|
|
2802
2822
|
route,
|
|
@@ -2808,7 +2828,7 @@ export class AgentSession {
|
|
|
2808
2828
|
hasText: msg.content.some(content => content.type === "text"),
|
|
2809
2829
|
goalModeEnabled: this.#goalModeState?.enabled === true,
|
|
2810
2830
|
goalStatus: this.#goalModeState?.goal.status,
|
|
2811
|
-
successfulYield:
|
|
2831
|
+
successfulYield: successfulYieldMessage !== undefined,
|
|
2812
2832
|
...extra,
|
|
2813
2833
|
});
|
|
2814
2834
|
};
|
|
@@ -2833,21 +2853,24 @@ export class AgentSession {
|
|
|
2833
2853
|
}
|
|
2834
2854
|
|
|
2835
2855
|
const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
|
|
2836
|
-
const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
|
|
2837
2856
|
// A successful `yield` in this run is terminal for execution purposes.
|
|
2838
2857
|
// Suppress empty-stop retry, unexpected-stop retry, queued-message drain,
|
|
2839
2858
|
// and compaction-driven continuations for the rest of this prompt cycle:
|
|
2840
2859
|
// the executor consumed the yield as the terminal result, so a trailing
|
|
2841
2860
|
// empty/aborted assistant stop must NOT revive the agent loop. The
|
|
2842
2861
|
// `#yieldTerminationPending` sticky flag clears on the next `prompt()`.
|
|
2843
|
-
if (
|
|
2862
|
+
if (successfulYieldMessage || this.#yieldTerminationPending) {
|
|
2844
2863
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
2845
|
-
if (
|
|
2846
|
-
maintenanceRoute(
|
|
2847
|
-
|
|
2864
|
+
if (successfulYieldMessage && activeGoal) {
|
|
2865
|
+
maintenanceRoute(
|
|
2866
|
+
yieldOnThisMessage
|
|
2867
|
+
? "successful-yield-active-goal-checkCompaction"
|
|
2868
|
+
: "post-yield-trailing-stop-active-goal-checkCompaction",
|
|
2869
|
+
);
|
|
2870
|
+
const compactionTask = this.#checkCompaction(successfulYieldMessage);
|
|
2848
2871
|
this.#trackPostPromptTask(compactionTask);
|
|
2849
2872
|
await compactionTask;
|
|
2850
|
-
} else if (
|
|
2873
|
+
} else if (successfulYieldMessage) {
|
|
2851
2874
|
maintenanceRoute("successful-yield-no-active-goal");
|
|
2852
2875
|
} else {
|
|
2853
2876
|
maintenanceRoute("post-yield-trailing-stop-suppressed");
|
|
@@ -6813,7 +6836,12 @@ export class AgentSession {
|
|
|
6813
6836
|
* the transcript can distinguish a deliberate user interrupt from an opaque
|
|
6814
6837
|
* abort. Omit it for internal/lifecycle aborts.
|
|
6815
6838
|
*/
|
|
6816
|
-
async abort(options?: {
|
|
6839
|
+
async abort(options?: {
|
|
6840
|
+
goalReason?: "interrupted" | "internal";
|
|
6841
|
+
reason?: string;
|
|
6842
|
+
/** Internal `/compact` startup keeps the manual-compaction marker alive while aborting the active turn. */
|
|
6843
|
+
preserveCompaction?: boolean;
|
|
6844
|
+
}): Promise<void> {
|
|
6817
6845
|
const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
|
|
6818
6846
|
if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
|
|
6819
6847
|
// Pull advisor concerns out of the steer/follow-up queues before any await so
|
|
@@ -6828,7 +6856,17 @@ export class AgentSession {
|
|
|
6828
6856
|
this.abortRetry();
|
|
6829
6857
|
this.#promptGeneration++;
|
|
6830
6858
|
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
6831
|
-
|
|
6859
|
+
if (options?.preserveCompaction) {
|
|
6860
|
+
// Manual `/compact` installed its own #compactionAbortController before
|
|
6861
|
+
// this internal abort and must keep it alive (that marker is what makes
|
|
6862
|
+
// isCompacting report true during startup). Any in-flight
|
|
6863
|
+
// auto-compaction MUST still be cancelled, though: otherwise a
|
|
6864
|
+
// background maintenance pass races the manual run and both
|
|
6865
|
+
// appendCompaction/replaceMessages, double-rewriting session history.
|
|
6866
|
+
this.#autoCompactionAbortController?.abort();
|
|
6867
|
+
} else {
|
|
6868
|
+
this.abortCompaction();
|
|
6869
|
+
}
|
|
6832
6870
|
this.abortHandoff();
|
|
6833
6871
|
this.abortBash();
|
|
6834
6872
|
this.abortEval();
|
|
@@ -7801,12 +7839,12 @@ export class AgentSession {
|
|
|
7801
7839
|
if (compactMode?.rejectsFocus && customInstructions) {
|
|
7802
7840
|
throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
|
|
7803
7841
|
}
|
|
7804
|
-
this.#disconnectFromAgent();
|
|
7805
|
-
await this.abort({ goalReason: "internal" });
|
|
7806
7842
|
const compactionAbortController = new AbortController();
|
|
7807
7843
|
this.#compactionAbortController = compactionAbortController;
|
|
7808
7844
|
|
|
7809
7845
|
try {
|
|
7846
|
+
this.#disconnectFromAgent();
|
|
7847
|
+
await this.abort({ goalReason: "internal", preserveCompaction: true });
|
|
7810
7848
|
if (!this.model) {
|
|
7811
7849
|
throw new Error("No model selected");
|
|
7812
7850
|
}
|
|
@@ -8591,9 +8629,7 @@ export class AgentSession {
|
|
|
8591
8629
|
}
|
|
8592
8630
|
return COMPACTION_CHECK_NONE;
|
|
8593
8631
|
}
|
|
8594
|
-
#
|
|
8595
|
-
const toolCallId = this.#lastSuccessfulYieldToolCallId;
|
|
8596
|
-
if (!toolCallId) return false;
|
|
8632
|
+
#assistantMessageHasSuccessfulYieldToolCall(assistantMessage: AssistantMessage, toolCallId: string): boolean {
|
|
8597
8633
|
const lastToolCall = assistantMessage.content
|
|
8598
8634
|
.slice()
|
|
8599
8635
|
.reverse()
|
|
@@ -8601,6 +8637,22 @@ export class AgentSession {
|
|
|
8601
8637
|
return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
|
|
8602
8638
|
}
|
|
8603
8639
|
|
|
8640
|
+
#assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
|
|
8641
|
+
const toolCallId = this.#lastSuccessfulYieldToolCallId;
|
|
8642
|
+
return toolCallId ? this.#assistantMessageHasSuccessfulYieldToolCall(assistantMessage, toolCallId) : false;
|
|
8643
|
+
}
|
|
8644
|
+
|
|
8645
|
+
#findSuccessfulYieldAssistantMessage(messages: readonly AgentMessage[]): AssistantMessage | undefined {
|
|
8646
|
+
const toolCallId = this.#lastSuccessfulYieldToolCallId;
|
|
8647
|
+
if (!toolCallId) return undefined;
|
|
8648
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
8649
|
+
const message = messages[i];
|
|
8650
|
+
if (message.role !== "assistant") continue;
|
|
8651
|
+
if (this.#assistantMessageHasSuccessfulYieldToolCall(message, toolCallId)) return message;
|
|
8652
|
+
}
|
|
8653
|
+
return undefined;
|
|
8654
|
+
}
|
|
8655
|
+
|
|
8604
8656
|
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
|
|
8605
8657
|
if (!this.#isEmptyAssistantStop(assistantMessage)) {
|
|
8606
8658
|
this.#emptyStopRetryCount = 0;
|
|
@@ -10619,7 +10671,16 @@ export class AgentSession {
|
|
|
10619
10671
|
#getRetryFallbackChains(): RetryFallbackChains {
|
|
10620
10672
|
const configuredChains = this.settings.get("retry.fallbackChains");
|
|
10621
10673
|
if (!configuredChains || typeof configuredChains !== "object") return {};
|
|
10622
|
-
|
|
10674
|
+
const chains: RetryFallbackChains = { ...(configuredChains as RetryFallbackChains) };
|
|
10675
|
+
const defaultChain = chains.default;
|
|
10676
|
+
if (Array.isArray(defaultChain)) {
|
|
10677
|
+
for (const role of Object.keys(this.settings.getModelRoles())) {
|
|
10678
|
+
if (role !== "default" && chains[role] === undefined) {
|
|
10679
|
+
chains[role] = defaultChain;
|
|
10680
|
+
}
|
|
10681
|
+
}
|
|
10682
|
+
}
|
|
10683
|
+
return chains;
|
|
10623
10684
|
}
|
|
10624
10685
|
|
|
10625
10686
|
#validateRetryFallbackChains(): void {
|
|
@@ -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/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/todo.ts
CHANGED
|
@@ -534,21 +534,31 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
|
|
|
534
534
|
lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
|
|
535
535
|
}
|
|
536
536
|
}
|
|
537
|
+
// Closed = completed + abandoned, mirroring the per-phase `done` count.
|
|
538
|
+
const closedAll = tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
|
|
539
|
+
// The active phase is the EARLIEST one still holding open work, so the
|
|
540
|
+
// in-progress pointer can sit in a phase whose successors already have
|
|
541
|
+
// completed tasks. Detect that "worked ahead" case to explain the
|
|
542
|
+
// otherwise-surprising backward pointer instead of letting it read as a
|
|
543
|
+
// completed task reverting to pending.
|
|
544
|
+
const workedAhead = phases.some(
|
|
545
|
+
(phase, idx) =>
|
|
546
|
+
idx > currentIdx && phase.tasks.some(task => task.status === "completed" || task.status === "abandoned"),
|
|
547
|
+
);
|
|
548
|
+
lines.push(`Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open.`);
|
|
537
549
|
lines.push(
|
|
538
|
-
`
|
|
550
|
+
`Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${
|
|
551
|
+
workedAhead
|
|
552
|
+
? " — earliest phase with open tasks; the in-progress pointer auto-advances to the earliest open task on each completion, so it can sit behind out-of-order work (nothing was un-completed)."
|
|
553
|
+
: "."
|
|
554
|
+
}`,
|
|
539
555
|
);
|
|
540
556
|
for (const phase of phases) {
|
|
541
557
|
lines.push(` ${phase.name}:`);
|
|
542
558
|
for (const task of phase.tasks) {
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
: task.status === "in_progress"
|
|
547
|
-
? "→"
|
|
548
|
-
: task.status === "abandoned"
|
|
549
|
-
? "✗"
|
|
550
|
-
: "○";
|
|
551
|
-
lines.push(` ${sym} ${task.content}`);
|
|
559
|
+
const checkbox = task.status === "completed" ? "[X]" : "[ ]";
|
|
560
|
+
const tag = task.status === "in_progress" ? " (in progress)" : task.status === "abandoned" ? " (dropped)" : "";
|
|
561
|
+
lines.push(` - ${checkbox} ${task.content}${tag}`);
|
|
552
562
|
}
|
|
553
563
|
}
|
|
554
564
|
return lines.join("\n");
|