Skip to content

flows: named agent = reusable flow composition (use:) — SURFACE §2 rule 6 #300

Description

@kjgbot

Spec citation — SURFACE.md §2 rule 6 (lines 72–82)

Agent definitions escalate by composition — and a reusable agent is a flow:

- agent: Review this diff for security issues.        # 1. anonymous
agents:
  reviewer: { cli: claude, model: claude-sonnet-4-6 } # 2. named — explicit and reusable

The declarative named-agent schema in this slice is exactly { cli, model };
unknown fields fail closed. Defining a richer team reviewer means writing
reviewer.flow.ts (identity + memory + body); other flows compose it with
use: / f.agent(reviewer, task). Persona import is flow composition, not
a special mechanism.

What's shipped

The declarative slice — agents: { reviewer: { cli, model } } in YAML/JSON — is
merged (see SURFACE.md lines 196–201 and the #132-line comment in the doc).
The FlowHeader.agents TS type is in place for that declarative slice.

What this issue delivers

The composition rung of rule 6: a full flow used as an agent by another
flow. reviewer.flow.ts declares identity + memory + body, other flows
import it via a use: header entry, and call it with f.agent(reviewer, { task }).
The parent journal records a nested run using the imported flow's identity, and
the imported flow's memory scope is honored per docs/GATE5-MEMORY-CONTRACT.md.

Non-goal: cross-package/npm imports (only local relative paths), YAML use:
syntax (this slice ships the TS shape only; YAML follows in a later slice),
shared memory across parent+child beyond what GATE5-MEMORY-CONTRACT already
allows.

Scope

  1. FlowHeader.use — surface accepts use?: string[], each an author-side
    relative path to a .flow.ts sibling.
    • packages/surface/src/flow.ts: add use to the allowed keys list,
      freezeHeader, assertFlowHeader, ReadonlyFlowHeader, and
      assertKnownKeys (currently at
      packages/surface/src/flow.ts:152 and 171).
    • Duplicates and empty strings fail closed at assertOptionalStringArray.
  2. Loader resolves use: before executor runs.
    • packages/sdk/src/authored-flow-loader.ts (117 lines) is the natural
      landing spot. After it imports the root .flow.ts, it reads header.use
      and eagerly imports each entry, resolving paths relative to the root
      flow's file URL. Each import is memoized by absolute path so a diamond
      use: graph (A uses B, B uses C, D uses B and C) resolves once.
    • Refusal codes (thrown as typed errors that the CLI turns into refusals):
      • use_not_found — the resolved absolute path does not exist.
      • use_invalid — import threw (syntax error, missing dep) or returned
        something that isn't an authored flow handle.
      • use_cycle — the transitive use: graph contains a cycle. Refuse at
        load time; do not run.
  3. Executor dispatches f.agent(handle, options).
    • packages/sdk/src/authored-flow-executor.ts (333 lines) currently
      resolves f.agent(name: string, options) against the CLI adapter table.
      Extend the executor's agent handler so when the first argument is a
      resolved authored-flow handle (from the surface's opaque handle map), it
      runs that handle's body as a nested run:
      • Journal a run.spawned for the nested run with the child's identity
        from getFlowDefinition(handle).header.identity (falling back to the
        flow's name if identity is unset). The nested run inherits the
        parent's data-dir and observer key.
      • f.agent(reviewer, { task }) passes the task string as the child
        flow body's input. The child body sees input === task in its
        second argument.
      • The child's f.done(reason) becomes the parent step's completion; a
        child failure surfaces as the parent step's worker_error with the
        child completion reason threaded through.
      • Child memory scope: read getFlowDefinition(handle).header.memory and
        forward it as the memory-injection request for the nested run per
        docs/GATE5-MEMORY-CONTRACT.md. No shared pool — the child's memory
        tokens are charged to the child's steps, itemized, per RFC decision 10.
  4. Compile-time refusal use_undeclared.
    • If a f.agent(reviewer, ...) in the body binds a reviewer handle
      whose source module is not in header.use, flows check refuses with
      use_undeclared before submission. This preserves the "declared
      honestly" covenant: every imported flow the body composes must appear
      in the header. Detection is at flows check: import the root flow,
      walk header.use transitively, build the set of absolute-path handles,
      and compare against the set of handles the body actually calls.
      (Static detection is imperfect — a variable-passed handle can defeat
      it. Refusal fires only for handles that the loader observes returned
      from a use:-declared module and NOT in header.use; unknown handles
      escape to runtime, where the executor refuses use_undeclared at the
      first f.agent(handle, ...) call.)

Acceptance evidence

The PR must include a test-driven checklist demonstrating each of:

  • A1 — testdata/authored/chief.flow.ts imports
    reviewer.flow.ts via use: ['./reviewer.flow.ts']. flows check chief.flow.ts prints the resolved use graph and returns ok: true.
  • A2 — flows run chief.flow.ts --input '{...}' --local-agent
    journals a nested run whose run.spawned header shows
    identity = reviewer's identity. The parent step's
    step.completed references the child run id.
  • A3 — reviewer.flow.ts header sets memory: { script: true };
    the nested run journals a memory.injected entry per
    kernel/MEMORY.md, with the consuming step id set to a child step id
    (not the parent's).
  • A4 — Removing ./reviewer.flow.ts from the fixture makes
    flows check refuse with use_not_found.
  • A5 — A reviewer.flow.ts whose module throws at import time
    makes flows check refuse with use_invalid.
  • A6 — A parent flow that calls f.agent(otherHandle, ...)
    where otherHandle was imported outside header.use refuses with
    use_undeclared at check-time when statically visible, or at
    runtime otherwise.
  • A7 — Diamond graph fixture (A uses B, B uses C, D uses B and C)
    loads once each per absolute path. Cycle fixture (X uses Y, Y uses X)
    refuses use_cycle at load.

Files touched (expected)

  • packages/surface/src/flow.ts — extend FlowHeader with use?: string[],
    update freezeHeader, assertFlowHeader, ReadonlyFlowHeader.
  • packages/sdk/src/authored-flow-loader.ts — resolve use: graph, memoize
    by absolute path, refuse use_not_found / use_invalid / use_cycle.
  • packages/sdk/src/authored-flow-executor.ts — dispatch
    f.agent(handle, opts) to a nested run; forward child identity and memory
    scope.
  • packages/sdk/src/preflight.ts — walk header.use transitively during
    flows check; static use_undeclared refusal for statically observed
    handles.
  • packages/sdk/src/failure-kinds.ts — new refusal codes.
  • testdata/authored/{chief,reviewer,cycle-a,cycle-b,diamond-*}.flow.ts —
    fixtures.
  • packages/sdk/tests/authored-flow.test.ts — extended vitest coverage.
  • packages/surface/src/*.test.ts — header shape coverage for use.

Non-goals

  • Cross-package / npm imports. Only relative paths for this slice.
  • YAML use: — the canonical declarative dialect follows in a later slice.
  • Kernel changes. This is a surface + SDK executor change; the nested-run
    contract already exists in the journal.
  • Sharing memory pools between parent and child (RFC decision 10 keeps
    step-scoped memory).

Dependencies / adjacent

  • docs/GATE5-MEMORY-CONTRACT.md — child memory scope forwarding.
  • flows#132 — the declarative agents: compiler this slice sits next to
    (already merged; do not re-touch its YAML compiler).
  • The nested-run journal shape is already exercised by f.dispatch; reuse
    the same code path where possible.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions