Skip to content

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)
    }
}