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 Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Consolidate all AI/LLM code into com.getorcha.ai, add LangChain4j-powered agent loop that reuses existing tool definitions.

Architecture: Move workers.llm, prompt functions, and MCP tool registry+handlers into com.getorcha.ai.*. Add agent loop using raw ChatModel.chat(ChatRequest) with programmatic tool bridging via ToolSpecification + ToolExecutor. See docs/plans/2026-03-16-ai-module-design.md for full design.

Tech Stack: Clojure 1.12, LangChain4j 1.12.2 (anthropic + google-ai-gemini), Java interop via reify


Task 1: Move workers.llmai.llm

Pure namespace rename. Same API, all callers updated.

Files:

Step 1: Create ai/llm.clj

Copy src/com/getorcha/workers/llm.clj to src/com/getorcha/ai/llm.clj. Change the namespace declaration:

(ns com.getorcha.ai.llm
  "Shared LLM utilities with provider dispatch via multimethods.
  ...)

Everything else stays identical.

Step 2: Update all source callers

In each of these files, change the require from [com.getorcha.workers.llm :as llm] to [com.getorcha.ai.llm :as llm]:

Step 3: Update qualified keyword references

Two files reference the full namespace in error :kind keywords:

src/com/getorcha/workers/ap/matching/llm_decision.clj — change:

:com.getorcha.workers.llm/api-error       :com.getorcha.ai.llm/api-error
:com.getorcha.workers.llm/truncation      :com.getorcha.ai.llm/truncation
:com.getorcha.workers.llm/empty-response  :com.getorcha.ai.llm/empty-response
:com.getorcha.workers.llm/json-parse-error  :com.getorcha.ai.llm/json-parse-error

src/com/getorcha/workers/ap/matching/worker.clj — change:

:com.getorcha.workers.llm/api-error   :com.getorcha.ai.llm/api-error
:com.getorcha.workers.llm/truncation  :com.getorcha.ai.llm/truncation

Step 4: Update test files

Move test/com/getorcha/workers/llm_test.cljtest/com/getorcha/ai/llm_test.clj. Change ns to com.getorcha.ai.llm-test, update require.

Update requires in these test files (workers.llmai.llm):

Update qualified keyword references in test/com/getorcha/workers/ap/matching/llm_decision_test.clj:

:com.getorcha.workers.llm/api-error   :com.getorcha.ai.llm/api-error
:com.getorcha.workers.llm/truncation  :com.getorcha.ai.llm/truncation

Step 5: Delete old file

rm src/com/getorcha/workers/llm.clj
rm test/com/getorcha/workers/llm_test.clj

Step 6: Lint and run tests

clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.ai.llm-test]'

Step 7: Commit

git add src/com/getorcha/ai/llm.clj test/com/getorcha/ai/llm_test.clj
git add -u  # stages deletions and modifications
git commit -m "refactor: move workers.llm to ai.llm"

Task 2: Move prompt functions → ai.prompts

Move the prompt multimethod and template functions. The defmethod registrations scattered across worker files stay where they are — they just update their require alias.

Files:

Step 1: Create ai/prompts.clj

Copy src/com/getorcha/workers.clj to src/com/getorcha/ai/prompts.clj. Change namespace:

(ns com.getorcha.ai.prompts
  "Prompt registry and template rendering.

   The `-prompt` multimethod defines base prompt templates.
   Individual worker namespaces register their prompts via defmethod.
   Template substitution uses Apache Commons StringSubstitutor."
  (:require [com.getorcha.db.sql :as db.sql])
  (:import (java.util HashMap)
           (org.apache.commons.text StringSubstitutor)))

Functions prompt, legal-entity-prompt, and multimethod -prompt stay identical.

Step 2: Update all source callers

In each file, change [com.getorcha.workers :as workers] to [com.getorcha.ai.prompts :as ai.prompts], and update all call sites:

Files to update (12 source files):

Step 3: Update admin prompt customizations

src/com/getorcha/admin/http/tenants/prompt_customizations.clj:

Change require:

[com.getorcha.workers :as workers]    [com.getorcha.ai.prompts :as ai.prompts]

Update references:

The loading requires for prompt namespaces stay as-is — they still load from workers.ap.* because the defmethod registrations stay in those files. Only the multimethod they register against changed namespace.

Step 4: Update test file

test/com/getorcha/workers/ap/ingestion/transcription_test.clj:

[com.getorcha.workers :as workers]    [com.getorcha.ai.prompts :as ai.prompts]

Update any workers/ references to ai.prompts/.

Step 5: Delete old file

rm src/com/getorcha/workers.clj

Step 6: Lint and test

clj-kondo --lint src test dev
clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Ran .* tests)"

Step 7: Commit

git add src/com/getorcha/ai/prompts.clj
git add -u
git commit -m "refactor: move prompt functions to ai.prompts"

Task 3: Move tool registry → ai.tools

Move the -tool multimethod, list-tools, and init-registry! from link.mcp.tools to ai.tools. The MCP module becomes a thin adapter with scope checking and identity resolution.

Files:

Step 1: Create ai/tools.clj

(ns com.getorcha.ai.tools
  "AI tool registry using multimethods.

  Each tool is a `defmethod` of `-tool` dispatched on a qualified keyword.
  Tool namespaces define methods that return the full tool declaration:

  {:name        \"tool-name\"
   :description \"...\"
   :inputSchema {...}
   :handler     (fn [args context] ...)
   :scope       \"required:scope\"}"
  (:require [clojure.tools.logging :as log]))


(defmulti -tool
  "Returns the tool declaration for a given tool key.
  Dispatches on tool-key (qualified keyword).

  Returns:
  {:name        \"tool-name\"
   :description \"...\"
   :inputSchema {...}
   :handler     (fn [args context] ...)
   :scope       \"required:scope\"}"
  identity)


(defn list-tools
  "Returns all registered tool definitions.

  Returns sequence of maps with :name, :description, :inputSchema, :scope"
  []
  (->> (keys (methods -tool))
       (map -tool)
       (sort-by :name)))


(defn init-registry!
  "Initializes the tool registry by loading tool namespaces.

  Each namespace registers defmethod entries when loaded.
  Returns the number of registered tools."
  []
  (require 'com.getorcha.ai.tools.docs)
  (require 'com.getorcha.ai.tools.fpna)
  (require 'com.getorcha.ai.tools.master-data)
  (require 'com.getorcha.link.mcp.resources.fpna)
  (log/info "Tool registry initialized with" (count (methods -tool)) "tools")
  (count (methods -tool)))

Note: list-tools no longer does scope filtering — that's the MCP adapter's job. It returns all tools.

Step 2: Rewrite link/mcp/tools.clj as thin adapter

(ns com.getorcha.link.mcp.tools
  "MCP adapter over the AI tool registry.

  Adds MCP-specific concerns:
  - OAuth scope checking
  - Identity resolution (legal entity from OAuth claims)
  - JSON-RPC error formatting"
  (:require [clojure.string :as str]
            [clojure.tools.logging :as log]
            [com.getorcha.ai.tools :as ai.tools]
            [com.getorcha.link.mcp.identity :as mcp.identity]))


(defn ^:private has-scope?
  "Checks if the OAuth claims include the required scope."
  [oauth-claims required-scope]
  (when-let [scopes (:scope oauth-claims)]
    (let [scope-set (set (str/split scopes #"\s+"))]
      (contains? scope-set required-scope))))


(defn list-tools
  "Returns tools available to the client based on OAuth scopes."
  [oauth-claims]
  (->> (ai.tools/list-tools)
       (filter #(has-scope? oauth-claims (:scope %)))
       (map #(select-keys % [:name :description :inputSchema]))))


(defn call-tool
  "Executes a tool by name with identity resolution and scope checking."
  [name args context]
  (if-let [tool (->> (ai.tools/list-tools)
                     (some (fn [t] (when (= name (:name t)) t))))]
    (if (has-scope? (:oauth-claims context) (:scope tool))
      (try
        ;; Resolve legal entity for tools that need it
        (let [legal-entity-id (when (:legal_entity_id args)
                                (mcp.identity/resolve-legal-entity-from-args
                                 (:legal_entity_id args) (:legal-entity-ids context)))
              ;; If resolution returned an error map, return it
              tool-context (cond-> context
                             (uuid? legal-entity-id) (assoc :legal-entity-id legal-entity-id))]
          (if (and (map? legal-entity-id) (:isError legal-entity-id))
            {:ok legal-entity-id}
            {:ok ((:handler tool) args tool-context)}))
        (catch Exception e
          (log/error e "Tool execution failed:" name)
          {:error {:code    -32603
                   :message (str "Tool execution failed: " (.getMessage e))}}))
      {:error {:code    -32602
               :message (str "Missing required scope: " (:scope tool))}})
    {:error {:code    -32601
             :message (str "Unknown tool: " name)}}))

Step 3: Update link/http.clj

Line 17: Change require and init call:

[com.getorcha.link.mcp.tools :as mcp.tools]    [com.getorcha.ai.tools :as ai.tools]

Line 117: (mcp.tools/init-registry!)(ai.tools/init-registry!)

Keep mcp.tools required if mcp.http still imports it for list-tools/call-tool — check link/mcp/http.clj. It requires [com.getorcha.link.mcp.tools :as tools] and calls tools/list-tools and tools/call-tool — those stay, just the init moves.

So link/http.clj changes:

Step 4: Update test file

test/com/getorcha/link/mcp_test.clj line 16: [com.getorcha.link.mcp.tools :as mcp.tools] — check if it calls init-registry! or just uses list-tools/call-tool. If it calls init-registry!, update to ai.tools. If it only uses list-tools/call-tool via the MCP adapter, keep as-is.

Step 5: Lint and test

clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.link.mcp-test]'

Step 6: Commit

git add src/com/getorcha/ai/tools.clj
git add -u
git commit -m "refactor: move tool registry to ai.tools, MCP becomes adapter"

Task 4: Move tool handlers → ai.tools.*

Move all handler files and tool registrations. Refactor FP&A + master-data handlers to take resolved :legal-entity-id instead of calling mcp.identity internally. Docs handlers keep using :legal-entity-ids set since they query across all accessible entities.

Files to move (namespace rename + handler refactoring):

From To
src/com/getorcha/link/mcp/tools/fpna.clj src/com/getorcha/ai/tools/fpna.clj
src/com/getorcha/link/mcp/tools/fpna/data_map.clj src/com/getorcha/ai/tools/fpna/data_map.clj
src/com/getorcha/link/mcp/tools/fpna/list_files.clj src/com/getorcha/ai/tools/fpna/list_files.clj
src/com/getorcha/link/mcp/tools/fpna/excel.clj src/com/getorcha/ai/tools/fpna/excel.clj
src/com/getorcha/link/mcp/tools/fpna/excel/sandbox.clj src/com/getorcha/ai/tools/fpna/excel/sandbox.clj
src/com/getorcha/link/mcp/tools/fpna/excel/functions.clj src/com/getorcha/ai/tools/fpna/excel/functions.clj
src/com/getorcha/link/mcp/tools/fpna/save_dm.clj src/com/getorcha/ai/tools/fpna/save_dm.clj
src/com/getorcha/link/mcp/tools/docs.clj src/com/getorcha/ai/tools/docs.clj
src/com/getorcha/link/mcp/tools/docs/get.clj src/com/getorcha/ai/tools/docs/get.clj
src/com/getorcha/link/mcp/tools/docs/list.clj src/com/getorcha/ai/tools/docs/list.clj
src/com/getorcha/link/mcp/tools/docs/search.clj src/com/getorcha/ai/tools/docs/search.clj
src/com/getorcha/link/mcp/tools/docs/line_items.clj src/com/getorcha/ai/tools/docs/line_items.clj
src/com/getorcha/link/mcp/tools/master_data.clj src/com/getorcha/ai/tools/master_data.clj

Step 1: Create directory structure

mkdir -p src/com/getorcha/ai/tools/fpna/excel
mkdir -p src/com/getorcha/ai/tools/docs

Step 2: Move and refactor FP&A tool registrations

src/com/getorcha/ai/tools/fpna.clj — update ns and requires:

(ns com.getorcha.ai.tools.fpna
  "Tool registrations for FP&A tools."
  (:require [com.getorcha.ai.tools :as tools]
            [com.getorcha.ai.tools.fpna.data-map :as fpna.data-map]
            [com.getorcha.ai.tools.fpna.excel :as fpna.excel]
            [com.getorcha.ai.tools.fpna.list-files :as fpna.list-files]
            [com.getorcha.ai.tools.fpna.save-dm :as fpna.save-dm]))

Change all tools/-tool references to use the ai.tools multimethod.

Step 3: Refactor FP&A handlers

For each FP&A handler, remove the mcp.identity dependency and change the context destructuring:

Pattern — before (e.g., data_map.clj):

(defn handle-fpna-data-map
  [args {:keys [db-pool legal-entity-ids] :as _context}]
  (let [resolved (mcp.identity/resolve-legal-entity-from-args (:legal_entity_id args) legal-entity-ids)]
    (if (map? resolved)
      resolved
      ;; ... use resolved as the legal entity ID
      )))

Pattern — after:

(defn handle-fpna-data-map
  [args {:keys [db-pool legal-entity-id] :as _context}]
  ;; legal-entity-id is already resolved by the caller (MCP adapter or agent)
  (let [legal-entity-id (or legal-entity-id
                            (some-> (:legal_entity_id args) parse-uuid))]
    ;; ... use legal-entity-id directly
    ))

Remove the com.getorcha.link.mcp.identity require from each handler file.

Apply this pattern to:

Step 4: Move Excel sandbox and functions

These files have no MCP dependencies — just update namespace declarations:

Update excel.clj to require from new sandbox path.

Step 5: Move and update docs tools

Docs tool registrations (docs.clj):

(ns com.getorcha.ai.tools.docs
  "Tool registrations for document tools."
  (:require [com.getorcha.ai.tools :as tools]
            [com.getorcha.ai.tools.docs.get :as docs.get]
            [com.getorcha.ai.tools.docs.line-items :as docs.line-items]
            [com.getorcha.ai.tools.docs.list :as docs.list]
            [com.getorcha.ai.tools.docs.search :as docs.search]))

Docs handlers (get, list, search, line-items): update namespace only. These handlers take legal-entity-ids (set) and pass it to query functions — no refactoring needed, just rename the namespace and update the require for link.queries.documents (which stays at its current location).

Step 6: Move and refactor master-data

src/com/getorcha/ai/tools/master_data.clj:

Step 7: Update MCP adapter (link/mcp/tools.clj)

The call-tool function in the MCP adapter (written in Task 3) handles identity resolution before calling handlers. Verify it correctly passes :legal-entity-id for FP&A/master-data tools and :legal-entity-ids for docs/legal-entities tools.

The MCP adapter needs to resolve the legal entity from args for tools that accept legal_entity_id, and for tools that don't (docs), it passes the full legal-entity-ids set from the OAuth context.

Step 8: Delete old tool files

rm -r src/com/getorcha/link/mcp/tools/fpna
rm -r src/com/getorcha/link/mcp/tools/docs
rm src/com/getorcha/link/mcp/tools/master_data.clj

Keep src/com/getorcha/link/mcp/tools.clj — it's the MCP adapter now.

Step 9: Update ai/tools.clj init-registry!

Verify the init-registry! requires point to the new locations:

(require 'com.getorcha.ai.tools.docs)
(require 'com.getorcha.ai.tools.fpna)
(require 'com.getorcha.ai.tools.master-data)

Step 10: Lint and test

clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.link.mcp-test]'

The existing MCP integration tests should verify the adapter still works end-to-end.

Step 11: Commit

git add src/com/getorcha/ai/tools/
git add -u
git commit -m "refactor: move tool handlers to ai.tools, MCP wraps with identity resolution"

Task 5: Add LangChain4j dependencies

Files:

Step 1: Add dependencies

Add to the :deps map in 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"}

Step 2: Verify resolution

clj -Stree 2>&1 | grep langchain4j | head -5

Expected: shows langchain4j and its transitive deps resolving.

Step 3: Commit

git add deps.edn
git commit -m "deps: add LangChain4j for agent loop"

Task 6: Implement tool bridging (ai.agent.interop)

Converts the existing -tool multimethod definitions to LangChain4j ToolSpecification + ToolExecutor pairs.

Files:

Step 1: Write the test

(ns com.getorcha.ai.agent.interop-test
  (:require [clojure.test :refer [deftest is testing use-fixtures]]
            [com.getorcha.ai.agent.interop :as interop]
            [com.getorcha.ai.tools :as ai.tools]
            [com.getorcha.test.fixtures :as fixtures])
  (:import (dev.langchain4j.agent.tool ToolSpecification)))


(use-fixtures :once fixtures/with-running-system)


(deftest build-tool-map-test
  (testing "converts all registered tools to LC4j format"
    (ai.tools/init-registry!)
    (let [context         {:db-pool (:com.getorcha.db/pool fixtures/*system*)}
          legal-entity-id (random-uuid)
          tool-map        (interop/build-tool-map context legal-entity-id)]
      (is (pos? (count tool-map)) "should have at least one tool")

      (testing "each key is a ToolSpecification"
        (doseq [spec (keys tool-map)]
          (is (instance? ToolSpecification spec))
          (is (string? (.name spec)))
          (is (string? (.description spec)))))

      (testing "tool names match registered tools"
        (let [spec-names  (set (map #(.name %) (keys tool-map)))
              tool-names  (set (map :name (ai.tools/list-tools)))]
          (is (= tool-names spec-names))))

      (testing "each value is a ToolExecutor (callable)"
        (doseq [[spec executor] tool-map]
          (is (some? executor) (str "executor missing for " (.name spec))))))))

Step 2: Run test to verify it fails

clj -X:test:silent :nses '[com.getorcha.ai.agent.interop-test]'

Expected: FAIL — namespace not found.

Step 3: Implement ai.agent.interop

(ns com.getorcha.ai.agent.interop
  "Bridges AI tool definitions to LangChain4j ToolSpecification + ToolExecutor format."
  (:require [cheshire.core :as json]
            [clojure.tools.logging :as log]
            [com.getorcha.ai.tools :as ai.tools])
  (:import (dev.langchain4j.agent.tool ToolSpecification)
           (dev.langchain4j.model.chat.request.json JsonArraySchema
                                                    JsonBooleanSchema
                                                    JsonEnumSchema
                                                    JsonIntegerSchema
                                                    JsonNumberSchema
                                                    JsonObjectSchema
                                                    JsonStringSchema)
           (dev.langchain4j.internal Json)
           (dev.langchain4j.service.tool ToolExecutor)))


(set! *warn-on-reflection* true)


(defn ^:private json-type->schema
  "Converts a single JSON Schema property to its LC4j JsonSchemaElement."
  [{:keys [type enum items description] :as _property}]
  (case type
    "string"  (if enum
                (-> (JsonEnumSchema/builder)
                    (.description description)
                    (.enumValues enum)
                    (.build))
                (-> (JsonStringSchema/builder)
                    (.description description)
                    (.build)))
    "integer" (-> (JsonIntegerSchema/builder)
                  (.description description)
                  (.build))
    "number"  (-> (JsonNumberSchema/builder)
                  (.description description)
                  (.build))
    "boolean" (-> (JsonBooleanSchema/builder)
                  (.description description)
                  (.build))
    "array"   (-> (JsonArraySchema/builder)
                  (.description description)
                  (.items (json-type->schema items))
                  (.build))
    ;; Default to string for unknown types
    (-> (JsonStringSchema/builder)
        (.description description)
        (.build))))


(defn ^:private input-schema->json-object-schema
  "Converts a tool's :inputSchema (JSON Schema map) to LC4j JsonObjectSchema."
  ^JsonObjectSchema [{:keys [properties required]}]
  (let [builder (JsonObjectSchema/builder)]
    (doseq [[prop-name prop-schema] properties]
      (.addProperty builder (name prop-name) (json-type->schema prop-schema)))
    (when (seq required)
      (.required builder ^java.util.List (mapv name required)))
    (.build builder)))


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

   Returns a java.util.LinkedHashMap of {ToolSpecification → ToolExecutor}
   with each executor calling the tool handler with the provided context."
  [context legal-entity-id]
  (let [tools    (ai.tools/list-tools)
        tool-ctx (assoc context
                        :legal-entity-id  legal-entity-id
                        :legal-entity-ids #{legal-entity-id})
        result   (java.util.LinkedHashMap.)]
    (doseq [{:keys [name description inputSchema handler]} tools]
      (let [spec     (-> (ToolSpecification/builder)
                         (.name name)
                         (.description description)
                         (.parameters (input-schema->json-object-schema inputSchema))
                         (.build))
            executor (reify ToolExecutor
                       (execute [_ request _memory-id]
                         (let [args (into {} (Json/fromJson (.arguments request) java.util.Map))]
                           (log/info "Agent tool call:" name args)
                           (try
                             (let [result (handler args tool-ctx)]
                               (json/generate-string result))
                             (catch Exception e
                               (log/error e "Agent tool execution failed:" name)
                               (json/generate-string
                                {:isError true
                                 :content [{:type "text"
                                            :text (str "Error: " (.getMessage e))}]}))))))]
        (.put result spec executor)))
    result))

Step 4: Run test to verify it passes

clj -X:test:silent :nses '[com.getorcha.ai.agent.interop-test]'

Step 5: Commit

git add src/com/getorcha/ai/agent/interop.clj test/com/getorcha/ai/agent/interop_test.clj
git commit -m "feat: add LC4j tool bridging for agent loop"

Task 7: Implement agent loop (ai.agent)

The core agent loop using ChatModel.chat(ChatRequest) directly.

Files:

Step 1: Write the integration test

(ns com.getorcha.ai.agent-test
  "Integration tests for the agent loop.

   Uses a mock ChatModel that returns scripted responses
   to verify the loop mechanics without hitting real LLM APIs."
  (:require [clojure.test :refer [deftest is testing]]
            [com.getorcha.ai.agent :as agent])
  (:import (dev.langchain4j.data.message AiMessage ToolExecutionResultMessage UserMessage)
           (dev.langchain4j.agent.tool ToolExecutionRequest)
           (dev.langchain4j.model.chat ChatModel)
           (dev.langchain4j.model.chat.request ChatRequest)
           (dev.langchain4j.model.chat.response ChatResponse)
           (dev.langchain4j.model.output FinishReason TokenUsage)))


(defn ^:private mock-chat-model
  "Creates a ChatModel that returns scripted responses.

   responses is a vector of fns, each taking a ChatRequest and returning a ChatResponse.
   Calls are served in order."
  [responses]
  (let [call-count (atom 0)]
    (reify ChatModel
      (chat [_ ^ChatRequest request]
        (let [idx  (dec (swap! call-count inc))
              resp-fn (get responses idx)]
          (if resp-fn
            (resp-fn request)
            (throw (ex-info "Mock exhausted" {:calls @call-count}))))))))


(defn ^:private tool-call-response
  "Builds a ChatResponse with tool execution requests."
  [tool-calls]
  (let [requests (mapv (fn [{:keys [id name arguments]}]
                         (ToolExecutionRequest/builder)
                         (-> (ToolExecutionRequest/builder)
                             (.id (or id (str (random-uuid))))
                             (.name name)
                             (.arguments arguments)
                             (.build)))
                       tool-calls)
        ai-msg   (AiMessage. requests)]
    (-> (ChatResponse/builder)
        (.aiMessage ai-msg)
        (.tokenUsage (TokenUsage. 100 50))
        (.finishReason FinishReason/TOOL_EXECUTION)
        (.build))))


(defn ^:private text-response
  "Builds a ChatResponse with a final text answer."
  [text]
  (-> (ChatResponse/builder)
      (.aiMessage (AiMessage. ^String text))
      (.tokenUsage (TokenUsage. 200 100))
      (.finishReason FinishReason/STOP)
      (.build)))


(deftest agent-loop-direct-answer-test
  (testing "model responds without tool calls"
    (let [model  (mock-chat-model [(fn [_] (text-response "The answer is 42."))])
          result (agent/run-with-model model [] {}
                                       {:prompt "What is the answer?"})]
      (is (= "The answer is 42." (:text result)))
      (is (= 0 (:iterations result)))
      (is (empty? (:tool-calls result))))))


(deftest agent-loop-with-tool-calls-test
  (testing "model calls a tool then responds"
    (let [;; Simple echo tool
          echo-executor (fn [request _memory-id]
                          (str "Echo: " (.arguments request)))
          tool-spec     (-> (dev.langchain4j.agent.tool.ToolSpecification/builder)
                            (.name "echo")
                            (.description "Echoes input")
                            (.build))
          tool-map      (doto (java.util.LinkedHashMap.)
                          (.put tool-spec
                                (reify dev.langchain4j.service.tool.ToolExecutor
                                  (execute [_ request memory-id]
                                    (echo-executor request memory-id)))))
          model         (mock-chat-model
                         [(fn [_]
                            (tool-call-response [{:name "echo" :arguments "{\"msg\":\"hello\"}"}]))
                          (fn [_]
                            (text-response "Tool said: Echo: {\"msg\":\"hello\"}"))])
          result        (agent/run-with-model model tool-map {}
                                              {:prompt "Call echo"})]
      (is (= "Tool said: Echo: {\"msg\":\"hello\"}" (:text result)))
      (is (= 1 (:iterations result)))
      (is (= 1 (count (:tool-calls result))))
      (is (= "echo" (:name (first (:tool-calls result))))))))


(deftest agent-loop-max-iterations-test
  (testing "loop terminates at max-iterations"
    (let [model  (mock-chat-model
                  (vec (repeat 20
                         (fn [_] (tool-call-response
                                  [{:name "echo" :arguments "{}"}])))))
          tool-spec (-> (dev.langchain4j.agent.tool.ToolSpecification/builder)
                        (.name "echo")
                        (.description "Echoes")
                        (.build))
          tool-map  (doto (java.util.LinkedHashMap.)
                      (.put tool-spec
                            (reify dev.langchain4j.service.tool.ToolExecutor
                              (execute [_ _ _] "ok"))))
          result    (agent/run-with-model model tool-map {}
                                          {:prompt "Loop forever"
                                           :max-iterations 3})]
      (is (= 3 (:iterations result)))
      (is (<= (count (:tool-calls result)) 3)))))

Step 2: Run test to verify it fails

clj -X:test:silent :nses '[com.getorcha.ai.agent-test]'

Step 3: Implement ai.agent

(ns com.getorcha.ai.agent
  "Agent loop using LangChain4j ChatModel.

   Sends a prompt with tools to an LLM, executes tool calls,
   feeds results back, and loops until the model returns a final answer
   or max-iterations is reached."
  (:require [clojure.tools.logging :as log]
            [com.getorcha.ai.agent.interop :as interop]
            [com.getorcha.ai.tools :as ai.tools])
  (:import (dev.langchain4j.data.message AiMessage SystemMessage
                                         ToolExecutionResultMessage UserMessage)
           (dev.langchain4j.model.anthropic AnthropicChatModel)
           (dev.langchain4j.model.chat ChatModel)
           (dev.langchain4j.model.chat.request ChatRequest)
           (dev.langchain4j.model.chat.response ChatResponse)
           (dev.langchain4j.model.googleai GoogleAiGeminiChatModel)
           (dev.langchain4j.model.output TokenUsage)
           (dev.langchain4j.service.tool ToolExecutor)))


(set! *warn-on-reflection* true)


(def ^:private default-max-iterations 10)


(defn ^:private make-chat-model
  "Creates a ChatModel from provider config."
  ^ChatModel [{:keys [provider model api-key max-tokens]}]
  (case provider
    :anthropic (-> (AnthropicChatModel/builder)
                   (.apiKey api-key)
                   (.modelName model)
                   (.maxTokens (int (or max-tokens 32768)))
                   (.temperature (double 0.0))
                   (.build))
    :google    (-> (GoogleAiGeminiChatModel/builder)
                   (.apiKey api-key)
                   (.modelName model)
                   (.build))
    (throw (ex-info (str "Unknown provider: " provider) {:provider provider}))))


(defn ^:private find-executor
  "Finds the ToolExecutor for a tool name in the tool map."
  ^ToolExecutor [tool-map tool-name]
  (some (fn [[spec executor]]
          (when (= tool-name (.name spec))
            executor))
        tool-map))


(defn ^:private add-token-usage
  "Adds token usage from a response to accumulator."
  [{:keys [input-tokens output-tokens]} ^TokenUsage usage]
  (if usage
    {:input-tokens  (+ (or input-tokens 0) (.inputTokenCount usage))
     :output-tokens (+ (or output-tokens 0) (.outputTokenCount usage))}
    {:input-tokens  (or input-tokens 0)
     :output-tokens (or output-tokens 0)}))


(defn run-with-model
  "Runs the agent loop with an already-constructed ChatModel and tool map.

   This is the testable core — `run` is the public API that constructs
   the model and tools from config.

   Arguments:
   - chat-model:   ChatModel instance
   - tool-map:     java.util.Map of {ToolSpecification → ToolExecutor}
   - context:      execution context (passed for logging/tracing)
   - model-config: {:prompt \"...\" :system \"...\" :max-iterations N}"
  [^ChatModel chat-model tool-map context
   {:keys [prompt system max-iterations]
    :or   {max-iterations default-max-iterations}}]
  (let [tool-specs (when (seq tool-map)
                     (java.util.ArrayList. (keys tool-map)))
        messages   (cond-> []
                     system (conj (SystemMessage. ^String system))
                     true   (conj (UserMessage. ^String prompt)))]
    (loop [messages   messages
           iteration  0
           tool-calls []
           usage      {:input-tokens 0 :output-tokens 0}]
      (let [request  (cond-> (ChatRequest/builder)
                       true       (.messages (java.util.ArrayList. messages))
                       tool-specs (.toolSpecifications tool-specs)
                       true       (.build))
            ^ChatResponse response (.chat chat-model request)
            ^AiMessage ai-msg     (.aiMessage response)
            usage    (add-token-usage usage (.tokenUsage response))]
        (if (and (.hasToolExecutionRequests ai-msg)
                 (< iteration max-iterations))
          ;; Execute tools and continue
          (let [requests   (.toolExecutionRequests ai-msg)
                results    (mapv (fn [req]
                                  (let [tool-name (.name req)
                                        executor  (find-executor tool-map tool-name)]
                                    (if executor
                                      (let [result (.execute executor req nil)]
                                        (log/info "Tool" tool-name "executed"
                                                  {:iteration iteration
                                                   :result-length (count result)})
                                        {:name   tool-name
                                         :args   (.arguments req)
                                         :result result
                                         :msg    (ToolExecutionResultMessage/from req result)})
                                      (do
                                        (log/warn "Unknown tool requested:" tool-name)
                                        {:name   tool-name
                                         :args   (.arguments req)
                                         :result (str "Error: unknown tool " tool-name)
                                         :msg    (ToolExecutionResultMessage/from
                                                  req (str "Error: unknown tool " tool-name))}))))
                                requests)
                new-msgs   (into messages
                                 (cons ai-msg (map :msg results)))
                new-calls  (into tool-calls
                                 (map #(select-keys % [:name :args :result]) results))]
            (recur new-msgs (inc iteration) new-calls usage))
          ;; Final response
          {:text       (.text ai-msg)
           :iterations iteration
           :tool-calls tool-calls
           :usage      usage})))))


(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...\"
                     :max-iterations 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.tools/init-registry!)
  (let [chat-model      (make-chat-model model-config)
        legal-entity-id (:legal-entity/id legal-entity)
        tool-map        (interop/build-tool-map context legal-entity-id)]
    (run-with-model chat-model tool-map context model-config)))

Step 4: Run tests

clj -X:test:silent :nses '[com.getorcha.ai.agent-test]'

Step 5: Commit

git add src/com/getorcha/ai/agent.clj test/com/getorcha/ai/agent_test.clj
git commit -m "feat: add agent loop with LangChain4j ChatModel"

Task 8: Final verification

Step 1: Full lint

clj-kondo --lint src test dev

Fix any issues.

Step 2: Full test suite

clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Ran .* tests)"

All tests should pass.

Step 3: Verify no stale references

# Should return nothing:
grep -r "com.getorcha.workers.llm" src/ test/ --include="*.clj"
grep -r "com.getorcha.workers :as" src/ test/ --include="*.clj"
grep -r "com.getorcha.link.mcp.tools.fpna" src/ test/ --include="*.clj"
grep -r "com.getorcha.link.mcp.tools.docs" src/ test/ --include="*.clj"
grep -r "com.getorcha.link.mcp.tools.master" src/ test/ --include="*.clj"

Step 4: Commit any fixes

git add -u
git commit -m "fix: resolve lint and stale reference issues"