I am trying to do a few things at once with a Clojure macro but it's not working. I want to either learn how to fix it or understand why it is impossible.
I want to:
- Create a Java interface in Clojure
- ... that is immediately usable from the REPL (i.e., no AOT/compilation step needed, and I am strenuously avoiding writing this in Java)
- ... that has method- and parameter-level Java annotations
- ... that also correctly provides the method-level annotation with a String array instead of a single String.
I want to do this because I am playing with Langchain4j and I want a way to define my LLM prompts inline, right in my REPL (CIDER). I don't want to store my prompts in a resource or Java file because (1) Clojure is my thinking language, so I want to keep as much in it as possible, and (2) I want to keep the maximal amount of context in front of me in one buffer so I don't have to pay the cognitize tax of frequently switching buffers.
I want to invoke my macro like this:
(defprompt foo
"You are a {{role}} and need to solve this: {{problem-statement}}.")
I want that macro to basically translate into the code below. The code below works but requires that I store the prompt in a resource file, because I can't pass a String array to the annotation this way:
(definterface Foo
(^{UserMessage {:fromResource "foo-prompt.txt"}} ;; can't see my prompt right here :'(
^String foo [^{V "role"} role ^{V "problem-statement"} problem-statement]))
Now for my attempt. I am inexperienced with macros and so used Claude, and then tweaked it in my own experiments. (Updated 2025-04-14; see update at bottom of question.)
(defmacro defprompt
"Define a Langchain4j prompt interface for AiServices.
Arguments:
name - the name for the interface (will be capitalized)
prompt - a string prompt template with {{variable}} placeholders
method-name - the name of the single method
Example:
(defprompt brainstorming
\"ACT as a professional {{role}} and brainstorm on this problem statement: {{problemStatement}} Give different possible solutions that make sense.\")
Returns the interface symbol that can be passed to AiServices/create."
[name prompt]
(let [interface-name (symbol (str name))
;; Extract variables from the prompt
pattern #"\{\{([^}]+)\}\}"
param-names (->> (re-seq pattern prompt)
(map second)
(distinct))
;; Create parameter declarations with annotations
param-decls (mapv (fn [param]
(let [param-sym (symbol param)]
(with-meta param-sym
{V {:value param}
:tag String})))
param-names)]
`(definterface ~interface-name
(~(with-meta 'prompt
{:tag String
UserMessage {:value (into-array java.lang.String (list prompt))}})
[~@param-decls]))))
The problem is that the interface is defined but it doesn't have the method it should have. Sounds like an AOT/compilation thing; I ran into this problem previously with gen-class and realized in that situation that I needed to compile the namespace to make my gen'd class usable. As I said, I want to avoid AOT or compilation; I want the interface available immediately in the REPL (as it was with my definterface solution) so that I can quickly iterate on ideas in my REPL.
Resources I've followed:
- Clojure macros, reify and Java annotations. Oh my!
- Lots of trial and error, docs, and
macroexpand[-1]. The macro seems to expand correctly but it still doesn't make my interface's method available in the REPL.
Update, 2025-04-14
- When I say that the method isn't present, I mean that the method
foodoesn't appear in the output of this command:
(->> Foo
.getClass
.getDeclaredMethods
#_(mapcat #(.getAnnotations %))
pprint
)
- I haven't written the Java interface I want; I used the
definterfaceapproach shown above. But the Java would probably look like:
interface Foo {
@UserMessage("The {{role}} says {{sound}}.")
String foo(@V("role") String role, @V("sound") String sound);
}
- I modified the macro above to my latest version. When I
macroexpand-1it with*print-meta*, I get this — which looks right. But Langchain4j is complaining that it doesn't see my parameter (@V) )annotations.
(clojure.core/definterface Foo
(^{:tag java.lang.String
dev.langchain4j.service.UserMessage {:value #object["[Ljava.lang.String;" 0x92b8d3e "[Ljava.lang.String;@92b8d3e"]}}
prompt
[^{dev.langchain4j.service.V "role",
:tag java.lang.String} role
^{dev.langchain4j.service.V "sound",
:tag java.lang.String} sound]))
- Thanks to @amalloy, I ran
javap -v -pon the interface.classfile. It has correct types (so type hints are right) but no annotations.
definterfacehas one method. Do you mean it has the expected method but with the wrong annotations? Try(set! *print-meta* true)before macroexpanding - then you can see whether the problem is that you're failing to produce the metadata you want, or whether the metadata you (correctly) produce doesn't lead to the annotations you want.*print-meta*.(.getDeclaredMethods (.getClass foo)), the method isn't on the list. But when I useclojure.reflect/reflect, the method is present. I thought those two approaches were equivalent but they are apparently not.