@jopqior/pi-subagents 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2705 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/public.d.ts +331 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +1566 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +77 -0
- package/docs/configuration.md +364 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
- package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
- package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
- package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
- package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
- package/package.json +91 -0
- package/src/config/agent-types.ts +135 -0
- package/src/config/custom-agents.ts +151 -0
- package/src/config/default-agents.ts +121 -0
- package/src/config/invocation-config.ts +167 -0
- package/src/config/thinking-level.ts +58 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +71 -0
- package/src/handlers/widget-events.ts +49 -0
- package/src/index.ts +292 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +115 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +335 -0
- package/src/lifecycle/parent-snapshot.ts +119 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/selection-scope.ts +116 -0
- package/src/lifecycle/spawn-selection.ts +259 -0
- package/src/lifecycle/subagent-manager.ts +546 -0
- package/src/lifecycle/subagent-session.ts +347 -0
- package/src/lifecycle/subagent-state.ts +404 -0
- package/src/lifecycle/subagent.ts +885 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +76 -0
- package/src/lifecycle/workspace.ts +46 -0
- package/src/observation/composite-subagent-observer.ts +74 -0
- package/src/observation/notification.ts +430 -0
- package/src/observation/outcome-delivery.ts +239 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +161 -0
- package/src/observation/subagent-events-observer.ts +148 -0
- package/src/runtime.ts +137 -0
- package/src/service/service-adapter.ts +201 -0
- package/src/service/service.ts +246 -0
- package/src/session/ask-parent-tool.ts +69 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +126 -0
- package/src/session/notify-parent-tool.ts +83 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +231 -0
- package/src/session/provider-inheritance.ts +56 -0
- package/src/session/selection-catalogue.ts +143 -0
- package/src/session/session-config.ts +202 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +447 -0
- package/src/tools/agent-tool.ts +305 -0
- package/src/tools/background-spawner.ts +83 -0
- package/src/tools/foreground-runner.ts +159 -0
- package/src/tools/get-result-renderer.ts +119 -0
- package/src/tools/get-result-report.ts +84 -0
- package/src/tools/get-result-tool.ts +192 -0
- package/src/tools/helpers.ts +118 -0
- package/src/tools/result-renderer.ts +153 -0
- package/src/tools/spawn-config.ts +192 -0
- package/src/tools/steer-tool.ts +109 -0
- package/src/types.ts +143 -0
- package/src/ui/agent-widget.ts +333 -0
- package/src/ui/bounded-lines.ts +45 -0
- package/src/ui/display.ts +180 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +150 -0
- package/src/ui/session-navigator.ts +255 -0
- package/src/ui/subagents-settings.ts +179 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +301 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation.ts — Render a subagent session's messages as formatted text.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent-runner.ts (issue #265) into the session domain, where the
|
|
5
|
+
* other message-extraction helpers (content-items, context) live. Consumed by
|
|
6
|
+
* the get_subagent_result tool's verbose output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { extractAssistantContent } from "#src/session/content-items";
|
|
11
|
+
import { extractText } from "#src/session/context";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the subagent's conversation messages as formatted text.
|
|
15
|
+
*/
|
|
16
|
+
export function getAgentConversation(session: AgentSession): string {
|
|
17
|
+
const parts: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (const msg of session.messages) {
|
|
20
|
+
if (msg.role === "user") {
|
|
21
|
+
const text =
|
|
22
|
+
typeof msg.content === "string"
|
|
23
|
+
? msg.content
|
|
24
|
+
: extractText(msg.content);
|
|
25
|
+
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
|
|
26
|
+
} else if (msg.role === "assistant") {
|
|
27
|
+
const { textParts, toolNames } = extractAssistantContent(msg.content);
|
|
28
|
+
const attribution = formatAttribution(msg);
|
|
29
|
+
if (textParts.length > 0)
|
|
30
|
+
parts.push(`[Assistant${attribution}]: ${textParts.join("\n")}`);
|
|
31
|
+
if (toolNames.length > 0)
|
|
32
|
+
parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
|
|
33
|
+
} else if (msg.role === "toolResult") {
|
|
34
|
+
const text = extractText(msg.content);
|
|
35
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
|
|
36
|
+
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return parts.join("\n\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build a `(provider/model)` attribution suffix for assistant messages. */
|
|
44
|
+
function formatAttribution(msg: { provider?: string; model?: string }): string {
|
|
45
|
+
const { provider, model } = msg;
|
|
46
|
+
if (!provider && !model) return "";
|
|
47
|
+
if (provider && model) return ` (${provider}/${model})`;
|
|
48
|
+
return ` (${provider ?? model})`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env.ts — Detect environment info (git, platform) for subagent system prompts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { debugLog } from "#src/debug";
|
|
6
|
+
import type { ShellExec } from "#src/types";
|
|
7
|
+
|
|
8
|
+
export interface EnvInfo {
|
|
9
|
+
isGitRepo: boolean;
|
|
10
|
+
branch: string;
|
|
11
|
+
platform: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function detectEnv(exec: ShellExec, cwd: string): Promise<EnvInfo> {
|
|
15
|
+
let isGitRepo = false;
|
|
16
|
+
let branch = "";
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const result = await exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
|
|
20
|
+
isGitRepo = result.code === 0 && result.stdout.trim() === "true";
|
|
21
|
+
} catch (err) {
|
|
22
|
+
debugLog("git rev-parse", err);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isGitRepo) {
|
|
26
|
+
try {
|
|
27
|
+
const result = await exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
|
|
28
|
+
branch = result.code === 0 ? result.stdout.trim() : "unknown";
|
|
29
|
+
} catch (err) {
|
|
30
|
+
debugLog("git branch", err);
|
|
31
|
+
branch = "unknown";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
isGitRepo,
|
|
37
|
+
branch,
|
|
38
|
+
platform: process.platform,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
|
|
3
|
+
*/
|
|
4
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
5
|
+
|
|
6
|
+
export interface ModelRegistry {
|
|
7
|
+
find(provider: string, modelId: string): Model<any> | undefined;
|
|
8
|
+
getAll(): Model<any>[];
|
|
9
|
+
getAvailable?(): Model<any>[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Successful model resolution — `model` is the resolved or inherited model instance. */
|
|
13
|
+
export interface ModelResolutionResult {
|
|
14
|
+
model: Model<any> | undefined;
|
|
15
|
+
error?: undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Failed model resolution when the model was user-specified (params) — surface the error. */
|
|
19
|
+
export interface ModelResolutionError {
|
|
20
|
+
model?: undefined;
|
|
21
|
+
error: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Discriminated union returned by `resolveInvocationModel`. */
|
|
25
|
+
export type ModelResolution = ModelResolutionResult | ModelResolutionError;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the effective model for an agent invocation.
|
|
29
|
+
*
|
|
30
|
+
* Encapsulates the three-branch fallback policy used in `Agent.execute`:
|
|
31
|
+
* 1. No `modelInput` → inherit `parentModel`.
|
|
32
|
+
* 2. `modelInput` resolves → return the resolved model.
|
|
33
|
+
* 3. `modelInput` fails:
|
|
34
|
+
* - `modelFromParams` true → return `{ error }` so the caller can surface it.
|
|
35
|
+
* - `modelFromParams` false → silent fallback to `parentModel`.
|
|
36
|
+
*
|
|
37
|
+
* `modelFromParams` reports which side supplied the winning string, not merely whether
|
|
38
|
+
* the caller passed one — a caller whose value an agent's `locked:` frontmatter discarded
|
|
39
|
+
* did not win, so its typo is not the string being resolved here.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveInvocationModel(
|
|
42
|
+
parentModel: Model<any> | undefined,
|
|
43
|
+
modelInput: string | undefined,
|
|
44
|
+
modelFromParams: boolean,
|
|
45
|
+
registry: ModelRegistry | undefined,
|
|
46
|
+
): ModelResolution {
|
|
47
|
+
if (!modelInput) return { model: parentModel };
|
|
48
|
+
if (!registry) return { error: "No model registry available." };
|
|
49
|
+
const resolved = resolveModel(modelInput, registry);
|
|
50
|
+
if (typeof resolved !== "string") return { model: resolved };
|
|
51
|
+
if (modelFromParams) return { error: resolved };
|
|
52
|
+
return { model: parentModel };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a model string to a Model instance.
|
|
57
|
+
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
|
58
|
+
* Returns the Model on success, or an error message string on failure.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveModel(
|
|
61
|
+
input: string,
|
|
62
|
+
registry: ModelRegistry,
|
|
63
|
+
): Model<any> | string {
|
|
64
|
+
// Available models (those with auth configured)
|
|
65
|
+
const all = registry.getAvailable?.() ?? registry.getAll();
|
|
66
|
+
const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
|
|
67
|
+
|
|
68
|
+
// 1. Exact match: "provider/modelId" — only if available (has auth)
|
|
69
|
+
const slashIdx = input.indexOf("/");
|
|
70
|
+
if (slashIdx !== -1) {
|
|
71
|
+
const provider = input.slice(0, slashIdx);
|
|
72
|
+
const modelId = input.slice(slashIdx + 1);
|
|
73
|
+
if (availableSet.has(input.toLowerCase())) {
|
|
74
|
+
const found = registry.find(provider, modelId);
|
|
75
|
+
if (found) return found;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Fuzzy match against available models
|
|
80
|
+
const bestMatch = findBestFuzzyMatch(all, input.toLowerCase());
|
|
81
|
+
if (bestMatch) {
|
|
82
|
+
const found = registry.find(bestMatch.provider, bestMatch.id);
|
|
83
|
+
if (found) return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. No match — list available models
|
|
87
|
+
const modelList = all
|
|
88
|
+
.map(m => ` ${m.provider}/${m.id}`)
|
|
89
|
+
.sort()
|
|
90
|
+
.join("\n");
|
|
91
|
+
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Score each candidate model — prefer exact id match > id contains > name
|
|
96
|
+
* contains > provider+id contains — and return the best match at or above
|
|
97
|
+
* the acceptance threshold (20), or undefined if nothing scores high enough.
|
|
98
|
+
*/
|
|
99
|
+
function findBestFuzzyMatch(all: Model<any>[], query: string): Model<any> | undefined {
|
|
100
|
+
let bestMatch: Model<any> | undefined;
|
|
101
|
+
let bestScore = 0;
|
|
102
|
+
|
|
103
|
+
for (const m of all) {
|
|
104
|
+
const id = m.id.toLowerCase();
|
|
105
|
+
const name = m.name.toLowerCase();
|
|
106
|
+
const full = `${m.provider}/${m.id}`.toLowerCase();
|
|
107
|
+
|
|
108
|
+
let score = 0;
|
|
109
|
+
if (id === query || full === query) {
|
|
110
|
+
score = 100; // exact
|
|
111
|
+
} else if (id.includes(query) || full.includes(query)) {
|
|
112
|
+
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
|
|
113
|
+
} else if (name.includes(query)) {
|
|
114
|
+
score = 40 + (query.length / name.length) * 20;
|
|
115
|
+
} else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
|
|
116
|
+
score = 20; // all parts present somewhere
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (score > bestScore) {
|
|
120
|
+
bestScore = score;
|
|
121
|
+
bestMatch = m;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return bestScore >= 20 ? bestMatch : undefined;
|
|
126
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-parent-tool.ts — The tool a child sends its delegating agent a one-way
|
|
3
|
+
* update with.
|
|
4
|
+
*
|
|
5
|
+
* A running child that discovers something material — a course change, a wrong
|
|
6
|
+
* premise, a scope problem — says so without abandoning its run. The call
|
|
7
|
+
* returns at once; the parent may reply by steering, which the child picks up
|
|
8
|
+
* at its next turn boundary.
|
|
9
|
+
*
|
|
10
|
+
* Supplied to every child, gated only on the `midRunUpdates` setting. Where
|
|
11
|
+
* the message lands is decided per call rather than per child: while a carrier
|
|
12
|
+
* holds this run's outcome it is blocked awaiting the child, so that carrier
|
|
13
|
+
* renders the update into its own return instead of an announcement arriving
|
|
14
|
+
* after it. A child whose parent is free is nudged as it goes.
|
|
15
|
+
*
|
|
16
|
+
* Lives in `session/` alongside `ask-parent-tool.ts`, for the same reason.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Type } from "@sinclair/typebox";
|
|
21
|
+
|
|
22
|
+
export const NOTIFY_PARENT_TOOL_NAME = "notify_parent";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The longest update the channel carries.
|
|
26
|
+
*
|
|
27
|
+
* Neither carrier can be pulled from again — unlike a result, there is no
|
|
28
|
+
* `get_subagent_result` that returns the untruncated text — so the cut is made
|
|
29
|
+
* here, where the child is told about it and can be briefer next time.
|
|
30
|
+
*/
|
|
31
|
+
export const UPDATE_MESSAGE_MAX_LENGTH = 2000;
|
|
32
|
+
|
|
33
|
+
const RESULT_TEXT =
|
|
34
|
+
"Update sent to the delegating agent. Continue working; it may steer you if it wants to redirect.";
|
|
35
|
+
|
|
36
|
+
/** Announces a child's update to its parent. */
|
|
37
|
+
export type UpdateAnnouncer = (message: string) => void;
|
|
38
|
+
|
|
39
|
+
export class NotifyParentTool {
|
|
40
|
+
constructor(private readonly announce: UpdateAnnouncer) {}
|
|
41
|
+
|
|
42
|
+
execute(
|
|
43
|
+
_toolCallId: string,
|
|
44
|
+
params: { message: string },
|
|
45
|
+
_signal: AbortSignal,
|
|
46
|
+
_onUpdate: unknown,
|
|
47
|
+
_ctx: unknown,
|
|
48
|
+
) {
|
|
49
|
+
const truncated = params.message.length > UPDATE_MESSAGE_MAX_LENGTH;
|
|
50
|
+
this.announce(truncated ? params.message.slice(0, UPDATE_MESSAGE_MAX_LENGTH) : params.message);
|
|
51
|
+
const note = truncated
|
|
52
|
+
? ` Your message was truncated to ${UPDATE_MESSAGE_MAX_LENGTH} characters; keep the next one shorter.`
|
|
53
|
+
: "";
|
|
54
|
+
// `details` is required by the SDK's result type; this tool renders none.
|
|
55
|
+
return { content: [{ type: "text" as const, text: RESULT_TEXT + note }], details: undefined };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
toToolDefinition() {
|
|
59
|
+
return defineTool({
|
|
60
|
+
name: NOTIFY_PARENT_TOOL_NAME,
|
|
61
|
+
label: "Notify Parent",
|
|
62
|
+
promptSnippet: "Send the delegating agent a one-way update without pausing.",
|
|
63
|
+
description:
|
|
64
|
+
"Send a one-way update to the agent that delegated this task and keep working. Use it only " +
|
|
65
|
+
"for a material finding that changes what the delegating agent would do — a course change, a " +
|
|
66
|
+
"wrong premise, a scope problem — not for routine progress. It does not wait for a reply; the " +
|
|
67
|
+
"delegating agent may steer you if it wants to redirect.",
|
|
68
|
+
parameters: Type.Object({
|
|
69
|
+
message: Type.String({
|
|
70
|
+
description: "The finding the delegating agent should know about now rather than at the end.",
|
|
71
|
+
}),
|
|
72
|
+
}),
|
|
73
|
+
// The tool's own work is synchronous; the SDK's execute contract is not.
|
|
74
|
+
execute: (
|
|
75
|
+
toolCallId: string,
|
|
76
|
+
params: { message: string },
|
|
77
|
+
signal: AbortSignal,
|
|
78
|
+
onUpdate: unknown,
|
|
79
|
+
ctx: unknown,
|
|
80
|
+
) => Promise.resolve(this.execute(toolCallId, params, signal, onUpdate, ctx)),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package-exclusions.ts — build a child-local view of Pi's package settings
|
|
3
|
+
* that disables selected packages' extensions (issue #696).
|
|
4
|
+
*
|
|
5
|
+
* Pi resolves a child session's resources from `Settings.packages`. An entry in
|
|
6
|
+
* object form with `extensions: []` loads none of that package's extensions in
|
|
7
|
+
* both of Pi's filter modes — the default mode treats the empty array as an
|
|
8
|
+
* explicit disable, and `autoload: false` delta mode starts empty and only adds
|
|
9
|
+
* explicitly listed patterns. Filtering here, at resolution time, means the
|
|
10
|
+
* excluded package's extension module is never imported and its factory never
|
|
11
|
+
* runs, which is what makes this a prevent-load seam rather than a deny-at-use
|
|
12
|
+
* one. The package's skills, prompts, and themes are untouched.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PackageSource, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
/** Pi does not export `Settings` from its package root; derive it from the accessor. */
|
|
18
|
+
type PiSettings = ReturnType<SettingsManager["getGlobalSettings"]>;
|
|
19
|
+
|
|
20
|
+
/** Pi does not export `SettingsStorage` either; derive it from the public factory. */
|
|
21
|
+
type PiSettingsStorage = Parameters<typeof SettingsManager.fromStorage>[0];
|
|
22
|
+
|
|
23
|
+
/** The parent-settings reads a child view needs — narrower than `SettingsManager`. */
|
|
24
|
+
export interface ParentSettingsView {
|
|
25
|
+
getGlobalSettings(): PiSettings;
|
|
26
|
+
getProjectSettings(): PiSettings;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A storage backend that serves the parent's settings with the excluded
|
|
31
|
+
* packages' extensions disabled, for `SettingsManager.fromStorage`.
|
|
32
|
+
*
|
|
33
|
+
* Reads pass through to `parent` on every load, so a `reload()` reflects the
|
|
34
|
+
* parent's current values rather than a construction-time snapshot.
|
|
35
|
+
*
|
|
36
|
+
* Writes are discarded deliberately. `withLock` persists whatever its callback
|
|
37
|
+
* returns, and the callback here is handed synthesized `extensions: []` entries;
|
|
38
|
+
* forwarding that write would disable those packages in the user's real
|
|
39
|
+
* settings file, for the parent and every future session.
|
|
40
|
+
*/
|
|
41
|
+
export function createExcludedPackagesStorage(
|
|
42
|
+
parent: ParentSettingsView,
|
|
43
|
+
excluded: ReadonlySet<string>,
|
|
44
|
+
): PiSettingsStorage {
|
|
45
|
+
return {
|
|
46
|
+
withLock(scope, fn) {
|
|
47
|
+
const settings = scope === "global" ? parent.getGlobalSettings() : parent.getProjectSettings();
|
|
48
|
+
fn(JSON.stringify(withPackageExtensionsDisabled(settings, excluded)));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Return `settings` with every excluded package's `extensions` filter emptied.
|
|
55
|
+
* Non-matched entries are returned by identity and the input is never mutated.
|
|
56
|
+
*/
|
|
57
|
+
export function withPackageExtensionsDisabled(
|
|
58
|
+
settings: PiSettings,
|
|
59
|
+
excluded: ReadonlySet<string>,
|
|
60
|
+
): PiSettings {
|
|
61
|
+
if (!settings.packages || excluded.size === 0) return settings;
|
|
62
|
+
return {
|
|
63
|
+
...settings,
|
|
64
|
+
packages: settings.packages.map((pkg) => disableExtensionsIfExcluded(pkg, excluded)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function disableExtensionsIfExcluded(
|
|
69
|
+
pkg: PackageSource,
|
|
70
|
+
excluded: ReadonlySet<string>,
|
|
71
|
+
): PackageSource {
|
|
72
|
+
const source = typeof pkg === "string" ? pkg : pkg.source;
|
|
73
|
+
if (!excluded.has(source)) return pkg;
|
|
74
|
+
return typeof pkg === "string" ? { source, extensions: [] } : { ...pkg, extensions: [] };
|
|
75
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompts.ts — System prompt builder for agents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EnvInfo } from "#src/session/env";
|
|
6
|
+
import type { AgentPromptConfig, PromptInheritance } from "#src/types";
|
|
7
|
+
|
|
8
|
+
/** The parent session's contribution to a child prompt, plus the cwd that text claims. */
|
|
9
|
+
export interface InheritedPrompt {
|
|
10
|
+
/** The parent agent's effective system prompt. */
|
|
11
|
+
systemPrompt: string;
|
|
12
|
+
/** The parent's working directory — the cwd its prompt footer names. */
|
|
13
|
+
cwd: string;
|
|
14
|
+
/**
|
|
15
|
+
* Which of the parent's contributions the child adopts as its identity.
|
|
16
|
+
* Absent means `"full"`, the strategy every provider gets unless its
|
|
17
|
+
* operator has said otherwise.
|
|
18
|
+
*/
|
|
19
|
+
strategy?: PromptInheritance;
|
|
20
|
+
/**
|
|
21
|
+
* The parent's operator-authored parts, for a `"portable"` child. May be
|
|
22
|
+
* absent even then — the parent may have assembled no prompt yet, or have no
|
|
23
|
+
* such parts.
|
|
24
|
+
*/
|
|
25
|
+
portablePrompt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the system prompt for an agent from its config.
|
|
30
|
+
*
|
|
31
|
+
* Both modes place the shared/stable parent prompt (or `genericBase` when no
|
|
32
|
+
* parent is available) first, so the inherited identity is a leading prefix the
|
|
33
|
+
* child shares with its parent across all subagent invocations. What that is
|
|
34
|
+
* worth is host-dependent — see ADR 0008.
|
|
35
|
+
*
|
|
36
|
+
* - "replace" mode: parent/genericBase + active_agent tag + env header +
|
|
37
|
+
* config.systemPrompt. No `<agent_instructions>` wrapper — the custom
|
|
38
|
+
* prompt has full control and the final say.
|
|
39
|
+
* - "append" mode: parent/genericBase + active_agent tag + env header +
|
|
40
|
+
* config.systemPrompt (wrapped in `<agent_instructions>` when non-empty).
|
|
41
|
+
* - "append" with empty systemPrompt: pure parent clone.
|
|
42
|
+
*
|
|
43
|
+
* The two modes now differ only in the `<agent_instructions>` wrapper. The
|
|
44
|
+
* `<sub_agent_context>` bridge append mode used to carry was removed in #890:
|
|
45
|
+
* its tool bullets duplicated the `promptGuidelines` Pi's own tools contribute
|
|
46
|
+
* to every child's prompt, and it asserted them unconditionally — telling a
|
|
47
|
+
* read-only child to use `edit` and `write` when it has neither.
|
|
48
|
+
*
|
|
49
|
+
* Both modes include an `<active_agent name="${config.name}"/>` tag so
|
|
50
|
+
* downstream extensions (e.g. `@gotgenes/pi-permission-system`) can resolve
|
|
51
|
+
* per-agent policy inside the child session by parsing the system prompt.
|
|
52
|
+
* The tag follows the cacheable parent prefix in both modes.
|
|
53
|
+
*
|
|
54
|
+
* Only the parent prompt's identity is inherited — see `inheritedIdentity`.
|
|
55
|
+
*
|
|
56
|
+
* @param inherited The parent agent's effective system prompt and the cwd it names.
|
|
57
|
+
*/
|
|
58
|
+
export function buildAgentPrompt(
|
|
59
|
+
config: AgentPromptConfig,
|
|
60
|
+
cwd: string,
|
|
61
|
+
env: EnvInfo,
|
|
62
|
+
inherited?: InheritedPrompt,
|
|
63
|
+
): string {
|
|
64
|
+
const header = buildPromptHeader(config.name, cwd, env);
|
|
65
|
+
|
|
66
|
+
const identity = inherited ? adoptedIdentity(inherited) : genericBase;
|
|
67
|
+
|
|
68
|
+
if (config.promptMode === "append") {
|
|
69
|
+
const customSection = config.systemPrompt.trim()
|
|
70
|
+
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
|
71
|
+
: "";
|
|
72
|
+
|
|
73
|
+
// Place the inherited identity first so it forms a shared leading prefix
|
|
74
|
+
// with the parent session, which prefix-reusing inference engines reuse
|
|
75
|
+
// instead of reprocessing. The <active_agent> tag and env block vary per
|
|
76
|
+
// call and are placed after that prefix.
|
|
77
|
+
return identity + "\n\n" + header + customSection;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// "replace" mode — identity prefix first, then the active_agent tag, env
|
|
81
|
+
// block, and the config's full system prompt. Unlike append mode, no
|
|
82
|
+
// <agent_instructions> wrapper is injected — the custom prompt retains full
|
|
83
|
+
// control.
|
|
84
|
+
return identity + "\n\n" + header + "\n\n" + config.systemPrompt;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The parent contribution the child adopts, per the strategy its provider set.
|
|
89
|
+
*
|
|
90
|
+
* `full` takes the assembled prompt's identity region, which stays a leading
|
|
91
|
+
* prefix shared with the parent (ADR 0008). `portable` takes the parent's
|
|
92
|
+
* operator-authored parts instead, for a provider that re-homes the prompt into
|
|
93
|
+
* a harness supplying its own base (ADR 0009).
|
|
94
|
+
*
|
|
95
|
+
* An absent or whitespace-only portable capture falls back to the generic base,
|
|
96
|
+
* never to the full prompt: opting into portable must never silently re-embed
|
|
97
|
+
* the harness base it exists to avoid.
|
|
98
|
+
*/
|
|
99
|
+
function adoptedIdentity(inherited: InheritedPrompt): string {
|
|
100
|
+
if (inherited.strategy !== "portable") {
|
|
101
|
+
return inheritedIdentity(inherited.systemPrompt, inherited.cwd);
|
|
102
|
+
}
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: a whitespace-only capture must fall back too, which ?? would not do
|
|
104
|
+
return inherited.portablePrompt?.trim() || genericBase;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The per-call header both prompt modes share: the `<active_agent>` tag and the
|
|
109
|
+
* environment block. Both vary per invocation, so both sit after the cacheable
|
|
110
|
+
* identity prefix — and both modes need any content added here, which is why it
|
|
111
|
+
* has one home rather than being composed at each `return`.
|
|
112
|
+
*/
|
|
113
|
+
function buildPromptHeader(agentName: string, cwd: string, env: EnvInfo): string {
|
|
114
|
+
const activeAgentTag = `<active_agent name="${agentName}"/>\n\n`;
|
|
115
|
+
|
|
116
|
+
const envBlock = `# Environment
|
|
117
|
+
Working directory: ${cwd}
|
|
118
|
+
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
|
|
119
|
+
Platform: ${env.platform}`;
|
|
120
|
+
|
|
121
|
+
return `${activeAgentTag}${envBlock}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** First line of the section Pi writes above the `<available_skills>` catalogue. */
|
|
125
|
+
const SKILLS_SECTION_HEADING =
|
|
126
|
+
"The following skills provide specialized instructions for specific tasks.";
|
|
127
|
+
|
|
128
|
+
/** Closing tag of that catalogue. */
|
|
129
|
+
const SKILLS_CATALOGUE_CLOSE = "</available_skills>";
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reduce an inherited prompt to the identity a child may adopt as its own.
|
|
133
|
+
*
|
|
134
|
+
* Pi's `buildSystemPrompt` ends every prompt with layers it resolves per
|
|
135
|
+
* session — the `<available_skills>` catalogue, then a
|
|
136
|
+
* `Current working directory:` footer — and extensions append further blocks
|
|
137
|
+
* after those from `before_agent_start`, rebuilt from the base prompt on every
|
|
138
|
+
* turn. The child's own session rebuilds all of it against the child's
|
|
139
|
+
* directory, tool set, and extensions, so an inherited copy is a second, stale
|
|
140
|
+
* claim of each: a catalogue naming skills the child may not have (#801), and
|
|
141
|
+
* a footer that walks a workspace-isolated child back into the parent's
|
|
142
|
+
* directory (#640).
|
|
143
|
+
*
|
|
144
|
+
* Everything from the first such layer onward is therefore dropped. What
|
|
145
|
+
* precedes it is returned byte for byte, so it stays a shared prefix with the
|
|
146
|
+
* parent's prompt for hosts that reuse one over the system text (#180, #400).
|
|
147
|
+
* That is why no extension may edit the region in place: `Available tools:`
|
|
148
|
+
* sits a few hundred characters into it, and narrowing it there ended the
|
|
149
|
+
* shared prefix for every child with a narrowed tool set (#890).
|
|
150
|
+
*
|
|
151
|
+
* A prompt carrying neither layer is not one `buildSystemPrompt` assembled, and
|
|
152
|
+
* is returned unchanged.
|
|
153
|
+
*/
|
|
154
|
+
function inheritedIdentity(prompt: string, parentCwd: string): string {
|
|
155
|
+
const lines = prompt.split("\n");
|
|
156
|
+
const tailStart = sessionResolvedTailStart(lines, parentCwd);
|
|
157
|
+
return tailStart === -1
|
|
158
|
+
? prompt
|
|
159
|
+
: lines.slice(0, tailStart).join("\n").trimEnd();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Line index at which Pi's per-session layers begin, or -1 when none is present.
|
|
164
|
+
*
|
|
165
|
+
* The catalogue precedes the footer, so cutting at the catalogue already
|
|
166
|
+
* removes it; the footer is the anchor only for a parent session that resolved
|
|
167
|
+
* no skills. Matching whole lines makes the footer match exact, so a footer
|
|
168
|
+
* naming a directory that merely shares a prefix with the parent's is not
|
|
169
|
+
* mistaken for it, and it mirrors the separator normalization
|
|
170
|
+
* `buildSystemPrompt` applies.
|
|
171
|
+
*/
|
|
172
|
+
function sessionResolvedTailStart(
|
|
173
|
+
lines: readonly string[],
|
|
174
|
+
parentCwd: string,
|
|
175
|
+
): number {
|
|
176
|
+
const footerAt = lines.lastIndexOf(
|
|
177
|
+
`Current working directory: ${toPromptPath(parentCwd)}`,
|
|
178
|
+
);
|
|
179
|
+
const catalogueAt = skillsSectionStart(lines, footerAt);
|
|
180
|
+
return catalogueAt === -1 ? footerAt : catalogueAt;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Line index of the skills section's heading, or -1 when the section is absent.
|
|
185
|
+
*
|
|
186
|
+
* The heading is located by searching back from the catalogue's closing tag, so
|
|
187
|
+
* prose quoting Pi's heading ahead of the section is not mistaken for it.
|
|
188
|
+
*/
|
|
189
|
+
function skillsSectionStart(
|
|
190
|
+
lines: readonly string[],
|
|
191
|
+
footerAt: number,
|
|
192
|
+
): number {
|
|
193
|
+
const catalogueEnd = catalogueCloseBefore(lines, footerAt);
|
|
194
|
+
return catalogueEnd === -1
|
|
195
|
+
? -1
|
|
196
|
+
: lines.lastIndexOf(SKILLS_SECTION_HEADING, catalogueEnd);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Line index of Pi's own catalogue closing tag, or -1 when it wrote none.
|
|
201
|
+
*
|
|
202
|
+
* `buildSystemPrompt` writes the cwd footer immediately after the catalogue, in
|
|
203
|
+
* both of its branches and unconditionally, so the tag on the line before the
|
|
204
|
+
* footer is Pi's own. Identifying it by that position rather than by document
|
|
205
|
+
* order keeps a catalogue quoted elsewhere — in a project-context file, or in a
|
|
206
|
+
* block an extension appended after the footer — from being taken for the
|
|
207
|
+
* section, in either direction.
|
|
208
|
+
*
|
|
209
|
+
* Without a footer to anchor on, something downstream has rewritten Pi's
|
|
210
|
+
* output; the last closing tag is the best remaining guess.
|
|
211
|
+
*/
|
|
212
|
+
function catalogueCloseBefore(
|
|
213
|
+
lines: readonly string[],
|
|
214
|
+
footerAt: number,
|
|
215
|
+
): number {
|
|
216
|
+
if (footerAt === -1) {
|
|
217
|
+
return lines.lastIndexOf(SKILLS_CATALOGUE_CLOSE);
|
|
218
|
+
}
|
|
219
|
+
return lines[footerAt - 1] === SKILLS_CATALOGUE_CLOSE ? footerAt - 1 : -1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Render a path the way `buildSystemPrompt` writes it into a prompt. */
|
|
223
|
+
function toPromptPath(cwd: string): string {
|
|
224
|
+
return cwd.replaceAll("\\", "/");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Fallback base prompt when parent system prompt is unavailable (both modes). */
|
|
228
|
+
const genericBase = `# Role
|
|
229
|
+
You are a general-purpose coding agent for complex, multi-step tasks.
|
|
230
|
+
You have full access to read, write, edit files, and execute commands.
|
|
231
|
+
Do what has been asked; nothing more, nothing less.`;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider inheritance — replay a parent session's runtime provider
|
|
3
|
+
* registrations onto a child session's model runtime.
|
|
4
|
+
*
|
|
5
|
+
* Pi builds a fresh ModelRuntime for every session that is not handed one, and
|
|
6
|
+
* runtime registrations (pi.registerProvider) live on that instance rather than
|
|
7
|
+
* in models.json or auth.json. A child therefore starts with none of them and
|
|
8
|
+
* cannot resolve the parent's provider, which surfaces as
|
|
9
|
+
* "No API key found for <provider>" (Refs #812).
|
|
10
|
+
*
|
|
11
|
+
* The collaborators are narrow structural contracts rather than the SDK's
|
|
12
|
+
* ModelRegistry: the concrete class carries a private field, which would force
|
|
13
|
+
* test doubles to cast or replicate internals. Provider and config values are
|
|
14
|
+
* pass-through only — this module never inspects them — so they stay generic,
|
|
15
|
+
* which also avoids naming SDK types the package entry does not export.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Enumerates the providers registered on a session at runtime. */
|
|
19
|
+
export interface RegisteredProviderSource<TNative, TConfig> {
|
|
20
|
+
getRegisteredProviderIds(): readonly string[];
|
|
21
|
+
getRegisteredNativeProvider(id: string): TNative | undefined;
|
|
22
|
+
getRegisteredProviderConfig(id: string): TConfig | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Accepts provider registrations on behalf of a session.
|
|
27
|
+
*
|
|
28
|
+
* The two forms are separate methods rather than the SDK's single overloaded
|
|
29
|
+
* `registerProvider`, so a plain `vi.fn()` can stand in for each without a cast.
|
|
30
|
+
*/
|
|
31
|
+
export interface ProviderRegistrar<TNative, TConfig> {
|
|
32
|
+
registerNative(provider: TNative): void;
|
|
33
|
+
registerConfigured(id: string, config: TConfig): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Copy every runtime-registered provider from `source` onto `target`.
|
|
38
|
+
*
|
|
39
|
+
* Pi keeps native and configured registrations in disjoint maps — registering
|
|
40
|
+
* one form deletes the id from the other — so each id resolves to exactly one
|
|
41
|
+
* form. An id that resolves to neither is skipped rather than registered empty.
|
|
42
|
+
*/
|
|
43
|
+
export function inheritRegisteredProviders<TNative, TConfig>(
|
|
44
|
+
source: RegisteredProviderSource<TNative, TConfig>,
|
|
45
|
+
target: ProviderRegistrar<TNative, TConfig>,
|
|
46
|
+
): void {
|
|
47
|
+
for (const id of source.getRegisteredProviderIds()) {
|
|
48
|
+
const native = source.getRegisteredNativeProvider(id);
|
|
49
|
+
if (native !== undefined) {
|
|
50
|
+
target.registerNative(native);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const config = source.getRegisteredProviderConfig(id);
|
|
54
|
+
if (config !== undefined) target.registerConfigured(id, config);
|
|
55
|
+
}
|
|
56
|
+
}
|