agentfs

package
v0.13.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 4, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package agentfs implements configurable AGENT DEFINITIONS — named subagent specialists (a prompt persona + a scoped tool allowlist + a model + a permission mode + run limits) discovered from operator-controlled markdown files. It is the agents analogue of the skills Source seam (internal/adapter/skills): a pluggable Source over precedence-ordered directories, a forgiving frontmatter parser, and a name-indexed Registry the composition root threads into BOTH the Subagent tool and (in a later slice) the team-member factory.

This package graduated from internal/adapter/agents into the importable engine module (engine/adapter/agentfs) per #328; the root package re-exports it via alias.

ONE DEFINITION, TWO CONSUMERS. A `<name>.md` file under a conventional dir is reusable as a Subagent delegate (Subagent(agent="<name>")) and, in a following slice, as a team-member role (MemberSpec.AgentType). This package only PRODUCES the definitions; the registry→engine translation lives in internal/app, exactly where buildChildEngine/buildMemberEngine already live.

LAYERING: this is an ADAPTER. It reads files (discovery is an adapter concern) and may import os/yaml and the domain (session). NOTHING here is imported by a domain package, by engine/agent, or by internal/app's hot path — the agent loop receives only plain map[string]*Engine + metadata structs, never this package's types, preserving the no-adapter-import-from-agent layering rule.

TRUST BOUNDARY: an agent-definition body is OPERATOR-CONTROLLED content (like a SKILL.md / AGENTS.md) — it legitimately steers the model and belongs in the system prompt, NOT the untrusted-user channel. Conventional dirs are therefore strict opt-in (mirroring skills), and there is no model-writable agent-draft path in this tier.

Package agentfs carries small, tool-agnostic helpers this adapter needs that previously lived in root-module adapters (internal/adapter/toolkit, internal/adapter/xdgconfig). The engine module must not import the root module (the fstools precedent, #269), so the EXACT bodies are carried here and pinned byte-identical to their origins. Keep them in sync with the originals; do not diverge behaviour.

Index

Constants

View Source
const (
	// ProjectDirMecatl is the project-level agents dir under the workspace.
	ProjectDirMecatl = ".mecatl/agents"
	// ProjectDirClaude is the Claude-Code-compatible project-level agents dir.
	ProjectDirClaude = ".claude/agents"
)

Conventional agent-def sub-paths (Claude-Code-style "extra paths"). A def is laid out as <conventional-dir>/<name>.md.

View Source
const AgentFileExt = ".md"

AgentFileExt is the conventional extension of an agent-definition file. A def lives at <dir>/<name>.md (a FLAT file, not a <name>/AGENT.md subdir), matching Claude Code's .claude/agents/ layout.

Variables

View Source
var OSEnv = ResolveEnv{Getenv: os.Getenv, UserHomeDir: os.UserHomeDir}

OSEnv binds a resolver to the real process environment + filesystem.

mirrors internal/adapter/xdgconfig.OSEnv EXACTLY (minus ReadFile) — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

Functions

func Discover

func Discover(dir string) ([]Discovered, []SkipError, error)

Discover scans dir for agent defs and returns them. It is a thin convenience wrapper over DirSource for callers (and tests) that want single-directory discovery without composing a Source.

func IndexClosingDelim

func IndexClosingDelim(s string) int

IndexClosingDelim returns the byte offset, within s, of the start of the first line that is exactly "---" (the closing frontmatter delimiter), or -1 if none.

mirrors internal/adapter/toolkit.IndexClosingDelim EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

func NewFSSource

func NewFSSource(ctx context.Context, sources ...AgentSource) (*FSSource, []SkipError, error)

NewFSSource resolves the given sources ONCE (highest-precedence first, the NewMultiSource collision rule) and returns the snapshot source plus the aggregated discovery diagnostics. A genuine discovery fault returns a non-nil error (with the diagnostics gathered so far); an absent dir is simply "no defs" (opt-in), exactly as before.

func NormalizeHeaders

func NormalizeHeaders(in map[string]string) map[string]string

NormalizeHeaders trims keys/values and drops empties, returning nil for an empty/absent map so a server with no headers carries a nil Headers. Exported so the remote-driver client applies the SAME normalization to wire headers (the values are SECRET-SHAPED and are never logged anywhere; see tool.AgentMCPServer.Headers).

func NormalizeHooks

func NormalizeHooks(in map[string]string) map[string]string

NormalizeHooks trims each phase key and command value and drops any entry whose key or value is empty, returning nil for an empty/absent map so a def with no hooks carries a nil Hooks (not an empty non-nil map). It does NOT validate phase names against the governance taxonomy — that is a composition-time concern, kept out of this catalog-free parser (mirroring how tool names are not validated here). Exported so the remote-driver client applies the SAME normalization to wire hooks.

func ResolveRegistry

func ResolveRegistry(ctx context.Context, src AgentSource) (*Registry, []SkipError, error)

ResolveRegistry runs the source to completion and builds a Registry from the resulting defs. It is the convenience the composition root uses: resolve the MultiSource, surface diagnostics, build the registry in one step. The returned SkipErrors are the source's diagnostics (shadowed/truncated/skipped); a non-nil error is a hard fault that prevented discovery.

func SplitFrontmatter

func SplitFrontmatter(s string) (fm, body string, ok bool)

SplitFrontmatter separates a leading YAML frontmatter block, delimited by a line containing only "---" at the very start and a matching closing "---" line, from the markdown body that follows. It returns the frontmatter text (without the delimiters), the body, and whether a well-formed frontmatter block was found. A leading UTF-8 BOM is tolerated and CRLF line endings are normalised so the delimiter match is line-ending agnostic. It is the single source of truth for the agents/skills frontmatter parsers.

mirrors internal/adapter/toolkit.SplitFrontmatter EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

func TruncateRunes

func TruncateRunes(s string, maxBytes int) string

TruncateRunes trims s to at most maxBytes on a rune boundary and appends a single-character ellipsis ("…"). Unlike Truncate (which appends a verbose, byte-count marker for tool output), this is the compact form used to cap always-in-context metadata such as agent/skill descriptions and bodies. When s already fits within maxBytes it is returned unchanged.

mirrors internal/adapter/toolkit.TruncateRunes EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269). The rune-boundary check is stdlib utf8.RuneStart (byte-identical to the old carried UTF8RuneStart helper, deleted per the #328 panel review).

func UserConfigDir

func UserConfigDir(env ResolveEnv) string

UserConfigDir returns the XDG config base for a user-level config location: the value of $XDG_CONFIG_HOME when set, else ~/.config. It returns "" when neither can be resolved (the caller then skips the user-level source). This preserves the exact semantics the four adapters shared before extraction.

mirrors internal/adapter/xdgconfig.UserConfigDir EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

Types

type AgentDef

type AgentDef = tool.AgentDef

AgentDef is the pure value object for one agent definition. Phase C2 moved the type WHOLESALE (minus the old Path locator, plus the Origin tier label) to engine/tool as the payload of the tool.AgentDefSource port; this alias keeps every existing literal and signature in this adapter and its consumers compiling unmodified. Where a def was discovered is now the adapter-private detail channel (Discovered.Detail / Registry.Detail), never a field on the value object.

type AgentMCPServer

type AgentMCPServer = tool.AgentMCPServer

AgentMCPServer is one entry of a def's `mcpServers` (reference or inline streamable-HTTP server). Moved to engine/tool alongside AgentDef; aliased here for compatibility (see AgentDef).

type AgentSource

type AgentSource interface {
	Agents(ctx context.Context) ([]Discovered, []SkipError, error)
}

AgentSource is the pluggable EXTENSIBILITY POINT for where agent definitions come from. A Source produces a set of Discovered entries (the AgentDef value object + its adapter-private Detail) together with non-fatal diagnostics (SkipError), and a fatal error only for a genuine infrastructure fault that prevented the source from being consulted at all.

It is the verbatim shape of skills.Source: the local-OS-filesystem layout (DirSource) is just ONE implementation; an embedded default set or a remote registry would satisfy the same interface and slot in via MultiSource without touching the consumer or the AgentDef value object.

Agents(ctx) returns:

  • the discovered defs (deterministically ordered by the implementation),
  • the non-fatal diagnostics (skipped/duplicate/truncated/shadowed entries),
  • a non-nil error ONLY for a hard fault. An absent source (e.g. a missing directory) is "no defs", not an error — agent defs are opt-in.

func ResolveSources

func ResolveSources(opts ResolveOptions) []AgentSource

ResolveSources builds the ORDERED, highest-precedence-first Source list from the conventional locations plus any explicit paths, ready to hand to NewMultiSource. The precedence is:

explicit (--agents-dir, in flag order)                   [highest]
  > project: <workspace>/.mecatl/agents, <workspace>/.claude/agents
    > user: $XDG_CONFIG_HOME/mecatl/agents (or ~/.config/mecatl/agents),
            ~/.claude/agents                                  [lowest]

so a project def overrides a personal one of the same name, and an explicit def overrides both. Each location becomes a labelled DirSource; missing directories are harmless. When Conventional is false only the Explicit paths are included.

type DirSource

type DirSource struct {
	// Dir is the directory to scan. An empty or absent Dir yields no defs (opt-in).
	Dir string
	// Label is an optional human-readable name for this source (e.g. "project",
	// "user", "explicit"), surfaced in diagnostics (it prefixes each Discovered
	// entry's Detail). It does not affect discovery.
	Label string
	// Tier is the admission tier stamped onto every def this source produces
	// (AgentDef.Origin on the port). ResolveSources sets it per conventional
	// location; a zero Tier defaults to tool.AgentOriginExplicit (a
	// hand-constructed source is an operator-configured location).
	Tier tool.AgentOrigin
}

DirSource is the local-OS-filesystem implementation of AgentSource: it produces the defs laid out as <Dir>/<name>.md (flat files) under a single directory.

func (DirSource) Agents

func (s DirSource) Agents(_ context.Context) ([]Discovered, []SkipError, error)

Agents implements AgentSource for a single local directory. It scans Dir for flat <name>.md files, parses each one's YAML frontmatter and markdown body, and returns the valid defs sorted by name.

It is forgiving by design: an empty or missing Dir yields no defs and no error (opt-in); a malformed or frontmatter-less file is SKIPPED and reported via the returned []SkipError rather than aborting the scan. It returns a non-nil error only for a genuine I/O fault reading the directory itself.

A def's frontmatter `name` is the source of truth (NOT the filename); duplicate effective names WITHIN this directory are resolved keep-first in sorted-path order. Cross-source collisions are resolved one level up by MultiSource.

Every kept def is stamped with this source's admission tier (Origin) and carried with its adapter-private locator (Detail = "<label>: <path>") — SkipError diagnostics keep the verbatim path as before.

type Discovered

type Discovered struct {
	Def    AgentDef
	Detail string
}

Discovered is one discovered agent definition together with its adapter-private locator string. The PORT value object (tool.AgentDef, the aliased AgentDef) carries no path/dir/root concept; Detail is the NON-PORT diagnostics channel a reviewer traces a def back through — "<label>: <path>" for a filesystem source, "driver: <target>" for a remote driver. It never crosses the tool.AgentDefSource port.

type FSSource

type FSSource struct {
	// contains filtered or unexported fields
}

FSSource is the FILESYSTEM implementation of the tool.AgentDefSource port: a snapshot of the agent definitions discovered from the composed Source list (explicit dirs + conventional locations). The port carries no path/dir/root concept; the locator business the composition layer's diagnostics still need is exposed as adapter-public NON-PORT methods (Discovered/Detail).

SNAPSHOT SEMANTICS: discovery runs ONCE at construction (NewMultiSource over the given sources) and the defs are retained in memory, so ListAgentDefs is stable for the life of the source — the build-once trust-gate invariant (an untrusted workspace's project tier is gated at SOURCE CONSTRUCTION, in ResolveSources).

func (*FSSource) Detail

func (s *FSSource) Detail(name string) (string, bool)

Detail returns the named def's adapter-private locator ("<label>: <path>") and whether the snapshot holds one. NON-PORT, diagnostics only.

func (*FSSource) Discovered

func (s *FSSource) Discovered() []Discovered

Discovered returns the name-sorted Discovered snapshot (def + adapter-private Detail). Adapter-public, NON-PORT: it backs NewRegistryDiscovered in the composition layer, never the port.

func (*FSSource) ListAgentDefs

func (s *FSSource) ListAgentDefs(_ context.Context) ([]AgentDef, error)

ListAgentDefs returns the name-sorted, unique definition snapshot.

type MultiSource

type MultiSource struct {
	// contains filtered or unexported fields
}

MultiSource composes an ORDERED list of Sources into one, with a defined precedence on name collisions and aggregated diagnostics.

PRECEDENCE (collision rule): EARLIER sources win. When two sources both produce a def with the same effective name, the one from the earlier source is kept and the later one is SHADOWED — dropped, with a SkipError notice. Callers order the slice highest-precedence-first; ResolveSources builds it as explicit > project > user.

A fatal error from ANY source is returned immediately (with the diagnostics gathered so far). Output is sorted by name for deterministic, cache-stable ordering.

func NewMultiSource

func NewMultiSource(sources ...AgentSource) MultiSource

NewMultiSource builds a MultiSource over the given ordered sources (highest-precedence first). nil entries are ignored so callers can assemble the slice conditionally without sprinkling nil checks.

func (MultiSource) Agents

func (m MultiSource) Agents(ctx context.Context) ([]Discovered, []SkipError, error)

Agents aggregates every composed source, applies the earlier-wins precedence on name collisions, and returns the merged defs sorted by name. Shadowed lower-precedence defs are dropped and reported.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is an immutable, name-indexed view of the discovered agent definitions: the ONE registry both consumers (the Subagent tool now; the team member factory in a later slice) share. It is built once at composition time from a resolved Source and never mutated thereafter, so it is safe to read concurrently.

Alongside the port-shaped defs it retains each def's adapter-private locator (Discovered.Detail), exposed via Detail for composition-layer diagnostics — the NON-PORT replacement for the old AgentDef.Path field.

func NewRegistry

func NewRegistry(defs []AgentDef) *Registry

NewRegistry builds a Registry from the given defs (typically the output of a MultiSource). On a duplicate name the FIRST occurrence wins (callers pass already-deduped, precedence-ordered defs from MultiSource, so this is only a defensive backstop). Iteration order is by name for determinism. The registry carries no per-def details — Detail returns "" for every name; use NewRegistryDiscovered to retain the locator channel.

func NewRegistryDiscovered

func NewRegistryDiscovered(discovered []Discovered) *Registry

NewRegistryDiscovered builds a Registry from Discovered entries, retaining each def's adapter-private Detail for diagnostics. Same keep-first dedup and name-sorted iteration as NewRegistry.

func (*Registry) Detail

func (r *Registry) Detail(name string) string

Detail returns the adapter-private locator string the named def was discovered through ("<label>: <path>" for a filesystem source, "driver: <target>" for a remote driver), or "" when the registry carries none (an unknown name, or a NewRegistry-built registry). It is the NON-PORT diagnostics channel composition logs read; it never reaches the port or any model-facing surface.

func (*Registry) Get

func (r *Registry) Get(name string) (AgentDef, bool)

Get returns the def registered under name and true, or a zero def and false.

func (*Registry) Len

func (r *Registry) Len() int

Len reports how many defs the Registry holds.

func (*Registry) List

func (r *Registry) List() []AgentDef

List returns every def, ordered by name for determinism. The returned slice is a fresh copy; mutating it does not affect the Registry.

type ResolveEnv

type ResolveEnv struct {
	Getenv      func(string) string
	UserHomeDir func() (string, error)
}

ResolveEnv abstracts the process environment so a resolver is testable with a fake home / XDG, without touching the real one. The composition root binds OSEnv (the real os funcs); tests pass a fake.

trimmed to the fields this adapter uses; the root xdgconfig.ResolveEnv also carries ReadFile for permconfig/soul.

mirrors internal/adapter/xdgconfig.ResolveEnv EXACTLY (minus ReadFile) — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).

type ResolveOptions

type ResolveOptions struct {
	// Explicit are operator-configured directories (e.g. from a repeatable
	// --agents-dir flag), highest precedence, in the order given. Always honoured
	// regardless of Conventional.
	Explicit []string
	// Conventional, when true, adds the built-in conventional project- and
	// user-level locations (lower precedence than Explicit). Default false: a
	// strict opt-in.
	Conventional bool
	// Workspace is the session workspace root used to resolve the project-level
	// conventional paths. Only consulted when Conventional is true and non-empty.
	Workspace string
	// IncludeProjectTier, when true, admits the PROJECT-tier conventional locations
	// (<workspace>/.mecatl/agents, <workspace>/.claude/agents). The composition layer
	// sets it false when the workspace is UNTRUSTED (Workspace-Trust feature, Phase
	// 2a) so a cloned repo's project agent defs cannot steer the model before the
	// operator trusts it; the user-tier and explicit sources stay active regardless.
	// It gates ONLY the project tier. Mirrors skills.ResolveOptions.IncludeProjectTier.
	//
	// ZERO-VALUE NOTE: false by default; callers set it explicitly. Composition uses
	// the folded trust bool. Only Conventional==true makes the project tier eligible.
	IncludeProjectTier bool
}

ResolveOptions configures the known-path resolver. The zero value resolves NOTHING (no explicit paths, conventional set OFF) — discovery stays strictly opt-in unless the operator asks for it, since a def body steers the model.

type SkipError

type SkipError struct {
	// Path is the <name>.md (or directory) the problem was found at.
	Path string
	// Reason is a short, human-readable description of the problem.
	Reason string
	// Fatal reports whether the def was DROPPED (true) or KEPT-but-ADJUSTED
	// (false, the zero value). See the type doc-comment.
	Fatal bool
}

SkipError records one diagnostic from discovery. It carries a structural two-way split via Fatal — NOT a string-matched one — so the composition root can word the log honestly instead of overloading "skipped":

  • Fatal == true: the def was DROPPED (excluded from the registry). Causes: it could not be read, its frontmatter was malformed / missing a required field, a duplicate name within a directory, or it was SHADOWED by a higher-precedence source.
  • Fatal == false (the zero value): the def was KEPT but ADJUSTED — a non-fatal modification was applied (e.g. its description/body was truncated to a cap, an mcpServers entry was skipped, or an unsupported memory tier was ignored). The def is still in the registry.

Discovery keeps scanning rather than aborting and returns the collected diagnostics so the composition root can surface them. A SkipError is never returned as a Source's fatal error.

func (SkipError) Error

func (e SkipError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL