Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

AI Module Design: com.getorcha.ai

Goal

Consolidate all AI/LLM code into com.getorcha.ai, add an agent loop powered by LangChain4j that reuses existing tool definitions to answer FP&A queries against legal entity file stores.

Package Structure

com.getorcha.ai
├── llm.clj                          ;; moved from workers.llm (same API)
├── prompts.clj                      ;; moved from workers (-prompt, prompt, legal-entity-prompt)
├── agent.clj                        ;; NEW: agent loop using raw ChatModel.chat(ChatRequest)
├── agent/
│   └── interop.clj                  ;; NEW: bridge tool defs → LC4j ToolSpecification + executors
└── tools.clj                        ;; moved from link.mcp.tools (-tool multimethod, init-registry!)
    tools/
    ├── fpna.clj                     ;; defmethod registrations
    ├── fpna/
    │   ├── data_map.clj             ;; handlers (resolved legal-entity-id in context)
    │   ├── list_files.clj
    │   ├── excel.clj
    │   ├── excel/
    │   │   ├── sandbox.clj
    │   │   └── functions.clj
    │   └── save_dm.clj
    ├── docs.clj                     ;; defmethod registrations
    │   docs/
    │   ├── get.clj
    │   ├── list.clj
    │   ├── search.clj
    │   └── line_items.clj
    └── master_data.clj

Key Decisions

1. LangChain4j for the Agent Loop Only

LC4j is used exclusively for the new agent loop (ai.agent). The existing ai.llm/generate HTTP-based path is untouched — the 12+ worker pipeline callers keep using it. Two separate LLM interaction paths coexist without interference.

Dependencies added to deps.edn:

dev.langchain4j/langchain4j                  {:mvn/version "1.12.2"}
dev.langchain4j/langchain4j-anthropic         {:mvn/version "1.12.2"}
dev.langchain4j/langchain4j-google-ai-gemini  {:mvn/version "1.12.2"}

2. Raw ChatModel Loop (Not AiServices)

The agent loop uses ChatModel.chat(ChatRequest) directly rather than AiServices with definterface. This gives full control over:

The loop is ~30 lines of Clojure. AiServices would save ~10 lines but hide the iteration.

3. Programmatic Tool Bridging (No Annotations)

Tools are bridged from the existing -tool multimethod to LC4j format programmatically:

No @Tool annotations, no gen-class, no Java source files. Pure Clojure interop.

Handlers currently receive {:legal-entity-ids #{uuid1 uuid2 ...}} in context and call mcp.identity/resolve-legal-entity-from-args internally to pick one.

After the move to ai.tools.*, handlers receive {:legal-entity-id uuid} — a single resolved UUID. Identity resolution becomes the MCP adapter's responsibility:

MCP request → identity resolution → handler(args, {:legal-entity-id resolved-id ...})
Agent call  → already resolved    → handler(args, {:legal-entity-id known-id ...})

5. Tool Registry Ownership

The -tool multimethod and init-registry! move from link.mcp.tools to ai.tools. MCP becomes a transport adapter that reads from the ai registry and adds:

Architecture

graph TB
    subgraph "com.getorcha.ai"
        AI_LLM[ai.llm<br/>generate, parse-json-response]
        AI_PROMPTS[ai.prompts<br/>-prompt, prompt, legal-entity-prompt]
        AI_TOOLS[ai.tools<br/>-tool multimethod, init-registry!]
        AI_AGENT[ai.agent<br/>run — agent loop]
        AI_INTEROP[ai.agent.interop<br/>build-tool-map]

        subgraph "ai.tools.*"
            FPNA_HANDLERS[fpna handlers<br/>data-map, list-files, excel, save-dm]
            DOCS_HANDLERS[docs handlers<br/>get, list, search, line-items]
            MD_HANDLER[master-data handler]
        end
    end

    subgraph "com.getorcha.link.mcp"
        MCP_TOOLS[mcp.tools<br/>thin adapter: scope check + identity resolution]
        MCP_HTTP[mcp.http<br/>JSON-RPC transport]
        MCP_IDENTITY[mcp.identity<br/>resolve-legal-entity-from-args]
    end

    subgraph "com.getorcha.workers.ap.*"
        WORKERS[12+ pipeline modules<br/>transcription, classification, extraction, ...]
    end

    AI_AGENT --> AI_INTEROP
    AI_INTEROP --> AI_TOOLS
    AI_INTEROP -->|reify ToolExecutor| FPNA_HANDLERS
    AI_INTEROP -->|reify ToolExecutor| DOCS_HANDLERS
    AI_INTEROP -->|reify ToolExecutor| MD_HANDLER
    AI_AGENT -->|ChatModel.chat| LC4J[LangChain4j ChatModel<br/>Anthropic / Gemini]

    MCP_HTTP --> MCP_TOOLS
    MCP_TOOLS --> AI_TOOLS
    MCP_TOOLS -->|wraps with identity resolution| MCP_IDENTITY
    MCP_TOOLS --> FPNA_HANDLERS
    MCP_TOOLS --> DOCS_HANDLERS
    MCP_TOOLS --> MD_HANDLER

    WORKERS --> AI_LLM
    WORKERS --> AI_PROMPTS

    FPNA_HANDLERS --> FILE_STORE[link.mcp.file-store]

Agent Loop Flow

sequenceDiagram
    participant Caller
    participant ai.agent
    participant ChatModel
    participant ai.agent.interop
    participant ai.tools.*

    Caller->>ai.agent: run(context, legal-entity, model-config)
    ai.agent->>ai.agent.interop: build-tool-map(context, legal-entity-id)
    ai.agent.interop-->>ai.agent: {ToolSpecification → ToolExecutor}

    ai.agent->>ChatModel: chat(ChatRequest[system + prompt + tool-specs])
    loop until no tool calls or max-iterations
        ChatModel-->>ai.agent: ChatResponse with ToolExecutionRequests
        ai.agent->>ai.agent.interop: execute each tool via ToolExecutor
        ai.agent.interop->>ai.tools.*: handler(args, context)
        ai.tools.*-->>ai.agent.interop: result string
        ai.agent->>ChatModel: chat(ChatRequest[history + ToolExecutionResultMessages])
    end
    ChatModel-->>ai.agent: final text response
    ai.agent-->>Caller: {:text "..." :iterations N :tool-calls [...] :usage {...}}

Function Signatures

ai.agent/run

(defn run
  "Runs an agent loop with tool access against a legal entity's data.

   Arguments:
   - context:      System context {:db-pool pool :s3-client s3 ...}
   - legal-entity: Legal entity map {:legal-entity/id uuid ...}
   - model-config: {:provider :anthropic|:google
                     :model    \"claude-sonnet-4-20250514\"
                     :api-key  \"...\"
                     :prompt   \"What were the top cost centers by spend?\"
                     :system   \"You are a financial analyst...\"  ;; optional
                     :max-iterations 10}                           ;; optional, default 10

   Returns:
   {:text       \"The top 5 cost centers were...\"
    :iterations 3
    :tool-calls [{:name \"orcha-fpna-excel\" :args {...} :result \"...\"}]
    :usage      {:input-tokens N :output-tokens N}}"
  [context legal-entity model-config]
  ...)

ai.agent.interop/build-tool-map

(defn build-tool-map
  "Converts registered ai.tools/-tool definitions to LC4j format.

   Returns a java.util.Map of {ToolSpecification → ToolExecutor}
   with each executor calling the tool handler with the resolved
   legal-entity-id in context."
  [context legal-entity-id]
  ...)

Files That Move

From To
com.getorcha.workers.llm com.getorcha.ai.llm
com.getorcha.workers/{-prompt,prompt,legal-entity-prompt} com.getorcha.ai.prompts
com.getorcha.link.mcp.tools (multimethod + registry) com.getorcha.ai.tools
com.getorcha.link.mcp.tools.fpna com.getorcha.ai.tools.fpna
com.getorcha.link.mcp.tools.fpna.* (handlers + excel sandbox) com.getorcha.ai.tools.fpna.*
com.getorcha.link.mcp.tools.docs com.getorcha.ai.tools.docs
com.getorcha.link.mcp.tools.docs.* com.getorcha.ai.tools.docs.*
com.getorcha.link.mcp.tools.master-data com.getorcha.ai.tools.master-data

Files That Update Requires

Files That Stay Unchanged

Testing

Integration tests that maximize coverage without exhaustive edge cases:

Out of Scope