Files
can1357--oh-my-pi/docs/task-agent-discovery.md
2026-07-13 12:22:59 +08:00

8.7 KiB

Task Agent Discovery and Selection

This document describes how the task subsystem discovers agent definitions, merges multiple sources, and resolves a requested agent at execution time.

It covers runtime behavior as implemented today, including precedence, invalid-definition handling, and spawn/depth constraints that can make an agent effectively unavailable.

Implementation files


Agent definition shape

Task agents normalize into AgentDefinition (src/task/types.ts):

  • name, description, systemPrompt (required for a valid loaded agent)
  • optional tools, spawns, model, thinkingLevel, output, blocking, autoloadSkills, readSummarize
  • source: "bundled" | "user" | "project"
  • optional filePath

Parsing comes from frontmatter via parseAgentFields() (src/discovery/helpers.ts):

  • missing name or description => invalid (null), caller treats as parse failure
  • tools accepts CSV or array; if provided, yield is auto-added
  • spawns accepts *, CSV, or array
  • backward-compat behavior: if spawns missing but tools includes task, spawns becomes *
  • output is passed through as opaque schema data
  • read-summarize: false (parsed as readSummarize) forces the subagent's read tool to return verbatim file content instead of structural summaries — runSubprocess applies it as a read.summarize.enabled: false override on the subagent's isolated settings (src/task/executor.ts). scout and librarian ship with it disabled. Defaults to enabled when the field is absent.

Bundled agents

Bundled agents are embedded at build time (src/task/agents.ts) using text imports.

EMBEDDED_AGENT_DEFS defines:

  • scout, designer, reviewer, librarian from prompt files
  • task and sonic from shared task.md body plus injected frontmatter

Loading path:

  1. loadBundledAgents() parses embedded markdown with parseAgent(..., "bundled", "fatal")
  2. results are cached in-memory (bundledAgentsCache)
  3. clearBundledAgentsCache() is test-only cache reset

Because bundled parsing uses level: "fatal", malformed bundled frontmatter throws and can fail discovery entirely.

Filesystem and plugin discovery

discoverAgents(cwd, home) (src/task/discovery.ts) merges agents from OMP-native roots and Claude plugin roots before appending bundled definitions. Cross-harness roots such as .claude/agents, .codex/agents, and .gemini/agents are intentionally skipped — their frontmatter schema is not the OMP task-agent contract (TASK_AGENT_CONFIG_SOURCE = ".omp" filters both dir lists).

Discovery inputs

  1. Nearest project .omp agents dir from findAllNearestProjectConfigDirs("agents", cwd) (filtered to .omp; first hit only)
  2. User .omp agents dir from getConfigDirs("agents", { project: false }) (filtered to .omp; first hit only)
  3. Claude plugin roots (listClaudePluginRoots(home, cwd)) with agents/ subdirs — only when isProviderEnabled("claude-plugins"); project-scope plugins sort before user-scope
  4. Bundled agents (loadBundledAgents())

Actual source order

  1. project .omp/agents
  2. user ~/.omp/agent/agents
  3. plugin agents/ dirs (project-scope first, then user-scope)
  4. bundled agents last

Merge and collision rules

Discovery uses first-wins dedup by exact agent.name:

  • A Set<string> tracks seen names.
  • Loaded agents are flattened in directory order and kept only if name unseen.
  • Bundled agents are filtered against the same set and only added if still unseen.

Implications:

  • Project .omp overrides user .omp.
  • Non-bundled agents override bundled agents with the same name.
  • Name matching is case-sensitive (Task and task are distinct).
  • Within one directory, markdown files are read in lexicographic filename order before dedup.

Invalid/missing agent file behavior

Per directory (loadAgentsFromDir):

  • unreadable/missing directory: treated as empty (readdir(...).catch(() => []))
  • file read or parse failure: warning logged, file skipped
  • parse path uses parseAgent(..., level: "warn")

Frontmatter failure behavior comes from parseFrontmatter:

  • parse error at warn level logs warning
  • parser falls back to a simple key: value line parser
  • if required fields are still missing, parseAgentFields fails, then AgentParsingError is thrown and caught by caller (file skipped)

Net effect: one bad custom agent file does not abort discovery of other files.

Agent lookup and selection

Lookup is exact-name linear search:

  • getAgent(agents, name) => agents.find(a => a.name === name)

In spawn execution (TaskTool.#executeSync#runSpawn):

  1. agents are rediscovered at execution time (discoverAgents(this.session.cwd))
  2. requested params.agent is resolved through getAgent
  3. missing agent returns immediate tool response:
    • Unknown agent "...". Available: ...
    • no subprocess runs

Description vs execution-time discovery

TaskTool.create() builds the tool description from discovery results at initialization time. #executeSync rediscovers agents, so the runtime set can differ from what was listed in the earlier tool description if agent files changed mid-session. The async entry path still uses the initialization-time list to decide whether an agent is marked blocking before scheduling.

Structured-output guardrails and schema precedence

Runtime output schema precedence in TaskTool.#runSpawn:

  1. agent frontmatter output
  2. parent session outputSchema

(effectiveOutputSchema = effectiveAgent.output ?? this.session.outputSchema — the task call itself never carries a schema; ad-hoc structured workflows go through the eval bridge's agent(prompt, schema).)

The model-facing prompt (src/prompts/tools/task.md) no longer carries the old structured-output mismatch warning; it tags read-only agents and warns against offloading reasoning to explore/sonic instead.

Command discovery interaction

src/task/commands.ts is parallel infrastructure for workflow commands (not agent definitions), but it follows the same overall pattern:

  • discover from capability providers first
  • deduplicate by name with first-wins
  • append bundled commands if still unseen
  • exact-name lookup via getCommand

In src/task/index.ts, command helpers are re-exported with agent discovery helpers. Agent discovery itself does not depend on command discovery at runtime.

Availability constraints beyond discovery

An agent can be discoverable but still unavailable to run because of execution guardrails.

Disabled-agent settings

TaskTool.#executeSync checks task.disabledAgents after resolving the agent. If the requested name is disabled, execution returns an immediate error listing enabled alternatives when available.

Parent spawn policy

TaskTool.#executeSync checks session.getSessionSpawns():

  • "*" => allow any
  • "" => deny all
  • CSV list => allow only listed names

If denied: immediate Cannot spawn '...'. Allowed: ... response.

Blocked self-recursion env guard

PI_BLOCKED_AGENT is read at tool construction. If request matches, execution is rejected with recursion-prevention message.

Recursion-depth gating (task tool availability inside child sessions)

In runSubprocess (src/task/executor.ts):

  • depth computed from taskDepth
  • task.maxRecursionDepth controls cutoff
  • when at max depth:
    • task tool is removed from child tool list
    • child spawns env is set to empty

So deeper levels cannot spawn further tasks even if the agent definition includes spawns.

Plan mode behavior

When parent plan mode is enabled, TaskTool.#runSpawn builds an effectiveAgent before launching subprocesses:

  • prepends the plan-mode subagent system prompt
  • restricts tools to read, search, find, lsp, and web_search, plus ast_grep/report_finding when the agent's own tool list declares them (PLAN_MODE_AGENT_TOOL_ALLOWLIST)
  • clears child spawns

The same effectiveAgent is used for subprocess launch, model/thinking overrides, and output-schema selection.