Skip to content

Example 03: Tool Schemas

What It Is

Example 03 is about the contract between a model-facing tool definition and the DAG that actually executes work. The Archivist lets a model choose book-search tools, but JSON Schema describes the allowed input shape and Dagonizer still owns validation, routing, retries, and merge behavior.

The page also covers a small but painful detail: schema examples can leak into model output. The tool definitions use shape-only placeholders so a model learns the structure without copying fake titles or identifiers into a visitor-facing answer.

How It Works

The model sees tool names, descriptions, and JSON Schema input shapes. It can propose { name, arguments } calls, but it does not get arbitrary access to application code. The DAG receives the selected calls, validates their arguments, builds concrete worksets, and sends those worksets through registered nodes and sub-DAGs.

The schemas describe input contracts only. The dispatcher still controls which node runs, how outputs merge, and which terminal route the flow takes. This keeps the model in the planning lane and keeps execution inside the graph.

Diagrams, Examples, and Outputs

The diagram is the book-search-scatter DAG from the Archivist. Tool planning happens before this sub-DAG; this graph shows what the application does after it turns a model plan into concrete search work.

DAG registration and diagram

The Archivist exposes its book-search capabilities to the LLM as typed tools with JSON Schema 2020-12 inputSchema definitions. decideTools creates a toolPlan; the book-search-scatter DAG turns that plan into concrete scout work.

book-search-scatter

16 placements
DAG JSON-LD registered with the dispatcher
{
  "@context": {
    "@version": 1.1,
    "name": {
      "@id": "https://noocodec.dev/ontology/dag/name"
    },
    "version": {
      "@id": "https://noocodec.dev/ontology/dag/version"
    },
    "entrypoints": {
      "@id": "https://noocodec.dev/ontology/dag/entrypoints",
      "@container": "@index"
    },
    "nodes": {
      "@id": "https://noocodec.dev/ontology/dag/nodes",
      "@container": "@set"
    },
    "outputs": {
      "@id": "https://noocodec.dev/ontology/dag/outputs"
    },
    "node": {
      "@id": "https://noocodec.dev/ontology/dag/node"
    },
    "dag": {
      "@id": "https://noocodec.dev/ontology/dag/dag"
    },
    "body": {
      "@id": "https://noocodec.dev/ontology/dag/body"
    },
    "source": {
      "@id": "https://noocodec.dev/ontology/dag/source"
    },
    "sources": {
      "@id": "https://noocodec.dev/ontology/dag/sources",
      "@container": "@index"
    },
    "itemKey": {
      "@id": "https://noocodec.dev/ontology/dag/itemKey"
    },
    "execution": {
      "@id": "https://noocodec.dev/ontology/dag/execution"
    },
    "concurrency": {
      "@id": "https://noocodec.dev/ontology/dag/concurrency"
    },
    "throttle": {
      "@id": "https://noocodec.dev/ontology/dag/throttle"
    },
    "reservoir": {
      "@id": "https://noocodec.dev/ontology/dag/reservoir"
    },
    "gather": {
      "@id": "https://noocodec.dev/ontology/dag/gather"
    },
    "dagReference": {
      "@id": "https://noocodec.dev/ontology/dag/dagReference",
      "@type": "@id"
    },
    "DagReference": {
      "@id": "https://noocodec.dev/ontology/dag/DagReference"
    },
    "from": {
      "@id": "https://noocodec.dev/ontology/dag/from"
    },
    "path": {
      "@id": "https://noocodec.dev/ontology/dag/path"
    },
    "candidates": {
      "@id": "https://noocodec.dev/ontology/dag/candidates",
      "@container": "@set"
    },
    "candidateDag": {
      "@id": "https://noocodec.dev/ontology/dag/candidateDag",
      "@type": "@id"
    },
    "selectedDag": {
      "@id": "https://noocodec.dev/ontology/dag/selectedDag",
      "@type": "@id"
    },
    "resultField": {
      "@id": "https://noocodec.dev/ontology/dag/resultField"
    },
    "policy": {
      "@id": "https://noocodec.dev/ontology/dag/policy"
    },
    "reducer": {
      "@id": "https://noocodec.dev/ontology/dag/reducer"
    },
    "outcome": {
      "@id": "https://noocodec.dev/ontology/dag/outcome"
    },
    "phase": {
      "@id": "https://noocodec.dev/ontology/dag/phase"
    },
    "stateMapping": {
      "@id": "https://noocodec.dev/ontology/dag/stateMapping"
    },
    "container": {
      "@id": "https://noocodec.dev/ontology/dag/container"
    },
    "DAG": {
      "@id": "https://noocodec.dev/ontology/dag/DAG"
    },
    "Placement": {
      "@id": "https://noocodec.dev/ontology/dag/Placement"
    },
    "SingleNode": {
      "@id": "https://noocodec.dev/ontology/dag/SingleNode"
    },
    "ScatterNode": {
      "@id": "https://noocodec.dev/ontology/dag/ScatterNode"
    },
    "EmbeddedDAGNode": {
      "@id": "https://noocodec.dev/ontology/dag/EmbeddedDAGNode"
    },
    "GatherNode": {
      "@id": "https://noocodec.dev/ontology/dag/GatherNode"
    },
    "TerminalNode": {
      "@id": "https://noocodec.dev/ontology/dag/TerminalNode"
    },
    "PhaseNode": {
      "@id": "https://noocodec.dev/ontology/dag/PhaseNode"
    }
  },
  "@id": "urn:noocodec:dag:book-search-scatter",
  "@type": "DAG",
  "name": "book-search-scatter",
  "version": "1.0",
  "entrypoints": {
    "main": "urn:noocodec:dag:book-search-scatter/node/extract-query"
  },
  "nodes": [
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/extract-query",
      "@type": "SingleNode",
      "name": "extract-query",
      "node": "urn:noocodec:node:extract-query",
      "outputs": {
        "success": "urn:noocodec:dag:book-search-scatter/node/decide-tools",
        "retry": "urn:noocodec:dag:book-search-scatter/node/extract-query",
        "salvage": "urn:noocodec:dag:book-search-scatter/node/extract-query-salvage"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/extract-query-salvage",
      "@type": "SingleNode",
      "name": "extract-query-salvage",
      "node": "urn:noocodec:node:extract-query-salvage",
      "outputs": {
        "done": "urn:noocodec:dag:book-search-scatter/node/decide-tools"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/decide-tools",
      "@type": "SingleNode",
      "name": "decide-tools",
      "node": "urn:noocodec:node:decide-tools",
      "outputs": {
        "tools": "urn:noocodec:dag:book-search-scatter/node/recall-candidates",
        "no-tools": "urn:noocodec:dag:book-search-scatter/node/recall-candidates",
        "retry": "urn:noocodec:dag:book-search-scatter/node/decide-tools",
        "salvage": "urn:noocodec:dag:book-search-scatter/node/decide-tools-salvage"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/decide-tools-salvage",
      "@type": "SingleNode",
      "name": "decide-tools-salvage",
      "node": "urn:noocodec:node:decide-tools-salvage",
      "outputs": {
        "done": "urn:noocodec:dag:book-search-scatter/node/recall-candidates"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/recall-candidates",
      "@type": "SingleNode",
      "name": "recall-candidates",
      "node": "urn:noocodec:node:recall-candidates",
      "outputs": {
        "recalled": "urn:noocodec:dag:book-search-scatter/node/build-book-worksets"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/build-book-worksets",
      "@type": "SingleNode",
      "name": "build-book-worksets",
      "node": "urn:noocodec:node:build-book-worksets",
      "outputs": {
        "ready": "urn:noocodec:dag:book-search-scatter/node/book-search-scatter"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/book-search-scatter",
      "@type": "ScatterNode",
      "name": "book-search-scatter",
      "source": "bookWorksets",
      "body": {
        "dag": {
          "@type": "DagReference",
          "from": "item",
          "path": "dagIri",
          "candidates": [
            "urn:noocodec:tool:web_search_books",
            "urn:noocodec:tool:google_books_search",
            "urn:noocodec:tool:subject_search",
            "urn:noocodec:tool:wikipedia_summary"
          ]
        }
      },
      "outputs": {
        "success": "urn:noocodec:dag:book-search-scatter/node/book-search-gather",
        "error": "urn:noocodec:dag:book-search-scatter/node/book-search-gather",
        "empty": "urn:noocodec:dag:book-search-scatter/node/rank-candidates"
      },
      "itemKey": "currentItem",
      "reducer": "any-success",
      "execution": {
        "mode": "item",
        "concurrency": 4
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/book-search-gather",
      "@type": "GatherNode",
      "name": "book-search-gather",
      "sources": {
        "urn:noocodec:dag:book-search-scatter/node/book-search-scatter": {}
      },
      "gather": {
        "strategy": "tool-candidate-merge"
      },
      "outputs": {
        "success": "urn:noocodec:dag:book-search-scatter/node/rank-candidates",
        "error": "urn:noocodec:dag:book-search-scatter/node/rank-candidates",
        "empty": "urn:noocodec:dag:book-search-scatter/node/rank-candidates"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/rank-candidates",
      "@type": "SingleNode",
      "name": "rank-candidates",
      "node": "urn:noocodec:node:rank-candidates",
      "outputs": {
        "ranked": "urn:noocodec:dag:book-search-scatter/node/merge-candidates",
        "retry": "urn:noocodec:dag:book-search-scatter/node/rank-candidates",
        "salvage": "urn:noocodec:dag:book-search-scatter/node/rank-candidates-salvage"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/rank-candidates-salvage",
      "@type": "SingleNode",
      "name": "rank-candidates-salvage",
      "node": "urn:noocodec:node:rank-candidates-salvage",
      "outputs": {
        "done": "urn:noocodec:dag:book-search-scatter/node/merge-candidates"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/merge-candidates",
      "@type": "SingleNode",
      "name": "merge-candidates",
      "node": "urn:noocodec:node:merge-candidates",
      "outputs": {
        "ranked": "urn:noocodec:dag:book-search-scatter/node/record-findings",
        "empty": "urn:noocodec:dag:book-search-scatter/node/no-results"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/record-findings",
      "@type": "SingleNode",
      "name": "record-findings",
      "node": "urn:noocodec:node:record-findings",
      "outputs": {
        "recorded": "urn:noocodec:dag:book-search-scatter/node/has-citations-gate"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/has-citations-gate",
      "@type": "SingleNode",
      "name": "has-citations-gate",
      "node": "urn:noocodec:node:has-citations-gate",
      "outputs": {
        "pass": "urn:noocodec:dag:book-search-scatter/node/recall-past-visits",
        "fail": "urn:noocodec:dag:book-search-scatter/node/no-results"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/recall-past-visits",
      "@type": "SingleNode",
      "name": "recall-past-visits",
      "node": "urn:noocodec:node:recall-past-visits",
      "outputs": {
        "recalled": "urn:noocodec:dag:book-search-scatter/node/found"
      }
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/found",
      "@type": "TerminalNode",
      "name": "found",
      "outcome": "completed"
    },
    {
      "@id": "urn:noocodec:dag:book-search-scatter/node/no-results",
      "@type": "TerminalNode",
      "name": "no-results",
      "outcome": "failed"
    }
  ]
}
Mermaid generated from the same DAG
Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
  %% book-search-scatter (v1.0)
  entry_main(["main"])
  entry_main --> urn_noocodec_dag_book-search-scatter/node/extract-query
  urn_noocodec_dag_book-search-scatter/node/extract-query["extract-query"]
  urn_noocodec_dag_book-search-scatter/node/extract-query -->|success| urn_noocodec_dag_book-search-scatter/node/decide-tools
  urn_noocodec_dag_book-search-scatter/node/extract-query -->|retry| urn_noocodec_dag_book-search-scatter/node/extract-query
  urn_noocodec_dag_book-search-scatter/node/extract-query -->|salvage| urn_noocodec_dag_book-search-scatter/node/extract-query-salvage
  urn_noocodec_dag_book-search-scatter/node/extract-query-salvage["extract-query-salvage"]
  urn_noocodec_dag_book-search-scatter/node/extract-query-salvage -->|done| urn_noocodec_dag_book-search-scatter/node/decide-tools
  urn_noocodec_dag_book-search-scatter/node/decide-tools["decide-tools"]
  urn_noocodec_dag_book-search-scatter/node/decide-tools -->|tools| urn_noocodec_dag_book-search-scatter/node/recall-candidates
  urn_noocodec_dag_book-search-scatter/node/decide-tools -->|no-tools| urn_noocodec_dag_book-search-scatter/node/recall-candidates
  urn_noocodec_dag_book-search-scatter/node/decide-tools -->|retry| urn_noocodec_dag_book-search-scatter/node/decide-tools
  urn_noocodec_dag_book-search-scatter/node/decide-tools -->|salvage| urn_noocodec_dag_book-search-scatter/node/decide-tools-salvage
  urn_noocodec_dag_book-search-scatter/node/decide-tools-salvage["decide-tools-salvage"]
  urn_noocodec_dag_book-search-scatter/node/decide-tools-salvage -->|done| urn_noocodec_dag_book-search-scatter/node/recall-candidates
  urn_noocodec_dag_book-search-scatter/node/recall-candidates["recall-candidates"]
  urn_noocodec_dag_book-search-scatter/node/recall-candidates -->|recalled| urn_noocodec_dag_book-search-scatter/node/build-book-worksets
  urn_noocodec_dag_book-search-scatter/node/build-book-worksets["build-book-worksets"]
  urn_noocodec_dag_book-search-scatter/node/build-book-worksets -->|ready| urn_noocodec_dag_book-search-scatter/node/book-search-scatter
  urn_noocodec_dag_book-search-scatter/node/book-search-scatter[/"book-search-scatter"/]
  urn_noocodec_dag_book-search-scatter/node/book-search-scatter -->|success| urn_noocodec_dag_book-search-scatter/node/book-search-gather
  urn_noocodec_dag_book-search-scatter/node/book-search-scatter -->|error| urn_noocodec_dag_book-search-scatter/node/book-search-gather
  urn_noocodec_dag_book-search-scatter/node/book-search-scatter -->|empty| urn_noocodec_dag_book-search-scatter/node/rank-candidates
  urn_noocodec_dag_book-search-scatter/node/book-search-gather{"book-search-gather"}
  urn_noocodec_dag_book-search-scatter/node/book-search-gather -->|success| urn_noocodec_dag_book-search-scatter/node/rank-candidates
  urn_noocodec_dag_book-search-scatter/node/book-search-gather -->|error| urn_noocodec_dag_book-search-scatter/node/rank-candidates
  urn_noocodec_dag_book-search-scatter/node/book-search-gather -->|empty| urn_noocodec_dag_book-search-scatter/node/rank-candidates
  urn_noocodec_dag_book-search-scatter/node/rank-candidates["rank-candidates"]
  urn_noocodec_dag_book-search-scatter/node/rank-candidates -->|ranked| urn_noocodec_dag_book-search-scatter/node/merge-candidates
  urn_noocodec_dag_book-search-scatter/node/rank-candidates -->|retry| urn_noocodec_dag_book-search-scatter/node/rank-candidates
  urn_noocodec_dag_book-search-scatter/node/rank-candidates -->|salvage| urn_noocodec_dag_book-search-scatter/node/rank-candidates-salvage
  urn_noocodec_dag_book-search-scatter/node/rank-candidates-salvage["rank-candidates-salvage"]
  urn_noocodec_dag_book-search-scatter/node/rank-candidates-salvage -->|done| urn_noocodec_dag_book-search-scatter/node/merge-candidates
  urn_noocodec_dag_book-search-scatter/node/merge-candidates["merge-candidates"]
  urn_noocodec_dag_book-search-scatter/node/merge-candidates -->|ranked| urn_noocodec_dag_book-search-scatter/node/record-findings
  urn_noocodec_dag_book-search-scatter/node/merge-candidates -->|empty| urn_noocodec_dag_book-search-scatter/node/no-results
  urn_noocodec_dag_book-search-scatter/node/record-findings["record-findings"]
  urn_noocodec_dag_book-search-scatter/node/record-findings -->|recorded| urn_noocodec_dag_book-search-scatter/node/has-citations-gate
  urn_noocodec_dag_book-search-scatter/node/has-citations-gate["has-citations-gate"]
  urn_noocodec_dag_book-search-scatter/node/has-citations-gate -->|pass| urn_noocodec_dag_book-search-scatter/node/recall-past-visits
  urn_noocodec_dag_book-search-scatter/node/has-citations-gate -->|fail| urn_noocodec_dag_book-search-scatter/node/no-results
  urn_noocodec_dag_book-search-scatter/node/recall-past-visits["recall-past-visits"]
  urn_noocodec_dag_book-search-scatter/node/recall-past-visits -->|recalled| urn_noocodec_dag_book-search-scatter/node/found
  urn_noocodec_dag_book-search-scatter/node/found((("found")))
  urn_noocodec_dag_book-search-scatter/node/no-results>"no-results"]

Run

bash
npm run docs:dev

What It Lets You Do

Tool schemas let applications expose useful model-callable capabilities without giving the model arbitrary application access. The model can choose a tool and fill an argument object; the DAG still decides validation, fan-out, retry, dedupe, ranking, and response composition.

Code Samples

These snippets are the runnable model/tool boundary inside The Archivist. The model sees schema-backed tool definitions through decideTools; the example code then converts the selected calls into DAG worksets and deduplicates the returned candidates after execution.

Code

DecideToolsNode: schema-backed tool planning

DecideToolsNode passes registered tool definitions to the LLM adapter, applies deterministic safety nets for common query shapes, and writes the selected calls to state.toolPlan:

ts
/**
 * decideTools: non-deterministic node that asks the LLM which tools
 * (if any) to invoke for this query.
 *
 * The LLM receives the tool definitions via the adapter's native
 * channel (Gemini API's `functionDeclarations`, the browser built-in model's
 * `responseConstraint`, WebLLM's `response_format`. There is no
 * tool-listing in the prompt itself; the API enforces the shape.
 *
 * Outputs:
 *   'tools':    LLM asked for ≥1 tool; `state.toolPlan` populated.
 *   'no-tools': LLM is confident the local catalog suffices.
 *
 * Downstream gating:
 *   openLibraryScout checks `state.toolPlan` for a `web_search_books`
 *   entry and short-circuits to 'empty' when absent.
 *   googleBooksScout checks for `google_books_search` and short-circuits
 *   to 'empty' when absent.
 *   subjectScout checks for `subject_search` and short-circuits to 'empty'
 *   when absent.
 *
 * Per-intent tool advertisement:
 *   find-reviews      → OpenLibrary + GoogleBooks + SubjectSearch
 *   lookup-author     → OpenLibrary + GoogleBooks + SubjectSearch
 *   recommend-similar → OpenLibrary + GoogleBooks + SubjectSearch
 *   describe-book     → OpenLibrary + SubjectSearch
 *   general on-topic  → OpenLibrary + SubjectSearch
 *
 * Safety net: for FULL_CATALOG_INTENTS, if the LLM omits any of the three
 * primary catalog tools, the safety net appends the missing entries using
 * the same query text so all scouts run.
 */

import { Batch, MonadicNode, NodeOutput, RoutedBatch } from '@studnicky/dagonizer';
import type { ItemType, NodeContextType, SchemaObjectType } from '@studnicky/dagonizer';
import { Signal } from '@studnicky/signal';

import type { ArchivistState } from '../ArchivistState.ts';
import type { ArchivistServices } from '../services.ts';

/**
 * Intents that require the full three-source catalog.
 * The safety net enforces all three when the LLM is too conservative.
 */
const FULL_CATALOG_INTENTS = new Set(['find-reviews', 'lookup-author', 'recommend-similar']);

/** All three primary catalog tool names (Wikipedia runs unconditionally). */
const FULL_CATALOG_TOOL_NAMES = ['web_search_books', 'google_books_search', 'subject_search'] as const;

type ToolCall = { readonly name: string; readonly arguments: Record<string, unknown> };

/**
 * Result of a deterministic-shortcut pattern match. `null` when no
 * pattern fires; the LLM path runs as usual. Otherwise carries the
 * pre-populated tool plan and the named pattern for the log.
 */
interface ShortcutMatch {
  readonly pattern: string;
  readonly calls:   readonly ToolCall[];
}

const SHORTCUT_LIMIT = 8;

// ISBN-10 / ISBN-13 detection. Both formats (with or without hyphens).
// OpenLibrary's ?q= field handles both as a high-priority identifier lookup.
const ISBN_RE         = /\b(97[89]-?\d-?\d{2,5}-?\d{2,7}-?\d|\d{9}[\dXx]|97[89]\d{10})\b/u;
const AUTHOR_HINT_RE  = /\b(?:by|author|wrote|written\s+by)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/iu;
const QUOTED_TITLE_RE = /^\s*['""']([^'""']+)['""']\s*$/u;
const PROPER_NOUN_RE  = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/u;
const TOPIC_RE        = /^(?:books?|works|literature|stories|novels)\s+(?:about|on)\s+(\S.*)$/iu;
const BROWSING_RE     = /^(?:do\s+you\s+have|what\s+(?:do\s+you\s+have|titles\s+do\s+you\s+have)|show\s+me|recommend)/iu;

const FULL_SCOUT_PLAN: readonly ToolCall[] = [
  { 'name': 'web_search_books',    'arguments': { 'limit': SHORTCUT_LIMIT } },
  { 'name': 'google_books_search', 'arguments': { 'maxResults': SHORTCUT_LIMIT } },
  { 'name': 'subject_search',      'arguments': { 'limit': SHORTCUT_LIMIT } },
  { 'name': 'wikipedia_summary',   'arguments': {} },
];

/**
 * ShortcutMatcher: deterministic pattern matching for common query shapes.
 * Static methods only; no instance state.
 */
export class ShortcutMatcher {
  /**
   * Safety-net post-processor: for full-catalog intents, ensure the tool plan
   * contains all three primary sources. Missing tools are appended using the
   * same query string the LLM chose (or the raw visitor query as default).
   */
  static enforceFullCatalog(
    calls: readonly ToolCall[],
    query: string,
  ): readonly ToolCall[] {
    // Derive the preferred query from the first tool call that has one.
    const firstQueryValue = calls.find((c) => typeof c.arguments['query'] === 'string')?.arguments['query'];
    const firstQuery: string | undefined = typeof firstQueryValue === 'string' ? firstQueryValue : undefined;
    const defaultQuery = firstQuery ?? query;

    const names = new Set(calls.map((c) => c.name));
    const additions: ToolCall[] = [];

    if (!names.has('web_search_books')) {
      additions.push({ 'name': 'web_search_books',   'arguments': { 'query': defaultQuery, 'limit': 8 } });
    }
    if (!names.has('google_books_search')) {
      additions.push({ 'name': 'google_books_search', 'arguments': { 'query': defaultQuery, 'maxResults': 8 } });
    }
    if (!names.has('subject_search')) {
      additions.push({ 'name': 'subject_search',      'arguments': { 'subject': defaultQuery, 'limit': 8 } });
    }

    return additions.length > 0 ? [...calls, ...additions] : calls;
  }

  /**
   * Detect whether the visitor query matches one of the deterministic
   * shortcut patterns. Returns the populated tool plan when a pattern
   * fires; otherwise `null`. The LLM call is bypassed only when this
   * returns non-null.
   *
   *   - isbn-lookup          → direct OpenLibrary ISBN lookup
   *   - author-lookup        → full 4-scout plan with typed author arg
   *   - quoted-single-title  → wikipedia first then web_search_books
   *   - topic-or-subject     → subject_search + web_search_books with typed subject arg
   *   - catalog-browsing     → full 4-scout plan
   */
  static match(query: string, intent: string): ShortcutMatch | null {
    const trimmed = query.trim();
    if (trimmed.length === 0) return null;

    // 0. ISBN-10 / ISBN-13 detection. Both formats (with or without hyphens).
    //    OpenLibrary's ?q= field handles both as a high-priority identifier lookup.
    const isbnMatch = trimmed.match(ISBN_RE);
    if (isbnMatch !== null) {
      const isbn = isbnMatch[1] ?? isbnMatch[0];
      return {
        'pattern': 'isbn-lookup',
        'calls': [
          { 'name': 'web_search_books', 'arguments': { 'isbn': isbn, 'limit': 1 } },
        ],
      };
    }

    // 1. Author lookup: either an explicit "by X Y" pattern OR
    //    lookup-author intent with a multi-word capitalised proper noun.
    //    Carry the captured author name as a typed arg so the scout uses
    //    OpenLibrary's ?author= axis instead of keyword query.
    const authorMatch = trimmed.match(AUTHOR_HINT_RE);
    if (authorMatch !== null ||
        (intent === 'lookup-author' && PROPER_NOUN_RE.test(trimmed))) {
      const authorName = authorMatch !== null
        ? (authorMatch[1] ?? '')
        : (trimmed.match(PROPER_NOUN_RE)?.[1] ?? trimmed);
      return {
        'pattern': 'author-lookup',
        'calls': [
          { 'name': 'web_search_books',    'arguments': { 'author': authorName, 'limit': SHORTCUT_LIMIT } },
          { 'name': 'google_books_search', 'arguments': { 'author': authorName, 'maxResults': SHORTCUT_LIMIT } },
          { 'name': 'subject_search',      'arguments': { 'limit': SHORTCUT_LIMIT } },
          { 'name': 'wikipedia_summary',   'arguments': { 'query': authorName } },
        ],
      };
    }

    // 2. Quoted single title: "X Y Z" style; route to wikipedia first.
    if (QUOTED_TITLE_RE.test(trimmed)) {
      return {
        'pattern': 'quoted-single-title',
        'calls': [
          { 'name': 'wikipedia_summary',  'arguments': {} },
          { 'name': 'web_search_books',   'arguments': { 'limit': SHORTCUT_LIMIT } },
        ],
      };
    }

    // 2b. describe-book intent with exactly one capitalised multi-word phrase.
    if (intent === 'describe-book') {
      const matches = trimmed.match(new RegExp(PROPER_NOUN_RE.source, 'gu'));
      if (matches !== null && matches.length === 1) {
        return {
          'pattern': 'single-title-describe',
          'calls': [
            { 'name': 'wikipedia_summary',  'arguments': {} },
            { 'name': 'web_search_books',   'arguments': { 'limit': SHORTCUT_LIMIT } },
          ],
        };
      }
    }

    // 3. Topic / subject: "books about X" etc.
    //    Capture the topic term and pass it as a typed subject arg so scouts
    //    use OpenLibrary's ?subject= axis and the subject facet directly.
    const topicMatch = trimmed.match(TOPIC_RE);
    if (topicMatch !== null) {
      const topicTerm = (topicMatch[1] ?? '').trim();
      return {
        'pattern': 'topic-or-subject',
        'calls': [
          { 'name': 'subject_search',   'arguments': { 'subject': topicTerm, 'limit': SHORTCUT_LIMIT } },
          { 'name': 'web_search_books', 'arguments': { 'subject': topicTerm, 'limit': SHORTCUT_LIMIT } },
        ],
      };
    }

    // 4. Catalog browsing: "do you have...", "show me...", "recommend..."
    if (BROWSING_RE.test(trimmed)) {
      return { 'pattern': 'catalog-browsing', 'calls': FULL_SCOUT_PLAN };
    }

    return null;
  }
}

/** Per-node timeout: generous for Gemini Nano's constrained-output path (20-60 s typical). */
const NODE_TIMEOUT_MS = 30_000;

/** Total attempts (initial + retries) before routing to salvage. */
const RETRY_BUDGET = 2;

export class DecideToolsNode extends MonadicNode<ArchivistState, 'tools' | 'no-tools' | 'retry' | 'salvage'> {
  private readonly services: ArchivistServices;
  readonly name = 'decide-tools';
  readonly '@id' = 'urn:noocodec:node:decide-tools';
  constructor(services: ArchivistServices) {
    super();
    this.services = services;
  }
  readonly outputs = ['tools', 'no-tools', 'retry', 'salvage'] as const;
  override get outputSchema(): Record<'tools' | 'no-tools' | 'retry' | 'salvage', SchemaObjectType> {
    return {
      'tools':    { 'type': 'object' },
      'no-tools': { 'type': 'object' },
      'retry':    { 'type': 'object' },
      'salvage':  { 'type': 'object' },
    };
  }

  override async execute(batch: Batch<ArchivistState>, context: NodeContextType) {
    const toolsItems: ItemType<ArchivistState>[] = [];
    const noToolsItems: ItemType<ArchivistState>[] = [];
    const retryItems: ItemType<ArchivistState>[] = [];
    const salvageItems: ItemType<ArchivistState>[] = [];

    for (const item of batch) {
      const { state } = item;
      // ── Deterministic shortcut prelude ────────────────────────────────────
      // Pattern-match common query shapes (author lookup, single quoted title,
      // "books about X", catalog browsing). When a pattern fires, populate
      // state.toolPlan directly and skip the LLM round-trip. The existing
      // safety nets only fire on LLM-path output, so shortcuts don't need them.
      const shortcut = ShortcutMatcher.match(state.query, state.intent);
      if (shortcut !== null) {
        state.toolPlan = shortcut.calls;
        state.clearAttempts(context.nodeName);
        const result = NodeOutput.create('tools');
        for (const error of result.errors) state.collectError(error);
        toolsItems.push(item);
        continue;
      }

      const isFullCatalog = FULL_CATALOG_INTENTS.has(state.intent);
      const available = isFullCatalog
        ? [this.services.webSearch.definition, this.services.googleBooks.definition, this.services.subjectSearch.definition]
        : [this.services.webSearch.definition, this.services.subjectSearch.definition];

      const signal = Signal.compose({
        'deadlineMs': this.services.nodeTimeouts[context.nodeName] ?? NODE_TIMEOUT_MS,
        'signal':     context.signal,
      });

      try {
        let calls = await this.services.llm.decideTools(state.query, available, signal);
        // LLM responded; the retry budget for this placement is spent.
        state.clearAttempts(context.nodeName);

        // Safety net (Option B): if the LLM returned fewer than all three
        // catalog tools for a full-catalog intent, add the missing ones so
        // all scouts run across all sources.
        if (isFullCatalog) {
          calls = ShortcutMatcher.enforceFullCatalog(calls, state.query);
        }

        // Safety net for on-topic intent with a sparse tool plan: force the full
        // four-scout set. WebLLM and Gemini Nano have unreliable structured output
        // so the LLM may under-propose tools; the scouts run in parallel so the
        // cost of running all four is bounded.
        //
        // Arguments intentionally omit `query` / `subject`. Each scout uses
        // to `state.terms.join(' ')` (the keywords produced by `extract-query`)
        // when its query arg is missing. Passing `state.query` here would make
        // OpenLibrary search for the literal visitor sentence; 0 hits.
        if (!isFullCatalog && state.intent === 'search' && calls.length < 2) {
          calls = [
            { 'name': 'web_search_books',    'arguments': { 'limit': 8 } },
            { 'name': 'google_books_search', 'arguments': { 'maxResults': 8 } },
            { 'name': 'subject_search',      'arguments': { 'limit': 8 } },
            { 'name': 'wikipedia_summary',   'arguments': {} },
          ];
        } else if (!isFullCatalog && calls.length === 0) {
          // Minimal safety net for other non-full-catalog intents: ensure at least
          // web_search_books is in the plan so openLibraryScout runs.
          calls = [{ 'name': 'web_search_books', 'arguments': { 'limit': 8 } }];
        }

        state.toolPlan = calls;
        if (calls.length > 0) {
          const result = NodeOutput.create('tools');
          for (const error of result.errors) state.collectError(error);
          toolsItems.push(item);
        } else {
          state.failureCause += 'ToolInterface plan: no tools selected. ';
          const result = NodeOutput.create('no-tools');
          for (const error of result.errors) state.collectError(error);
          noToolsItems.push(item);
        }
      } catch (err) {
        // External cancellation / run deadline propagates unchanged.
        if (context.signal.aborted) throw err;
        // Node-local timeout or LLM failure -> retry budget decides the flow. The
        // minimal-plan recovery lives in decide-tools-salvage, not here.
        if (state.withinRetryBudget(context.nodeName, RETRY_BUDGET)) {
          const result = NodeOutput.create('retry');
          for (const error of result.errors) state.collectError(error);
          retryItems.push(item);
        } else {
          state.clearAttempts(context.nodeName);
          const result = NodeOutput.create('salvage');
          for (const error of result.errors) state.collectError(error);
          salvageItems.push(item);
        }
      }
    }

    const routes: Array<readonly ['tools' | 'no-tools' | 'retry' | 'salvage', Batch<ArchivistState>]> = [];
    if (toolsItems.length > 0) routes.push(['tools', Batch.from(toolsItems)]);
    if (noToolsItems.length > 0) routes.push(['no-tools', Batch.from(noToolsItems)]);
    if (retryItems.length > 0) routes.push(['retry', Batch.from(retryItems)]);
    if (salvageItems.length > 0) routes.push(['salvage', Batch.from(salvageItems)]);
    return RoutedBatch.create(routes);
  }
}

// Export tool names list for tests / documentation.
export { FULL_CATALOG_TOOL_NAMES };

export type { ShortcutMatch };

BuildBookWorksetsNode: tool calls become DAG references

The workset builder turns state.toolPlan into JSON-serializable items. Each item carries dagIri: 'urn:noocodec:tool:<name>', which the scatter placement resolves through a dynamic DagReference:

ts
/**
 * buildBookWorksets: builds the scatter workset from the LLM tool plan.
 *
 * Reads `state.toolPlan` (populated by decideTools) and `state.terms`, and
 * emits a `bookWorksets` array where each entry is a JSON-serialisable
 * `{ dagIri: string; arguments: Record<string, unknown> }` object. The
 * `dagIri` field carries the embedded tool DAG IRI registered by
 * ToolRegistry; the scatter placement uses an item-scoped DagReference to
 * resolve the body DAG at runtime.
 *
 * Argument-building mirrors the old per-scout logic exactly:
 *
 *   web_search_books  — OpenLibrary keyword / isbn / author / subject search.
 *                       Priority: isbn > author > subject > keyword.
 *   google_books_search — Google Books keyword search.
 *   subject_search    — OpenLibrary subject-facet search with LCSH heuristic.
 *   wikipedia_summary — Wikipedia page/summary enrichment via state.terms.
 *
 * Routes 'ready' after writing bookWorksets. Always succeeds (no tool is
 * required; an empty workset means the scatter body is skipped).
 */

import { MonadicNode, RoutedBatch } from '@studnicky/dagonizer';
import type { SchemaObjectType } from '@studnicky/dagonizer';
import type { Batch, NodeContextType } from '@studnicky/dagonizer';
import type { JsonObjectType } from '@studnicky/dagonizer/types';

import type { ArchivistState } from '../ArchivistState.ts';
import { UserLanguage } from '../language/UserLanguage.ts';
import { ScoutUtils } from './scouts.ts';

/** A single scatter workset entry: names the tool DAG and its call arguments. */
export type BookWorksetItemType = {
  readonly dagIri: string;
  readonly arguments: JsonObjectType;
};

export class BuildBookWorksetsNode extends MonadicNode<ArchivistState, 'ready'> {
  readonly name = 'build-book-worksets';
  readonly '@id' = 'urn:noocodec:node:build-book-worksets';
  readonly outputs = ['ready'] as const;

  override get outputSchema(): Record<'ready', SchemaObjectType> {
    return {
      'ready': { 'type': 'object' },
    };
  }

  override async execute(batch: Batch<ArchivistState>, _context: NodeContextType) {
    for (const { state } of batch) {
      const worksets: BookWorksetItemType[] = [];

    // ── web_search_books (OpenLibrary) ───────────────────────────────────────
    const openLibraryPlan = state.toolPlan.find((c) => c.name === 'web_search_books');
    if (openLibraryPlan !== undefined) {
      const args = openLibraryPlan.arguments;
      const rawLimit = args['limit'];
      const limit = typeof rawLimit === 'number' ? rawLimit : 8;
      const lang = UserLanguage.toIso6392(state.userLanguage);

      let toolArguments: JsonObjectType | null = null;

      const rawIsbn = args['isbn'];
      const rawAuthor = args['author'];
      const rawSubject = args['subject'];
      const rawQuery = args['query'];

      if (typeof rawIsbn === 'string' && rawIsbn.length > 0) {
        toolArguments = { 'isbn': rawIsbn, limit, lang };
      } else if (typeof rawAuthor === 'string' && rawAuthor.length > 0) {
        toolArguments = { 'author': rawAuthor, limit, lang };
      } else if (typeof rawSubject === 'string' && rawSubject.length > 0) {
        toolArguments = { 'subject': rawSubject, limit, lang };
      } else {
        const queryStr = typeof rawQuery === 'string' && rawQuery.length > 0
          ? rawQuery
          : state.terms.join(' ');
        const query = ScoutUtils.unquote(queryStr);
        if (query.length > 0) {
          toolArguments = { 'query': query, limit, lang };
        }
      }

      if (toolArguments !== null) {
        worksets.push({ 'dagIri': 'urn:noocodec:tool:web_search_books', 'arguments': toolArguments });
      }
    }

    // ── google_books_search (Google Books) ───────────────────────────────────
    const googleBooksPlan = state.toolPlan.find((c) => c.name === 'google_books_search');
    if (googleBooksPlan !== undefined) {
      const args = googleBooksPlan.arguments;
      const rawQuery = args['query'];
      const rawMax = args['maxResults'];
      const queryStr = typeof rawQuery === 'string' && rawQuery.length > 0
        ? rawQuery
        : state.terms.join(' ');
      const query = ScoutUtils.unquote(queryStr);
      if (query.length > 0) {
        const langRestrict = UserLanguage.normalize(state.userLanguage);
        worksets.push({
          'dagIri': 'urn:noocodec:tool:google_books_search',
          'arguments': { 'query': query, 'maxResults': typeof rawMax === 'number' ? rawMax : 8, 'langRestrict': langRestrict },
        });
      }
    }

    // ── subject_search (OpenLibrary subject facet) ───────────────────────────
    const subjectPlan = state.toolPlan.find((c) => c.name === 'subject_search');
    if (subjectPlan !== undefined) {
      const args = subjectPlan.arguments;
      const rawSubject = args['subject'];
      const rawLimit = args['limit'];
      const subjectStr = typeof rawSubject === 'string' && rawSubject.length > 0
        ? rawSubject
        : ScoutUtils.pickSubjectTerm(state.terms);
      const subject = ScoutUtils.unquote(subjectStr);
      if (subject.length > 0) {
        const lang = UserLanguage.toIso6392(state.userLanguage);
        worksets.push({
          'dagIri': 'urn:noocodec:tool:subject_search',
          'arguments': { 'subject': subject, 'limit': typeof rawLimit === 'number' ? rawLimit : 8, 'lang': lang },
        });
      }
    }

    // ── wikipedia_summary (Wikipedia enrichment) ─────────────────────────────
    // Runs even without a toolPlan entry; uses state.terms as the query.
    // Skips only when terms is empty.
    if (state.terms.length > 0) {
      const query = ScoutUtils.pickWikipediaQuery(state.terms).trim();
      if (query.length > 0) {
        const lang = UserLanguage.normalize(state.userLanguage);
        worksets.push({
          'dagIri': 'urn:noocodec:tool:wikipedia_summary',
          'arguments': { 'query': query, 'lang': lang },
        });
      }
    }

      state.bookWorksets = worksets;
    }
    return RoutedBatch.create('ready', batch);
  }
}

export const buildBookWorksets = new BuildBookWorksetsNode();

MergeCandidatesNode: cross-source deduplication

Every tool produces candidates that merge through CanonicalId.dedupe in the runnable node. The same work indexed by OpenLibrary, Google Books, or Wikipedia folds into one shortlist entry before response composition:

ts
/**
 * mergeCandidates: cross-source dedupe, rank by score, keep top five.
 *
 * Uses `CanonicalId.dedupe` to collapse multi-source hits sharing the
 * same canonical id (ISBN-13 → ISBN-10 → work URN). A book seen by
 * both OpenLibrary and Google Books becomes one richer `Candidate` with
 * `notes.sources: ['web-search', 'google-books']`. Wikipedia enrichment
 * folds in the same way.
 *
 * After dedupe the shortlist is sorted by score and capped at five.
 *
 * Prior memory participates in the same pool as live candidates.
 * When both exist, the merge keeps the higher-scoring candidate for each
 * canonical id. If both pools are empty, the node routes 'empty'.
 *
 * Demonstrates: a routing decision based on state contents, and a
 * named output union narrower than the default `'success'`.
 */

import { Batch, MonadicNode, NodeOutput, RoutedBatch } from '@studnicky/dagonizer';
import type { ItemType, NodeContextType, SchemaObjectType } from '@studnicky/dagonizer';

import type { CandidateType } from '../entities/Book.ts';
import type { ArchivistState } from '../ArchivistState.ts';
import { UserLanguage } from '../language/UserLanguage.ts';
import { CanonicalId } from '@studnicky/dagonizer-book-entities';

const SHORTLIST_LIMIT = 8;

export class MergeCandidatesNode extends MonadicNode<ArchivistState, 'ranked' | 'empty'> {
  readonly name = 'merge-candidates';
  readonly '@id' = 'urn:noocodec:node:merge-candidates';
  readonly outputs = ['ranked', 'empty'] as const;
  override get outputSchema(): Record<'ranked' | 'empty', SchemaObjectType> {
    return {
      'ranked': { 'type': 'object' },
      'empty':  { 'type': 'object' },
    };
  }

  override async execute(batch: Batch<ArchivistState>, _context: NodeContextType) {
    const rankedItems: ItemType<ArchivistState>[] = [];
    const emptyItems: ItemType<ArchivistState>[] = [];

    for (const item of batch) {
      const { state } = item;
    const targetIso2 = UserLanguage.toIso6392(state.userLanguage);

    // ── Both pools empty → soft gate ──────────────────────────────────────
    if (state.candidates.length === 0 && state.priorCandidates.length === 0) {
      state.shortlist = [];
      if (state.failureCause.trim().length === 0) {
        state.failureCause = 'No candidates found after searching all available sources. ';
      }
      const result = NodeOutput.create('empty');
      for (const error of result.errors) state.collectError(error);
      emptyItems.push(item);
      continue;
    }

    // ── Build the combined pool ────────────────────────────────────────────
    let pool: readonly CandidateType[];

    if (state.candidates.length === 0) {
      // Prior memory only.
      pool = state.priorCandidates;
    } else if (state.priorCandidates.length === 0) {
      // Live candidates only.
      pool = state.candidates;
    } else {
      // Merge both pools and keep one entry per ISBN.
      const liveIsbns = new Set(state.candidates.map((c) => c.book.identity.isbn));
      const priorOnly = state.priorCandidates.filter((c) => !liveIsbns.has(c.book.identity.isbn));
      pool = [...state.candidates, ...priorOnly];
    }

    // #region merge-aggregation
    // Cross-source dedupe: collapses hits sharing the same canonical id,
    // accumulating notes.sources[] and keeping the richest fields.
    const deduped = CanonicalId.dedupe(pool);
    // Defensive language filter: scouts already filter, but a candidate
    // can land here from a stale checkpoint or a future source that
    // skipped the per-scout filter. Candidates without language metadata
    // pass through unchanged.
    const inLanguage = deduped.filter((c) => {
      const langs = c.book.publication.languages;
      if (langs.length === 0) return true;
      return langs.includes(targetIso2);
    });
    const ranked = [...inLanguage]
      .sort((a, b) => b.score - a.score)
      .slice(0, SHORTLIST_LIMIT);

    state.shortlist = ranked;
    if (ranked.length === 0 && state.failureCause.trim().length === 0) {
      state.failureCause = 'No candidates found after searching all available sources. ';
    }
      const result = NodeOutput.create(ranked.length > 0 ? 'ranked' : 'empty');
      for (const error of result.errors) state.collectError(error);
      if (result.output === 'ranked') {
        rankedItems.push(item);
      } else {
        emptyItems.push(item);
      }
    // #endregion merge-aggregation
    }

    const routes: Array<readonly ['ranked' | 'empty', Batch<ArchivistState>]> = [];
    if (rankedItems.length > 0) routes.push(['ranked', Batch.from(rankedItems)]);
    if (emptyItems.length > 0) routes.push(['empty', Batch.from(emptyItems)]);
    return RoutedBatch.create(routes);
  }
}

/** Singleton node instance referenced by the DAG wiring. */
export const mergeCandidates = new MergeCandidatesNode();

Details for Nerds

The tool boundary is a schema boundary, not a trust boundary by itself. You still validate arguments before execution, normalize identifiers across providers, and merge duplicate candidates by canonical ID. The schema narrows what the model can ask for; the DAG decides what actually happens.

What it demonstrates

  • additionalProperties: true. The schema lets the LLM pass extra OpenLibrary parameters (lang, first_publish_year) without a schema change. Strict mode on input validation would reject them; additionalProperties: true allows pass-through.
  • Shape-only examples. '<subject-or-theme>', '<plot-motif>' are descriptive placeholders. Never use real data in examples fields when the LLM will see the schema; it may copy them back verbatim into responses.
  • strict: true. Signals to the Gemini API that the tool definition should be treated as a strict JSON schema. The field is passed through to the model's function declaration.
  • CanonicalId.pick. Resolves ISBN-13, ISBN-10, then urn:work:<slug> in priority order. All four scouts call it so CanonicalId.dedupe in mergeCandidates can collapse cross-source duplicates by the same stable key.
  • CanonicalId.merge. When two candidates share the same canonical id, merge unions their authors, subjects, publishers, and sources[] arrays, keeping the richer description and higher score.

See this in action in the Archivist live demo.

Read these next when you want to connect model-facing schemas to DAG validation and runtime errors.

Watched over by the Order of Dagon.