ai_macro.kt
src/jvmMain/kotlin/afield/runtime/macros/ai_macro.kt · 559 lines
package and imports — 57 lines, from line 1
package afield.runtime.macros
import afield.ai.agent.*
import afield.ai.conversation.*
import afield.ai.tools.*
import afield.animation.gesture.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.app.diagnostics.*
import afield.app.notifications.*
import afield.app.protocol.*
import afield.app.server.*
import afield.app.settings.*
import afield.audio.synth.*
import afield.data.table.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.history.*
import afield.document.persistence.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.graphics.color.*
import afield.graphics.paint.*
import afield.graphics.vulkan.*
import afield.math.functions.*
import afield.media.capture.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.completion.*
import afield.runtime.execution.*
import afield.runtime.kotlin.*
import afield.runtime.lifecycle.*
import afield.tools.automation.*
import afield.tools.docs.*
import afield.ui.controls.*
import afield.ui.layout.*
import afield.ui.notation.*
import afield.ui.output.*
import afield.ui.output.templates.*
import afield.ui.slides.*
import afield.ui.visualization.*
import afield.ai.tools.DefinitionInfo
import afield.ai.tools.ListDefinitionsResult
import afield.ai.models.Ai4ModelDefaults
import afield.ai.models.LlmBridge
import afield.ai.models.LlmReasoningEffort
import afield.ai.models.LlmThreadState
import afield.ai.diagnostics.StdoutLog
import afield.ai.tools.ToolExecutionContext
import afield.ai.models.aiProviderConfigRevision
import afield.ai.models.createLlmBridge
import afield.app.diagnostics.logging.lprintln
import afield.runtime.macros.MacroRegistry.MacroExpansionProblem
import afield.runtime.macros.MacroRegistry.callRewriteWithContext
import kotlin.script.experimental.api.ScriptDiagnostic
import kotlinx.coroutines.runBlocking
import java.util.concurrent.ConcurrentHashMap
The ai("...") macro, which asks a language model to write the body of the function it is called
in and caches the result in the box's attic.
The containing function must declare its parameter types and return type; the signature and the
prompt go to the model through AiMacroBridge, and the generated function is stored between
// @ai-gen: and // @ai-gen-end: comment lines so it is reused rather than regenerated
(parseAtticBlock, appendAtticBlock, removeAtticBlock). resolvePatternRewriteAi serves the
ai(...) side contributions of PatternRewrite rules through the same cache.
-- Metadata format for AI-generated blocks in attic --
// @ai-gen: __foo | sig: (a: Double, v: Vec3): FLine | prompt: a random wiggly FLine... fun __foo(a: Double, v: Vec3): FLine { ... } // @ai-gen-end: __foo
private val aiGenHeaderRegex = Regex("""^// @ai-gen:\s*(\S+)\s*\|\s*sig:\s*(.*?)\s*\|\s*prompt:\s*(.*)$""")
private val aiGenEndRegex = Regex("""^// @ai-gen-end:\s*(\S+)\s*$""")
data class AtticBlock
data class AtticBlock(
val functionName: String,
val signature: String,
val prompt: String,
val body: String
)
fun parseAtticBlock(attic: String, functionName: String): AtticBlock?
fun parseAtticBlock(attic: String, functionName: String): AtticBlock? {
val lines = attic.lines()
var i = 0
while (i < lines.size) {
val match = aiGenHeaderRegex.matchEntire(lines[i].trim())
if (match != null && match.groupValues[1] == functionName) {
val sig = match.groupValues[2]
val prompt = match.groupValues[3]
val bodyLines = mutableListOf<String>()
i++
while (i < lines.size) {
val endMatch = aiGenEndRegex.matchEntire(lines[i].trim())
if (endMatch != null && endMatch.groupValues[1] == functionName) break
bodyLines.add(lines[i])
i++
}
return AtticBlock(functionName, sig, prompt, bodyLines.joinToString("\n"))
}
i++
}
return null
}
fun removeAtticBlock(attic: String, functionName: String): String
fun removeAtticBlock(attic: String, functionName: String): String {
val lines = attic.lines()
val result = mutableListOf<String>()
var i = 0
while (i < lines.size) {
val match = aiGenHeaderRegex.matchEntire(lines[i].trim())
if (match != null && match.groupValues[1] == functionName) {
// skip until end marker
i++
while (i < lines.size) {
val endMatch = aiGenEndRegex.matchEntire(lines[i].trim())
if (endMatch != null && endMatch.groupValues[1] == functionName) {
i++
break
}
i++
}
continue
}
result.add(lines[i])
i++
}
return result.joinToString("\n").trimEnd()
}
fun appendAtticBlock(attic: String, block: AtticBlock): String
fun appendAtticBlock(attic: String, block: AtticBlock): String {
val header = "// @ai-gen: ${block.functionName} | sig: ${block.signature} | prompt: ${block.prompt}"
val footer = "// @ai-gen-end: ${block.functionName}"
val newBlock = "$header\n${block.body}\n$footer"
return if (attic.isBlank()) newBlock else "${attic.trimEnd()}\n\n$newBlock"
}
fun extractParamNames(parameterList: String): String
fun extractParamNames(parameterList: String): String {
// "(a: Double, v: Vec3)" -> "a, v"
val inner = parameterList.trim().removeSurrounding("(", ")")
if (inner.isBlank()) return ""
return inner.split(",").joinToString(", ") { param ->
param.trim().substringBefore(":").trim()
}
}
fun stripAiGenMetadata(attic: String): String
fun stripAiGenMetadata(attic: String): String {
return attic.lines().filterNot { line ->
val trimmed = line.trim()
trimmed.startsWith("// @ai-gen:") || trimmed.startsWith("// @ai-gen-end:")
}.joinToString("\n")
}
-- Lazy AI bridge singleton --
object AiMacroBridge
object AiMacroBridge {
@Volatile private var bridgeRevision = -1L
@Volatile private var activeBridge: LlmBridge? = null
private val threadStates = ConcurrentHashMap<String, LlmThreadState>()
val bridge: LlmBridge
val bridge: LlmBridge
get() = currentBridge()
@Synchronized
private fun currentBridge(): LlmBridge {
val revision = aiProviderConfigRevision()
activeBridge?.takeIf { bridgeRevision == revision }?.let { return it }
lprintln("info,ai.macro", "Initializing AI macro bridge")
return createLlmBridge(
model = System.getenv("AFIELD_AI_MACRO_MODEL") ?: Ai4ModelDefaults.flagshipModel(),
log = StdoutLog,
reasoningEffort = LlmReasoningEffort.MEDIUM
).also {
threadStates.clear()
activeBridge = it
bridgeRevision = revision
}
}
fun getOrCreateThread(boxId: String, contextCode: String): LlmThreadState
fun getOrCreateThread(boxId: String, contextCode: String): LlmThreadState {
val bridge = currentBridge()
return threadStates.getOrPut(boxId) {
bridge.startThread(
fileKey = "attic:$boxId",
fileText = contextCode,
instructions = """
You are a code-generation assistant embedded in Field4, a Kotlin live-coding environment.
Your job is to write Kotlin function implementations given a function signature and a natural-language description.
Rules:
- Return ONLY the function definition. No markdown fences, no explanation, no imports.
- The function name will be provided (it will start with double underscores, e.g. __foo).
- Match the exact parameter types and return type specified.
- The function body should fulfill the natural-language description as faithfully as possible.
- You may use standard Kotlin stdlib and Field4 types (`Vector3f`, `Matrix4f`, `FLine`, `Vec3`, etc.) that are available in context.
- If other functions or declarations are provided as context, you may call them.
- Keep the implementation concise and correct.
""".trimIndent()
)
}
}
fun clearThread(boxId: String)
fun clearThread(boxId: String) {
threadStates.remove(boxId)
}
}
-- The ai() macro implementation --
fun Box.macro_ai(args: List<String>, containingFunction: ContainingFunction?): Insert?
body — 233 lines, from line 215
fun Box.macro_ai(args: List<String>, containingFunction: ContainingFunction?): Insert? {
if (containingFunction == null) {
throw MacroExpansionProblem(
"ai() must be called inside a function with explicit parameter types and return type, e.g.:\n" +
" fun foo(a: Double): Double { ai(\"description\") }"
)
}
// Parse the prompt from args — args looks like: ["(\"some prompt text\")"]
val rawArgs = args.joinToString("")
val promptMatch = Regex("""^\(\s*"(.*)"\s*\)$""", RegexOption.DOT_MATCHES_ALL).find(rawArgs)
?: throw MacroExpansionProblem("ai() expects a single string argument, e.g. ai(\"description of what this function does\")")
val prompt = promptMatch.groupValues[1]
val funcName = containingFunction.name
val implName = "__$funcName"
val paramList = containingFunction.parameterList
val returnType = containingFunction.returnType
val signature = "$paramList: $returnType"
val paramNames = extractParamNames(paramList)
val forwardCall = if (paramNames.isBlank()) "return $implName()" else "return $implName($paramNames)"
// Check attic for existing matching block
val currentAttic = this.attic
val existing = parseAtticBlock(currentAttic, implName)
if (existing != null && existing.signature == signature && existing.prompt == prompt) {
// Cache hit — signature and prompt match
lprintln("info,ai.macro", "ai() cache hit for $implName in box ${this.id}")
safeNotify("ai($funcName): using cached implementation")
return Insert(here = forwardCall)
}
// Remove stale block if it exists
val cleanedAttic = if (existing != null) {
lprintln("info,ai.macro", "ai() removing stale block for $implName (sig or prompt changed)")
removeAtticBlock(currentAttic, implName)
} else {
currentAttic
}
// Build AI context
val atticContext = stripAiGenMetadata(cleanedAttic).trim()
// Get preview-expanded code with this ai() macro skipped and the containing function removed
val rawCode = this.code.orEmpty()
val codeWithoutFunction = removeFunctionByName(rawCode, funcName)
val expandedCode = try {
this.ki.applyMacros(this, codeWithoutFunction, skipMacros = setOf("ai"), previewOnly = true)
} catch (e: Exception) {
lprintln("warning,ai.macro", "Failed to preview-expand code for AI context: ${e.message}")
codeWithoutFunction
}
// Build the prompt for AI
val userPrompt = buildString {
appendLine("Write a Kotlin function with the following specification:")
appendLine()
appendLine("Function name: $implName")
appendLine("Parameters: $paramList")
appendLine("Return type: $returnType")
appendLine("Description: $prompt")
appendLine()
if (atticContext.isNotBlank()) {
appendLine("Here are other definitions already available in this scope (attic):")
appendLine("```")
appendLine(atticContext)
appendLine("```")
appendLine()
}
if (expandedCode.isNotBlank()) {
appendLine("Here is the rest of the code in this box for context:")
appendLine("```")
appendLine(expandedCode)
appendLine("```")
appendLine()
}
appendLine("Write ONLY the function definition for $implName. No markdown, no explanation.")
}
// Notify the user that generation is in progress
lprintln("info,ai.macro", "ai() generating $implName for box ${this.id}: \"$prompt\"")
safeNotify("ai($funcName): generating via AI — \"$prompt\"")
val contextForThread = buildString {
if (atticContext.isNotBlank()) {
appendLine(atticContext)
appendLine()
}
appendLine(expandedCode)
}
val maxAttempts = 3
var currentPrompt = userPrompt
var generatedCode: String? = null
for (attempt in 1..maxAttempts) {
// Generate code from AI
safeNotify("ai($funcName): calling AI${if (attempt > 1) " (retry $attempt/$maxAttempts)" else ""}...")
val candidate = try {
val thread = AiMacroBridge.getOrCreateThread(this.id, contextForThread)
// Build definitions from this box's KotlinInterface context
val box = this
val ctx = ToolExecutionContext(
listDefinitions = {
val defs = mutableListOf<DefinitionInfo>()
try {
box.ki.context.vars.values.mapTo(defs) { v ->
DefinitionInfo(name = v.name, type = "variable", fileRef = "box:${box.id}", language = "kotlin")
}
box.ki.context.functions.values.mapTo(defs) { f ->
DefinitionInfo(name = f.toString(true), type = "function", fileRef = "box:${box.id}", language = "kotlin")
}
} catch (e: Exception) {
lprintln("warning,ai.macro", "Failed to gather definitions for ListDefinitions: ${e.message}")
}
ListDefinitionsResult(definitions = defs)
}
)
val result = AiMacroBridge.bridge.send(
state = thread,
userPrompt = currentPrompt,
ctx = ctx,
maxOutputTokens = 4096
)
result.text.trim()
.removePrefix("```kotlin").removePrefix("```")
.removeSuffix("```")
.trim()
} catch (e: Exception) {
lprintln("error,ai.macro", "AI generation failed for $implName (attempt $attempt): ${e.message}")
if (attempt == maxAttempts) {
safeNotify("ai($funcName): AI generation failed after $maxAttempts attempts")
throw MacroExpansionProblem("AI generation failed for $implName: ${e.message}")
}
continue
}
// Validate that the generated code looks like a function
if (!candidate.contains("fun $implName")) {
lprintln("error,ai.macro", "AI did not generate expected function $implName (attempt $attempt). Got:\n$candidate")
if (attempt == maxAttempts) {
safeNotify("ai($funcName): AI failed to produce valid function after $maxAttempts attempts")
throw MacroExpansionProblem("AI did not generate expected function $implName. Response:\n$candidate")
}
currentPrompt = buildString {
appendLine("Your previous response did not contain the expected function definition 'fun $implName'.")
appendLine("Here is what you returned:")
appendLine("```")
appendLine(candidate)
appendLine("```")
appendLine()
appendLine("Please try again. Write ONLY the function definition for $implName with the exact signature: fun $implName$paramList: $returnType")
}
continue
}
// Compile-check the generated code
safeNotify("ai($funcName): compile-checking generated code...")
val compileErrors = compileCheckGenerated(candidate, cleanedAttic, expandedCode)
if (compileErrors == null) {
// Compilation succeeded
lprintln("info,ai.macro", "ai() compile check passed for $implName on attempt $attempt")
safeNotify("ai($funcName): compile check passed" + if (attempt > 1) " (after $attempt attempts)" else "")
generatedCode = candidate
break
} else {
// Compilation failed — show the first error in the notification
val firstError = compileErrors.lines().firstOrNull() ?: compileErrors
lprintln("warning,ai.macro", "ai() compile check failed for $implName (attempt $attempt):\n$compileErrors")
safeNotify("ai($funcName): compile error (attempt $attempt): $firstError")
if (attempt == maxAttempts) {
// Last attempt — use the code anyway but warn the user
lprintln("warning,ai.macro", "ai() giving up on compile-clean code for $implName after $maxAttempts attempts, using last result")
safeNotify("ai($funcName): could not get clean compile after $maxAttempts attempts — using last result")
generatedCode = candidate
break
}
// Build a fix-it prompt for the next iteration
currentPrompt = buildString {
appendLine("The function you generated has Kotlin compiler errors:")
appendLine()
appendLine("```")
appendLine(compileErrors)
appendLine("```")
appendLine()
appendLine("Here is the code you wrote:")
appendLine("```kotlin")
appendLine(candidate)
appendLine("```")
appendLine()
appendLine("Please fix these errors and return the corrected function definition for $implName.")
appendLine("Return ONLY the function definition, no markdown fences, no explanation.")
}
}
}
if (generatedCode == null) {
safeNotify("ai($funcName): generation failed")
throw MacroExpansionProblem("AI generation failed for $implName after $maxAttempts attempts")
}
// Build the attic block and patch attic
val block = AtticBlock(
functionName = implName,
signature = signature,
prompt = prompt,
body = generatedCode
)
val newAttic = appendAtticBlock(cleanedAttic, block)
// Update attic property (this triggers resync to frontend)
this.setPropertyRaw("attic", newAttic, doResync = true)
// _attic is intentionally NOT updated here so checkAtticAndExecute detects the change and executes the new code
lprintln("info,ai.macro", "ai() generated $implName successfully, attic updated")
safeNotify("ai($funcName): done ✓")
return Insert(here = forwardCall)
}
private fun safeNotify(text: String) {
try {
_send(Message("notify").payload(Notify(text, "AI")))
} catch (e: Exception) {
lprintln("warning,ai.macro", "safeNotify failed: ${e.message}")
}
}
Compile-check generated code by combining it with existing attic functions,
then trying to compile as a Kotlin snippet via the box's KotlinInterface.
Only checks the attic code — user code in code is not included since it
may legitimately be incomplete or non-compiling.
Returns null on success, or a string of error messages on failure.
private fun Box.compileCheckGenerated(candidate: String, cleanedAttic: String, expandedCode: String): String? {
val testCode = buildString {
val strippedAttic = stripAiGenMetadata(cleanedAttic).trim()
if (strippedAttic.isNotBlank()) {
appendLine(strippedAttic)
appendLine()
}
appendLine(candidate)
}
val errors = mutableListOf<String>()
this.ki.compileSnippetAndWait(
testCode,
implicits = listOf(this),
onCompileError = { reports ->
for (report in reports) {
if (report.severity == kotlin.script.experimental.api.ScriptDiagnostic.Severity.ERROR) {
errors.add(report.message + (report.location?.let { " (line ${it.start.line})" } ?: ""))
}
}
}
)
return if (errors.isEmpty()) null else errors.joinToString("\n")
}
Remove a top-level function by name from Kotlin source code.
Simple heuristic: finds fun <name>( and removes until matching closing brace.
private fun removeFunctionByName(code: String, functionName: String): String {
val pattern = Regex("""(?m)^([ \t]*)fun\s+${Regex.escape(functionName)}\s*\(""")
val match = pattern.find(code) ?: return code
val startIndex = match.range.first
// Find the opening brace
var braceIndex = code.indexOf('{', match.range.last)
if (braceIndex == -1) return code
// Count braces to find the matching close
var depth = 0
var i = braceIndex
while (i < code.length) {
when (code[i]) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) {
// Remove from startIndex to i (inclusive), plus any trailing newline
val endIndex = if (i + 1 < code.length && code[i + 1] == '\n') i + 2 else i + 1
return code.removeRange(startIndex, endIndex)
}
}
}
i++
}
return code
}
-- Pattern-rewrite ai("…") clause resolution --
fun Box.resolvePatternRewriteAi(req: PatternRewrite.AiRequest)
Resolve a pattern-rewrite ai("…") clause (see PatternRewrite). If the box attic already holds
a cached block for this request's stable key, do nothing — a cache hit, no model call. Otherwise
generate the code once via AiMacroBridge, append it to the attic keyed by
PatternRewrite.AiRequest.key, and leave _attic stale so that checkAtticAndExecute compiles
and executes the freshly generated code on this run.
Live (cache-miss) generation reuses the same bridge as macro_ai; only the cache-HIT path is
exercised by the smoke test — a real model is not available in CI.
fun Box.resolvePatternRewriteAi(req: PatternRewrite.AiRequest) {
val attic = (getPropertyRaw("attic", "") as? String).orEmpty()
if (PatternRewrite.atticHasAiBlock(attic, req.key)) return // cache hit — no model call
val generated = try {
val thread = AiMacroBridge.getOrCreateThread(this.id, this.code.orEmpty())
val ctx = ToolExecutionContext(listDefinitions = { ListDefinitionsResult(definitions = emptyList()) })
val prompt = buildString {
appendLine(req.prompt)
appendLine()
appendLine("This code will be spliced ahead of the call site: ${req.toText}")
append("Return ONLY the Kotlin declaration(s) the call site needs, no markdown fences.")
}
AiMacroBridge.bridge.send(state = thread, userPrompt = prompt, ctx = ctx, maxOutputTokens = 4096)
.text.trim().removePrefix("```kotlin").removePrefix("```").removeSuffix("```").trim()
} catch (t: Throwable) {
lprintln("warning,ai.macro", "rewrite ai(\"…\") generation failed (key=${req.key}): ${t.message}")
return
}
// Write `attic` but NOT `_attic`, so checkAtticAndExecute detects the change and runs it once.
setPropertyRaw("attic", PatternRewrite.appendAiBlock(attic, req.key, req.prompt, generated), doResync = true)
}
-- Macro registration --
fun registerAiMacro()
@RunsAt(macroRegistration)
fun registerAiMacro() {
callRewriteWithContext["ai"] = { box, _, args, containingFunction ->
box.macro_ai(args, containingFunction)
}
}