mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* agent-types.ts — Unified agent type registry.
|
||||
*
|
||||
* Merges embedded default agents with user-defined agents from .pi/agents/*.md, .agents/agents/*.md, and global agents.
|
||||
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
|
||||
*/
|
||||
|
||||
import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
|
||||
import { DEFAULT_AGENTS } from "./default-agents.js";
|
||||
import type { AgentConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
* All known built-in tool names, derived from pi's own tool factories rather
|
||||
* than hardcoded so the set tracks pi-mono if it adds/renames a built-in.
|
||||
* `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` →
|
||||
* read/grep/find/ls; their de-duplicated union is the 7 built-ins
|
||||
* (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool
|
||||
* operations we never invoke here — we read each tool's `.name` and discard it.
|
||||
*/
|
||||
export const BUILTIN_TOOL_NAMES: string[] = [
|
||||
...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
|
||||
];
|
||||
|
||||
/** Unified runtime registry of all agents (defaults + user-defined). */
|
||||
const agents = new Map<string, AgentConfig>();
|
||||
|
||||
/** When true, DEFAULT_AGENTS are skipped during registration. */
|
||||
let disableDefaults = false;
|
||||
|
||||
/** Check whether default agents are disabled. */
|
||||
export function isDefaultsDisabled(): boolean { return disableDefaults; }
|
||||
|
||||
/** Set whether default agents are disabled. */
|
||||
export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; }
|
||||
|
||||
/** `fallbackSubagent` value that disables the fallback entirely (strict dispatch). */
|
||||
export const NO_FALLBACK = "none";
|
||||
|
||||
/**
|
||||
* Agent type substituted when a caller-supplied `subagent_type` doesn't resolve
|
||||
* to exactly one enabled agent. `undefined` keeps the historical behavior
|
||||
* (general-purpose); `NO_FALLBACK` makes dispatch fail closed. Set from
|
||||
* `subagents.json` (`fallbackSubagent`).
|
||||
*
|
||||
* Module state rather than an index.ts closure because every caller-supplied
|
||||
* spawn path needs it — the Agent tool, the scheduler, and cross-extension RPC.
|
||||
*/
|
||||
let fallbackSubagent: string | undefined;
|
||||
|
||||
/** Get the configured fallback agent type. undefined = general-purpose. */
|
||||
export function getFallbackSubagent(): string | undefined { return fallbackSubagent; }
|
||||
|
||||
/** Set the configured fallback agent type. undefined = general-purpose. */
|
||||
export function setFallbackSubagent(v: string | undefined): void { fallbackSubagent = v; }
|
||||
|
||||
/**
|
||||
* Build a registry map: DEFAULT_AGENTS first (unless disabled via settings),
|
||||
* then user agents overlaid on top (same name overrides the default).
|
||||
* Pure — callers that must not disturb the process-wide registry (nested
|
||||
* delegation resolving agents from its own config root) build their own map.
|
||||
*/
|
||||
export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<string, AgentConfig> {
|
||||
const registry = new Map<string, AgentConfig>();
|
||||
if (!disableDefaults) {
|
||||
for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
|
||||
}
|
||||
for (const [name, config] of userAgents) registry.set(name, config);
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register agents into the unified registry.
|
||||
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
|
||||
* Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
|
||||
*/
|
||||
export function registerAgents(userAgents: Map<string, AgentConfig>): void {
|
||||
agents.clear();
|
||||
for (const [name, config] of buildAgentRegistry(userAgents)) {
|
||||
agents.set(name, config);
|
||||
}
|
||||
}
|
||||
|
||||
/** Case-insensitive key resolution within a registry. */
|
||||
function resolveKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
||||
if (registry.has(name)) return name;
|
||||
const lower = name.toLowerCase();
|
||||
for (const key of registry.keys()) {
|
||||
if (key.toLowerCase() === lower) return key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Case-insensitive key resolution. */
|
||||
function resolveKey(name: string): string | undefined {
|
||||
return resolveKeyIn(agents, name);
|
||||
}
|
||||
|
||||
/** Resolve a type name case-insensitively in a registry. Returns the canonical key or undefined. */
|
||||
export function resolveTypeIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
||||
return resolveKeyIn(registry, name);
|
||||
}
|
||||
|
||||
/** Get the agent config for a type (case-insensitive) from a registry. */
|
||||
export function getAgentConfigIn(registry: Map<string, AgentConfig>, name: string): AgentConfig | undefined {
|
||||
const key = resolveKeyIn(registry, name);
|
||||
return key ? registry.get(key) : undefined;
|
||||
}
|
||||
|
||||
/** Check if a type is valid and enabled (case-insensitive) in a registry. */
|
||||
export function isValidTypeIn(registry: Map<string, AgentConfig>, type: string): boolean {
|
||||
const key = resolveKeyIn(registry, type);
|
||||
if (!key) return false;
|
||||
return registry.get(key)?.enabled !== false;
|
||||
}
|
||||
|
||||
/** Get all enabled type names in a registry (for spawning and tool descriptions). */
|
||||
export function getAvailableTypesIn(registry: Map<string, AgentConfig>): string[] {
|
||||
return [...registry.entries()]
|
||||
.filter(([_, config]) => config.enabled !== false)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive resolution that refuses to guess. An exact match always wins;
|
||||
* otherwise the name must match exactly one key. Two agents differing only in
|
||||
* case are reachable (`loadCustomAgents` keys by filename across three
|
||||
* directories), and picking whichever came first would silently dispatch a
|
||||
* different agent, model and tool policy than the caller meant.
|
||||
*/
|
||||
function resolveUnambiguousKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
||||
if (registry.has(name)) return name;
|
||||
const lower = name.toLowerCase();
|
||||
const matches = [...registry.keys()].filter(key => key.toLowerCase() === lower);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical key for a caller-supplied name that identifies exactly one
|
||||
* ENABLED agent, or undefined. Strict by construction: no fallback, no guessing
|
||||
* between case-variants. Nested delegation resolves with this directly, since
|
||||
* "unknown types are rejected rather than falling back" is its own contract.
|
||||
*/
|
||||
export function resolveEnabledTypeIn(
|
||||
registry: Map<string, AgentConfig>,
|
||||
requested: unknown,
|
||||
): string | undefined {
|
||||
const raw = typeof requested === "string" ? requested.trim() : "";
|
||||
if (!raw) return undefined;
|
||||
const key = resolveUnambiguousKeyIn(registry, raw);
|
||||
return key !== undefined && registry.get(key)?.enabled !== false ? key : undefined;
|
||||
}
|
||||
|
||||
/** Outcome of resolving a caller-supplied `subagent_type` into a spawnable type. */
|
||||
export type SpawnTypeResolution =
|
||||
/** Spawn this type. `fellBackFrom` is set when it isn't what the caller asked for. */
|
||||
| { ok: true; type: string; fellBackFrom?: string }
|
||||
/** Refuse the spawn and return this message to the caller. */
|
||||
| { ok: false; message: string };
|
||||
|
||||
/**
|
||||
* Resolve a caller-supplied agent type against a registry, applying the
|
||||
* `fallbackSubagent` policy. The single decision point for every caller-supplied
|
||||
* spawn — the Agent tool, the scheduler, cross-extension RPC, and the nested
|
||||
* tools — so a type that fails here never reaches `runAgent`, where `getConfig`
|
||||
* would silently substitute general-purpose.
|
||||
*
|
||||
* Unknown, disabled, and case-ambiguous names are all treated the same way:
|
||||
* the caller named something that doesn't identify exactly one enabled agent.
|
||||
*
|
||||
* Pure over `registry` — callers that need fresh agent files reload before
|
||||
* calling (the Agent tool already does, per spawn). Reloading here would mean
|
||||
* importing custom-agents.ts, which imports this module.
|
||||
*/
|
||||
export function resolveSpawnTypeIn(
|
||||
registry: Map<string, AgentConfig>,
|
||||
requested: unknown,
|
||||
): SpawnTypeResolution {
|
||||
const raw = typeof requested === "string" ? requested.trim() : "";
|
||||
const available = () => getAvailableTypesIn(registry).join(", ") || "(none)";
|
||||
|
||||
const key = resolveEnabledTypeIn(registry, raw);
|
||||
if (key !== undefined) return { ok: true, type: key };
|
||||
|
||||
// A missing type follows the same policy as a wrong one rather than always
|
||||
// erroring: before this setting existed an empty type fell back like any
|
||||
// other unresolvable name, and only opting in should change that.
|
||||
const reason = raw ? `Unknown or disabled agent type: "${raw}".` : "No agent type given.";
|
||||
|
||||
// Trimmed like `requested`: a padded value set programmatically would
|
||||
// otherwise be reported as a missing agent.
|
||||
const configured = typeof fallbackSubagent === "string" ? fallbackSubagent.trim() : undefined;
|
||||
|
||||
if (configured !== undefined && configured.toLowerCase() === NO_FALLBACK) {
|
||||
return { ok: false, message: `${reason} Available: ${available()}.` };
|
||||
}
|
||||
|
||||
if (configured !== undefined) {
|
||||
// An explicitly configured fallback that is itself unusable is a
|
||||
// misconfiguration, not a second chance to guess — say so rather than
|
||||
// quietly dropping to general-purpose.
|
||||
const fallbackKey = resolveUnambiguousKeyIn(registry, configured);
|
||||
if (fallbackKey === undefined || registry.get(fallbackKey)?.enabled === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`${reason} The configured fallbackSubagent "${configured}" is itself ` +
|
||||
`unknown or disabled. Available: ${available()}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, type: fallbackKey, fellBackFrom: raw };
|
||||
}
|
||||
|
||||
// Unset: historical behavior, deliberately unchanged. #183 asks for the
|
||||
// fallback to remain the default, so the pre-existing hole it leaves — an
|
||||
// unregistered general-purpose resolving to `getConfig`'s all-tools hardcoded
|
||||
// tier — is what `fallbackSubagent: none` is for, not something to close
|
||||
// under everyone silently.
|
||||
return { ok: true, type: "general-purpose", fellBackFrom: raw };
|
||||
}
|
||||
|
||||
/** Resolve a caller-supplied agent type against the process-wide registry. */
|
||||
export function resolveSpawnType(requested: unknown): SpawnTypeResolution {
|
||||
return resolveSpawnTypeIn(agents, requested);
|
||||
}
|
||||
|
||||
/** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
|
||||
export function resolveType(name: string): string | undefined {
|
||||
return resolveKey(name);
|
||||
}
|
||||
|
||||
/** Get the agent config for a type (case-insensitive). */
|
||||
export function getAgentConfig(name: string): AgentConfig | undefined {
|
||||
return getAgentConfigIn(agents, name);
|
||||
}
|
||||
|
||||
/** Get all enabled type names (for spawning and tool descriptions). */
|
||||
export function getAvailableTypes(): string[] {
|
||||
return getAvailableTypesIn(agents);
|
||||
}
|
||||
|
||||
/** Get all type names including disabled (for UI listing). */
|
||||
export function getAllTypes(): string[] {
|
||||
return [...agents.keys()];
|
||||
}
|
||||
|
||||
/** Get names of default agents currently in the registry. */
|
||||
export function getDefaultAgentNames(): string[] {
|
||||
return [...agents.entries()]
|
||||
.filter(([_, config]) => config.isDefault === true)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/** Get names of user-defined agents (non-defaults) currently in the registry. */
|
||||
export function getUserAgentNames(): string[] {
|
||||
return [...agents.entries()]
|
||||
.filter(([_, config]) => config.isDefault !== true)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/** Check if a type is valid and enabled (case-insensitive). */
|
||||
export function isValidType(type: string): boolean {
|
||||
return isValidTypeIn(agents, type);
|
||||
}
|
||||
|
||||
/** Tool names required for memory management. */
|
||||
const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
|
||||
|
||||
/**
|
||||
* Get memory tool names (read/write/edit) not already in the provided set.
|
||||
*/
|
||||
export function getMemoryToolNames(existingToolNames: Set<string>): string[] {
|
||||
return MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
|
||||
}
|
||||
|
||||
/** Tool names needed for read-only memory access. */
|
||||
const READONLY_MEMORY_TOOL_NAMES = ["read"];
|
||||
|
||||
/**
|
||||
* Get read-only memory tool names not already in the provided set.
|
||||
*/
|
||||
export function getReadOnlyMemoryToolNames(existingToolNames: Set<string>): string[] {
|
||||
return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
|
||||
}
|
||||
|
||||
/** Get built-in tool names for a type (case-insensitive). */
|
||||
export function getToolNamesForType(type: string): string[] {
|
||||
const key = resolveKey(type);
|
||||
const raw = key ? agents.get(key) : undefined;
|
||||
const config = raw?.enabled !== false ? raw : undefined;
|
||||
// `undefined` (definition omitted the field) → all built-ins; an explicit `[]`
|
||||
// (`tools: none` or a `tools:` with only `ext:` entries) → zero built-ins.
|
||||
return config?.builtinToolNames ?? [...BUILTIN_TOOL_NAMES];
|
||||
}
|
||||
|
||||
/** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
|
||||
export function getConfig(type: string): {
|
||||
displayName: string;
|
||||
color?: string;
|
||||
description: string;
|
||||
builtinToolNames: string[];
|
||||
extensions: true | string[] | false;
|
||||
excludeExtensions?: string[];
|
||||
skills: true | string[] | false;
|
||||
promptMode: "replace" | "append";
|
||||
} {
|
||||
const key = resolveKey(type);
|
||||
const config = key ? agents.get(key) : undefined;
|
||||
if (config && config.enabled !== false) {
|
||||
return {
|
||||
displayName: config.displayName ?? config.name,
|
||||
color: config.color,
|
||||
description: config.description,
|
||||
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
||||
extensions: config.extensions,
|
||||
excludeExtensions: config.excludeExtensions,
|
||||
skills: config.skills,
|
||||
promptMode: config.promptMode,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown/disabled types — general-purpose config
|
||||
const gp = agents.get("general-purpose");
|
||||
if (gp && gp.enabled !== false) {
|
||||
return {
|
||||
displayName: gp.displayName ?? gp.name,
|
||||
color: gp.color,
|
||||
description: gp.description,
|
||||
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
||||
extensions: gp.extensions,
|
||||
excludeExtensions: gp.excludeExtensions,
|
||||
skills: gp.skills,
|
||||
promptMode: gp.promptMode,
|
||||
};
|
||||
}
|
||||
|
||||
// Absolute fallback (should never happen)
|
||||
return {
|
||||
displayName: "Agent",
|
||||
description: "General-purpose agent for complex, multi-step tasks",
|
||||
builtinToolNames: BUILTIN_TOOL_NAMES,
|
||||
extensions: true,
|
||||
skills: true,
|
||||
promptMode: "append",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user