Files
my-pi/pi-subagents/src/agent-types.ts
T

347 lines
14 KiB
TypeScript

/**
* 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",
};
}