The Orchestration Model

Invocation

When ctx.manager.invoke(ref, args) is called:

  1. $NAME extraction. Any $-prefixed key in args (e.g., $context) is extracted and mapped to the corresponding invoke option (e.g., options.context). The $ prefix is stripped. Explicit options win over $NAME$NAME is a serializable alias, not an override. When both the explicit option and the $NAME value are objects, they are merged: $NAME fills in missing fields without overwriting existing ones. This lets $context add nonlocals/locals alongside an invoker-set parent. The cleaned args (without $-keys) become the effective args.
  2. Resolve ref via findTool() using the current paths. Throw if not found.
  3. Create a child context. Set target from options (for chain entries) or default to self. Apply context seed: any property in options.context (except envelope, parent, target, and signal, handled at creation time) is overlaid onto the new context.
  4. Set ctx.run.origin.uri, ctx.locals.prompt, ctx.run.origin.frontmatter, and ctx.run.tool.* from the tool definition. ctx.args was already set at context creation time.
  5. Push a history frame { tool: name, args, timestamp }.
  6. Find the tool's authority via findAuthority().
  7. Run the cascade: per-level interpolation, extract annotations, merge with $merge policies, apply $remove, compute toolKeys from $dynamic/$static.
  8. Resolve the middleware chain: for each key in toolKeys that matches a tool, build an entry with $order constraints, topo-sort, run via ctx.manager.next().

The full pipeline runs for every invocation. There are no shortcuts.

The Middleware Chain

The chain is a mutable sorted array of tool references. Each entry is a name, not a pre-resolved function. When ctx.manager.next() reaches an entry, it invokes the tool through the orchestrator — full pipeline, own context, with target set to the caller's context. This means every middleware entry gets its own middleware chain, and markdown tools used as middleware get their directives expanded and agent run.

Entries with an explicit fn (injected continuations, programmatic entries) are called directly without going through the orchestrator.

Sorting uses topological order based on before/after constraints, with insertion order as tiebreaker (stable sort). Phase sentinels (strings starting with $) act as virtual anchors — they constrain ordering but are excluded from the output.

Extra middleware (from the context seed, $context.middleware in args, or dynamic injection) supports three modes: override (same name as a cascade entry replaces it, inheriting the original's ordering constraints as fallback), remove (remove: true suppresses the matching cascade entry), and append (new name adds to the chain). Entries without fn are serializable — agents and MCP callers can pass them via $context in args.

Each ctx.manager.next() is a real await. The call stack IS the onion. Returns propagate. Exceptions propagate. try/catch/finally works naturally.

Rules:

  • Injected middleware can only run downstream.
  • next() called after the chain is finished returns ctx.locals.result immediately.
  • Empty chain next() resolves immediately.
  • Cycles in constraints throw a configuration error.
  • Cycles in the invocation stack (tool A's pipeline invokes tool B whose pipeline invokes tool A) are silently skipped: the chain entry is not executed, and a warning is logged. Cycle detection only applies to chain entries (middleware in a pipeline). Independent ctx.manager.invoke() calls start a separate pipeline and cannot trigger false cycle detection — the same middleware (e.g., agent-execute) can appear in multiple independent pipelines without conflict.

Execution Flow

  1. Descend: Middleware runs "before" logic, calls await ctx.manager.next().
  2. Recurse: next() pops and calls the next middleware.
  3. Bottom: A middleware returns a value, calls finish(), or doesn't call next().
  4. Unwind: Control returns up through "after" logic. finally blocks run.

Return Values

Returning non-undefined sets ctx.locals.result and marks the chain as finished. Returning undefined does NOT clobber an existing result. ctx.manager.next() resolves to ctx.locals.result, so middleware can inspect/transform on the way back up.

Error Handling

Error handling uses the Koa-style onion pattern — middleware with try/catch around target.manager.next(). There is no separate error phase. Error-handling middleware positions itself before execute to wrap the execution phase:

metadata:
  $order:
    my-error-handler: { after: [$pre-execute], before: [execute] }
  my-error-handler:
import { ctxTarget } from 'agent-apps';

export default async function(ctx) {
  const target = ctxTarget(ctx);
  try {
    await target.manager.next();
  } catch (err) {
    // Inspect err, attempt recovery, set target.locals.result to suppress
    target.locals.result = 'recovered';
  }
}

Multiple error handlers compose naturally via the onion. Inner handlers catch first; if they rethrow, outer handlers get the error. Each can attempt a different recovery strategy.

Middleware

Middleware is tools participating in the pipeline — gathered from multiple sources and merged into a single ordered chain.

Sources

The middleware chain for each invocation is built from metadata keys. The orchestrator resolves the tool's metadata through the cascade (authority → inherit chain with public/private visibility → tool's own keys → CLI overrides), interpolates strings, then converts each metadata key into a middleware entry:

  1. For each key in the merged metadata, check if a tool with that exact name exists via findTool(). If not, skip silently. There is no prefix expansion — metadata keys map to tool names directly.
  2. The metadata value becomes the middleware's args (after $-sigil annotation extraction for ordering).

Additional middleware sources:

  • Explicit middleware. Passed via the context seed on ctx.manager.invoke() (e.g., context: { locals: { middleware: { ... } } }).
  • Directive middleware. Injected by directives based on remark directives. Directive :name triggers tool name.

Naming Convention

Pipeline middleware uses descriptive names: execute, validate-args, validate-returns, validate-allowed-tools, agent-execute, directives. Metadata middleware uses names matching their metadata key: cwd, paths, params, delegate, model, trust, log.

Entry Normalization

Middleware entries are { name, args, before?, after?, remove? }. The resolver builds entries from the cascade's merged metadata: each key that matches a tool becomes an entry with the metadata value as args. Ordering comes from the $order annotation, not from per-value annotations. Entries without $order constraints get the default after: [$configure], before: [$post-configure].

Dynamic Injection

When metadata or directive middleware injects new entries:

  1. New entries are inserted immediately after the current chain position.
  2. Constraints referencing already-executed middleware are silently stripped.
  3. Entries with explicit before/after constraints trigger a re-sort of the remaining chain.
  4. Entries without constraints are inserted in the order provided.

Directive Ordering

Directive middleware always runs after all metadata middleware. Within directives, execution follows document order.

Leaf Middleware and Cycle Prevention

When a middleware tool is invoked as a chain entry, it gets its own full pipeline — the orchestrator resolves its authority, walks the inherit chain, and builds a middleware chain from the merged metadata. For middleware that appears in the default pipeline, this creates a cycle: the middleware's pipeline includes itself.

The runtime prevents this automatically via auto-static cycle detection. At cascade time, the orchestrator computes the strongly connected components (SCCs) of the tool dependency graph using Tarjan's algorithm. Any toolKey whose target is in the same SCC as the current tool is demoted to data (not invoked as middleware). Self-references are always removed. The result is cached and invalidated when tool caches are cleared.

As a second safety net, the runtime detects cycles at invocation time by walking the parent context chain. If a tool appears in its own pipeline ancestry, the invocation is silently skipped and a warning is logged.

This means middleware authors do not need to declare $inherit: false or $static to prevent cycles — the runtime handles it. These annotations are still supported as explicit overrides for tools that want to opt out of inheritance or control which keys are tool invocations.

// No $inherit: false or $static needed — auto-static handles cycles
export const frontmatter = {
  name: "role",
  metadata: {
    role: "middleware",
    visibility: "hidden",
    tags: ["meta-internal", "meta-metadata"],
    params: { type: "string" },
  },
};

Ask AI