@preapexis/piexis 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -5
- package/extensions/agents.ts +220 -66
- package/extensions/ui.ts +18 -7
- package/lib/agent-config.ts +5 -1
- package/lib/agents.ts +35 -2
- package/lib/subagent-runner.ts +4 -1
- package/lib/subagent-scheduler.ts +221 -0
- package/package.json +1 -1
- package/schemas/agent.schema.json +4 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Restart Pi or run `/reload` after installation. The package loads AskQuestion, a
|
|
|
13
13
|
## Resources
|
|
14
14
|
|
|
15
15
|
- **AskQuestion** provides keyboard-driven single/multi selection and text questions in the interactive TUI, with validation, answer review, and cancellation. It requires terminal input and output.
|
|
16
|
-
- **UI** shows project information, agent activity, Git branch, model, and context usage. The startup header shows three random, category-labelled tips from `lib/ui-tips.json`, preferring different categories and staying stable across redraws. Tips cycle without repeats until the list is exhausted within the extension runtime; `/reload` starts a new cycle. Each JSON entry has `category` and `text` fields; edit them to change the recommendations.
|
|
16
|
+
- **UI** shows project information, agent activity, Git branch, model, and context usage. When automatic profile routing is enabled, the footer shows `agent:<profile> (auto)` (or `(manual)` for a pinned profile) where space permits; narrow layouts retain the profile first. The startup header shows three random, category-labelled tips from `lib/ui-tips.json`, preferring different categories and staying stable across redraws. Tips cycle without repeats until the list is exhausted within the extension runtime; `/reload` starts a new cycle. Each JSON entry has `category` and `text` fields; edit them to change the recommendations.
|
|
17
17
|
- **Recommended tools** offers to enable native `grep`, `find`, and `ls` for the project at startup; no slash command is needed.
|
|
18
18
|
- **Agents** provides `plan`, `orchestrator`, `debug`, `ask`, and `code` profiles, all callable through the `subagent` tool. Small delegated tasks can automatically use a cheaper model.
|
|
19
19
|
- **Skills** cover implementation, debugging, review, design, refactoring, validation, and document workflows. Bundled **caveman** adds concise chat style; **ponytail** guides the smallest complete coding change. Only their core instructions are included, not upstream hooks or companion skills. Source revisions and licenses live beside each skill.
|
|
@@ -46,7 +46,7 @@ The setup extension is `extensions/recommended-tools.ts`; package resource filte
|
|
|
46
46
|
|
|
47
47
|
## Agents and subagents
|
|
48
48
|
|
|
49
|
-
Use `/profile` to select a profile, `/profile plan` to switch directly, or `pi --agent orchestrator` at startup. **Alt+A** cycles `plan → orchestrator → debug → ask → code` while idle; cycling is blocked while an agent is running. The footer shows the active profile (`agent:code`) and, on wider terminals, the shortcut hint. `/hotkeys` lists the shortcut. `code` is the default. Profile choices follow the active session branch. Profiles add instructions rather than replacing repository guidance or changing the main session's tools:
|
|
49
|
+
Use `/profile` to select a profile or `auto` to resume automatic routing, `/profile plan` to switch directly, or `pi --agent orchestrator` at startup. **Alt+A** cycles `plan → orchestrator → debug → ask → code` while idle; cycling is blocked while an agent is running. The footer shows the active profile (`agent:code`) and, on wider terminals, its automatic/manual routing mode and the shortcut hint. `/hotkeys` lists the shortcut. `code` is the default. Profile choices follow the active session branch. Profiles add instructions rather than replacing repository guidance or changing the main session's tools:
|
|
50
50
|
|
|
51
51
|
| Profile | Purpose |
|
|
52
52
|
| --- | --- |
|
|
@@ -56,9 +56,23 @@ Use `/profile` to select a profile, `/profile plan` to switch directly, or `pi -
|
|
|
56
56
|
| `ask` | Answer questions from code/documentation; no changes |
|
|
57
57
|
| `code` | Implement scoped changes and run checks |
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
With `auto_profile: true`, Piexis checks each eligible new request using conservative English leading-action rules:
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
| Clear request wording | Profile |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| Plan / make a plan | `plan` |
|
|
64
|
+
| Debug / investigate / fix this bug | `debug` |
|
|
65
|
+
| Explain / answer / what or how question | `ask` |
|
|
66
|
+
| Orchestrate / coordinate / delegate tasks | `orchestrator` |
|
|
67
|
+
| Implement / build / write / **Execute** | `code` |
|
|
68
|
+
|
|
69
|
+
All five are callable with `subagent({ "agent": "ask", "task": "Locate the configuration loader" })`. The orchestrator chooses when to delegate; delegation is not an automatic pipeline. Each child receives its task, normal Pi resources, and profile instructions, **not** the parent's conversation. Supply necessary context explicitly. Children share the workspace. Only an `orchestrator` parent can run up to **two** independent jobs concurrently; other profiles remain sequential. Delegation stops at two child levels, and nested writes must remain within the inherited parent scope. A delegated orchestrator can call other subagents at the second level.
|
|
70
|
+
|
|
71
|
+
Declare ownership with `subagent({ "agent": "code", "mode": "write", "paths": ["lib/example.ts", "tests/example.test.mjs"], "task": "..." })`. File/directory overlaps serialize writes. Missing or empty write paths, repository-root ownership, and shared configuration (`package.json`, lockfiles, `tsconfig*.json`, `.pi/`, `.git/`, `.github/`, and common root tool configs) reserve broad write ownership and serialize with all writes. Paths are normalized repository-relative paths; absolute, escaping, and symlinked write paths are rejected. `mode: "read"` selects only read tools and reserves no paths. `plan`, `ask`, and `purpose: "explore"` are always read-only. Read jobs can run beside writes, subject to capacity and parent-profile policy; their observations are not snapshot-isolated. Results/progress include mode, normalized paths, queue reason, and status.
|
|
72
|
+
|
|
73
|
+
Scopes are cooperative ownership declarations, **not sandboxing**. Do not write outside them or edit the parent workspace while write children are running. No output is automatically merged or reverted. Pi's global sequential setting, or a sequential tool in the same batch, can still serialize calls. Concurrency is fixed at two per parent scheduler; no new `agent.json` setting is required.
|
|
74
|
+
|
|
75
|
+
Optional configuration lives in `~/.pi/agent/agent.json` (or `$PI_CODING_AGENT_DIR/agent.json`) and trusted-project `.pi/agent.json`. Legacy `agents.json` is still read when `agent.json` is absent in that scope; no files are renamed or deleted. Project fields override user fields; per-variant overrides merge by field. Accepting **Enable** during project setup creates the project scaffold only when no project agent configuration exists; user-level files are never written. Defaults use the current model unless user-level configuration specifies another model. New scaffolds set `delegate_exploration` and `auto_profile` to `false`; inherited user values are preserved unless a trusted project explicitly supplies its own value. `/setup-agents` creates a missing project config or fills only its missing settings with defaults, preserving every configured value. It uses the active model when it must choose a model. Generated files include `$schema` for editor validation; configuration reloads on each profile switch and delegation. Use exact `provider/model-id` strings from `/model`, not fuzzy aliases:
|
|
62
76
|
|
|
63
77
|
```json
|
|
64
78
|
{
|
|
@@ -67,6 +81,7 @@ Optional configuration lives in `~/.pi/agent/agent.json` (or `$PI_CODING_AGENT_D
|
|
|
67
81
|
"small_model": "provider/default-model",
|
|
68
82
|
"subagent_model": "provider/default-model",
|
|
69
83
|
"delegate_exploration": true,
|
|
84
|
+
"auto_profile": false,
|
|
70
85
|
"subagent_variant_overrides": {
|
|
71
86
|
"plan": { "model": "provider/default-model", "effort": "high" },
|
|
72
87
|
"orchestrator": { "model": "provider/default-model", "effort": "high" },
|
|
@@ -77,6 +92,10 @@ Optional configuration lives in `~/.pi/agent/agent.json` (or `$PI_CODING_AGENT_D
|
|
|
77
92
|
}
|
|
78
93
|
```
|
|
79
94
|
|
|
95
|
+
`auto_profile` defaults to `false`; set it to `true` in user configuration or a trusted project configuration to switch profiles for each clear English request without confirmation. Unmatched or ambiguous wording keeps the current profile; `Execute` alone selects `code` and uses the existing conversation as context. The outer request wins: “Explain how to implement login” selects `ask`, not `code`; quoted/code mentions and negations do not trigger the mentioned action. `/profile`, **Alt+A**, and `--agent` pin manual selection on the current branch; `/profile auto` releases the pin without changing the current profile or model, so the next clear request can route again.
|
|
96
|
+
|
|
97
|
+
Only idle main-session user input is eligible. Queued steering/follow-up messages, slash commands, extension-originated input, and delegated agents do not auto-route. A failed automatic activation does not submit the request; correct the configuration or model and retry. These conservative rules use no classifier/model call, new keyboard shortcut, automatic pipeline/plan handoff, or tool-access/sandboxing changes. Non-TUI clients receive supported status/notifications; Piexis does not add a browser-specific badge.
|
|
98
|
+
|
|
80
99
|
**Alt+A** and `/profile` apply the selected profile's `model` and `effort` from `subagent_variant_overrides` to the main session too. Without a profile model override, switching uses the top-level `model` if configured; otherwise it keeps the current model. Missing effort overrides leave the current thinking level alone (subject to Pi's model-capability clamping). `subagent_model` and `small_model` remain delegation-only settings. Invalid configuration or an unavailable model leaves the current profile unchanged and shows a warning.
|
|
81
100
|
|
|
82
101
|
Replace the example model IDs with available, authenticated models. `model` initializes a new main session; explicit CLI model selections and resumed sessions win at startup. An explicit profile switch subsequently applies the configured settings. `subagent_model` defaults to the current main-session model. A per-variant `model` wins over all automatic routing. New scaffolds explicitly pin each variant to the default subagent model; remove the `model` field from a variant's override to let it follow automatic routing or later `subagent_model` changes. Otherwise, short lookups/summaries in `ask` and clearly mechanical typo/comment/label/symbol edits in `code` use `small_model` when configured. Ambiguous, longer, or complex tasks, including planning, debugging, and orchestration, keep the normal subagent model. This is a conservative text heuristic, not a classifier or a guarantee of task difficulty. Omit `small_model` to disable automatic small-task routing.
|
|
@@ -87,7 +106,7 @@ Effort accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. Var
|
|
|
87
106
|
|
|
88
107
|
Child agents run in separate Pi processes with ephemeral sessions and the same OS permissions. Their tool selection is a subset of the parent's active tools; `plan`/`ask` children select only `read`, `grep`, `find`, and `ls`, and disable native MCP access. With `delegate_exploration`, a non-exploration `plan` or `ask` child may also receive `subagent` to perform one nested exploration; exploration children never receive it. `plan`/`ask` parents may delegate only to those two profiles. Other children omit `AskQuestion` and return questions to the parent. Main profile instructions and child tool selection are **not sandboxing**. Normal configured extensions and explicitly supplied CLI extensions load in children, with the parent's project-trust decision. Runtime-only provider/tool registrations, CLI `--api-key` overrides, credentials supplied only to an embedded SDK session, and in-memory state in other extensions are not copied to child processes. Use Pi's stored or environment-based authentication for delegation. Other extensions remain responsible for their own restrictions; verify their subprocess behavior before using delegation in a restricted host.
|
|
89
108
|
|
|
90
|
-
Abort and
|
|
109
|
+
Abort removes queued work without spawning a child and terminates active children (including their process group on Unix). Profile switching is blocked during preparation, queued work, and active work. Shutdown cancels all queued/active jobs and waits for active children to settle. Windows termination is best-effort for descendants. Model-facing output is capped at 50,000 characters; tool details retain up to 1 MB. JSON/print mode supports subagents but has no profile picker or child terminal interaction. Avoid loading another extension that registers `subagent`, `/profile`, `/setup-agents`, or `--agent` alongside this one.
|
|
91
110
|
|
|
92
111
|
## AskQuestion
|
|
93
112
|
|
package/extensions/agents.ts
CHANGED
|
@@ -5,81 +5,153 @@ import { Key } from "@earendil-works/pi-tui";
|
|
|
5
5
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import * as piSdk from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import {
|
|
8
|
-
agentNames, childResourceArgs, completeProjectAgentConfig, isAgentName, loadAgentConfig, profiles, routeSubagent, subagentTools,
|
|
9
|
-
type AgentName, type Effort,
|
|
8
|
+
agentNames, childResourceArgs, completeProjectAgentConfig, detectProfileIntent, isAgentName, loadAgentConfig, profiles, routeSubagent, subagentTools,
|
|
9
|
+
type AgentConfig, type AgentName, type Effort,
|
|
10
10
|
} from "../lib/agents.ts";
|
|
11
11
|
import { limitOutput, piInvocation, runSubagent } from "../lib/subagent-runner.ts";
|
|
12
|
+
import { normalizeSubagentScope, SubagentScheduler, type SchedulerStatus } from "../lib/subagent-scheduler.ts";
|
|
12
13
|
|
|
13
14
|
const AgentSchema = Type.Union(agentNames.map((name) => Type.Literal(name)));
|
|
14
15
|
const parameters = Type.Object({
|
|
15
16
|
agent: AgentSchema,
|
|
16
17
|
purpose: Type.Optional(Type.Literal("explore", { description: "Use the configured small_model for read-only codebase exploration; available only for ask when delegate_exploration is enabled." })),
|
|
18
|
+
mode: Type.Optional(Type.Union([Type.Literal("read"), Type.Literal("write")], { description: "Declared work mode. Defaults to write, except read-only plan/ask and exploration. Read mode selects only read tools." })),
|
|
19
|
+
paths: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Repository-relative files or directories the task may modify. Missing paths mean broad write ownership. Overlaps and shared configuration serialize writes." })),
|
|
17
20
|
task: Type.String({ minLength: 1, maxLength: 50_000, description: "Self-contained task including needed context; conversation history is not inherited." }),
|
|
18
21
|
});
|
|
19
22
|
const stateType = "piexis-agent";
|
|
23
|
+
const profileChoices = [...agentNames, "auto"];
|
|
20
24
|
|
|
21
25
|
export default function agentsExtension(pi: ExtensionAPI) {
|
|
22
26
|
let active: AgentName = "code";
|
|
27
|
+
let pinned = false;
|
|
28
|
+
let activationOrigin: "manual" | "automatic" | undefined;
|
|
29
|
+
let autoProfileEnabled = false;
|
|
23
30
|
let explorationEnabled = false;
|
|
24
31
|
let switching = false;
|
|
32
|
+
let startupHandled = false;
|
|
25
33
|
const depthText = process.env.PIEXIS_SUBAGENT_DEPTH ?? "0";
|
|
26
34
|
const depth = /^[0-2]$/.test(depthText) ? Number(depthText) : 2;
|
|
27
35
|
const jobs = new Map<AbortController, Promise<unknown>>();
|
|
36
|
+
let scheduler = new SubagentScheduler();
|
|
37
|
+
const childStatuses = new Map<AbortController, { agent: AgentName; status: "queued" | "running" }>();
|
|
38
|
+
|
|
39
|
+
function childStatus(ctx: ExtensionContext) {
|
|
40
|
+
if (!ctx.hasUI) return;
|
|
41
|
+
const groups = (["running", "queued"] as const).map((state) => {
|
|
42
|
+
const names = [...childStatuses.values()].filter((job) => job.status === state).map((job) => job.agent);
|
|
43
|
+
return names.length ? `${names.length} ${state}: ${names.join(", ")}` : "";
|
|
44
|
+
}).filter(Boolean);
|
|
45
|
+
ctx.ui.setStatus("piexis-subagents", groups.length ? `subagents · ${groups.join(" · ")}` : undefined);
|
|
46
|
+
}
|
|
47
|
+
const inheritedScope = process.env.PIEXIS_SUBAGENT_SCOPE ? JSON.parse(process.env.PIEXIS_SUBAGENT_SCOPE) : undefined;
|
|
28
48
|
const extensionPath = fileURLToPath(import.meta.url);
|
|
29
49
|
|
|
30
50
|
pi.registerFlag("agent", { description: `Agent profile: ${agentNames.join(", ")}`, type: "string" });
|
|
31
51
|
|
|
32
52
|
function status(ctx: ExtensionContext) {
|
|
33
|
-
if (ctx.hasUI)
|
|
53
|
+
if (!ctx.hasUI) return;
|
|
54
|
+
ctx.ui.setStatus("piexis-agent", `agent:${active}`);
|
|
55
|
+
ctx.ui.setStatus("piexis-agent-mode", autoProfileEnabled ? (pinned ? "manual" : "auto") : "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function persist() {
|
|
59
|
+
pi.appendEntry(stateType, { agent: active, origin: activationOrigin, pinned });
|
|
34
60
|
}
|
|
35
61
|
|
|
36
|
-
|
|
37
|
-
|
|
62
|
+
function canSwitch(ctx: ExtensionContext) {
|
|
63
|
+
return !switching && ctx.isIdle() && jobs.size === 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function activate(name: AgentName, ctx: ExtensionContext, origin: "manual" | "automatic", config?: AgentConfig): Promise<boolean> {
|
|
67
|
+
if (!canSwitch(ctx)) {
|
|
38
68
|
ctx.ui.notify("Wait for the current agent to finish before switching profiles.", "warning");
|
|
39
|
-
return;
|
|
69
|
+
return false;
|
|
40
70
|
}
|
|
41
71
|
switching = true;
|
|
72
|
+
const previousModel = ctx.model;
|
|
73
|
+
const previousEffort = (ctx.thinkingLevel ?? pi.getThinkingLevel()) as Effort;
|
|
74
|
+
const previousState = { active, activationOrigin, pinned };
|
|
75
|
+
let modelChanged = false;
|
|
76
|
+
let effortChanged = false;
|
|
42
77
|
try {
|
|
43
|
-
|
|
78
|
+
config ??= await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted());
|
|
44
79
|
const override = config.subagent_variant_overrides?.[name];
|
|
45
80
|
const modelId = override?.model ?? config.model;
|
|
46
81
|
if (modelId) {
|
|
47
82
|
const slash = modelId.indexOf("/");
|
|
48
83
|
const model = ctx.modelRegistry.find(modelId.slice(0, slash), modelId.slice(slash + 1));
|
|
49
84
|
if (!model || !(await pi.setModel(model))) throw new Error(`Configured model unavailable: ${modelId}. Check /model and /login.`);
|
|
85
|
+
modelChanged = true;
|
|
50
86
|
}
|
|
51
|
-
if (override?.effort !== undefined) pi.setThinkingLevel(override.effort);
|
|
87
|
+
if (override?.effort !== undefined) { effortChanged = true; pi.setThinkingLevel(override.effort); }
|
|
52
88
|
active = name;
|
|
53
|
-
|
|
89
|
+
activationOrigin = origin;
|
|
90
|
+
pinned = origin === "manual";
|
|
91
|
+
persist();
|
|
92
|
+
autoProfileEnabled = config.auto_profile === true;
|
|
54
93
|
status(ctx);
|
|
55
|
-
|
|
94
|
+
if (origin === "manual" || name !== previousState.active) {
|
|
95
|
+
ctx.ui.notify(`Agent: ${active}${origin === "automatic" ? " (automatic)" : ""}`, "info");
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
56
98
|
} catch (error) {
|
|
99
|
+
// setModel can succeed before a later setting throws; keep a failed route atomic.
|
|
100
|
+
active = previousState.active;
|
|
101
|
+
activationOrigin = previousState.activationOrigin;
|
|
102
|
+
pinned = previousState.pinned;
|
|
103
|
+
try {
|
|
104
|
+
if (modelChanged && previousModel && !(await pi.setModel(previousModel))) throw new Error("Previous model unavailable");
|
|
105
|
+
if (modelChanged || effortChanged) pi.setThinkingLevel(previousEffort);
|
|
106
|
+
} catch (rollbackError) {
|
|
107
|
+
ctx.ui.notify(`Agents: could not restore previous settings: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, "warning");
|
|
108
|
+
}
|
|
57
109
|
ctx.ui.notify(`Agents: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
110
|
+
return false;
|
|
58
111
|
} finally {
|
|
59
112
|
switching = false;
|
|
60
113
|
}
|
|
61
114
|
}
|
|
62
115
|
|
|
63
|
-
function restore(ctx: ExtensionContext
|
|
116
|
+
function restore(ctx: ExtensionContext) {
|
|
64
117
|
active = "code";
|
|
118
|
+
pinned = false;
|
|
119
|
+
activationOrigin = undefined;
|
|
65
120
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
66
121
|
if (entry.type !== "custom" || entry.customType !== stateType) continue;
|
|
67
|
-
const
|
|
68
|
-
if (isAgentName(agent))
|
|
122
|
+
const data = entry.data as { agent?: unknown; origin?: unknown; pinned?: unknown } | undefined;
|
|
123
|
+
if (!isAgentName(data?.agent)) continue;
|
|
124
|
+
active = data.agent;
|
|
125
|
+
activationOrigin = data.origin === "automatic" ? "automatic" : "manual";
|
|
126
|
+
pinned = typeof data.pinned === "boolean" ? data.pinned : true;
|
|
69
127
|
}
|
|
70
|
-
const flag = applyFlag ? pi.getFlag("agent") : undefined;
|
|
71
|
-
if (isAgentName(flag)) active = flag;
|
|
72
|
-
else if (flag !== undefined && flag !== "") ctx.ui.notify(`Unknown agent: ${flag}. Choose ${agentNames.join(", ")}.`, "warning");
|
|
73
128
|
status(ctx);
|
|
74
129
|
}
|
|
75
130
|
|
|
76
|
-
pi.on("session_start", async (
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
|
|
131
|
+
pi.on("session_start", async (event, ctx) => {
|
|
132
|
+
if (jobs.size === 0) scheduler = new SubagentScheduler();
|
|
133
|
+
try {
|
|
134
|
+
const config = await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted());
|
|
135
|
+
explorationEnabled = Boolean(config.delegate_exploration);
|
|
136
|
+
autoProfileEnabled = config.auto_profile === true;
|
|
137
|
+
} catch { explorationEnabled = false; autoProfileEnabled = false; }
|
|
138
|
+
restore(ctx);
|
|
139
|
+
childStatus(ctx);
|
|
140
|
+
if (!startupHandled && (!event.reason || event.reason === "startup")) {
|
|
141
|
+
const flag = pi.getFlag("agent");
|
|
142
|
+
if (isAgentName(flag)) {
|
|
143
|
+
active = flag;
|
|
144
|
+
activationOrigin = "manual";
|
|
145
|
+
// Child processes receive --agent as their fixed role; only main branches persist a pin.
|
|
146
|
+
if (depth === 0) { pinned = true; persist(); }
|
|
147
|
+
} else if (flag !== undefined && flag !== "") ctx.ui.notify(`Unknown agent: ${flag}. Choose ${agentNames.join(", ")}.`, "warning");
|
|
148
|
+
}
|
|
149
|
+
startupHandled = true;
|
|
150
|
+
status(ctx);
|
|
80
151
|
if (depth >= 2) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "subagent"));
|
|
81
152
|
// Only initialize a new main session; explicit CLI choices and resumed models remain authoritative.
|
|
82
|
-
if (depth !== 0 ||
|
|
153
|
+
if (depth !== 0 || (event.reason && event.reason !== "startup" && event.reason !== "new") ||
|
|
154
|
+
ctx.sessionManager.getBranch().some((entry) => entry.type === "message")) return;
|
|
83
155
|
const args = process.argv.slice(2).slice(0, process.argv.indexOf("--") < 0 ? undefined : process.argv.indexOf("--") - 2);
|
|
84
156
|
if (args.some((arg) => arg === "--model" || arg === "--models" || arg.startsWith("--model=") || arg.startsWith("--models="))) return;
|
|
85
157
|
try {
|
|
@@ -93,6 +165,29 @@ export default function agentsExtension(pi: ExtensionAPI) {
|
|
|
93
165
|
});
|
|
94
166
|
pi.on("session_tree", (_event, ctx) => restore(ctx));
|
|
95
167
|
|
|
168
|
+
pi.on("input", async (event, ctx) => {
|
|
169
|
+
if (depth !== 0 || switching || jobs.size > 0 || !ctx.isIdle() ||
|
|
170
|
+
(event.source !== "interactive" && event.source !== "rpc") || event.streamingBehavior === "steer" || event.streamingBehavior === "followUp" ||
|
|
171
|
+
event.text.trimStart().startsWith("/")) return { action: "continue" as const };
|
|
172
|
+
let config;
|
|
173
|
+
try { config = await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted()); }
|
|
174
|
+
catch (error) {
|
|
175
|
+
ctx.ui.notify(`Agents: ${error instanceof Error ? error.message : String(error)}. Request was not submitted.`, "warning");
|
|
176
|
+
return { action: "handled" as const };
|
|
177
|
+
}
|
|
178
|
+
autoProfileEnabled = config.auto_profile === true;
|
|
179
|
+
status(ctx);
|
|
180
|
+
if (!autoProfileEnabled || pinned) return { action: "continue" as const };
|
|
181
|
+
const name = detectProfileIntent(event.text);
|
|
182
|
+
// The initial code profile has not applied a variant yet; an explicit first Execute must do so.
|
|
183
|
+
if (!name || (name === active && activationOrigin !== undefined)) return { action: "continue" as const };
|
|
184
|
+
if (!(await activate(name, ctx, "automatic", config))) {
|
|
185
|
+
ctx.ui.notify("Automatic profile activation failed; request was not submitted. Fix the configuration or model and retry.", "warning");
|
|
186
|
+
return { action: "handled" as const };
|
|
187
|
+
}
|
|
188
|
+
return { action: "continue" as const };
|
|
189
|
+
});
|
|
190
|
+
|
|
96
191
|
pi.registerCommand("setup-agents", {
|
|
97
192
|
description: "Fill missing project agent.json settings with defaults without replacing configured values",
|
|
98
193
|
handler: async (_args, ctx) => {
|
|
@@ -112,17 +207,33 @@ export default function agentsExtension(pi: ExtensionAPI) {
|
|
|
112
207
|
});
|
|
113
208
|
|
|
114
209
|
pi.registerCommand("profile", {
|
|
115
|
-
description: "Switch agent profile (plan, orchestrator, debug, ask, code)",
|
|
210
|
+
description: "Switch agent profile (plan, orchestrator, debug, ask, code) or resume automatic routing (auto)",
|
|
211
|
+
getArgumentCompletions: (prefix) => {
|
|
212
|
+
const matches = profileChoices.filter((choice) => choice.startsWith(prefix));
|
|
213
|
+
return matches.length > 0 ? matches.map((choice) => ({ value: choice, label: choice })) : null;
|
|
214
|
+
},
|
|
116
215
|
handler: async (args, ctx) => {
|
|
117
216
|
await ctx.waitForIdle();
|
|
118
217
|
let name: string | undefined = args.trim() || undefined;
|
|
119
218
|
if (!name) {
|
|
120
|
-
if (!ctx.hasUI) { ctx.ui.notify(`Current agent: ${active}. Use /profile <${
|
|
121
|
-
name = await ctx.ui.select(`Agent profile (current: ${active})`,
|
|
219
|
+
if (!ctx.hasUI) { ctx.ui.notify(`Current agent: ${active}. Use /profile <${profileChoices.join("|")}>.`, "info"); return; }
|
|
220
|
+
name = await ctx.ui.select(`Agent profile (current: ${active})`, profileChoices);
|
|
122
221
|
if (!name) return;
|
|
123
222
|
}
|
|
124
|
-
if (
|
|
125
|
-
|
|
223
|
+
if (name === "auto") {
|
|
224
|
+
let config;
|
|
225
|
+
try { config = await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted()); }
|
|
226
|
+
catch (error) { ctx.ui.notify(`Agents: ${error instanceof Error ? error.message : String(error)}`, "warning"); return; }
|
|
227
|
+
if (!config.auto_profile) { ctx.ui.notify("Enable auto_profile in agent.json before using /profile auto.", "warning"); return; }
|
|
228
|
+
if (!canSwitch(ctx)) { ctx.ui.notify("Wait for the current agent to finish before switching profiles.", "warning"); return; }
|
|
229
|
+
autoProfileEnabled = true;
|
|
230
|
+
if (pinned) { pinned = false; persist(); }
|
|
231
|
+
status(ctx);
|
|
232
|
+
ctx.ui.notify("Agent profile routing is automatic.", "info");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!isAgentName(name)) { ctx.ui.notify(`Unknown agent: ${name}. Choose ${profileChoices.join(", ")}.`, "error"); return; }
|
|
236
|
+
await activate(name, ctx, "manual");
|
|
126
237
|
},
|
|
127
238
|
});
|
|
128
239
|
|
|
@@ -130,13 +241,13 @@ export default function agentsExtension(pi: ExtensionAPI) {
|
|
|
130
241
|
description: "Cycle agent profile (plan → orchestrator → debug → ask → code)",
|
|
131
242
|
handler: async (ctx) => {
|
|
132
243
|
if (ctx.mode !== "tui") return;
|
|
133
|
-
await activate(agentNames[(agentNames.indexOf(active) + 1) % agentNames.length], ctx);
|
|
244
|
+
await activate(agentNames[(agentNames.indexOf(active) + 1) % agentNames.length], ctx, "manual");
|
|
134
245
|
},
|
|
135
246
|
});
|
|
136
247
|
|
|
137
248
|
pi.on("before_agent_start", (event) => ({
|
|
138
249
|
systemPrompt: `${event.systemPrompt}\n\nPiexis agent profile: ${active}\n${profiles[active]}\n${depth > 0
|
|
139
|
-
?
|
|
250
|
+
? `You are a delegated subagent. Work only on the supplied task.${inheritedScope ? ` Declared ownership: ${inheritedScope.mode}; paths: ${JSON.stringify(inheritedScope.paths)}. Modify only these paths (an empty write list means broad ownership); keep nested delegation within this ownership.` : ""} If user input is needed, return the question to the parent; no terminal interaction is available. Report changed files, results, and unexecuted checks. If the subagent tool is available, use purpose: explore only for bounded read-only codebase exploration and return concise file paths, line numbers, and uncertainties.`
|
|
140
251
|
: explorationEnabled
|
|
141
252
|
? "All five profiles are callable through subagent. Delegate only when a separate context helps; supply a focused task and all relevant context. For bounded codebase exploration involving discovery, several files, or a summary, delegate to subagent with agent: ask and purpose: explore. It uses the configured small model and must return concise findings with file paths, line numbers, and uncertainties. Keep planning, judgment, edits, and final verification here; do not delegate a trivial single read."
|
|
142
253
|
: "All five profiles are callable through subagent. Delegate only when a separate context helps; supply a focused task and all relevant context."}`,
|
|
@@ -145,61 +256,104 @@ export default function agentsExtension(pi: ExtensionAPI) {
|
|
|
145
256
|
pi.registerTool({
|
|
146
257
|
name: "subagent",
|
|
147
258
|
label: "Subagent",
|
|
148
|
-
description: "Delegate a self-contained task to plan, orchestrator, debug, ask, or code in an isolated Pi process in this workspace. With delegate_exploration enabled, ask plus purpose: explore always uses the configured small_model with read-only tools. Other small tasks can automatically use small_model; variant overrides win. Model, effort, and routing reason are returned.
|
|
149
|
-
executionMode: "
|
|
259
|
+
description: "Delegate a self-contained task to plan, orchestrator, debug, ask, or code in an isolated Pi process in this workspace. With delegate_exploration enabled, ask plus purpose: explore always uses the configured small_model with read-only tools. Other small tasks can automatically use small_model; variant overrides win. Model, effort, and routing reason are returned. Orchestrator calls can run concurrently, up to two, only for non-conflicting declared scopes. Other profiles remain sequential. Declare mode and precise paths; broad writes and shared configuration serialize writes. Scopes are cooperative, not sandboxing; child questions return to the parent.",
|
|
260
|
+
executionMode: "parallel",
|
|
150
261
|
parameters,
|
|
151
262
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
152
|
-
|
|
153
|
-
if (!isAgentName(params.agent) || !params.task.trim() || params.task.length > 50_000) throw new Error("A valid agent and non-empty task of at most 50000 characters are required");
|
|
154
|
-
if ((active === "plan" || active === "ask") && params.agent !== "plan" && params.agent !== "ask") {
|
|
155
|
-
throw new Error(`${active} may delegate only to plan or ask; switch to code, debug, or orchestrator for changes`);
|
|
156
|
-
}
|
|
157
|
-
const exploration = params.purpose === "explore";
|
|
158
|
-
if (exploration && params.agent !== "ask") throw new Error("Exploration delegation requires agent: ask");
|
|
159
|
-
if (!ctx.model) throw new Error("Select a main model before delegating");
|
|
160
|
-
const config = await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted());
|
|
161
|
-
if (exploration && !config.delegate_exploration) throw new Error("Enable delegate_exploration in agent.json before delegating exploration");
|
|
162
|
-
const route = routeSubagent(config, params.agent, params.task, `${ctx.model.provider}/${ctx.model.id}`, (ctx.thinkingLevel ?? pi.getThinkingLevel()) as Effort, exploration);
|
|
163
|
-
const slash = route.model.indexOf("/");
|
|
164
|
-
const selected = ctx.modelRegistry.find(route.model.slice(0, slash), route.model.slice(slash + 1));
|
|
165
|
-
if (!selected || !ctx.modelRegistry.hasConfiguredAuth(selected)) throw new Error(`Subagent model unavailable: ${route.model}. Check /model and /login.`);
|
|
166
|
-
const tools = subagentTools(params.agent, pi.getActiveTools(), depth + 1, config.delegate_exploration && !exploration);
|
|
263
|
+
const parentProfile = active;
|
|
167
264
|
const controller = new AbortController();
|
|
168
265
|
const abort = () => controller.abort(signal?.reason);
|
|
169
266
|
signal?.addEventListener("abort", abort, { once: true });
|
|
170
267
|
if (signal?.aborted) abort();
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
"--thinking", route.effort, "--agent", params.agent,
|
|
174
|
-
ctx.isProjectTrusted() ? "--approve" : "--no-approve", ...childResourceArgs(process.argv.slice(2)),
|
|
175
|
-
"--extension", resolve(extensionPath), "--tools", tools.join(",")];
|
|
176
|
-
if (tools.length === 0) args.push("--no-tools");
|
|
177
|
-
// Callable MCP tools must not escape a read-only child's explicit built-in loadout.
|
|
178
|
-
if (params.agent === "plan" || params.agent === "ask") {
|
|
179
|
-
if ("createMcpExtension" in piSdk) args.push("--no-mcp");
|
|
180
|
-
args.push("--exclude-tools", "mcp__*");
|
|
181
|
-
}
|
|
182
|
-
const job = runSubagent({ invocation: piInvocation(), args, cwd: ctx.cwd, depth: depth + 1,
|
|
183
|
-
task: params.task, signal: controller.signal,
|
|
184
|
-
onProgress: (text) => onUpdate?.({ content: [{ type: "text", text: `${params.agent} · ${route.model} · ${route.effort} (${route.reason})\n${text}` }], details }),
|
|
185
|
-
});
|
|
268
|
+
// Track preparation as well as queued/running work so shutdown and switching cannot race it.
|
|
269
|
+
const job = executeJob();
|
|
186
270
|
jobs.set(controller, job);
|
|
187
|
-
try {
|
|
188
|
-
|
|
271
|
+
try { return await job; }
|
|
272
|
+
finally {
|
|
273
|
+
jobs.delete(controller);
|
|
274
|
+
childStatuses.delete(controller);
|
|
275
|
+
childStatus(ctx);
|
|
276
|
+
signal?.removeEventListener("abort", abort);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function executeJob() {
|
|
280
|
+
controller.signal.throwIfAborted();
|
|
281
|
+
if (depth >= 2) throw new Error("Subagent delegation depth limit (2) reached");
|
|
282
|
+
if (!isAgentName(params.agent) || !params.task.trim() || params.task.length > 50_000) throw new Error("A valid agent and non-empty task of at most 50000 characters are required");
|
|
283
|
+
if ((parentProfile === "plan" || parentProfile === "ask") && params.agent !== "plan" && params.agent !== "ask") {
|
|
284
|
+
throw new Error(`${parentProfile} may delegate only to plan or ask; switch to code, debug, or orchestrator for changes`);
|
|
285
|
+
}
|
|
286
|
+
const exploration = params.purpose === "explore";
|
|
287
|
+
if (exploration && params.agent !== "ask") throw new Error("Exploration delegation requires agent: ask");
|
|
288
|
+
const readOnly = exploration || params.agent === "plan" || params.agent === "ask";
|
|
289
|
+
const parentScope = inheritedScope ? await normalizeSubagentScope(ctx.cwd, inheritedScope) : undefined;
|
|
290
|
+
const scope = await normalizeSubagentScope(ctx.cwd, {
|
|
291
|
+
mode: params.mode,
|
|
292
|
+
paths: params.paths ?? (parentScope?.mode === "write" && !readOnly && params.mode !== "read" ? parentScope.paths : undefined),
|
|
293
|
+
}, readOnly);
|
|
294
|
+
if (scope.mode === "write" && parentScope) {
|
|
295
|
+
if (parentScope.mode === "read" || (parentScope.paths.length > 0 && !parentScope.paths.includes(".") &&
|
|
296
|
+
(scope.paths.length === 0 || scope.paths.some((path) => !parentScope.paths.some((owner) => path === owner || path.startsWith(`${owner}/`)))))) {
|
|
297
|
+
throw new Error("Nested write scope must stay within the parent's declared ownership");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!ctx.model) throw new Error("Select a main model before delegating");
|
|
301
|
+
const config = await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted());
|
|
302
|
+
if (exploration && !config.delegate_exploration) throw new Error("Enable delegate_exploration in agent.json before delegating exploration");
|
|
303
|
+
const route = routeSubagent(config, params.agent, params.task, `${ctx.model.provider}/${ctx.model.id}`, (ctx.thinkingLevel ?? pi.getThinkingLevel()) as Effort, exploration);
|
|
304
|
+
const slash = route.model.indexOf("/");
|
|
305
|
+
const selected = ctx.modelRegistry.find(route.model.slice(0, slash), route.model.slice(slash + 1));
|
|
306
|
+
if (!selected || !ctx.modelRegistry.hasConfiguredAuth(selected)) throw new Error(`Subagent model unavailable: ${route.model}. Check /model and /login.`);
|
|
307
|
+
const tools = subagentTools(scope.mode === "read" ? "ask" : params.agent, pi.getActiveTools(), depth + 1, config.delegate_exploration && !exploration);
|
|
308
|
+
let scheduling: SchedulerStatus = { status: "queued" };
|
|
309
|
+
const details = () => ({ agent: params.agent, ...route, mode: scope.mode, paths: scope.paths, broad: scope.broad, ...scheduling });
|
|
310
|
+
const update = (text: string) => onUpdate?.({ content: [{ type: "text" as const, text: `${params.agent} · ${route.model} · ${route.effort} (${route.reason})\n${text}` }], details: details() });
|
|
311
|
+
const args = ["--mode", "json", "--print", "--no-session", "--model", route.model,
|
|
312
|
+
"--thinking", route.effort, "--agent", params.agent,
|
|
313
|
+
ctx.isProjectTrusted() ? "--approve" : "--no-approve", ...childResourceArgs(process.argv.slice(2)),
|
|
314
|
+
"--extension", resolve(extensionPath), "--tools", tools.join(",")];
|
|
315
|
+
if (tools.length === 0) args.push("--no-tools");
|
|
316
|
+
// Callable MCP tools must not escape a read-only child's explicit built-in loadout.
|
|
317
|
+
if (scope.mode === "read") {
|
|
318
|
+
if ("createMcpExtension" in piSdk) args.push("--no-mcp");
|
|
319
|
+
args.push("--exclude-tools", "mcp__*");
|
|
320
|
+
}
|
|
321
|
+
const reportStatus = (state: SchedulerStatus) => {
|
|
322
|
+
scheduling = state;
|
|
323
|
+
if (state.status === "queued" || state.status === "running") {
|
|
324
|
+
childStatuses.set(controller, { agent: params.agent, status: state.status });
|
|
325
|
+
} else childStatuses.delete(controller);
|
|
326
|
+
childStatus(ctx);
|
|
327
|
+
update(`${state.status}${state.reason ? ` (${state.reason})` : ""} · ${scope.mode}${scope.paths.length ? ` · ${scope.paths.join(", ")}` : ""}`);
|
|
328
|
+
};
|
|
329
|
+
controller.signal.throwIfAborted();
|
|
330
|
+
const result = await scheduler.schedule({
|
|
331
|
+
scope, controller, serial: parentProfile !== "orchestrator",
|
|
332
|
+
run: async () => {
|
|
333
|
+
// A previous writer may have created a symlink while this request was queued.
|
|
334
|
+
await normalizeSubagentScope(ctx.cwd, scope);
|
|
335
|
+
return runSubagent({ invocation: piInvocation(), args, cwd: ctx.cwd, depth: depth + 1,
|
|
336
|
+
task: params.task, scope, signal: controller.signal, onProgress: update });
|
|
337
|
+
},
|
|
338
|
+
onStatus: (state) => {
|
|
339
|
+
// Runner results can resolve with provider errors; classify before reporting completion.
|
|
340
|
+
if (state.status !== "completed") reportStatus(state);
|
|
341
|
+
},
|
|
342
|
+
});
|
|
189
343
|
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted" || Boolean(result.error);
|
|
344
|
+
reportStatus({ status: result.stopReason === "aborted" ? "aborted" : failed ? "failed" : "completed" });
|
|
190
345
|
return {
|
|
191
346
|
content: [{ type: "text" as const, text: `${params.agent} · ${route.model} · ${route.effort} (${route.reason})\n${limitOutput(failed ? result.error || result.stderr || result.output || "Subagent failed" : result.output)}` }],
|
|
192
|
-
details: { ...details, ...result }, usage: result.usage, isError: failed,
|
|
347
|
+
details: { ...details(), ...result }, usage: result.usage, isError: failed,
|
|
193
348
|
};
|
|
194
|
-
} finally {
|
|
195
|
-
jobs.delete(controller);
|
|
196
|
-
signal?.removeEventListener("abort", abort);
|
|
197
349
|
}
|
|
198
350
|
},
|
|
199
351
|
});
|
|
200
352
|
|
|
201
353
|
pi.on("session_shutdown", async () => {
|
|
354
|
+
const closing = scheduler.shutdown("Session shutdown");
|
|
202
355
|
for (const controller of jobs.keys()) controller.abort("Session shutdown");
|
|
356
|
+
await closing;
|
|
203
357
|
await Promise.allSettled(jobs.values());
|
|
204
358
|
});
|
|
205
359
|
}
|
package/extensions/ui.ts
CHANGED
|
@@ -169,7 +169,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
169
169
|
|
|
170
170
|
let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
|
|
171
171
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
172
|
-
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
172
|
+
if (entry.type !== "message" || (entry.message.role !== "assistant" && entry.message.role !== "toolResult")) continue;
|
|
173
173
|
const u = entry.message.usage;
|
|
174
174
|
if (!u) continue;
|
|
175
175
|
input += u.input;
|
|
@@ -183,14 +183,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
183
183
|
const activity = state === "tool" ? `${frame} ${activeTool}`
|
|
184
184
|
: state === "thinking" ? `${frame} thinking` : "● ready";
|
|
185
185
|
const agent = footerData.getExtensionStatuses?.().get("piexis-agent");
|
|
186
|
+
const agentMode = footerData.getExtensionStatuses?.().get("piexis-agent-mode");
|
|
186
187
|
const branch = footerData.getGitBranch();
|
|
187
188
|
// Reserve a small model slot; long tool names must not displace the profile.
|
|
188
189
|
const modelReserve = width >= 32 ? 9 : 0;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
const fullProfile = agent && agentMode ? `${agent} (${agentMode})` : agent ?? "";
|
|
191
|
+
// Mode is optional; the profile itself remains ahead of activity and right-side fields.
|
|
192
|
+
const profileText = agent && width >= 48 && visibleWidth(fullProfile) + 6 + modelReserve <= width
|
|
193
|
+
? fullProfile
|
|
194
|
+
: agent ?? "";
|
|
195
|
+
const profileLimit = Math.max(0, width - 1);
|
|
196
|
+
const profile = profileText
|
|
197
|
+
? theme.fg("accent", truncateToWidth(profileText, profileLimit, "…")) + (profileLimit > 0 ? sep : "")
|
|
198
|
+
: "";
|
|
199
|
+
const activityWidth = Math.max(0, Math.min(24, width - visibleWidth(profile) - modelReserve));
|
|
200
|
+
const left1 = profile + (activityWidth ? theme.fg(state === "ready" ? "success" : "accent",
|
|
201
|
+
truncateToWidth(activity, activityWidth, "…")) : "");
|
|
194
202
|
const row1 = justify(left1, [
|
|
195
203
|
{ text: ctx.model?.id ?? "—", priority: 5 },
|
|
196
204
|
{ text: muted(`think ${pi.getThinkingLevel()}`), priority: 4 },
|
|
@@ -224,7 +232,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
224
232
|
{ text: muted(costText), priority: 3 }
|
|
225
233
|
], sep, width);
|
|
226
234
|
|
|
227
|
-
|
|
235
|
+
const children = footerData.getExtensionStatuses?.().get("piexis-subagents");
|
|
236
|
+
return children
|
|
237
|
+
? [row1, row2, theme.fg("accent", truncateToWidth(children, width, "…"))]
|
|
238
|
+
: [row1, row2];
|
|
228
239
|
},
|
|
229
240
|
|
|
230
241
|
dispose() {
|
package/lib/agent-config.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface AgentConfig {
|
|
|
11
11
|
small_model?: string;
|
|
12
12
|
subagent_model?: string;
|
|
13
13
|
delegate_exploration?: boolean;
|
|
14
|
+
auto_profile?: boolean;
|
|
14
15
|
subagent_variant_overrides?: Partial<Record<AgentName, { model?: string; effort?: Effort }>>;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -21,7 +22,8 @@ export interface AgentConfig {
|
|
|
21
22
|
export const initialAgentConfigDefaults = {
|
|
22
23
|
"$schema": agentConfigSchemaUrl,
|
|
23
24
|
delegate_exploration: false,
|
|
24
|
-
|
|
25
|
+
auto_profile: false,
|
|
26
|
+
} satisfies Pick<AgentConfig, "$schema" | "delegate_exploration" | "auto_profile">;
|
|
25
27
|
|
|
26
28
|
/** Header copy for incomplete project agent configuration. */
|
|
27
29
|
export type AgentConfigSetting = Exclude<keyof AgentConfig, "$schema">;
|
|
@@ -31,6 +33,7 @@ export const agentConfigCapabilities = {
|
|
|
31
33
|
small_model: "use a cheaper model for eligible small tasks",
|
|
32
34
|
subagent_model: "choose the normal model for delegated work",
|
|
33
35
|
delegate_exploration: "opt in to cheap, read-only codebase exploration",
|
|
36
|
+
auto_profile: "automatically select a profile for clear requests",
|
|
34
37
|
subagent_variant_overrides: "customize model and effort for each agent profile",
|
|
35
38
|
} satisfies Record<AgentConfigSetting, string>;
|
|
36
39
|
|
|
@@ -50,6 +53,7 @@ export function createInitialAgentConfig(selected: string, inherited: AgentConfi
|
|
|
50
53
|
small_model: inherited.small_model ?? selected,
|
|
51
54
|
subagent_model: inherited.subagent_model ?? selected,
|
|
52
55
|
delegate_exploration: inherited.delegate_exploration ?? initialAgentConfigDefaults.delegate_exploration,
|
|
56
|
+
auto_profile: inherited.auto_profile ?? initialAgentConfigDefaults.auto_profile,
|
|
53
57
|
subagent_variant_overrides: overrides,
|
|
54
58
|
};
|
|
55
59
|
}
|
package/lib/agents.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { agentNames, type AgentConfig, type AgentName, effortLevels, type Effort
|
|
|
8
8
|
|
|
9
9
|
export const profiles: Record<AgentName, string> = {
|
|
10
10
|
plan: "Explore the request and relevant code, clarify important unknowns, and return an actionable plan with acceptance checks. Do not implement or delegate implementation. Stop after the plan.",
|
|
11
|
-
orchestrator: "Break work into focused tasks and delegate to plan, debug, ask, or code using subagent. Include all necessary context in each task; children do not inherit the conversation.
|
|
11
|
+
orchestrator: "Break work into focused tasks and delegate to plan, debug, ask, or code using subagent. Include all necessary context in each task; children do not inherit the conversation. Declare mode: read for read-only work and mode: write with precise repository-relative paths for edits. Divide parallel work by non-overlapping ownership; at most two jobs can run concurrently. Leave uncertain, overlapping, or shared-configuration writes sequential. Do not edit the workspace yourself while write children are running. Inspect results, reconcile integration issues, and run final verification after all children settle. Do not delegate the same orchestration task recursively.",
|
|
12
12
|
debug: "Reproduce or establish evidence for the failure, trace its cause before editing, and make the smallest justified fix. Verify with a regression check. Separate confirmed facts from hypotheses and report any unexecuted checks.",
|
|
13
13
|
ask: "Answer the user's question using relevant code or documentation as evidence. Do not change files or delegate changes. When context is missing, ask rather than invent facts. Stop when the question is answered.",
|
|
14
14
|
code: "Implement the requested scope using existing repository patterns. Preserve unrelated changes, add focused coverage for behavior changes, run relevant checks, and report results. Stop when acceptance conditions are met.",
|
|
@@ -34,7 +34,7 @@ export function parseAgentConfig(value: unknown): AgentConfig {
|
|
|
34
34
|
const source = object(value, "Agent configuration");
|
|
35
35
|
const result: AgentConfig = {};
|
|
36
36
|
for (const key of Object.keys(source)) {
|
|
37
|
-
if (!["$schema", "model", "small_model", "subagent_model", "delegate_exploration", "subagent_variant_overrides"].includes(key)) {
|
|
37
|
+
if (!["$schema", "model", "small_model", "subagent_model", "delegate_exploration", "auto_profile", "subagent_variant_overrides"].includes(key)) {
|
|
38
38
|
throw new Error(`Unknown agent configuration key: ${key}`);
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -49,6 +49,10 @@ export function parseAgentConfig(value: unknown): AgentConfig {
|
|
|
49
49
|
if (typeof source.delegate_exploration !== "boolean") throw new Error("delegate_exploration must be a boolean");
|
|
50
50
|
result.delegate_exploration = source.delegate_exploration;
|
|
51
51
|
}
|
|
52
|
+
if (source.auto_profile !== undefined) {
|
|
53
|
+
if (typeof source.auto_profile !== "boolean") throw new Error("auto_profile must be a boolean");
|
|
54
|
+
result.auto_profile = source.auto_profile;
|
|
55
|
+
}
|
|
52
56
|
if (source.subagent_variant_overrides !== undefined) {
|
|
53
57
|
result.subagent_variant_overrides = {};
|
|
54
58
|
for (const [name, value] of Object.entries(object(source.subagent_variant_overrides, "subagent_variant_overrides"))) {
|
|
@@ -166,6 +170,35 @@ export async function completeProjectAgentConfig(cwd: string, agentDir: string,
|
|
|
166
170
|
});
|
|
167
171
|
}
|
|
168
172
|
|
|
173
|
+
const profileIntentRules: [AgentName, RegExp][] = [
|
|
174
|
+
["plan", /^(?:let'?s\s+plan|(?:make|create)\s+(?:me\s+)?(?:a\s+)?plan|help\s+me\s+plan|plan)\b/i],
|
|
175
|
+
["debug", /^(?:help\s+me\s+)?(?:debug|investigate|fix\s+(?:a\s+|the\s+|this\s+)?(?:bug|error|crash|failure))\b/i],
|
|
176
|
+
["orchestrator", /^(?:orchestrate|coordinate|delegate)\b/i],
|
|
177
|
+
["code", /^(?:implement|build|write|execute)\b/i],
|
|
178
|
+
["ask", /^(?:explain|answer(?:\s+(?:this\s+)?question)?|what|how|why|where|when|who)\b/i],
|
|
179
|
+
];
|
|
180
|
+
const politePrefix = /^(?:(?:please|kindly|hey)[,\s]+|(?:could|would|can)\s+you\s+)+/i;
|
|
181
|
+
|
|
182
|
+
export function detectProfileIntent(text: string): AgentName | undefined {
|
|
183
|
+
const input = text.trim().replace(/\u2019/g, "'").replace(politePrefix, "");
|
|
184
|
+
if (!input || /^[/`>~"'\u201c\u2018]/.test(input)) return undefined;
|
|
185
|
+
const match = profileIntentRules.find(([, expression]) => expression.test(input));
|
|
186
|
+
if (!match) return undefined;
|
|
187
|
+
const [profile, expression] = match;
|
|
188
|
+
const rest = input.slice(input.match(expression)![0].length);
|
|
189
|
+
if (profile !== "ask" && /^\s+(?:is|means|refers\s+to)\b/i.test(rest)) return undefined;
|
|
190
|
+
// shortcut: bounded English outer-action rules; expand only with evaluated intent examples.
|
|
191
|
+
const nested = /^(?:\s+(?:how|why|what|when|where)\b)|\bto\s+(?:debug|investigate|implement|build|write|execute)\b/i.test(rest);
|
|
192
|
+
if (!nested) {
|
|
193
|
+
const ownText = rest.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`]*`|"[^"]*"|“[^”]*”|'[^']*'|‘[^’]*’/g, "");
|
|
194
|
+
for (const clause of ownText.split(/\b(?:and(?:\s+then)?|then|but|also)\s+|[.!?]\s+|\n+/i).slice(1)) {
|
|
195
|
+
const other = profileIntentRules.find(([, rule]) => rule.test(clause.trim().replace(politePrefix, "")));
|
|
196
|
+
if (other && other[0] !== profile) return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return profile;
|
|
200
|
+
}
|
|
201
|
+
|
|
169
202
|
export function isSmallTask(agent: AgentName, task: string): boolean {
|
|
170
203
|
// shortcut: conservative text heuristic, replace with evaluated routing if real tasks outgrow it.
|
|
171
204
|
if (task.length > 400 || task.split(/\s+/).length > 65) return false;
|
package/lib/subagent-runner.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { SubagentScope } from "./subagent-scheduler.ts";
|
|
6
7
|
|
|
7
8
|
type Usage = NonNullable<Awaited<ReturnType<ToolDefinition["execute"]>>["usage"]>;
|
|
8
9
|
|
|
@@ -50,6 +51,7 @@ export async function runSubagent(options: {
|
|
|
50
51
|
cwd: string;
|
|
51
52
|
depth: number;
|
|
52
53
|
task: string;
|
|
54
|
+
scope?: SubagentScope;
|
|
53
55
|
signal: AbortSignal;
|
|
54
56
|
onProgress?: (text: string) => void;
|
|
55
57
|
}): Promise<ChildResult> {
|
|
@@ -60,7 +62,8 @@ export async function runSubagent(options: {
|
|
|
60
62
|
const child = spawn(options.invocation.command, [...options.invocation.args, ...options.args], {
|
|
61
63
|
cwd: options.cwd, shell: false, detached: grouped,
|
|
62
64
|
stdio: ["pipe", "pipe", "pipe"],
|
|
63
|
-
env: { ...process.env, PIEXIS_SUBAGENT_DEPTH: String(options.depth)
|
|
65
|
+
env: { ...process.env, PIEXIS_SUBAGENT_DEPTH: String(options.depth),
|
|
66
|
+
...(options.scope ? { PIEXIS_SUBAGENT_SCOPE: JSON.stringify(options.scope) } : {}) },
|
|
64
67
|
});
|
|
65
68
|
let buffer = "", bytes = 0, timer: NodeJS.Timeout | undefined, failure: Error | undefined;
|
|
66
69
|
let closed = false;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { basename, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type SubagentScope = { mode: "read" | "write"; paths: string[]; broad: boolean };
|
|
5
|
+
|
|
6
|
+
const sharedRootFiles = new Set([
|
|
7
|
+
"package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
|
|
8
|
+
"bun.lock", "bun.lockb", ".npmrc", "biome.json", "turbo.json", "nx.json",
|
|
9
|
+
]);
|
|
10
|
+
const sharedRootPatterns = [
|
|
11
|
+
/^tsconfig(?:\.[^/]+)?\.json$/,
|
|
12
|
+
/^(?:vite|webpack|rollup|esbuild|eslint|prettier)\.config\.[^/]+$/,
|
|
13
|
+
/^\.eslintrc(?:\.[^/]+)?$/,
|
|
14
|
+
/^\.prettierrc(?:\.[^/]+)?$/,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function isSharedPath(path: string): boolean {
|
|
18
|
+
path = path.toLowerCase();
|
|
19
|
+
const first = path.split("/")[0];
|
|
20
|
+
if ([".pi", ".git", ".github"].includes(first)) return true;
|
|
21
|
+
const name = basename(path);
|
|
22
|
+
if (sharedRootFiles.has(name) || /^tsconfig(?:\.[^/]+)?\.json$/.test(name)) return true;
|
|
23
|
+
return !path.includes("/") && sharedRootPatterns.some((pattern) => pattern.test(path));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function rejectSymlinkComponents(cwd: string, path: string): Promise<void> {
|
|
27
|
+
let current = cwd;
|
|
28
|
+
for (const segment of path.split("/")) {
|
|
29
|
+
current = resolve(current, segment);
|
|
30
|
+
try {
|
|
31
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error(`Write path contains a symbolic-link component: ${path}`);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Validates a declared scope without resolving symlinks outside the workspace. */
|
|
40
|
+
export async function normalizeSubagentScope(
|
|
41
|
+
cwd: string,
|
|
42
|
+
input: { mode?: "read" | "write"; paths?: string[] },
|
|
43
|
+
readOnly = false,
|
|
44
|
+
): Promise<SubagentScope> {
|
|
45
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new TypeError("Subagent scope must be an object");
|
|
46
|
+
const mode = input.mode === undefined ? (readOnly ? "read" : "write") : input.mode;
|
|
47
|
+
if (mode !== "read" && mode !== "write") throw new TypeError("Subagent scope mode must be read or write");
|
|
48
|
+
if (readOnly && input.mode === "write") throw new Error("A read-only subagent cannot declare write scope");
|
|
49
|
+
if (input.paths !== undefined && !Array.isArray(input.paths)) throw new TypeError("Subagent scope paths must be an array");
|
|
50
|
+
|
|
51
|
+
const root = resolve(cwd);
|
|
52
|
+
const paths: string[] = [];
|
|
53
|
+
for (const value of input.paths ?? []) {
|
|
54
|
+
if (typeof value !== "string") throw new TypeError("Subagent scope paths must contain strings");
|
|
55
|
+
if (!value || value.includes("\0") || value.includes("\\") || /^(?:[A-Za-z]:|\/)/.test(value)) {
|
|
56
|
+
throw new Error(`Invalid repository-relative path: ${JSON.stringify(value)}`);
|
|
57
|
+
}
|
|
58
|
+
const absolute = resolve(root, value);
|
|
59
|
+
const fromRoot = relative(root, absolute);
|
|
60
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) throw new Error(`Path escapes workspace: ${value}`);
|
|
61
|
+
paths.push(fromRoot ? fromRoot.split(sep).join("/") : ".");
|
|
62
|
+
}
|
|
63
|
+
const normalized = [...new Set(paths)];
|
|
64
|
+
if (mode === "write") await Promise.all(normalized.filter((path) => path !== ".").map((path) => rejectSymlinkComponents(root, path)));
|
|
65
|
+
const broad = mode === "write" && (normalized.length === 0 || normalized.includes(".") || normalized.some(isSharedPath));
|
|
66
|
+
return { mode, paths: normalized, broad };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function overlaps(a: string, b: string): boolean {
|
|
70
|
+
// Case-fold conservatively on platforms whose default filesystems are case-insensitive.
|
|
71
|
+
if (process.platform === "win32" || process.platform === "darwin") { a = a.toLowerCase(); b = b.toLowerCase(); }
|
|
72
|
+
return a === "." || b === "." || a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function scopesConflict(a: SubagentScope, b: SubagentScope): boolean {
|
|
76
|
+
if (a.mode === "read" || b.mode === "read") return false;
|
|
77
|
+
if (a.broad || b.broad) return true;
|
|
78
|
+
return a.paths.some((left) => b.paths.some((right) => overlaps(left, right)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type SchedulerStatus = { status: "queued" | "running" | "completed" | "failed" | "aborted"; reason?: string };
|
|
82
|
+
|
|
83
|
+
type Job<T> = {
|
|
84
|
+
scope: SubagentScope;
|
|
85
|
+
controller: AbortController;
|
|
86
|
+
serial: boolean;
|
|
87
|
+
run: () => Promise<T>;
|
|
88
|
+
onStatus?: (status: SchedulerStatus) => void;
|
|
89
|
+
resolve: (value: T) => void;
|
|
90
|
+
reject: (reason?: unknown) => void;
|
|
91
|
+
abort: () => void;
|
|
92
|
+
state: "queued" | "active" | "settled";
|
|
93
|
+
lastStatus?: string;
|
|
94
|
+
done: Promise<void>;
|
|
95
|
+
finish: () => void;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export class SubagentScheduler {
|
|
99
|
+
#queued: Job<unknown>[] = [];
|
|
100
|
+
#active = new Set<Job<unknown>>();
|
|
101
|
+
#draining = false;
|
|
102
|
+
#stopping = false;
|
|
103
|
+
#shutdown?: Promise<void>;
|
|
104
|
+
readonly #limit: number;
|
|
105
|
+
|
|
106
|
+
constructor(limit = 2) {
|
|
107
|
+
if (!Number.isInteger(limit) || limit < 1) throw new RangeError("Subagent scheduler limit must be at least one");
|
|
108
|
+
this.#limit = limit;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get size(): number { return this.#queued.length + this.#active.size; }
|
|
112
|
+
|
|
113
|
+
schedule<T>(options: { scope: SubagentScope; controller: AbortController; serial?: boolean; run: () => Promise<T>; onStatus?: (status: SchedulerStatus) => void }): Promise<T> {
|
|
114
|
+
if (this.#stopping) return Promise.reject(this.#abortError("Subagent scheduler is shut down"));
|
|
115
|
+
options.controller.signal.throwIfAborted();
|
|
116
|
+
return new Promise<T>((resolve, reject) => {
|
|
117
|
+
let finish!: () => void;
|
|
118
|
+
const done = new Promise<void>((doneResolve) => { finish = doneResolve; });
|
|
119
|
+
const job: Job<T> = {
|
|
120
|
+
...options, serial: options.serial === true, resolve, reject, state: "queued", done, finish,
|
|
121
|
+
abort: () => this.#cancelQueued(job as unknown as Job<unknown>),
|
|
122
|
+
};
|
|
123
|
+
options.controller.signal.addEventListener("abort", job.abort, { once: true });
|
|
124
|
+
this.#queued.push(job as Job<unknown>);
|
|
125
|
+
this.#drain();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async shutdown(reason?: unknown): Promise<void> {
|
|
130
|
+
if (this.#shutdown) return this.#shutdown;
|
|
131
|
+
this.#stopping = true;
|
|
132
|
+
this.#shutdown = (async () => {
|
|
133
|
+
for (const job of [...this.#queued]) {
|
|
134
|
+
job.controller.abort(reason);
|
|
135
|
+
this.#cancelQueued(job);
|
|
136
|
+
}
|
|
137
|
+
for (const job of this.#active) job.controller.abort(reason);
|
|
138
|
+
await Promise.all([...this.#active].map((job) => job.done));
|
|
139
|
+
})();
|
|
140
|
+
return this.#shutdown;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#drain(): void {
|
|
144
|
+
if (this.#draining || this.#stopping) return;
|
|
145
|
+
this.#draining = true;
|
|
146
|
+
try {
|
|
147
|
+
let started: boolean;
|
|
148
|
+
do {
|
|
149
|
+
started = false;
|
|
150
|
+
for (const job of [...this.#queued]) {
|
|
151
|
+
if (this.#stopping) break;
|
|
152
|
+
if (job.state !== "queued") continue;
|
|
153
|
+
const reason = this.#blockingReason(job);
|
|
154
|
+
if (reason) { this.#status(job, { status: "queued", reason }); continue; }
|
|
155
|
+
this.#queued.splice(this.#queued.indexOf(job), 1);
|
|
156
|
+
job.controller.signal.removeEventListener("abort", job.abort);
|
|
157
|
+
this.#start(job);
|
|
158
|
+
started = true;
|
|
159
|
+
}
|
|
160
|
+
} while (started && !this.#shutdown);
|
|
161
|
+
} finally { this.#draining = false; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#blockingReason(job: Job<unknown>): string | undefined {
|
|
165
|
+
if (this.#active.size >= this.#limit || job.serial && this.#active.size > 0 || [...this.#active].some((active) => active.serial)) return "capacity";
|
|
166
|
+
const conflict = [...this.#active].find((active) => scopesConflict(job.scope, active.scope));
|
|
167
|
+
if (!conflict) return undefined;
|
|
168
|
+
if (job.scope.broad || conflict.scope.broad) return "conflicting broad write";
|
|
169
|
+
const left = job.scope.paths.find((path) => conflict.scope.paths.some((other) => overlaps(path, other)))!;
|
|
170
|
+
return `conflicting path: ${left}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#start(job: Job<unknown>): void {
|
|
174
|
+
job.state = "active";
|
|
175
|
+
this.#active.add(job);
|
|
176
|
+
this.#status(job, { status: "running" });
|
|
177
|
+
let promise: Promise<unknown>;
|
|
178
|
+
try { job.controller.signal.throwIfAborted(); promise = Promise.resolve(job.run()); }
|
|
179
|
+
catch (error) { promise = Promise.reject(error); }
|
|
180
|
+
promise.then(
|
|
181
|
+
(value) => this.#settle(job, "completed", value),
|
|
182
|
+
(error) => this.#settle(job, job.controller.signal.aborted ? "aborted" : "failed", error),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#settle(job: Job<unknown>, status: SchedulerStatus["status"], value: unknown): void {
|
|
187
|
+
if (job.state === "settled") return;
|
|
188
|
+
if (status === "completed" && job.controller.signal.aborted) { status = "aborted"; value = job.controller.signal.reason; }
|
|
189
|
+
job.state = "settled";
|
|
190
|
+
this.#active.delete(job);
|
|
191
|
+
job.finish();
|
|
192
|
+
this.#status(job, { status });
|
|
193
|
+
if (status === "completed") job.resolve(value); else job.reject(value);
|
|
194
|
+
this.#drain();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#cancelQueued(job: Job<unknown>): void {
|
|
198
|
+
if (job.state !== "queued") return;
|
|
199
|
+
job.state = "settled";
|
|
200
|
+
const index = this.#queued.indexOf(job);
|
|
201
|
+
if (index >= 0) this.#queued.splice(index, 1);
|
|
202
|
+
job.finish();
|
|
203
|
+
job.controller.signal.removeEventListener("abort", job.abort);
|
|
204
|
+
this.#status(job, { status: "aborted" });
|
|
205
|
+
try { job.controller.signal.throwIfAborted(); } catch (error) { job.reject(error); }
|
|
206
|
+
this.#drain();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#status(job: Job<unknown>, status: SchedulerStatus): void {
|
|
210
|
+
const key = `${status.status}\0${status.reason ?? ""}`;
|
|
211
|
+
if (job.lastStatus === key) return;
|
|
212
|
+
job.lastStatus = key;
|
|
213
|
+
try { job.onStatus?.(status); } catch { /* Status observers cannot retain scheduler reservations. */ }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#abortError(message: string): Error {
|
|
217
|
+
const error = new Error(message);
|
|
218
|
+
error.name = "AbortError";
|
|
219
|
+
return error;
|
|
220
|
+
}
|
|
221
|
+
}
|
package/package.json
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"type": "boolean",
|
|
17
17
|
"description": "Allow read-only exploration to use small_model."
|
|
18
18
|
},
|
|
19
|
+
"auto_profile": {
|
|
20
|
+
"type": "boolean",
|
|
21
|
+
"description": "Automatically select a profile for clear requests."
|
|
22
|
+
},
|
|
19
23
|
"subagent_variant_overrides": {
|
|
20
24
|
"type": "object",
|
|
21
25
|
"additionalProperties": false,
|