@dougbots/pi-agents 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/extensions/agents/agent-profile.ts +107 -0
- package/extensions/agents/index.ts +182 -49
- package/extensions/agents/model-selection.ts +29 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -49,6 +49,36 @@ Profile fields:
|
|
|
49
49
|
- `excludeTools` — denylist of tool names (removed from available set)
|
|
50
50
|
- `permissions.bash` — `{ allow?: string[], deny?: string[] }` with glob patterns
|
|
51
51
|
|
|
52
|
+
### Runtime agent profiles
|
|
53
|
+
|
|
54
|
+
A runtime agent profile is a session-scoped overlay for role profiles. It can
|
|
55
|
+
change execution settings without changing the role's system prompt, tools, or
|
|
56
|
+
permission boundary. Define global profiles in `~/.pi/agent/agent-profiles.json`
|
|
57
|
+
and project overrides in `.pi/agent-profiles.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"cloud": {
|
|
62
|
+
"description": "Temporary cloud fallbacks",
|
|
63
|
+
"agents": {
|
|
64
|
+
"explore": { "model": "sparky/deepseek-flash" },
|
|
65
|
+
"mule": { "model": "sparky/deepseek-flash" },
|
|
66
|
+
"reviewer": { "model": "sparky/deepseek-flash" }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Only `model` and `thinkingLevel` may be overridden. Project profile entries
|
|
73
|
+
merge over global entries by profile and agent name. Malformed entries are
|
|
74
|
+
ignored and reported as a Pi warning. A selected profile is persisted in the Pi
|
|
75
|
+
session and restored by `/resume`.
|
|
76
|
+
|
|
77
|
+
`PI_AGENT_PROFILE=<name>` is an explicit launch-time override; `PROFILE=<name>`
|
|
78
|
+
is accepted as a convenience alias. Both take precedence over a saved session
|
|
79
|
+
selection. Explicit `--model` still takes precedence over an agent-profile's
|
|
80
|
+
model.
|
|
81
|
+
|
|
52
82
|
## Commands
|
|
53
83
|
|
|
54
84
|
| Command | Description |
|
|
@@ -57,18 +87,35 @@ Profile fields:
|
|
|
57
87
|
| `/agent none` | Deactivate agent, restore all tools |
|
|
58
88
|
| `/agent` | List available agents |
|
|
59
89
|
| `/agents` | Same as `/agent` |
|
|
90
|
+
| `/agent-profile <name>` | Select a session-scoped runtime agent profile |
|
|
91
|
+
| `/agent-profile none` | Clear the session runtime agent profile |
|
|
92
|
+
| `/agent-profile` | Show the active and available runtime agent profiles |
|
|
60
93
|
|
|
61
94
|
## Boot with agent
|
|
62
95
|
|
|
63
96
|
```bash
|
|
64
97
|
PI_AGENT=reviewer pi
|
|
65
98
|
PI_AGENT=jockey pi -c
|
|
99
|
+
PI_AGENT=explore PI_AGENT_PROFILE=cloud pi
|
|
66
100
|
```
|
|
67
101
|
|
|
68
102
|
## Avenor integration
|
|
69
103
|
|
|
70
104
|
When `avenor_spawn(agent: "reviewer", backend: "pi")` is called, avenor spawns `pi --mode rpc` with `PI_AGENT=reviewer`. The agents extension applies the full profile (model, systemPrompt, tools, permissions) automatically.
|
|
71
105
|
|
|
106
|
+
Pass `model` as well to override only the profile's default model while retaining its prompt, tools, and permissions:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
avenor_spawn(
|
|
110
|
+
agent: "reviewer",
|
|
111
|
+
model: "anthropic/claude-opus-4",
|
|
112
|
+
backend: "pi",
|
|
113
|
+
prompt: "Review the outage recovery changes",
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Explicit `--model` selection takes precedence over the profile default. Avenor forwards its `model` option to the pi subprocess as `--model`, so no agent config changes are needed for a one-off override.
|
|
118
|
+
|
|
72
119
|
Requires [avenor](https://github.com/sdougbrown/avenor) with the pi backend (v0.3.3+). Model resolution falls back to `~/.pi/agent/agents.json` when the agent is not found in opencode config.
|
|
73
120
|
|
|
74
121
|
## Profile vs. agent
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
2
|
+
|
|
3
|
+
/** Runtime-safe fields a named agent-profile may override on a base agent. */
|
|
4
|
+
export interface AgentProfileOverride {
|
|
5
|
+
model?: string;
|
|
6
|
+
thinkingLevel?: ThinkingLevel;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AgentProfileSet {
|
|
10
|
+
description?: string;
|
|
11
|
+
agents?: Record<string, AgentProfileOverride>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AgentProfilesConfig {
|
|
15
|
+
[name: string]: AgentProfileSet;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const thinkingLevels = new Set<ThinkingLevel>([
|
|
19
|
+
"off", "minimal", "low", "medium", "high", "xhigh",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Discard unsupported fields from untrusted JSON config. */
|
|
27
|
+
export function executionOverride(value: unknown): AgentProfileOverride | undefined {
|
|
28
|
+
if (!isRecord(value)) return undefined;
|
|
29
|
+
const override: AgentProfileOverride = {};
|
|
30
|
+
if (typeof value.model === "string") override.model = value.model;
|
|
31
|
+
if (typeof value.thinkingLevel === "string" && thinkingLevels.has(value.thinkingLevel as ThinkingLevel)) {
|
|
32
|
+
override.thinkingLevel = value.thinkingLevel as ThinkingLevel;
|
|
33
|
+
}
|
|
34
|
+
return Object.keys(override).length ? override : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Merge profile config sources: project entries override global entries by
|
|
39
|
+
* profile and agent name. Invalid JSON shapes are ignored rather than making
|
|
40
|
+
* the extension fail to load.
|
|
41
|
+
*/
|
|
42
|
+
export function mergeAgentProfilesConfigs(
|
|
43
|
+
configs: readonly unknown[],
|
|
44
|
+
onInvalid?: (message: string) => void,
|
|
45
|
+
): AgentProfilesConfig {
|
|
46
|
+
const merged: AgentProfilesConfig = {};
|
|
47
|
+
|
|
48
|
+
for (const config of configs) {
|
|
49
|
+
if (!isRecord(config)) {
|
|
50
|
+
onInvalid?.("top-level value must be an object");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const [name, rawProfile] of Object.entries(config)) {
|
|
55
|
+
if (!isRecord(rawProfile)) {
|
|
56
|
+
onInvalid?.(`profile "${name}" must be an object`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (rawProfile.agents !== undefined && !isRecord(rawProfile.agents)) {
|
|
60
|
+
onInvalid?.(`profile "${name}" has a non-object agents value`);
|
|
61
|
+
}
|
|
62
|
+
if (rawProfile.description !== undefined && typeof rawProfile.description !== "string") {
|
|
63
|
+
onInvalid?.(`profile "${name}" has a non-string description`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const current = Object.hasOwn(merged, name) ? merged[name] : undefined;
|
|
67
|
+
const rawAgents = isRecord(rawProfile.agents) ? rawProfile.agents : {};
|
|
68
|
+
const profile: AgentProfileSet = {
|
|
69
|
+
...(typeof current?.description === "string" ? { description: current.description } : {}),
|
|
70
|
+
...(typeof rawProfile.description === "string" ? { description: rawProfile.description } : {}),
|
|
71
|
+
agents: {
|
|
72
|
+
...current?.agents,
|
|
73
|
+
...rawAgents as Record<string, AgentProfileOverride>,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// defineProperty avoids the __proto__ setter on normal objects and makes
|
|
78
|
+
// profile-name lookups consistently own-property-only.
|
|
79
|
+
Object.defineProperty(merged, name, {
|
|
80
|
+
value: profile,
|
|
81
|
+
enumerable: true,
|
|
82
|
+
configurable: true,
|
|
83
|
+
writable: true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return merged;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Return the latest persisted profile selection, including an explicit clear. */
|
|
92
|
+
export function findPersistedProfileName(entries: readonly unknown[]): string | undefined {
|
|
93
|
+
let profileName: string | undefined;
|
|
94
|
+
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (!entry || typeof entry !== "object") continue;
|
|
97
|
+
const customEntry = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
98
|
+
if (customEntry.type !== "custom" || customEntry.customType !== "pi-agents:profile") continue;
|
|
99
|
+
if (!customEntry.data || typeof customEntry.data !== "object") continue;
|
|
100
|
+
|
|
101
|
+
const name = (customEntry.data as { name?: unknown }).name;
|
|
102
|
+
if (typeof name === "string") profileName = name;
|
|
103
|
+
if (name === null) profileName = undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return profileName;
|
|
107
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Type } from "typebox";
|
|
4
3
|
import { readFileSync, existsSync } from "node:fs";
|
|
5
4
|
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
executionOverride,
|
|
7
|
+
findPersistedProfileName,
|
|
8
|
+
mergeAgentProfilesConfigs,
|
|
9
|
+
type AgentProfilesConfig,
|
|
10
|
+
type ThinkingLevel,
|
|
11
|
+
} from "./agent-profile.ts";
|
|
12
|
+
import { findCliModelOverride, parseModelSpecifier } from "./model-selection.ts";
|
|
6
13
|
|
|
7
14
|
/* ------------------------------------------------------------------ */
|
|
8
15
|
/* Types */
|
|
@@ -17,7 +24,7 @@ interface AgentProfile {
|
|
|
17
24
|
description?: string;
|
|
18
25
|
model: string; // "provider/model-id"
|
|
19
26
|
systemPrompt: string; // inline text or "file:/absolute/path/to/prompt.md"
|
|
20
|
-
thinkingLevel?:
|
|
27
|
+
thinkingLevel?: ThinkingLevel;
|
|
21
28
|
tools?: string[]; // allowlist — if set, only these tools are callable
|
|
22
29
|
excludeTools?: string[]; // denylist — removed from available tools
|
|
23
30
|
permissions?: {
|
|
@@ -30,10 +37,10 @@ interface AgentsConfig {
|
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/* ------------------------------------------------------------------ */
|
|
33
|
-
/* Config loading
|
|
34
|
-
/* with project (.pi/agents.json), project wins. */
|
|
40
|
+
/* Config loading */
|
|
35
41
|
/* ------------------------------------------------------------------ */
|
|
36
42
|
|
|
43
|
+
/** Merge global ~/.pi/agent/agents.json with project .pi/agents.json. */
|
|
37
44
|
function loadAgentsConfig(): AgentsConfig {
|
|
38
45
|
const agentDir = getAgentDir();
|
|
39
46
|
const globalPath = join(agentDir, "agents.json");
|
|
@@ -51,10 +58,40 @@ function loadAgentsConfig(): AgentsConfig {
|
|
|
51
58
|
console.error(`[agents] Failed to parse ${label} agents.json (${path}):`, err);
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
|
-
// Later entries override earlier — project wins over global
|
|
61
|
+
// Later entries override earlier — project wins over global.
|
|
55
62
|
return configs.reduce<AgentsConfig>((merged, cfg) => ({ ...merged, ...cfg }), {});
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Load named runtime overlays. These can change only execution settings,
|
|
67
|
+
* leaving an agent's prompt, tools, and permission boundary authoritative.
|
|
68
|
+
*/
|
|
69
|
+
function loadAgentProfilesConfig(): { profiles: AgentProfilesConfig; errors: string[] } {
|
|
70
|
+
const agentDir = getAgentDir();
|
|
71
|
+
const globalPath = join(agentDir, "agent-profiles.json");
|
|
72
|
+
const projectPath = join(process.cwd(), ".pi", "agent-profiles.json");
|
|
73
|
+
|
|
74
|
+
let profiles: AgentProfilesConfig = {};
|
|
75
|
+
const errors: string[] = [];
|
|
76
|
+
for (const [label, path] of [
|
|
77
|
+
["global", globalPath],
|
|
78
|
+
["project", projectPath],
|
|
79
|
+
] as const) {
|
|
80
|
+
if (!existsSync(path)) continue;
|
|
81
|
+
try {
|
|
82
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
83
|
+
profiles = mergeAgentProfilesConfigs([profiles, config], (message) => {
|
|
84
|
+
errors.push(`${label} ${path}: ${message}`);
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const message = `Failed to parse ${label} agent-profiles.json (${path}): ${String(err)}`;
|
|
88
|
+
console.error(`[agents] ${message}`);
|
|
89
|
+
errors.push(message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { profiles, errors };
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
function resolveSystemPrompt(raw: string): string {
|
|
59
96
|
if (raw.startsWith("file:")) {
|
|
60
97
|
const path = raw.slice(5);
|
|
@@ -82,7 +119,7 @@ function checkBashPermission(
|
|
|
82
119
|
command: string,
|
|
83
120
|
): "allow" | "deny" {
|
|
84
121
|
if (!perms) return "allow";
|
|
85
|
-
// Deny wins over allow
|
|
122
|
+
// Deny wins over allow.
|
|
86
123
|
for (const pattern of perms.deny ?? []) {
|
|
87
124
|
if (matchPattern(pattern, command)) return "deny";
|
|
88
125
|
}
|
|
@@ -99,53 +136,112 @@ function checkBashPermission(
|
|
|
99
136
|
|
|
100
137
|
export default async function (pi: ExtensionAPI) {
|
|
101
138
|
const agents = loadAgentsConfig();
|
|
139
|
+
const { profiles: agentProfiles, errors: agentProfileConfigErrors } = loadAgentProfilesConfig();
|
|
140
|
+
// Built-in flags are not exposed through pi.getFlag(). Avenor forwards its
|
|
141
|
+
// model option to pi as --model, so argv is the extension-level signal that
|
|
142
|
+
// the caller's model should take precedence over the profile default.
|
|
143
|
+
const cliModelOverride = findCliModelOverride(process.argv.slice(2));
|
|
102
144
|
let activeAgent: { name: string; profile: AgentProfile } | null = null;
|
|
145
|
+
let activeAgentProfileName: string | undefined;
|
|
146
|
+
|
|
147
|
+
function getEffectiveAgentProfile(name: string): AgentProfile | undefined {
|
|
148
|
+
const base = agents[name];
|
|
149
|
+
if (!base) return undefined;
|
|
150
|
+
|
|
151
|
+
const override = activeAgentProfileName
|
|
152
|
+
? executionOverride(agentProfiles[activeAgentProfileName]?.agents?.[name])
|
|
153
|
+
: undefined;
|
|
154
|
+
return { ...base, ...override };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function setAgentProfileStatus(ctx: ExtensionContext) {
|
|
158
|
+
ctx.ui.setStatus(
|
|
159
|
+
"agent-profile",
|
|
160
|
+
activeAgentProfileName ? `agent-profile:${activeAgentProfileName}` : "",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Apply a named agent-profile, optionally saving the choice in this session. */
|
|
165
|
+
async function selectAgentProfile(
|
|
166
|
+
name: string | undefined,
|
|
167
|
+
ctx: ExtensionContext,
|
|
168
|
+
persist: boolean,
|
|
169
|
+
): Promise<boolean> {
|
|
170
|
+
if (name && !agentProfiles[name]) {
|
|
171
|
+
ctx.ui.notify(`Agent profile "${name}" not found in agent-profiles.json`, "error");
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const previousName = activeAgentProfileName;
|
|
176
|
+
activeAgentProfileName = name;
|
|
177
|
+
setAgentProfileStatus(ctx);
|
|
178
|
+
|
|
179
|
+
// The active role must receive a new effective model/thinking level now;
|
|
180
|
+
// future roles resolve the overlay when /agent is invoked. Do not persist
|
|
181
|
+
// a selection that cannot be applied to the current active role.
|
|
182
|
+
if (activeAgent && !(await applyAgent(activeAgent.name, ctx))) {
|
|
183
|
+
activeAgentProfileName = previousName;
|
|
184
|
+
setAgentProfileStatus(ctx);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (persist) pi.appendEntry("pi-agents:profile", { name: name ?? null });
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
103
191
|
|
|
104
192
|
/* ---- Apply agent profile to the current session ---- */
|
|
105
193
|
async function applyAgent(name: string, ctx: ExtensionContext): Promise<boolean> {
|
|
106
|
-
const profile =
|
|
194
|
+
const profile = getEffectiveAgentProfile(name);
|
|
107
195
|
if (!profile) {
|
|
108
196
|
ctx.ui.notify(`Agent "${name}" not found in agents.json`, "error");
|
|
109
197
|
return false;
|
|
110
198
|
}
|
|
111
199
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (!
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
200
|
+
// Explicit CLI selection wins over the profile default. This preserves
|
|
201
|
+
// `avenor_spawn(agent: ..., model: ..., backend: "pi")` overrides because
|
|
202
|
+
// Avenor forwards model to the pi subprocess as --model.
|
|
203
|
+
if (!cliModelOverride) {
|
|
204
|
+
const modelSpecifier = parseModelSpecifier(profile.model);
|
|
205
|
+
if (!modelSpecifier) {
|
|
206
|
+
ctx.ui.notify(`Invalid model "${profile.model}"; expected provider/model-id`, "error");
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
120
209
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
210
|
+
const model = ctx.modelRegistry.find(modelSpecifier.provider, modelSpecifier.modelId);
|
|
211
|
+
if (!model) {
|
|
212
|
+
ctx.ui.notify(`Model "${profile.model}" not found in registry`, "error");
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
await pi.setModel(model);
|
|
124
216
|
}
|
|
125
217
|
|
|
218
|
+
if (profile.thinkingLevel) pi.setThinkingLevel(profile.thinkingLevel);
|
|
219
|
+
|
|
126
220
|
// Tool restrictions — best-effort against currently registered tools.
|
|
127
221
|
// tool_call handler enforces restrictions regardless of registration timing.
|
|
128
222
|
try {
|
|
129
223
|
const allTools = pi.getAllTools();
|
|
130
224
|
let allowed: typeof allTools;
|
|
131
225
|
if (profile.tools) {
|
|
132
|
-
|
|
133
|
-
allowed = allTools.filter((t) => profile.tools!.includes(t.name));
|
|
134
|
-
// Also include any future tools whose name matches (checked at call time)
|
|
226
|
+
allowed = allTools.filter((tool) => profile.tools!.includes(tool.name));
|
|
135
227
|
} else {
|
|
136
228
|
allowed = [...allTools];
|
|
137
229
|
}
|
|
138
230
|
if (profile.excludeTools) {
|
|
139
|
-
allowed = allowed.filter((
|
|
231
|
+
allowed = allowed.filter((tool) => !profile.excludeTools!.includes(tool.name));
|
|
140
232
|
}
|
|
141
|
-
pi.setActiveTools(allowed.map((
|
|
233
|
+
pi.setActiveTools(allowed.map((tool) => tool.name));
|
|
142
234
|
} catch {
|
|
143
|
-
// setActiveTools may fail if called too early; tool_call gate is the fallback
|
|
235
|
+
// setActiveTools may fail if called too early; tool_call gate is the fallback.
|
|
144
236
|
}
|
|
145
237
|
|
|
146
238
|
activeAgent = { name, profile };
|
|
147
239
|
ctx.ui.setStatus("agent", `agent:${name}`);
|
|
148
|
-
|
|
240
|
+
const selected = activeAgentProfileName ? ` [${activeAgentProfileName}]` : "";
|
|
241
|
+
ctx.ui.notify(
|
|
242
|
+
`Agent "${name}" active${selected}${profile.description ? ": " + profile.description : ""}`,
|
|
243
|
+
"info",
|
|
244
|
+
);
|
|
149
245
|
return true;
|
|
150
246
|
}
|
|
151
247
|
|
|
@@ -153,44 +249,55 @@ export default async function (pi: ExtensionAPI) {
|
|
|
153
249
|
function deactivateAgent(ctx: ExtensionContext) {
|
|
154
250
|
activeAgent = null;
|
|
155
251
|
try {
|
|
156
|
-
pi.setActiveTools(pi.getAllTools().map((
|
|
252
|
+
pi.setActiveTools(pi.getAllTools().map((tool) => tool.name));
|
|
157
253
|
} catch { /* no-op if tools not fully registered */ }
|
|
158
254
|
ctx.ui.setStatus("agent", "");
|
|
159
255
|
ctx.ui.notify("Agent deactivated — all tools restored", "info");
|
|
160
256
|
}
|
|
161
257
|
|
|
162
|
-
/* ---- session_start: apply PI_AGENT
|
|
258
|
+
/* ---- session_start: restore profile, then apply PI_AGENT ---- */
|
|
163
259
|
pi.on("session_start", async (_event, ctx) => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
260
|
+
if (agentProfileConfigErrors.length) {
|
|
261
|
+
ctx.ui.notify(
|
|
262
|
+
`Ignored malformed agent-profile configuration:\n${agentProfileConfigErrors.join("\n")}`,
|
|
263
|
+
"warning",
|
|
264
|
+
);
|
|
167
265
|
}
|
|
266
|
+
|
|
267
|
+
// The environment is an explicit launch-time override. A persisted command
|
|
268
|
+
// selection is restored otherwise, including a saved `none` selection.
|
|
269
|
+
const environmentProfile = process.env.PI_AGENT_PROFILE ?? process.env.PROFILE;
|
|
270
|
+
const persistedProfile = findPersistedProfileName(ctx.sessionManager.getEntries());
|
|
271
|
+
const selectedProfile = environmentProfile ?? persistedProfile;
|
|
272
|
+
if (selectedProfile) await selectAgentProfile(selectedProfile, ctx, false);
|
|
273
|
+
else setAgentProfileStatus(ctx);
|
|
274
|
+
|
|
275
|
+
const envAgent = process.env.PI_AGENT;
|
|
276
|
+
if (envAgent && agents[envAgent]) await applyAgent(envAgent, ctx);
|
|
168
277
|
});
|
|
169
278
|
|
|
170
279
|
/* ---- before_agent_start: inject agent system prompt ---- */
|
|
171
280
|
pi.on("before_agent_start", async (event, _ctx) => {
|
|
172
281
|
if (!activeAgent) return undefined;
|
|
173
282
|
|
|
174
|
-
// Re-apply tool restrictions each turn (catches late-registered tools)
|
|
283
|
+
// Re-apply tool restrictions each turn (catches late-registered tools).
|
|
175
284
|
try {
|
|
176
285
|
const profile = activeAgent.profile;
|
|
177
286
|
const allTools = pi.getAllTools();
|
|
178
287
|
let allowed: typeof allTools;
|
|
179
288
|
if (profile.tools) {
|
|
180
|
-
allowed = allTools.filter((
|
|
289
|
+
allowed = allTools.filter((tool) => profile.tools.includes(tool.name));
|
|
181
290
|
} else {
|
|
182
291
|
allowed = [...allTools];
|
|
183
292
|
}
|
|
184
293
|
if (profile.excludeTools) {
|
|
185
|
-
allowed = allowed.filter((
|
|
294
|
+
allowed = allowed.filter((tool) => !profile.excludeTools.includes(tool.name));
|
|
186
295
|
}
|
|
187
|
-
pi.setActiveTools(allowed.map((
|
|
296
|
+
pi.setActiveTools(allowed.map((tool) => tool.name));
|
|
188
297
|
} catch { /* best effort */ }
|
|
189
298
|
|
|
190
299
|
const agentPrompt = resolveSystemPrompt(activeAgent.profile.systemPrompt);
|
|
191
|
-
return {
|
|
192
|
-
systemPrompt: agentPrompt + "\n\n" + event.systemPrompt,
|
|
193
|
-
};
|
|
300
|
+
return { systemPrompt: agentPrompt + "\n\n" + event.systemPrompt };
|
|
194
301
|
});
|
|
195
302
|
|
|
196
303
|
/* ---- tool_call: enforce tool allowlist/denylist + bash permissions ---- */
|
|
@@ -198,22 +305,18 @@ export default async function (pi: ExtensionAPI) {
|
|
|
198
305
|
if (!activeAgent) return;
|
|
199
306
|
const profile = activeAgent.profile;
|
|
200
307
|
|
|
201
|
-
// Tool allowlist gate
|
|
202
308
|
if (profile.tools && !profile.tools.includes(event.toolName)) {
|
|
203
309
|
return { block: true, reason: `Tool "${event.toolName}" not allowed for agent "${activeAgent.name}"` };
|
|
204
310
|
}
|
|
205
311
|
|
|
206
|
-
// Tool denylist gate
|
|
207
312
|
if (profile.excludeTools && profile.excludeTools.includes(event.toolName)) {
|
|
208
313
|
ctx.ui.notify(`Blocked tool: ${event.toolName}`, "warning");
|
|
209
314
|
return { block: true, reason: `Tool "${event.toolName}" excluded for agent "${activeAgent.name}"` };
|
|
210
315
|
}
|
|
211
316
|
|
|
212
|
-
// Bash permission gate
|
|
213
317
|
if (event.toolName === "bash" && profile.permissions?.bash) {
|
|
214
318
|
const command = (event.input as { command?: string })?.command ?? "";
|
|
215
|
-
|
|
216
|
-
if (verdict === "deny") {
|
|
319
|
+
if (checkBashPermission(profile.permissions.bash, command) === "deny") {
|
|
217
320
|
ctx.ui.notify(`Blocked: ${command.slice(0, 80)}`, "warning");
|
|
218
321
|
return { block: true, reason: `Denied by ${activeAgent.name} bash permissions` };
|
|
219
322
|
}
|
|
@@ -224,12 +327,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
224
327
|
pi.registerCommand("agent", {
|
|
225
328
|
description: "Switch to an agent profile (or 'none' to deactivate)",
|
|
226
329
|
getArgumentCompletions: (prefix) => {
|
|
227
|
-
const matches = Object.keys(agents).filter((
|
|
330
|
+
const matches = Object.keys(agents).filter((name) => name.startsWith(prefix ?? ""));
|
|
228
331
|
return matches.length
|
|
229
|
-
? matches.map((
|
|
230
|
-
value:
|
|
231
|
-
label:
|
|
232
|
-
description: agents[
|
|
332
|
+
? matches.map((name) => ({
|
|
333
|
+
value: name,
|
|
334
|
+
label: name,
|
|
335
|
+
description: agents[name].description ?? "",
|
|
233
336
|
}))
|
|
234
337
|
: null;
|
|
235
338
|
},
|
|
@@ -238,7 +341,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
238
341
|
if (!name) {
|
|
239
342
|
const current = activeAgent ? ` (active: ${activeAgent.name})` : "";
|
|
240
343
|
const list = Object.entries(agents)
|
|
241
|
-
.map(([
|
|
344
|
+
.map(([agentName, profile]) => ` ${agentName}${profile.description ? ": " + profile.description : ""}`)
|
|
242
345
|
.join("\n");
|
|
243
346
|
ctx.ui.notify(`Agent profiles${current}:\n${list}`, "info");
|
|
244
347
|
return;
|
|
@@ -257,9 +360,39 @@ export default async function (pi: ExtensionAPI) {
|
|
|
257
360
|
handler: async (_args, ctx) => {
|
|
258
361
|
const current = activeAgent ? ` [active: ${activeAgent.name}]` : "";
|
|
259
362
|
const list = Object.entries(agents)
|
|
260
|
-
.map(([
|
|
363
|
+
.map(([name, profile]) => ` ${name}${profile.description ? ": " + profile.description : ""}`)
|
|
261
364
|
.join("\n");
|
|
262
365
|
ctx.ui.notify(`Agent profiles${current}:\n${list}`, "info");
|
|
263
366
|
},
|
|
264
367
|
});
|
|
368
|
+
|
|
369
|
+
/* ---- /agent-profile <name> — select a session-scoped runtime overlay ---- */
|
|
370
|
+
pi.registerCommand("agent-profile", {
|
|
371
|
+
description: "Select a session-scoped agent runtime profile (or 'none' to clear)",
|
|
372
|
+
getArgumentCompletions: (prefix) => {
|
|
373
|
+
const names = ["none", ...Object.keys(agentProfiles)];
|
|
374
|
+
const matches = names.filter((name) => name.startsWith(prefix ?? ""));
|
|
375
|
+
return matches.length
|
|
376
|
+
? matches.map((name) => ({
|
|
377
|
+
value: name,
|
|
378
|
+
label: name,
|
|
379
|
+
description: name === "none"
|
|
380
|
+
? "Clear the session agent profile"
|
|
381
|
+
: agentProfiles[name]?.description ?? "",
|
|
382
|
+
}))
|
|
383
|
+
: null;
|
|
384
|
+
},
|
|
385
|
+
handler: async (args, ctx) => {
|
|
386
|
+
const name = args?.trim();
|
|
387
|
+
if (!name) {
|
|
388
|
+
const current = activeAgentProfileName ?? "none";
|
|
389
|
+
const list = Object.entries(agentProfiles)
|
|
390
|
+
.map(([profileName, profile]) => ` ${profileName}${profile.description ? ": " + profile.description : ""}`)
|
|
391
|
+
.join("\n");
|
|
392
|
+
ctx.ui.notify(`Agent runtime profiles (active: ${current})${list ? `:\n${list}` : ""}`, "info");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
await selectAgentProfile(name === "none" ? undefined : name, ctx, true);
|
|
396
|
+
},
|
|
397
|
+
});
|
|
265
398
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ModelSpecifier {
|
|
2
|
+
provider: string;
|
|
3
|
+
modelId: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Return the model explicitly supplied to pi, if any.
|
|
8
|
+
*
|
|
9
|
+
* Pi consumes --model itself, so extension flags cannot expose this value via
|
|
10
|
+
* pi.getFlag(). Inspecting argv lets agent profiles respect normal CLI
|
|
11
|
+
* precedence, including models forwarded by Avenor.
|
|
12
|
+
*/
|
|
13
|
+
export function findCliModelOverride(argv: readonly string[]): string | undefined {
|
|
14
|
+
for (let index = 0; index < argv.length - 1; index += 1) {
|
|
15
|
+
if (argv[index] === "--model") return argv[index + 1];
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Split provider/model-id at the first slash so model IDs may contain slashes. */
|
|
21
|
+
export function parseModelSpecifier(specifier: string): ModelSpecifier | undefined {
|
|
22
|
+
const separator = specifier.indexOf("/");
|
|
23
|
+
if (separator <= 0 || separator === specifier.length - 1) return undefined;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
provider: specifier.slice(0, separator),
|
|
27
|
+
modelId: specifier.slice(separator + 1),
|
|
28
|
+
};
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougbots/pi-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Agent profiles for pi — preconfigured model, system prompt, tool restrictions, and permission gates",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --experimental-strip-types --test tests/*.test.mts"
|
|
7
|
+
},
|
|
5
8
|
"keywords": ["pi-package", "pi", "pi-extension", "agents"],
|
|
6
9
|
"license": "MIT",
|
|
7
10
|
"repository": {
|