Skip to content
中文

Reference, reachability, and bounded-loop validation ​

The policy implementation follows.

ts
export class AgentWorkflow<State, Result, Runtime = undefined> {
  /** Workflow id, also used in diagnostics and error messages. */
  readonly id: string;
  /** First node visited; always starts a round. */
  readonly entryNodeId: string;
  /** Optional revision passed through to diagnostics. */
  readonly revision?: string;
  /** Optional result guard; when present it must return exactly true. */
  readonly isResult?: (result: Result) => boolean;
  private readonly chainSource?: WorkflowChainSource;
  private readonly nodes = new Map<string, WorkflowNode<State, Result, Runtime>>();

  /**
   * Build and validate a workflow.
   *
   * The entry node must start a round, because otherwise a workflow could loop forever
   * without consuming the round budget. Renamed top-level fields are rejected as well.
   *
   * @param definition - Nodes, entry, id, optional revision and result guard.
   * @throws TypeError When the definition, a node, a route or a graph invariant is invalid.
   */
  constructor(definition: AgentWorkflowDefinition<State, Result, Runtime>) {
    if ('entry' in definition || 'validateResult' in definition)
      throw new TypeError('Use entryNodeId and isResult');
    const entry = definition.entryNodeId ?? definition.nodes[0]?.id;
    if (!definition.id?.trim() || !entry?.trim() || !definition.nodes.length) {
      throw new TypeError('Invalid workflow');
    }
    this.chainSource = definition.chainSource?.bind(definition);
    this.id = definition.id;
    this.entryNodeId = entry;
    this.revision = definition.revision;
    this.isResult = definition.isResult?.bind(definition);
    for (const node of definition.nodes) this.register(node);
    if (!this.require(this.entryNodeId).startsRound)
      throw new TypeError('Workflow entry must start a bounded round');
    this.checkReferences();
    this.checkReachability();
    this.checkBoundedCycles();
  }

  /**
   * Derive an extended workflow without mutating this catalog.
   *
   * Every node is copied with its callbacks rebound, so the new catalog keeps the same
   * entry and result guard and can be composed repeatedly. Route overrides are keyed by
   * node id and must name a node that exists after the merge.
   *
   * @param extension - Extension patch, or another workflow whose definitions are merged.
   * @param routes - Route overrides keyed by node id.
   * @returns A new, fully re-validated workflow.
   * @throws TypeError When an override names an unknown node or a node is invalid.
   */
  add(
    extension: WorkflowExtension<State, Result, Runtime> | AgentWorkflow<State, Result, Runtime>,
    routes: WorkflowExtension<State, Result, Runtime>['routes'] = {},
  ): AgentWorkflow<State, Result, Runtime> {
    const patch =
      extension instanceof AgentWorkflow ? { nodes: extension.definitions, routes } : extension;
    for (const node of patch.nodes) validateNodeFields(node);
    const nodes = [...this.definitions, ...patch.nodes];
    for (const id of Object.keys(patch.routes ?? {})) {
      if (!nodes.some((node) => node.id === id))
        throw new TypeError(`Unknown workflow route source: ${id}`);
    }
    return new AgentWorkflow({
      id: this.id,
      revision: patch.revision,
      chainSource: this.chainSource,
      entryNodeId: this.entryNodeId,
      isResult: this.isResult,
      nodes: nodes.map((node) => ({
        id: node.id,
        description: node.description,
        name: node.name,
        startsRound: node.startsRound,
        onErrorNodeId: node.onErrorNodeId,
        onErrorRouteId: node.onErrorRouteId,
        requires: node.requires,
        provides: node.provides,
        run: node.run?.bind(node),
        report: node.report?.bind(node),
        routes: Object.hasOwn(patch.routes ?? {}, node.id) ? patch.routes![node.id] : node.routes,
      })),
    });
  }

  /** Export topology from the registered snapshot without executing callbacks. */
  toGraph(): WorkflowGraph {
    return projectWorkflowGraph(this.id, this.entryNodeId, this.revision, this.definitions);
  }

  /** Read an authorized run through the bound App source, or an explicit per-call source. */
  async toChain(
    runId: string,
    source: WorkflowChainSource | undefined = this.chainSource,
  ): Promise<WorkflowChain> {
    if (!runId.trim()) throw new TypeError('A runId is required');
    if (!source) throw new TypeError('Workflow chainSource is not configured');
    return projectWorkflowChain(this.id, runId, await source(runId));
  }

  /**
   * Look up a node that must exist.
   *
   * @param id - Node id.
   * @returns The registered node.
   * @throws TypeError When the node is unknown.
   */
  require(id: string): WorkflowNode<State, Result, Runtime> {
    const node = this.nodes.get(id);
    if (!node) throw new TypeError(`Unknown workflow node: ${id}`);
    return node;
  }

  /** Frozen snapshot of all nodes, in registration order. */
  get definitions(): readonly Readonly<WorkflowNode<State, Result, Runtime>>[] {
    return Object.freeze([...this.nodes.values()]);
  }
  /** @returns A frozen array of node ids in registration order. */
  ids(): readonly string[] {
    return Object.freeze([...this.nodes.keys()]);
  }
  /** @returns An iterator over the registered nodes. */
  values() {
    return this.nodes.values();
  }
  /** @returns An iterator over id/node pairs. */
  entries() {
    return this.nodes.entries();
  }
  /**
   * @param id - Node id.
   * @returns Whether the node is registered.
   */
  has(id: string): boolean {
    return this.nodes.has(id);
  }
  /**
   * @param id - Node id.
   * @returns The node, or undefined when it is not registered.
   */
  get(id: string) {
    return this.nodes.get(id);
  }

  /**
   * Produce a serializable description of every node and route.
   *
   * Used by documentation and registration listings; it reports resolved ids and which
   * routes are terminal or conditional, never the handler implementations.
   *
   * @returns An array of plain description objects in registration order.
   */
  describe() {
    return [...this.nodes.values()].map((node) => ({
      id: node.id,
      description: node.description,
      ...(node.name ? { name: node.name } : {}),
      startsRound: node.startsRound === true,
      onErrorNodeId: node.onErrorNodeId,
      requires: node.requires ?? [],
      provides: node.provides ?? [],
      routes: node.routes.map((route) => ({
        ...(route.id !== undefined ? { id: route.id } : {}),
        ...(route.description !== undefined ? { description: route.description } : {}),
        ...(route.conditionId !== undefined ? { conditionId: route.conditionId } : {}),
        targetNodeId: route.targetNodeId,
        terminal: !!route.complete,
        conditional: !!route.when,
      })),
    }));
  }

  /**
   * Validate and freeze one node, then add it to the catalog.
   *
   * A route must be either a jump with a target or a completion, never both; an
   * unconditional route must be last, because it would shadow every later route. Route
   * ids and condition ids are resolved here so the engine never interpolates templates at
   * run time.
   *
   * @param node - Node supplied by the workflow definition.
   * @throws TypeError When the node, a route or a state contract is invalid.
   */
  private register(node: WorkflowNode<State, Result, Runtime>): void {
    const declaredRoutes = node.routes;
    if (
      !node.id?.trim() ||
      !node.description?.trim() ||
      this.nodes.has(node.id) ||
      !declaredRoutes.length ||
      (node.run !== undefined && typeof node.run !== 'function')
    ) {
      throw new TypeError('Invalid or duplicate workflow node');
    }
    validateNodeFields(node);
    if (node.onErrorRouteId !== undefined && (!node.onErrorRouteId.trim() || !node.onErrorNodeId))
      throw new TypeError(`Invalid workflow error route identity: ${node.id}`);
    const ids = assignRouteIds(declaredRoutes);
    for (const [index, route] of declaredRoutes.entries()) {
      if (
        'to' in route ||
        (route.id !== undefined && !route.id.trim()) ||
        (route.conditionId !== undefined && (!route.conditionId.trim() || !route.when))
      )
        throw new TypeError(`Invalid workflow route identity: ${node.id}`);
      const hasTarget = typeof route.targetNodeId === 'string';
      const hasCompletion = typeof route.complete === 'function';
      const shadowsFollowingRoutes = !route.when && index !== declaredRoutes.length - 1;
      if (
        hasTarget === hasCompletion ||
        shadowsFollowingRoutes ||
        (route.when !== undefined && typeof route.when !== 'function')
      ) {
        throw new TypeError(`Invalid or unreachable workflow route: ${node.id}`);
      }
    }
    for (const fields of [node.requires, node.provides]) {
      if (
        fields &&
        (fields.some((field) => typeof field !== 'string' || !field.trim()) ||
          new Set(fields).size !== fields.length)
      ) {
        throw new TypeError(`Invalid workflow state contract: ${node.id}`);
      }
    }
    const routes = Object.freeze(
      declaredRoutes.map((route, index) => {
        const id = ids[index];
        const conditionId = route.conditionId?.replace(/\{(nid|rid)\}/g, (_, key) => {
          if (key === 'nid') return node.id;
          if (id === undefined)
            throw new TypeError(`Condition ID references a missing route ID: ${node.id}`);
          return id;
        });
        const common = {
          id,
          description: route.description,
          conditionId,
          when: route.when?.bind(route),
        };
        return Object.freeze(
          route.complete
            ? { ...common, complete: route.complete.bind(route) }
            : { ...common, targetNodeId: route.targetNodeId! },
        );
      }),
    );
    // The entry node starts a round unless it says otherwise; any other node starts one
    // only when it declares it explicitly.
    const startsRound = node.startsRound ?? (node.id === this.entryNodeId ? true : undefined);
    this.nodes.set(
      node.id,
      Object.freeze({
        id: node.id,
        description: node.description,
        name: node.name,
        report: node.report?.bind(node),
        startsRound,
        onErrorRouteId: node.onErrorRouteId,
        onErrorNodeId: node.onErrorNodeId,
        run: node.run?.bind(node),
        routes,
        requires: node.requires ? Object.freeze([...node.requires]) : undefined,
        provides: node.provides ? Object.freeze([...node.provides]) : undefined,
      }),
    );
  }

  /**
   * Collect every node a visit can hand control to.
   *
   * Error targets are included so a recovery node is treated as reachable and is subject
   * to the same cycle rule as a normal transition.
   *
   * @param node - Node whose outgoing edges are collected.
   * @returns Target node ids, including the error node.
   */
  private targets(node: WorkflowNode<State, Result, Runtime>): string[] {
    const targets = node.routes.flatMap((route) =>
      route.targetNodeId !== undefined ? [route.targetNodeId] : [],
    );
    if (node.onErrorNodeId !== undefined) targets.push(node.onErrorNodeId);
    return targets;
  }

  /**
   * Require every route target to name a registered node.
   *
   * @throws TypeError When any target is unknown.
   */
  private checkReferences(): void {
    for (const node of this.nodes.values()) {
      for (const target of this.targets(node)) this.require(target);
    }
  }

  /**
   * Require every node to be reachable from the entry.
   *
   * An unreachable node is a declaration mistake: it would never run, yet it still
   * appears in listings and documentation.
   *
   * @throws TypeError When any node cannot be reached.
   */
  private checkReachability(): void {
    const reachable = new Set<string>();
    const visit = (id: string) => {
      if (reachable.has(id)) return;
      reachable.add(id);
      for (const target of this.targets(this.require(id))) visit(target);
    };
    visit(this.entryNodeId);
    if (reachable.size !== this.nodes.size) throw new TypeError('Unreachable workflow node');
  }

  /**
   * Require every cycle to cross a round boundary.
   *
   * @throws TypeError When a cycle can be traversed without ever starting a new round.
   */
  private checkBoundedCycles(): void {
    // Graph traversal is procedural: every possible cycle, including error routes,
    // must cross a round boundary so the engine can enforce the shared budget.
    const active = new Set<string>();
    const visited = new Set<string>();
    const visit = (id: string) => {
      const node = this.require(id);
      if (node.startsRound || visited.has(id)) return;
      if (active.has(id)) throw new TypeError('Workflow cycle bypasses the round budget');
      active.add(id);
      for (const target of this.targets(node)) visit(target);
      active.delete(id);
      visited.add(id);
    };
    for (const id of this.nodes.keys()) visit(id);
  }
}

API reference, usage examples and developer guides.