pi-open-agents 0.1.28 → 0.1.30

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 CHANGED
@@ -167,6 +167,15 @@ active agent's rules *before it runs* — not just used to filter the toolset:
167
167
  > blocks matching *individual calls* (patterns included), not just statically
168
168
  > hiding whole tools. (#20)
169
169
 
170
+ > **⚠️ Behavior change (v0.1.30):** primary-agent static filtering now covers
171
+ > **all tool sources**, not just builtins (#18). If an agent declares
172
+ > `permission` or `tools:`, extension tools (web, LSP, MCP, …) not matched
173
+ > are hidden from the model — previously they were always kept. Only blanket
174
+ > `deny` rules hide a tool; argument-pattern denies and `ask` stay enforced
175
+ > by the runtime gate. If your allowlist drops `subagent`, the agent cannot
176
+ > delegate anymore — apply warns you explicitly. No restrictions = everything
177
+ > stays active, exactly as before.
178
+
170
179
  **Inheritance down the delegation chain (#8).** A subagent can never exceed
171
180
  the authority of the agent that delegated to it — in both directions of the
172
181
  rule language:
@@ -387,6 +396,7 @@ six sessions, zero chaos: that's the exact workstation setup documented in
387
396
  - ~~**Parent permission inheritance** for subagents~~ — ✅ shipped in [0.1.27](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.27) (#8)
388
397
  - ~~**Agent hot-reload**~~ — ✅ works via pi's built-in `/reload`, shipped in [0.1.27](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.27) (#9)
389
398
  - ~~**Per-call model override** in `subagent()`~~ — ✅ shipped in [0.1.19](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.19) (#10)
399
+ - ~~**Primary tool filters incl. extension tools**~~ — ✅ shipped in [0.1.30](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.30) (#18)
390
400
  - **Parallel subagent limit** — #11
391
401
 
392
402
  ---
@@ -398,7 +408,7 @@ opening an issue or pull request.
398
408
 
399
409
  ```bash
400
410
  npm install
401
- npm test # 191 tests
411
+ npm test # 207 tests
402
412
  npm run typecheck # tsc --noEmit
403
413
  ```
404
414
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-agents",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Unified agent and subagent management for pi coding agent, with OpenCode-compatible agent definitions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -75,47 +75,72 @@ function applyThinking(agent: AgentDefinition, pi: ExtensionAPI): void {
75
75
  *
76
76
  * Logic:
77
77
  * 1. If agent has permission rules → filter via permission engine
78
- * 2. Else if agent has tools whitelist → filter to only those (builtin only)
78
+ * 2. Else if agent has tools whitelist → filter to only those
79
79
  * 3. Otherwise → all tools active
80
80
  *
81
- * Extension-provided tools (MCP, SDK) always remain active.
81
+ * Filtering covers ALL registered tool sources (builtin, extension, MCP,
82
+ * SDK): a restricted profile can hide extension tools (web, LSP, …) from
83
+ * the model, not just block their calls. Only blanket `deny` rules hide a
84
+ * tool statically — argument-pattern denies and `ask` stay enforced by the
85
+ * runtime gate (#20), which also remains the backstop for tools registered
86
+ * after the agent started (issue #18).
87
+ *
88
+ * @returns The applied active tool names (for the subagent-hidden warning).
82
89
  */
83
- function applyTools(agent: AgentDefinition, pi: ExtensionAPI): void {
84
- const allTools = pi.getAllTools();
85
-
86
- // Case 1: Permission-based filtering
90
+ export function computeActiveTools(
91
+ agent: AgentDefinition,
92
+ allTools: Array<{ name: string }>,
93
+ ): string[] {
94
+ // Case 1: Permission-based filtering (all sources)
87
95
  if (agent.permission) {
88
96
  const rules = parsePermissionRules(agent.permission);
89
97
  const disabled = getDisabledTools(
90
98
  allTools.map((t) => t.name),
91
99
  rules,
92
100
  );
93
- const filtered = allTools
94
- .filter((t) => {
95
- // Always keep non-builtin tools (extensions, MCP)
96
- if (t.sourceInfo?.source !== "builtin") return true;
97
- return !disabled.has(t.name);
98
- })
99
- .map((t) => t.name);
100
- pi.setActiveTools(filtered);
101
- return;
101
+ return allTools.filter((t) => !disabled.has(t.name)).map((t) => t.name);
102
102
  }
103
103
 
104
- // Case 2: Simple tools whitelist (builtin only)
104
+ // Case 2: Simple tools whitelist (all sources)
105
105
  if (agent.tools && agent.tools.length > 0) {
106
106
  const whitelist = new Set(agent.tools.map((t) => t.toLowerCase()));
107
- const filtered = allTools
108
- .filter((t) => {
109
- if (t.sourceInfo?.source !== "builtin") return true;
110
- return whitelist.has(t.name.toLowerCase());
111
- })
107
+ return allTools
108
+ .filter((t) => whitelist.has(t.name.toLowerCase()))
112
109
  .map((t) => t.name);
113
- pi.setActiveTools(filtered);
114
- return;
115
110
  }
116
111
 
117
112
  // Case 3: No restrictions — keep all tools
118
- pi.setActiveTools(allTools.map((t) => t.name));
113
+ return allTools.map((t) => t.name);
114
+ }
115
+
116
+ function applyTools(agent: AgentDefinition, pi: ExtensionAPI): string[] {
117
+ const active = computeActiveTools(agent, pi.getAllTools());
118
+ pi.setActiveTools(active);
119
+ return active;
120
+ }
121
+
122
+ /**
123
+ * Warn when an agent's static restrictions hide the `subagent` tool (#18).
124
+ *
125
+ * Without it the model cannot delegate at all — that may be exactly what a
126
+ * locked-down profile wants, but it must be a deliberate choice, never a
127
+ * surprise from an allowlist that simply forgot to name it.
128
+ */
129
+ export function warnIfHidingSubagent(
130
+ agent: AgentDefinition,
131
+ allNames: string[],
132
+ activeNames: string[],
133
+ ui: ExtensionContext["ui"],
134
+ ): void {
135
+ const restricted = agent.permission !== undefined || (agent.tools?.length ?? 0) > 0;
136
+ if (!restricted) return;
137
+ if (!allNames.includes("subagent")) return;
138
+ if (activeNames.includes("subagent")) return;
139
+ ui.notify(
140
+ `Agent "${agent.name}": tool restrictions hide the "subagent" tool — this agent cannot delegate. ` +
141
+ `Add "subagent" to the allowlist if delegation is intended.`,
142
+ "warning",
143
+ );
119
144
  }
120
145
 
121
146
  // ─── Main Entry Point ────────────────────────────────────────────────────────
@@ -131,7 +156,9 @@ export async function applyAgentConfig(
131
156
  ): Promise<void> {
132
157
  await applyModel(agent, pi, ctx);
133
158
  applyThinking(agent, pi);
134
- applyTools(agent, pi);
159
+ const before = pi.getAllTools().map((t) => t.name);
160
+ const active = applyTools(agent, pi);
161
+ warnIfHidingSubagent(agent, before, active, ctx.ui);
135
162
  }
136
163
 
137
164
  /**
@@ -440,10 +440,28 @@ function usageFromMessage(
440
440
  };
441
441
  }
442
442
 
443
+ /**
444
+ * Extract the display model string from an assistant message (issue #27).
445
+ *
446
+ * `AssistantMessage` always carries both `provider` and `model`, so prefix the
447
+ * bare id (`provider/model`) to match the `model:` frontmatter format and the
448
+ * per-call override (`parseModelRef` splits on the first slash, so a
449
+ * namespaced id such as `anthropic/claude-opus-5.5` under `openrouter`
450
+ * round-trips exactly). No `provider` → bare id, as before.
451
+ *
452
+ * The `startsWith` guard prevents `provider/provider/...` when a host echoes
453
+ * an already-qualified id. Deliberately NOT `responseModel`: that is the
454
+ * provider-native answering snapshot (alias → dated version), a different
455
+ * granularity from "which provider ran the agent".
456
+ */
443
457
  function modelFromMessage(message: unknown): string | undefined {
444
458
  if (!message || typeof message !== "object") return undefined;
445
- const model = (message as { model?: unknown }).model;
446
- return typeof model === "string" ? model : undefined;
459
+ const data = message as { provider?: unknown; model?: unknown };
460
+ if (typeof data.model !== "string") return undefined;
461
+ if (typeof data.provider === "string" && !data.model.startsWith(`${data.provider}/`)) {
462
+ return `${data.provider}/${data.model}`;
463
+ }
464
+ return data.model;
447
465
  }
448
466
 
449
467
  function updateUsage(target: AgentUsage, update: Partial<AgentUsage> | undefined) {