Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
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
workers.llm → ai.llmPure namespace rename. Same API, all callers updated.
Files:
src/com/getorcha/ai/llm.clj (copy of src/com/getorcha/workers/llm.clj, change ns)src/com/getorcha/workers/llm.cljtest/com/getorcha/ai/llm_test.clj (move from test/com/getorcha/workers/llm_test.clj)test/com/getorcha/workers/llm_test.cljStep 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]:
src/com/getorcha/workers/ap/supplier_verification.cljsrc/com/getorcha/workers/ap/acquisition/email/triage.cljsrc/com/getorcha/workers/ap/matching/reconciliation.cljsrc/com/getorcha/workers/ap/matching/llm_decision.cljsrc/com/getorcha/workers/ap/ingestion/classification.cljsrc/com/getorcha/workers/ap/ingestion/extraction.cljsrc/com/getorcha/workers/ap/ingestion/post_process/accounts.cljsrc/com/getorcha/workers/ap/ingestion/post_process/cost_center.cljsrc/com/getorcha/workers/ap/ingestion/post_process/uncertain_validations.cljsrc/com/getorcha/workers/ap/ingestion/post_process/tax_compliance.cljsrc/com/getorcha/workers/ap/ingestion/post_process/financial_validation.cljsrc/com/getorcha/workers/ap/ingestion/post_process/accruals.cljStep 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.clj → test/com/getorcha/ai/llm_test.clj. Change ns to com.getorcha.ai.llm-test, update require.
Update requires in these test files (workers.llm → ai.llm):
test/com/getorcha/workers/ap/acquisition/triage_test.cljtest/com/getorcha/workers/ap/ingestion_test.cljtest/com/getorcha/workers/ap/matching/reconciliation_test.cljtest/com/getorcha/workers/ap/matching/llm_decision_test.cljtest/com/getorcha/workers/ap/ingestion/extraction_test.cljUpdate 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"
ai.promptsMove the prompt multimethod and template functions. The defmethod registrations scattered across worker files stay where they are — they just update their require alias.
Files:
src/com/getorcha/ai/prompts.cljsrc/com/getorcha/workers.clj (contains only prompt functions)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:
workers/-prompt → ai.prompts/-prompt (in defmethod declarations)workers/prompt → ai.prompts/promptworkers/legal-entity-prompt → ai.prompts/legal-entity-promptFiles to update (12 source files):
src/com/getorcha/workers/ap/matching/llm_decision.cljsrc/com/getorcha/workers/ap/matching/reconciliation.cljsrc/com/getorcha/workers/ap/acquisition/email/triage.cljsrc/com/getorcha/workers/ap/ingestion/extraction.cljsrc/com/getorcha/workers/ap/ingestion/transcription.cljsrc/com/getorcha/workers/ap/ingestion/classification.cljsrc/com/getorcha/workers/ap/ingestion/post_process/cost_center.cljsrc/com/getorcha/workers/ap/ingestion/post_process/accounts.cljsrc/com/getorcha/workers/ap/ingestion/post_process/accruals.cljsrc/com/getorcha/workers/ap/ingestion/post_process/tax_compliance.cljsrc/com/getorcha/workers/ap/ingestion/post_process/uncertain_validations.cljsrc/com/getorcha/workers/ap/ingestion/post_process/financial_validation.cljStep 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:
(methods workers/-prompt) → (methods ai.prompts/-prompt)(workers/-prompt prompt-kw) → (ai.prompts/-prompt prompt-kw)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"
ai.toolsMove 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:
src/com/getorcha/ai/tools.cljsrc/com/getorcha/link/mcp/tools.clj (thin adapter)src/com/getorcha/link/http.clj (init-registry call)src/com/getorcha/link/mcp/http.clj (may reference tools)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:
[com.getorcha.ai.tools :as ai.tools][com.getorcha.link.mcp.tools :as mcp.tools](ai.tools/init-registry!)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"
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:
data_map.clj — uses resolved for DB querylist_files.clj — uses resolved for file store + responseexcel.clj — uses resolved for file store + error messagessave_dm.clj — uses resolved for DB insert. Also uses :identity-id from context (keep that)Step 4: Move Excel sandbox and functions
These files have no MCP dependencies — just update namespace declarations:
sandbox.clj: com.getorcha.link.mcp.tools.fpna.excel.sandbox → com.getorcha.ai.tools.fpna.excel.sandboxfunctions.clj: com.getorcha.link.mcp.tools.fpna.excel.functions → com.getorcha.ai.tools.fpna.excel.functionsUpdate 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:
com.getorcha.ai.tools.master-datacom.getorcha.ai.tools instead of com.getorcha.link.mcp.toolshandle-master-data: refactor same pattern as FP&A — use :legal-entity-id from context, remove mcp.identity callhandle-legal-entities: this handler uses legal-entity-ids (set) to list accessible entities — keep using the set from contextStep 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"
Files:
deps.ednStep 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"
ai.agent.interop)Converts the existing -tool multimethod definitions to LangChain4j ToolSpecification + ToolExecutor pairs.
Files:
test/com/getorcha/ai/agent/interop_test.cljsrc/com/getorcha/ai/agent/interop.cljStep 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"
ai.agent)The core agent loop using ChatModel.chat(ChatRequest) directly.
Files:
test/com/getorcha/ai/agent_test.cljsrc/com/getorcha/ai/agent.cljStep 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"
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"