Machine learning
Models small enough to train inside a box, in plain Kotlin, while you watch. Flow matching carries one 2D or 3D point cloud onto another and hands back the deformation between them; the multi-output regression head it trains is usable on its own. See Flow Matching.
112 source file(s), 2616 public symbol(s), 1044 of them carrying KDoc. Everything below is the documentation that lives beside the code — the same text the completion documentation panel shows in the editor. A blank summary means the declaration has no KDoc yet, not that this page dropped it.
AgentEvalCorpus.kt
The agent eval corpus: tasks mined from the operation transcripts under dev_docs/agents/transcripts/
rather than written by hand.
InFieldAgentEvals is seventeen tasks, so a pass rate over it moves 6% per task. The transcripts hold
about a thousand boxes that someone wrote and ran as real work, so each runnable one is a task:
- goal: make a box of this name, under these parents, that does what the box's own leading comment says, and whose output includes what the real box printed or displayed;
- setup: the box's ancestors in its transcript, created with their code and run in order;
- solution: the box's real code (
NewBoxRequest+RunBoxRequest/LaunchBoxRequest), so every task carries a known-good answer to measure an agent against; - checks: derived from what the solution said when run, twice: only output that came out the same both times becomes a check.
Mining is two stages. mine is static, YAML only, and rejects a box with a reason (never executed by its
transcript, needs a renderer / audio / the clock / files, prints nothing). Stage two runs each surviving
candidate's solution twice in a live Field and hands the pair to calibrate, which either accepts it as a
CorpusTask or rejects it (solution-fails, no-stable-output). The calibrated corpus is checked in as
CHECKOUT_PATH, so an eval run does not re-mine.
The split is fixed by the task id (splitOf): about a third of the tasks are heldout, never shown to
AgentLessons.distil when the protocol is followed (distil from a train run, measure on heldout).
src/jvmMain/kotlin/afield/ai/agent/AgentEvalCorpus.kt — read the source
AgentEvalCorpus
Mining, calibration, and the checked-in form of the corpus.
AgentLessons.kt
Lessons AI4 distils from its own eval runs, kept in a reviewable Markdown file and added to the instructions of every turn that has the graph tools.
src/jvmMain/kotlin/afield/ai/agent/AgentLessons.kt — read the source
AgentLessons
What AI4 has learned from its own evals, as a file a person reviews with git diff.
| Declaration | Summary |
|---|---|
const val MAX_INSTRUCTION_CHARS |
|
const val MAX_DISTILLED |
|
const val RESOURCE |
|
const val CHECKOUT_PATH |
The tracked source of RESOURCE, relative to a checkout (Field runs from its checkout). |
fun enabledByDefault(): Boolean |
|
fun resolve(setting: String): Boolean |
on / off pin; anything else is enabledByDefault. |
fun sourceFile(): File? |
The writable file: AFIELD_AI4_LESSONS_FILE, else the checkout's tracked copy when it exists. |
fun rawText(): String? |
The file's text: the source file when there is one (so a distil is live at once), else the packaged copy. |
data class Parsed(val version: Int, val header: String, val curated: List<String>, val distilled: List<String>, val provenance: String) |
|
fun parse(text: String): Parsed |
|
fun instructionText(): String? |
The block added to AI4's instructions, or null when there are no lessons. Whole bullets only, curated first, until MAX_INSTRUCTION_CHARS. |
fun isMisuse(call: AgentToolCallRecord): Boolean |
A failed call that is a mistake about the tools rather than the box's own error. Run/Launch failures that reached execution (a stage) and compiles that produced diagnostics are the code's errors, which is what the task is about. |
fun misuseCount(result: AgentTaskResult): Int |
|
fun normalize(text: String, documentRef: String): String |
Replace what differs between runs by placeholders: the document's name as file refs spell it (documentRef, the scratch directory's uuid) becomes <document>, other uuids <uid>. |
data class Lesson(val tool: String, val message: String, val count: Int, val tasks: List<String>, val repeatedUnchanged: Int, val change: String) |
|
fun distil(results: List<AgentTaskResult>): List<Lesson> |
|
fun render(existing: String?, lessons: List<Lesson>, provenance: String): String |
existing with its ## Distilled section replaced by lessons, its version bumped, and a provenance line. The curated section and the header comment are kept as they are. |
const val DEFAULT_HEADER |
|
data class DistilOutcome(val written: Boolean, val path: String, val version: Int, val lessons: List<Lesson>, val message: String) |
|
fun readReport(path: String): AgentEvalReport |
A saved AgentEvalReport (the JSON agent-eval returned), for re-distilling without re-running. |
fun distilAndWrite(report: AgentEvalReport): DistilOutcome |
Distil report and write the file. Refuses when there is no writable source file. |
AgentTask.kt
Headless agent tasks: a goal, a document and a set of checks, run through AI4's own turn and judged from what the document's boxes did.
AgentTaskRequest describes the task and its AgentChecks, AgentTaskRunner runs it, and
AgentTaskResult carries the verdict, each check's AgentCheckOutcome and the tool calls the agent
made (AgentToolCallRecord).
src/jvmMain/kotlin/afield/ai/agent/AgentTask.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun remoteProviderRefusal(mode: String, provider: String, allowRemote: Boolean): String? |
The refusal for a live task that would reach a remote provider without opt-in, or null when it may run. Pure so the gate is testable without a provider. |
fun toolCallTally(calls: List<AgentToolCallRecord>): Map<String, ToolCallTally> |
Top-level values
| Declaration | Summary |
|---|---|
val HEADLESS_TASK_INSTRUCTIONS: String |
What a headless task adds to the ordinary AI4 chat instructions (afield.ai.conversation.buildDynamicInstructions, which already describes the graph tools via afield.ai.conversation.fieldGraphToolGuidance). Only the facts that differ from a person typing in the chat: nobody will answer a question, and the turn is judged by checks. |
AgentTaskRequest
A whole task handed to Field's own agent: a goal, a document, a tool-round budget, and checks that decide — from the document and what its boxes said, not from the agent's final text — whether the goal was met.
| Declaration | Summary |
|---|---|
val goal: String |
|
val checks: List<AgentCheck> |
Checks that decide the verdict. Empty = verdict unchecked. |
val setup: String |
Tool calls applied before the agent's turn (not counted against its budget or in its log). |
val focusBox: String |
Box the conversation is about (uid or name); optional. |
val maxToolRounds: Int |
|
val mode: String |
live = the configured provider; scripted = replay script. |
val script: String |
JSON script for scripted mode; see ScriptedLlmBridge.parse. |
val model: String |
Model override for live mode; blank = the configured flagship. |
val extraInstructions: String |
Appended to the harness instructions. |
val taskId: String |
|
val chatBox: String |
Run the task as this box's AI4 panel turn (uid or unique name): it goes through the real ai-assist handler with kind general, so the exchange is saved in the box's ai4_chat_history and shows in the normal chat UI. Live mode only. The per-call tool log is then not available here (the panel keeps it); the checks still decide the verdict. |
val lessons: String |
on / off pins the eval-distilled lessons in the instructions (AgentLessons); blank = default. |
val blind: Boolean |
Withhold ViewBoxOutputRequest — the counterfactual for a visual task: can it be done without looking? |
val programs: String |
off withholds RunToolProgramRequest (the tool-program A/B arm); on or blank leaves it to afield.ai.tools.program.ToolPrograms.enabled. |
val context: String |
legacy sends this task's instructions the pre-2026-10-02 way, inside the stored message, instead of as ephemeral per-request context (afield.ai.conversation.TurnContext): the context-pipeline A/B arm. |
val followUps: List<String> |
Further user turns, sent in order in the same conversation after the goal's turn finishes. The checks run after the last. Any non-empty list makes the task multi-turn. |
val withhold: List<String> |
Tool simple names withheld from this task's turn (as programs off withholds one); see AgentEvalRequest.sealed. |
val repoRoot: String |
A checkout the repo tools (afield.ai.tools.RepoFileTools) work in: blank = no repo tools. repoRead and repoWrite are globs over root-relative paths; the job may read only the first and write only the second. |
val repoRead: List<String> |
|
val repoWrite: List<String> |
|
val repoCommands: List<String> |
Names from afield.ai.tools.RepoCommandTemplates this job may run with RepoRunCommandRequest, in repoRoot (empty = no command tool); repoCommandMaxSec caps any one run's wall clock. |
val repoCommandMaxSec: Int |
|
val allowRemote: Boolean |
Whether a live turn may go to a provider other than local (OpenAI, OpenRouter, Anthropic), which sends the goal, the document's code and every tool result — docs pages included — off this machine. In-Field callers (the panel, box tasks) default to yes, since the user picked the provider. The backdoor sends false unless --allow-remote-provider or AFIELD_AGENT_TASK_ALLOW_REMOTE=1, so an unattended run cannot fall through to a remote model it was not told to use (remoteProviderRefusal). |
AgentCheck
One predicate over the document. Kept as data (not a lambda) so a task can arrive over HTTP or the backdoor, and so a result can say exactly what was checked.
| Declaration | Summary |
|---|---|
val kind: String |
|
val box: String |
|
val text: String |
|
val property: String |
|
val parent: String |
|
val child: String |
|
val ints: List<Long> |
|
val fraction: Double |
AgentCheckOutcome
data class AgentCheckOutcome(val check: AgentCheck, val passed: Boolean, val detail: String)
AgentToolCallRecord
AgentTaskResult
| Declaration | Summary |
|---|---|
val taskId: String |
|
val goal: String |
|
val mode: String |
|
val provider: String |
|
val model: String |
|
val verdict: String |
pass, fail, budget-exhausted, error, unchecked, leaked (a sealed corpus pass that read its solution), or running when observation ended before a live panel turn. |
val checks: List<AgentCheckOutcome> |
|
val toolCalls: List<AgentToolCallRecord> |
|
val finalText: String |
|
val rounds: Int |
|
val maxToolRounds: Int |
|
val hitToolRoundCap: Boolean |
|
val elapsedMs: Long |
|
val inputTokens: Long? |
|
val outputTokens: Long? |
|
val cacheReadTokens: Long? |
Input tokens the provider served from its prompt cache (see afield.ai.models.LlmSendResult.cacheReadTokens). |
val cacheWriteTokens: Long? |
|
val documentName: String |
|
val createdBoxes: List<String> |
|
val setupCalls: List<AgentToolCallRecord> |
|
val error: String |
|
val chatBox: String |
Set when the task ran as a box's panel turn (AgentTaskRequest.chatBox). |
val misuseCalls: Int |
Failed tool calls that were mistakes about the tools, not the box's own errors (AgentLessons.isMisuse). |
val imagesSeen: Int |
Tool results that attached a picture the model was shown. |
val lessons: Boolean |
Whether the eval-distilled lessons were in this turn's instructions. |
val documentRef: String |
The document as file refs spell it (afield://<documentRef>/<uid>/code): its directory's name. |
val programs: Boolean |
Whether RunToolProgramRequest was offered, how often the model called it, and the calls those programs made. |
val programCalls: Int |
|
val nestedCalls: Int |
|
val turns: Int |
User turns in the conversation: 1 + AgentTaskRequest.followUps. |
val contextPlacement: String |
Where the last turn's instructions went: instructions, system+prefix, or inline (the legacy arm). |
val leakedVia: String |
A mined-corpus task only: the tool whose result held a line of the task's own solution (see AgentEvalCorpus.leak). |
val repoWritten: List<String> |
Root-relative paths the repo tools wrote, in order (a path written twice appears twice). |
val repoRuns: List<String> |
The job's command runs, name args -> exit code (or timeout), in order. |
val toolTally: Map<String, ToolCallTally> |
Per tool name: calls and failed calls (toolCallTally), so an all-errored tool shows without reading toolCalls. |
val offMachine: Boolean |
True when the turn went to a provider other than local, i.e. its content left this machine. |
ToolCallTally
data class ToolCallTally(val calls: Int, val failed: Int)
AgentTaskRunner
| Declaration | Summary |
|---|---|
fun run(request: AgentTaskRequest, info: SessionInformation, host: FieldGraphHost = LiveFieldGraphHost, contextFor: (SessionInformation, FieldGraphTools, String?) -> ToolExecutionContext = { i, graph, focus -> Ai4BackdoorTools.buildContext(i, focus).copy(fieldGraph = graph) }, bridgeFor: (AgentTaskRequest) -> LlmBridge = ::defaultBridge): AgentTaskResult |
Run request against info. Blocking; call it off the Runloop (the tools dispatch onto it). |
fun defaultBridge(request: AgentTaskRequest): LlmBridge |
AgentTaskTranscript.kt
Writes an agent task into a box's AI4 chat transcript, so a finished task can be read and continued in that box's chat panel.
src/jvmMain/kotlin/afield/ai/agent/AgentTaskTranscript.kt — read the source
AgentTaskTranscript
Writes an agent task into a box's AI4 chat transcript (ai4_chat_history), in the transcript's own markup (<<you>>, <<ai>>, <<progress>>, <<model:…>>), and pushes it to the box's ai-chat panel. The panel then renders the task like any other turn, and the person can continue the conversation from there.
| Declaration | Summary |
|---|---|
val SCRIPTED_IDENTITY |
|
fun AgentCheck.spec(): String |
|
fun verdictBlock(result: AgentTaskResult): String |
The verdict block: a one-line summary (✓ box-exists:Primes ✓ … — PASS), then one line per check with its detail. |
fun appendVerdict(info: SessionInformation, box: Box, result: AgentTaskResult) |
|
fun appendScriptedTurn(info: SessionInformation, box: Box, goal: String, result: AgentTaskResult) |
|
fun appendError(info: SessionInformation, box: Box, goal: String, message: String) |
A task that could not start: the person sees why in the panel, not only in a return value. |
BoxAgentTask.kt
src/jvmMain/kotlin/afield/ai/agent/BoxAgentTask.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.askFieldAgent(goal: String, checks: List<String> = emptyList(), mode: String = "auto", script: String = "", maxToolRounds: Int = 16, chatBox: Any? = null): CompletableFuture<AgentTaskResult> |
Hand goal to Field's own agent as an AI4 chat turn. |
fun fieldAgentEvalScript(id: String): String |
The scripted solution of an eval task, for askFieldAgent's script — a no-credentials fallback. |
BoxOutputCapture.kt
Pictures of a box's output area for the agent, taken from a headless Playwright page on the box's document, with simple statistics about what the picture contains.
BoxOutputCapture owns the pages and their release; BoxOutputShot is one capture, and
PictureStats summarises its pixels: how much differs from the background, and the dominant colours.
src/jvmMain/kotlin/afield/ai/agent/BoxOutputCapture.kt — read the source
BoxOutputShot
One captured picture of a box's output area, or the reason there is none.
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val image: BufferedImage? |
|
val path: String |
|
val channels: List<String> |
|
val viewerStates: List<Map<String, Any?>> |
PictureStats
Measurements of a picture that a check, a log line, or a model can use without looking.
| Declaration | Summary |
|---|---|
val width: Int |
|
val height: Int |
|
val inkFraction: Double |
|
val dominant: List<Pair<String, Double>> |
Up to six #rrggbb → share, most common first (bin averages). |
fun describeDominant(): String |
#ffffff 62%, #1f77b4 12%, … |
BoxOutputCapture
The server-owned browser page AI4 looks through: a picture of one box's output area, taken headless, whether or not anyone has Field open.
| Declaration | Summary |
|---|---|
const val MAX_PAGES |
|
const val IDLE_RELEASE_MS |
|
val captureDir: File |
Where captures are kept as evidence: AFIELD_AI4_CAPTURE_DIR, else ~/.afield/logs/ai4_box_captures. |
fun openPages(): Int |
How many capture pages are open — for tests of the release rules and for status. |
fun isHeld(info: SessionInformation): Boolean |
|
fun capture(info: SessionInformation, uid: String, settleMs: Int = 300): BoxOutputShot |
Capture uid's output area in info. Blocking; call it off the Runloop and off the Playwright thread (it marshals onto the latter, and the page needs the former to answer its websocket). |
fun release(info: SessionInformation) |
Close info's capture page, if one is open. Safe from any thread. |
FieldGraphTools.kt
The box graph and execution tools of Field's own agent: list, create, delete, edit, connect, run, launch, stop and read the output of boxes in the current document.
These are the operations an external agent reaches through tools/field_backdoor.py (new-box,
set, connect, run, launch, stop, wait-output), registered as native tools in the AI4 registry.
Each one calls the same server function the websocket and backdoor handlers call
(createAgentBox, executeAllRequest, launchAllRequest, stopBoxRequest, and the action
table's -connect, -disconnect and -delete-box), so box execution is not reimplemented here.
A box that fails to compile or throws comes back as success: false with its diagnostics,
never as a silent success. FieldGraphHost is how these tools reach the server:
LiveFieldGraphHost is the real one, and tests such as InFieldAgentHarnessTest supply a
scripted host.
src/jvmMain/kotlin/afield/ai/agent/FieldGraphTools.kt — read the source
Used in: AI4's built-in tools
Top-level values
| Declaration | Summary |
|---|---|
val fieldGraphToolNames: List<String> |
The tool names registerFieldGraphTools adds, for instructions and tests. |
ListBoxesRequest
data class ListBoxesRequest(val unused: Int = 0)
NewBoxRequest
| Declaration | Summary |
|---|---|
val name: String |
|
val code: String |
|
val parent: String |
DeleteBoxRequest
data class DeleteBoxRequest(val box: String)
SetBoxPropertyRequest
| Declaration | Summary |
|---|---|
val box: String |
|
val property: String |
|
val value: String |
PatchBoxTextRequest
GetBoxPropertyRequest
data class GetBoxPropertyRequest(val box: String, val property: String = "code")
ConnectBoxesRequest
data class ConnectBoxesRequest(val parent: String, val child: String)
DisconnectBoxesRequest
data class DisconnectBoxesRequest(val parent: String, val child: String)
RunBoxRequest
| Declaration | Summary |
|---|---|
val box: String |
|
val code: String? |
|
val timeoutSeconds: Double |
LaunchBoxRequest
| Declaration | Summary |
|---|---|
val box: String |
|
val code: String? |
|
val timeoutSeconds: Double |
|
val settleSeconds: Double |
StopBoxRequest
data class StopBoxRequest(val box: String)
ReadBoxOutputRequest
| Declaration | Summary |
|---|---|
val box: String |
|
val since: Long |
|
val contains: String |
|
val waitSeconds: Double |
|
val maxEntries: Int |
ViewBoxOutputRequest
| Declaration | Summary |
|---|---|
val box: String |
|
val settleMs: Int |
|
val note: String |
FieldBoxSummary
| Declaration | Summary |
|---|---|
val uid: String |
|
val name: String |
|
val parents: List<String> |
|
val children: List<String> |
|
val running: Boolean |
|
val codeChars: Int |
|
val lastRunSuccess: Boolean? |
|
val lastRunError: String |
FieldOutputEntry
FieldGraphResult
One result shape for every graph tool, so the model always finds success and message.
BoxOutputView
What ViewBoxOutputRequest returns: the picture (as a tool image, not in the JSON) plus what was measured from it, so the model gets numbers alongside the pixels and the tool log shows whether a picture was attached at all.
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val uid: String |
|
val name: String |
|
val channels: List<String> |
|
val width: Int |
|
val height: Int |
|
val inkFraction: Double |
Share of pixels that are not the background colour. |
val dominantColours: List<String> |
#rrggbb share%, most common first. |
val path: String |
Where the full-resolution PNG was kept, as evidence. |
val imageAttached: Boolean |
|
val viewerStates: List<Map<String, Any?>> |
Geometry state actually shown in the independent capture browser. |
val dataUrl: String |
|
val note: String |
|
override val toolImages: List<afield.ai.tools.ToolImage> |
|
override fun toolLogSummary(): String |
FieldGraphHost
Where the graph tools meet Field. LiveFieldGraphHost is the real server; tests supply a host whose owner thread is the calling thread and whose "execution" is scripted, because no jvmTest can run the K2 REPL.
| Declaration | Summary |
|---|---|
fun <T> onOwner(info: SessionInformation, action: () -> T): T |
Run action where box graph mutations belong, with info bound as the current session. |
fun newBox(info: SessionInformation, name: String, frame: Rect, properties: Map<String, String>): Box |
|
fun delete(info: SessionInformation, box: Box) |
|
fun run(info: SessionInformation, box: Box, exec: Execute, reply: (Message) -> Unit) |
Start a whole-property run; the verdict arrives through reply as execution-verdict. |
fun launch(info: SessionInformation, box: Box, exec: Execute, reply: (Message) -> Unit) |
|
fun stop(info: SessionInformation, box: Box) |
|
fun captureOutput(info: SessionInformation, box: Box, settleMs: Int): BoxOutputShot |
A picture of box's output area. The default host cannot take one and says so. |
LiveFieldGraphHost
object LiveFieldGraphHost : FieldGraphHost
FieldGraphTools
| Declaration | Summary |
|---|---|
val info: SessionInformation |
|
val replyOwned: Set<Pair<String, String>> |
(uid, property) pairs whose text this turn's reply will replace — the editor chat's current box and property, whose rewritten code is the reply itself. Writing those through a tool would be silently overwritten when the reply lands, so the tools refuse and say where the edit goes. |
val lessons: Boolean |
Whether this turn's instructions carry the eval-distilled lessons (AgentLessons). Defaults to on unless AFIELD_AI4_LESSONS=off; an eval can pin it per task to measure it. |
val seesOutput: Boolean |
Whether ViewBoxOutputRequest is offered. Only an eval's counterfactual turns it off (blind): the same visual task with the model unable to look. |
val origin: String |
The OperationTranscript origin this turn's edits are recorded under (ai4:<turn>), so a rewound turn's edits can be undone without touching anyone else's. Blank: recorded like a person's. |
fun kit(q: InspectKitRequest): KitToolResult |
InspectKitRequest: which kit this document runs and what differs from it (KitTools.kt). |
val created: MutableList<String> |
Uids of boxes this instance created, in order — the task runner reports them. |
fun resolve(token: String): Box |
A box by uid or unique name; ambiguity is refused, never guessed. |
fun isRunning(box: Box): Boolean |
|
fun listBoxes(): FieldGraphResult |
|
fun newBox(req: NewBoxRequest): FieldGraphResult |
|
fun deleteBox(req: DeleteBoxRequest): FieldGraphResult |
|
fun setProperty(req: SetBoxPropertyRequest): FieldGraphResult |
|
fun patchText(req: PatchBoxTextRequest): FieldGraphResult |
|
fun getProperty(req: GetBoxPropertyRequest): FieldGraphResult |
|
fun symbolAt(req: SymbolAtRequest): SymbolAtResult |
SymbolAtRequest: the box is found where the graph lives; resolving runs off it, as the editor hover does. |
fun connect(req: ConnectBoxesRequest): FieldGraphResult |
|
fun disconnect(req: DisconnectBoxesRequest): FieldGraphResult |
|
fun run(req: RunBoxRequest): FieldGraphResult |
|
fun launch(req: LaunchBoxRequest): FieldGraphResult |
|
fun stop(req: StopBoxRequest): FieldGraphResult |
|
fun captureOutput(box: Box, settleMs: Int = 300): BoxOutputShot |
A picture of box's output area, through the host. Used by viewOutput and by picture checks. |
fun viewOutput(req: ViewBoxOutputRequest): BoxOutputView |
|
fun readOutput(req: ReadBoxOutputRequest): FieldGraphResult |
GeometryReviewTools.kt
AI4 tools for the geometry review inbox: listing a document's Three.js and canvas review views, reading and answering their notes, reporting findings, styling parts and inspecting a view.
Each request class here is one tool, described to the model by its @JsonClassDescription; every
call returns a GeometryToolResult, which can attach saved pictures as images. The tools read and
write the same notes the viewer shows, scoped to the current document.
src/jvmMain/kotlin/afield/ai/agent/GeometryReviewTools.kt — read the source
Used in: AI4's built-in tools, Point to geometry, keep the context
ListGeometryViewsRequest
data class ListGeometryViewsRequest(val unused: Int = 0)
List the review views this document owns. This is where a viewId and the current expectedRevision come from, and the other tools here need both. Views belonging to other documents are not listed.
StyleGeometryPartsRequest
Colour, highlight or ghost named parts of a solid viewer. The style map is stored on the source box, so it survives a rebuild that keeps the same part IDs; the geometry, the camera, the selection, the notes and the build's currency are all left alone.
GeometryPartColor
data class GeometryPartColor(val partId: String, val color: List<Double>)
One part's colour, as RGB from 0 to 1.
ReadGeometryReviewsRequest
A page of a view's notes, as summaries rather than whole notes: id, kind, status and the revision each was made against. Reading leaves every status where it was. A note that came from a build attempt says whether that attempt ran against earlier inputs, in which case it may describe a design that is gone.
ReadGeometryNoteRequest
One note whole: its text, the feature and view it points at, its replies and its resolution history, with the pictures saved when it was made attached as images. Those pictures are of the revision that was annotated, not of the geometry as it stands now; a picture whose bytes do not match its recorded hash is reported as a warning rather than attached.
ReplyGeometryNoteRequest
Add a reply to a note without closing it: what was checked, and against which revision. The note version from the latest read has to still be current, so two writers cannot answer the same note blind. ReviewGeometryNoteRequest is the one that changes a note's status.
ReviewGeometryNoteRequest
Resolve a note, or reopen one, with the revision the decision was checked against. The original note, its pictures, its replies and its history are kept either way. A note having been read, or the geometry having been rebuilt, is not a reason to resolve it.
ReportGeometryFindingRequest
Put a finding in front of a person, as a note in the same inbox their own annotations go to. It captures its own pictures in a separate viewer, so it never moves the camera the person is looking through, and it checks the revision again on the document thread: a capture that finished late cannot become a finding against newer geometry. Every field below is required, and two reports agreeing on key, stage, runId and producer are the same occurrence and reuse one thread.
| Declaration | Summary |
|---|---|
val viewId: String |
The view whose inbox the note goes to, from ListGeometryViewsRequest. |
val expectedRevision: String |
The revision this finding is about, which has to still be the view's when the capture lands. |
val stateJson: String |
A complete state from InspectGeometryViewRequest, and one with a point or region selected. |
val key: String |
What was checked and on what, as something stable across runs: wall-thickness/lid. |
val kind: String |
error for a failed check, warning for a suspected problem, question for a decision needed. |
val stage: String |
The build stage the check belongs to. |
val runId: String |
The build or diagnostic attempt it came from. |
val producer: String |
What made the finding. |
val text: String |
The finding itself, 1 to 16000 characters. |
val consequence: String |
What happens if it is left as it is, to 4000 characters. |
val prompt: String |
What is wanted back, to 4000 characters; a repair that needs no permission should say so. |
InspectGeometryViewRequest
Look at the geometry as it stands: a fresh picture from a capture browser of its own, a compact part hierarchy, and a state complete enough to frame the next inspection the same way. The person's camera and selection are untouched. Only a solid viewer can be inspected this way; a custom canvas is refused, with the output box to use ViewBoxOutputRequest on instead.
| Declaration | Summary |
|---|---|
val viewId: String |
The view to capture, which has to be a solid viewer rather than a custom canvas. |
val expectedRevision: String |
The revision from ListGeometryViewsRequest. Geometry that changed under it fails the call. |
val noteId: String |
A note to frame the view the way it was annotated, instead of stateJson, and never both. |
val stateJson: String |
The complete state a previous inspection returned, to 128000 characters, carrying at least its part IDs, camera and target. A partial or invented state is refused; to change only the framing, pass camera and target instead. |
val explode: Double? |
How far to pull the parts apart, as a finite distance of zero or more. |
val hidden: List<String>? |
Parts to leave out of the picture, at most 10000 of them. |
val xray: Boolean? |
Fade the surfaces that are not the subject. |
val xrayOpacity: Double? |
How solid the faded surfaces stay, from 0.03 to 1. |
val sectionNormal: List<Double>? |
The normal of a cutting plane, as a finite 3-vector that is not all zeroes. |
val sectionFraction: Double |
Where along that normal to cut, from 0 to 1 across the geometry. |
val pick: GeometryPick? |
A point to pick the nearest visible feature at, instead of a region. |
val region: GeometryRegion? |
A rectangle to select, instead of a point. |
val camera: List<Double>? |
Where to put the camera, as a finite 3-vector in viewer coordinates, Y up. |
val target: List<Double>? |
What to point it at, in the same coordinates; it has to differ from camera. |
val includeMetadata: Boolean |
Add the source provenance and the full part tree, which most inspections do not need. |
GeometryPick
data class GeometryPick(val x: Double, val y: Double, val kind: String = "face")
A point to pick at, in normalized view coordinates, and what kind of feature to pick there.
GeometryRegion
A rectangle to select, centred at cx, cy in normalized view coordinates and rotated by angle.
GeometryToolResult
What every geometry review tool answers with, pictures included.
| Declaration | Summary |
|---|---|
val success: Boolean |
False when the request was refused or the view was unavailable, with the reason in message. |
val message: String |
One sentence about what happened. |
val details: Map<String, Any?> |
Whatever the tool reports: the views, a page of notes, a captured observation, a note's new version. |
val warnings: List<String> |
Anything the reader should know about the answer, such as a picture that could not be verified. |
override val toolImages: List<ToolImage> |
The pictures themselves, kept out of the JSON the model reads as text. |
val imageCount: Int |
How many pictures came back. |
override fun toolLogSummary(): String |
The verdict, the picture count and the message, which is what the tool log keeps. |
GeometryViewCapture.kt
Independent viewer captures for the geometry review tools: a picture of a three.js view taken without moving the one the user is looking at.
Each capture opens its own browser context on that view's page, applies the request's view state (explode, hidden parts, x-ray, section plane, camera, picks and regions) through the page's inspector API, waits for a frame to render, screenshots, and returns the image alongside the resulting state, source list and part tree. The geometry's revision digest is read before and after, so a capture is always of the revision the request named and a change in between is reported rather than photographed. Captures collect in the box-output capture directory, where the hundred newest are kept.
src/jvmMain/kotlin/afield/ai/agent/GeometryViewCapture.kt — read the source
InFieldAgentEvals.kt
A small eval suite for Field's own agent: seventeen tasks (five graded by pixels, three made of many similar calls that a tool program does in one, two that are several turns of one conversation), each a goal, the setup it starts from, checks that decide it, and a scripted solution.
The scripted solutions are real Kotlin for real Field. Replayed through ScriptedLlmBridge against
a live Field (agent-eval --mode scripted) they test the tools, the execution path and the checkers
end to end with no model, and a scripted pass is the floor any model run should reach. Replayed in
jvmTest against a fake host, they test the harness logic. A live run hands the same goals and
checks to the configured provider and ignores the solutions.
Box names are fixed, so run each task in a fresh document (AgentEvalRunner does).
src/jvmMain/kotlin/afield/ai/agent/InFieldAgentEvals.kt — read the source
InFieldAgentEvals
The eval tasks, their scripted solutions, and the helpers that build them.
| Declaration | Summary |
|---|---|
data class EvalTask(val id: String, val request: AgentTaskRequest, val solution: String) |
|
fun call(tool: String, vararg args: Pair<String, Any?>) |
A step for steps: a tool call. |
fun steps(vararg items: Any): String |
Build a script / setup JSON from calls and (optionally) a final answer. |
val PRIMES_CODE |
|
val BROKEN_CODE |
|
val FIXED_CODE |
|
val SOURCE_CODE |
|
val READER_CODE |
|
val TABLE_CODE |
|
val TICKER_CODE |
|
val DIVIDER_CODE |
|
val DIVIDER_FIXED |
|
val SWATCH_CODE |
A swatch whose colour is decided by the cascade: a stylesheet !important red, a more specific green, an inline purple — and then a script that sets an inline !important blue, which beats them all. Reading the source invites "red"; the picture is blue. |
val ANSWER_CODE |
|
val SUN_CODE |
|
val BARS_CODE |
Percent heights inside a parent of auto height compute to auto: five empty bars, zero pixels tall. |
val BARS_FIXED |
|
val THEME_CODE |
Look → revise → look. Chart's container has class bars, and another box, Theme, displays a stylesheet that makes .bars flex-direction: row-reverse — output panels share one page, so a style in one panel reaches every other. The bars therefore appear in the reverse of the list's order, and sorting the list ascending, the obvious first move, draws them tallest-first. Chart's own code does not say so; the picture does. The stylesheet lives in Theme for that reason: in Chart's own code, reading it would stand in for looking at the output. The scripted solution makes the mistake first, sees it, and fixes it. |
val ROW_CODE |
|
val ROW_FIRST_TRY |
|
val ROW_FIXED |
|
val CELL_BAD |
|
fun cellCode(n: Int) |
|
val CELL_NAMES |
|
val FAILING_CELLS |
|
val REPORT_CODE |
|
fun sqCode(k: Int) |
A value nobody computes in their head: the hash of a string built from k, so the sweep has to run. |
val SWEEP_OUTPUTS |
What sqCode prints for k = 1..8, as Kotlin computes it. |
val RESULTS_CODE |
|
val TODO_NOTES |
|
fun noteCode(n: Int) |
|
fun noteName(n: Int) |
|
val COUNTER_3 |
|
val COUNTER_5 |
|
val COUNTER_SQUARES |
|
val TOTAL_CODE |
|
val POWERS_3 |
|
val POWERS_5 |
|
val POWERS_5_CUBES |
|
val CHART_CODE |
|
val CHART_FIRST_TRY |
The first try: sorted ascending, tallest red — drawn tallest-first because of row-reverse. |
val CHART_FIXED |
|
val tasks: List<EvalTask> |
|
val manyCallTaskIds |
Tasks made of many similar calls: what tool programs are for. |
val multiTurnTaskIds |
Tasks that are several user turns of one conversation: what the context pipeline is for. |
val visualTaskIds |
Tasks whose checks read pixels; they need a Field that can capture box output. |
fun byId(id: String): EvalTask |
AgentEvalRequest
| Declaration | Summary |
|---|---|
val ids: List<String> |
Task ids; empty = all. |
val mode: String |
|
val model: String |
|
val maxToolRounds: Int |
|
val lessons: String |
on / off pins the distilled lessons in every task's instructions; blank = default (AgentLessons). |
val distil: Boolean |
After the run, distil its tool misuse into the lessons file (AgentLessons.distilAndWrite). |
val distilFrom: String |
Path to a saved AgentEvalReport: run nothing, distil that report instead (implies distil). How a change to AgentLessons.distil is applied to a run already paid for. |
val blind: Boolean |
Withhold ViewBoxOutputRequest from every task (the counterfactual for the visual tasks). |
val programs: String |
off withholds RunToolProgramRequest from every task (the tool-program A/B arm); see AgentTaskRequest.programs. |
val context: String |
legacy puts every task's instructions back inside the stored message; see AgentTaskRequest.context. |
val corpus: String |
Run the mined corpus (AgentEvalCorpus) instead of the hand suite: all, train or heldout. Blank = the hand suite. ids then filter corpus ids (<transcript>/<box>). |
val sample: Int |
With corpus: run a seeded sample of this many tasks (0 = every task in the split). |
val seed: Long |
|
val calibrate: Boolean |
Re-mine the corpus: mine the transcripts, run every candidate's solution twice (scripted), derive checks from the output that matched, and rewrite AgentEvalCorpus.CHECKOUT_PATH. sample > 0 calibrates only that many candidates (and does not write). |
val sealed: Boolean |
Withhold the tools that read the repository (AgentEvalCorpus.SEALED_TOOLS) from every task. A mined task's solution is checked in — the transcript it came from — so with search on, a model can find the answer instead of writing it; the first live corpus runs did, in at least 3 and 5 of 20 tasks. |
AgentEvalReport
| Declaration | Summary |
|---|---|
val mode: String |
|
val provider: String |
|
val model: String |
|
val population: Int |
|
val passed: Int |
|
val results: List<AgentTaskResult> |
|
val elapsedMs: Long |
|
val inputTokens: Long? |
|
val outputTokens: Long? |
|
val error: String |
|
val cacheReadTokens: Long? |
Sums of AgentTaskResult.cacheReadTokens / AgentTaskResult.cacheWriteTokens; null when no task reported them. |
val cacheWriteTokens: Long? |
|
val misuseCalls: Int |
Sum of AgentTaskResult.misuseCalls. |
val imagesSeen: Int |
Sum of AgentTaskResult.imagesSeen. |
val rounds: Int |
|
val lessons: Boolean |
Whether the lessons were on, and which version of the file was read. |
val lessonsVersion: Int |
|
val distilled: List<String> |
Set when the request asked to distil: what was written. |
val distilMessage: String |
|
val documentsBefore: Int |
Leak evidence, filled by the server's entry points (afield.app.server.runAgentEvalHeadless): open documents before and after the run, and AI4 capture pages still open after it. -1 = not measured. |
val documentsAfter: Int |
|
val capturePagesOpenAfter: Int |
|
val programs: Boolean |
Whether tool programs were offered; sums of AgentTaskResult.programCalls and AgentTaskResult.nestedCalls. |
val programCalls: Int |
|
val nestedCalls: Int |
|
val context: String |
legacy when the run put instructions inside the stored message, else blank. |
val corpus: String |
The corpus split run (all / train / heldout), or blank for the hand suite; calibrate for a re-mine. |
val corpusMessage: String |
A calibration's summary: what was mined, accepted and written. |
val rejections: Map<String, Int> |
A calibration's rejections, counted by reason (static and calibration stages together). |
val sealed: Boolean |
Whether the repository search tools were withheld (AgentEvalRequest.sealed). |
val leaked: Int |
Corpus live runs: tasks whose tool results held a line of their own solution (AgentEvalCorpus.leak). |
AgentEvalRunner
| Declaration | Summary |
|---|---|
const val MAX_LIVE_TASKS |
Hard caps for a live eval: one run of at most this many tasks, at most this many rounds each. |
const val MAX_LIVE_ROUNDS |
|
fun run(request: AgentEvalRequest, runTask: (AgentTaskRequest) -> AgentTaskResult): AgentEvalReport |
Run the suite, one task at a time. runTask runs one request in a fresh document and returns its result; the server passes one that opens a headless scratch document per task. |
fun calibrate(request: AgentEvalRequest, runTask: (AgentTaskRequest) -> AgentTaskResult): AgentEvalReport |
Re-mine the corpus (AgentEvalCorpus): every statically mined candidate's solution runs twice, scripted, with no checks; the runs' output decides its checks. The report's results are the second runs, passed the tasks accepted. |
KitTools.kt
The AI4 tool that reports which kit a document runs and which of its kit-provided properties and assets have drifted from the kit.
InspectKitRequest is the tool and KitToolResult its answer. It reads only; migrating a
document to a newer kit is done by a person with tools/field_kit.py.
src/jvmMain/kotlin/afield/ai/agent/KitTools.kt — read the source
InspectKitRequest
| Declaration | Summary |
|---|---|
val kit: String |
KitToolResult
| Declaration | Summary |
|---|---|
override fun toolLogSummary() |
StudyBuildAwaits.kt
Waiting for study build jobs without the model: a box chat that ended its turn on AwaitStudyBuildsRequest is resumed by StudyBuildAwaits when its jobs are done or its deadline passes.
AiChatSuspension and AiChatAwaitedJob are the wait as ai-chat-history reports it to the panel.
src/jvmMain/kotlin/afield/ai/agent/StudyBuildAwaits.kt — read the source
AiChatAwaitedJob
A job a chat waits for, as ai-chat-history reports it. status is the last one seen, or "" before the first poll.
| Declaration | Summary |
|---|---|
val label: String |
AiChatSuspension
What a box chat is waiting for (AiBoxChatReply.suspendedOn): since when, until when at the latest, and the jobs.
StudyBuildAwaits
Box chats whose turn ended with AwaitStudyBuildsRequest, waiting for their build jobs without the model.
| Declaration | Summary |
|---|---|
val pollMs: Long |
|
val runnerGraceMs: Long |
|
data class SavedSuspension(val id: String, val property: String = "code", val maxToolRounds: Int = 24, val since: Long, val deadline: Long, val jobs: List<SavedJob>, val row: String) |
What PROPERTY holds. row is the transcript block the suspended turn ended with. |
data class SavedJob(val boxId: String, val name: String, val jobId: String, val stage: String = "", val label: String = "", val directory: String = "") |
One awaited job as saved: directory is the runner's, read when the runner is not running. |
fun suspend(info: SessionInformation, box: Box, suspension: TurnSuspension, property: String, maxToolRounds: Int): SavedSuspension |
Save suspension as box's wait and start watching it. A wait already armed for the box keeps its jobs: the new one waits for both sets, until the later deadline. |
fun rearm(info: SessionInformation) |
Arm every wait saved in info's boxes that is not armed yet; called when a document has loaded. |
fun cancel(info: SessionInformation, box: Box): SavedSuspension? |
Stop waiting (/stop-waiting, a cleared or rewound chat). The builds keep running. Returns what was waited for. |
fun suspensionOf(info: SessionInformation, box: Box): AiChatSuspension? |
What box's chat waits for, for ai-chat-history; null when it is not waiting. |
fun shown(info: SessionInformation, box: Box, markdown: String): String |
markdown as a panel should show it: while box waits, its waiting row open, like live activity. |
override fun close() |
StudyBuildTools.kt
AI4 tools over a box's study builds: list, inspect, start, wait for, cancel, await and prune build jobs.
Each request class is one tool and returns a StudyBuildToolResult. AwaitStudyBuildsRequest
is the exception: its StudyBuildAwaitResult can end the turn with a suspension, which
StudyBuildAwaits then holds until the jobs finish.
src/jvmMain/kotlin/afield/ai/agent/StudyBuildTools.kt — read the source
Used in: AI4's built-in tools, Studies, Let AI4 Wait for Long Builds
ListStudyBuildsRequest
data class ListStudyBuildsRequest(val unused: Int = 0)
List the document's build runners. This is where a boxId and name come from; every other tool here needs both, and a document with no runners answers with an empty list rather than a failure.
InspectStudyBuildsRequest
One runner's stages and a page of its attempt history, or one attempt named by jobId. Running, succeeded and current are three different things here: a stage's old success stops being current as soon as its inputs change. StartStudyBuildRequest needs the stage fingerprint this reports.
WaitStudyBuildRequest
Watch a job that is already running, reporting its progress into the chat without spending a model turn on each poll. The wait ending is not the build failing: the result says whether the job is terminal. Cancelling the wait leaves the build running.
StartStudyBuildRequest
Start one stage from the inputs it has now. The expected fingerprint comes from the latest InspectStudyBuildsRequest, and a stage whose dependencies are missing or stale, whose gate is closed, or that is already building, is refused. A positive waitSeconds starts the job and watches it in the same call; zero returns the job handle for WaitStudyBuildRequest or AwaitStudyBuildsRequest.
CancelStudyBuildRequest
data class CancelStudyBuildRequest(val boxId: String, val name: String, val jobId: String)
Ask a running build to stop. Success means the cancellation was recorded, not that the worker has stopped: it ends at its next cancellation check, so poll the same job until it is terminal. The stage keeps whatever result it had already published, and the attempt history survives.
AwaitStudyBuildsRequest
End the turn and have Field wait for up to twenty jobs without the model, then start the conversation again with their outcomes. This is the alternative to repeated WaitStudyBuildRequest calls for a build measured in hours: each wait costs a model round and this costs none. Jobs that have all already finished are answered at once and the turn carries on.
PruneStudyBuildsRequest
Delete attempt history nothing in this document still needs, across all of its runners. What is kept is each stage's current result, latest attempt and latest success, anything running, anything pinned, and whatever those were built from, gated on or resumed from. A deleted attempt cannot be inspected or resumed from again, so apply is false until asked for.
StudyBuildToolResult
What every build tool but the await answers with: a verdict, a sentence, and whatever the tool reports.
| Declaration | Summary |
|---|---|
override fun toolLogSummary() |
The verdict and the message, which is what the tool log keeps rather than the details. |
StudyBuildAwaitResult
AwaitStudyBuildsRequest's result. A set suspension ends the turn; the model reads only the rest.
| Declaration | Summary |
|---|---|
override fun toolLogSummary() |
The verdict, whether the turn was suspended, and the message. |
SymbolAtTool.kt
src/jvmMain/kotlin/afield/ai/agent/SymbolAtTool.kt — read the source
SymbolAtRequest
What a name means at one place in a box's code, as the compiler sees it there: the editor's hover, every completion candidate the compiler offers for the name (all overloads, with signatures and return types), the live value if a run left one in this box or an ancestor, where the box itself declares it, and the indexed Field declarations of that name with their KDoc and source.
| Declaration | Summary |
|---|---|
val box: String |
|
val symbol: String |
|
val near: String |
|
val line: Int |
|
val occurrence: Int |
|
val code: String |
SymbolCandidate
SymbolRuntimeValue
SymbolAtResult
AI4.kt
Re-exports the AI4 assistant's public types and helpers under one package, afield.ai.compat, so
code that imports from this one package keeps compiling.
Every declaration here is a typealias or a one-line forwarding val or fun into the package
that now owns it: logging sinks from afield.ai.diagnostics, language specs such as
KOTLIN_SCRIPT and the GLSL and Slang stage specs from afield.runtime.languages, tool request
and result schemas from afield.ai.tools, and the completion helpers from afield.ai.completion.
New code should import from those packages directly.
The file also declares two @RunsAt(installHandlers) hooks, ai4_install and
docs_search_install, which call installAi4 and installDocsSearch (the latter installs the
docs_search_input message handler).
src/jvmMain/kotlin/afield/ai/compat/AI4.kt — read the source
Top-level functions
LogSink
typealias LogSink = afield.ai.diagnostics.LogSink
Re-exports the AI4 assistant's public types and helpers under one package, afield.ai.compat, so code that imports from this one package keeps compiling.
NotifyLog
typealias NotifyLog = afield.ai.diagnostics.NotifyLog
TraceLogger
typealias TraceLogger = afield.ai.diagnostics.TraceLogger
LanguageSpec
typealias LanguageSpec = afield.runtime.languages.LanguageSpec
MalformedAI
typealias MalformedAI = afield.runtime.languages.MalformedAI
Commentary
typealias Commentary = afield.ai.tools.Commentary
ContextAttachment
typealias ContextAttachment = afield.ai.tools.ContextAttachment
ListAttachmentsRequest
typealias ListAttachmentsRequest = afield.ai.tools.ListAttachmentsRequest
ListAttachmentsResult
typealias ListAttachmentsResult = afield.ai.tools.ListAttachmentsResult
AttachmentInfo
typealias AttachmentInfo = afield.ai.tools.AttachmentInfo
AttachmentRequest
typealias AttachmentRequest = afield.ai.tools.AttachmentRequest
AttachmentResponse
typealias AttachmentResponse = afield.ai.tools.AttachmentResponse
ListDefinitionsRequest
typealias ListDefinitionsRequest = afield.ai.tools.ListDefinitionsRequest
ListDefinitionsResult
typealias ListDefinitionsResult = afield.ai.tools.ListDefinitionsResult
DefinitionInfo
typealias DefinitionInfo = afield.ai.tools.DefinitionInfo
ListContentsRequest
typealias ListContentsRequest = afield.ai.tools.ListContentsRequest
ListContentsResult
typealias ListContentsResult = afield.ai.tools.ListContentsResult
ListContentEntry
typealias ListContentEntry = afield.ai.tools.ListContentEntry
ListKotlinContextVariablesRequest
typealias ListKotlinContextVariablesRequest = afield.ai.tools.ListKotlinContextVariablesRequest
KotlinContextVariableInfo
typealias KotlinContextVariableInfo = afield.ai.tools.KotlinContextVariableInfo
ListKotlinContextVariablesResult
typealias ListKotlinContextVariablesResult = afield.ai.tools.ListKotlinContextVariablesResult
EvaluateKotlinExpressionRequest
typealias EvaluateKotlinExpressionRequest = afield.ai.tools.EvaluateKotlinExpressionRequest
EvaluateKotlinExpressionResult
typealias EvaluateKotlinExpressionResult = afield.ai.tools.EvaluateKotlinExpressionResult
EditFileRequest
typealias EditFileRequest = afield.ai.tools.EditFileRequest
EditFileResponse
typealias EditFileResponse = afield.ai.tools.EditFileResponse
CompileKotlinRequest
typealias CompileKotlinRequest = afield.ai.tools.CompileKotlinRequest
CompileKotlinResult
typealias CompileKotlinResult = afield.ai.tools.CompileKotlinResult
ReloadShadersRequest
typealias ReloadShadersRequest = afield.ai.tools.ReloadShadersRequest
ReloadShadersResult
typealias ReloadShadersResult = afield.ai.tools.ReloadShadersResult
ScratchpadReadRequest
typealias ScratchpadReadRequest = afield.ai.tools.ScratchpadReadRequest
ScratchpadUpdateRequest
typealias ScratchpadUpdateRequest = afield.ai.tools.ScratchpadUpdateRequest
ScratchpadResult
typealias ScratchpadResult = afield.ai.tools.ScratchpadResult
ChecklistReadRequest
typealias ChecklistReadRequest = afield.ai.tools.ChecklistReadRequest
ChecklistUpdateRequest
typealias ChecklistUpdateRequest = afield.ai.tools.ChecklistUpdateRequest
ChecklistItemInput
typealias ChecklistItemInput = afield.ai.tools.ChecklistItemInput
ChecklistItem
typealias ChecklistItem = afield.ai.tools.ChecklistItem
ChecklistResult
typealias ChecklistResult = afield.ai.tools.ChecklistResult
AgentMemoryState
typealias AgentMemoryState = afield.ai.tools.AgentMemoryState
WorkspaceSymbolSearch
typealias WorkspaceSymbolSearch = afield.ai.tools.WorkspaceSymbolSearch
WorkspaceSymbolSearchResult
typealias WorkspaceSymbolSearchResult = afield.ai.tools.WorkspaceSymbolSearchResult
WorkspaceSymbolMatch
typealias WorkspaceSymbolMatch = afield.ai.tools.WorkspaceSymbolMatch
GetUtcTime
typealias GetUtcTime = afield.ai.tools.GetUtcTime
Remember
typealias Remember = afield.ai.tools.Remember
Recall
typealias Recall = afield.ai.tools.Recall
ToolExecutionContext
typealias ToolExecutionContext = afield.ai.tools.ToolExecutionContext
OpenAiIdeCodexBridge
typealias OpenAiIdeCodexBridge = afield.ai.models.OpenAiIdeCodexBridge
IdeChatHelpers
typealias IdeChatHelpers = afield.ai.conversation.IdeChatHelpers
CompletionCandidate
typealias CompletionCandidate = afield.ai.completion.CompletionCandidate
DocumentGuideTools
typealias DocumentGuideTools = afield.ai.tools.DocumentGuideTools
DocumentGuideListRequest
typealias DocumentGuideListRequest = afield.ai.tools.DocumentGuideListRequest
DocumentGuideReadRequest
typealias DocumentGuideReadRequest = afield.ai.tools.DocumentGuideReadRequest
DocumentGuideSearchRequest
typealias DocumentGuideSearchRequest = afield.ai.tools.DocumentGuideSearchRequest
DocumentGuideCreateRequest
typealias DocumentGuideCreateRequest = afield.ai.tools.DocumentGuideCreateRequest
DocumentGuidePatchRequest
typealias DocumentGuidePatchRequest = afield.ai.tools.DocumentGuidePatchRequest
DocumentGuideAppendRequest
typealias DocumentGuideAppendRequest = afield.ai.tools.DocumentGuideAppendRequest
DocumentGuideToolResult
typealias DocumentGuideToolResult = afield.ai.tools.DocumentGuideToolResult
Ai4AutoBoxNamer.kt
src/jvmMain/kotlin/afield/ai/completion/Ai4AutoBoxNamer.kt — read the source
Ai4CompletionDoc.kt
Drafts documentation, with a language model, for a completion item whose declaration has no KDoc.
Ai4CompletionDoc.resolveDoc finds the item's definition through the Kotlin source index and
returns nothing when that definition already carries a KDoc. Otherwise it sends the declaration's
source to a fast model and caches the draft in GENERATED_COMPLETION_DOCS_FILE, keyed by symbol,
file, line and a hash of the declaration source, so an edited declaration gets a fresh draft.
Ai4CompletionDoc.dropStaleGeneratedDocs evicts drafts whose source has changed or gone; it is
covered by GeneratedCompletionDocSweepTest. Drafts the docs revision pass declined, listed in
GENERATED_COMPLETION_DOCS_DECLINED_FILE, are neither shown nor regenerated.
The model is AFIELD_COMPLETION_DOC_MODEL, defaulting to the fast model.
src/jvmMain/kotlin/afield/ai/completion/Ai4CompletionDoc.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun generatedCompletionDocsPath(): Path |
The drafts cache: GENERATED_COMPLETION_DOCS_ENV when set, else GENERATED_COMPLETION_DOCS_FILE in the filestore. |
Top-level values
| Declaration | Summary |
|---|---|
const val GENERATED_COMPLETION_DOCS_FILE |
The generated-completion-doc cache's file name, spelled once. |
const val GENERATED_COMPLETION_DOCS_DECLINED_FILE |
Keys of drafts the docs revision pass looked at and chose not to use, written beside the cache by tools/docs_notes.py (DRAFT_DECLINED_FILE). Completion neither shows nor regenerates them. |
const val GENERATED_COMPLETION_DOCS_ENV |
Points the drafts cache at one file, leaving the rest of the filestore where it is. The docs runner sets it to a fresh copy of a committed cache, so its captures of the drafts read the same input on every machine; tools/docs_notes.py (DRAFT_CACHE_ENV) honours it too. |
Ai4CompletionDoc
Ai4GlslAutocomplete.kt
Language-model autocomplete for shader properties (fragment, vertex, compute and the other
shader stages).
Ai4GlslAutocomplete.complete marks the cursor in the box's shader code, wraps it for the stage's
language, adds context from the shader include library, and asks a fast model for three
candidates, each with a one-line note. It returns them as CompletionCandidates. One model thread
is kept per file key, so later requests in the same editor reuse the conversation.
The model is AFIELD_SHADER_AUTOCOMPLETE_MODEL (or AFIELD_GLSL_AUTOCOMPLETE_MODEL), defaulting
to the fast model.
src/jvmMain/kotlin/afield/ai/completion/Ai4GlslAutocomplete.kt — read the source
CompletionCandidate
data class CompletionCandidate(val text: String, val documentation: String)
| Declaration | Summary |
|---|---|
val text: String |
|
val documentation: String |
Ai4GlslAutocomplete
Ai4KotlinAutocomplete.kt
Language-model autocomplete for box Kotlin code.
Ai4KotlinAutocomplete.complete marks the cursor in the code, wraps it as a Kotlin script and asks
a fast model for three short candidates, each with a one-line note, returned as
KotlinCompletionCandidates. One model thread is kept per file key, so later requests in the same
editor reuse the conversation. The Kotlin counterpart of Ai4GlslAutocomplete.
The model is AFIELD_KOTLIN_AUTOCOMPLETE_MODEL, defaulting to the fast model.
src/jvmMain/kotlin/afield/ai/completion/Ai4KotlinAutocomplete.kt — read the source
KotlinCompletionCandidate
data class KotlinCompletionCandidate(val text: String, val documentation: String)
| Declaration | Summary |
|---|---|
val text: String |
|
val documentation: String |
Ai4KotlinAutocomplete
| Declaration | Summary |
|---|---|
fun complete(fileKey: String, code: String, line: Int, column: Int, position: Int, maxOutputTokens: Long = 320): List<KotlinCompletionCandidate> |
AiBoxConversation.kt
Where an AI4 chat transcript is stored: on the box for a box's chat, and in a file beside the document for the document chat.
AiBoxConversation reads and writes the ai4_chat_history property and the turn's running flag;
the provider's own thread is only a cache that can be reseeded from that transcript.
DocumentChatStore keeps the document-level chat in <document>/ai4_document_chat.md.
src/jvmMain/kotlin/afield/ai/conversation/AiBoxConversation.kt — read the source
AiBoxConversation
The document owns the transcript; provider threads are only disposable continuation caches.
| Declaration | Summary |
|---|---|
const val PROPERTY |
|
const val RUNNING_PROPERTY |
|
fun read(box: Box): String |
|
fun write(box: Box, transcript: String) |
|
fun running(box: Box, value: Boolean) |
|
fun recoverInterrupted(box: Box) |
Called only when there is no live turn for the box in this server. |
fun resumeContext(transcript: String, limit: Int = 64_000): String |
Full images stay in the saved transcript, but base64 must not consume the model's text budget. |
DocumentChatStore
The document chat's transcript (the chat on the code-use page, about a whole document rather than one box).
| Declaration | Summary |
|---|---|
const val FILE |
|
fun read(info: afield.app.server.SessionInformation?): String |
|
fun appendTurn(info: afield.app.server.SessionInformation?, user: String, reply: String) |
AiChatFenceCatalog.kt
The fenced blocks AI4 chat draws as pictures rather than printing as code, read on the JVM from the same catalog the browser renderers read.
src/commonMain/resources/ai4-fence-renderers.json holds one entry per language, each naming the
renderer module and export that draws it in the chat panel alongside the syntax guidance and the
worked example the model is shown. There are two today, mermaid and math. The JVM reads that
file out of its resources and turns it into the paragraph of fence instructions added to the
chat system prompt, so a language the model is told about always has a renderer behind it and a
language the browser cannot draw is never offered. Only chat mode gets that paragraph; the other
instruction modes describe no fences.
The catalog is checked on first read for a known schema version, one entry per language, a lowercase language name and no blank field. A malformed or absent catalog stops the read and says what is wrong with it, instead of leaving the panel one renderer short of what it advertised.
src/jvmMain/kotlin/afield/ai/conversation/AiChatFenceCatalog.kt — read the source
AiLiveConversation.kt
The running transcript of one AI4 chat turn, assembled in the order things happened.
A turn interleaves three kinds of entry: text streamed from the model, the activity lines that
say which tool is running, and anything the person typed while the turn was still going. Each is
appended as it arrives, and the whole sequence renders into the <<ai>>, <<you>> and
<<progress>> markers that a box's ai4_chat_history property stores and aiChatHistoryHtml
turns into the chat panel.
Two rules keep that order readable while the turn is live. Consecutive text deltas merge into one
block instead of one block per delta, and a new activity line replaces the previous one only
where that one was still transient, so a finished tool's result or failure is never overwritten
by the next thing to start. The newest activity line of a live turn is marked <<progress-open>>
and renders expanded; the earlier ones stay collapsed.
At the end the bridge hands back the turn's final text, which usually repeats what was already streamed. Rendering drops the streamed copy when the final text starts with it and nothing but completion activity follows it, so the reader is left with one answer rather than two.
src/jvmMain/kotlin/afield/ai/conversation/AiLiveConversation.kt — read the source
AiRateLimitRecovery.kt
src/jvmMain/kotlin/afield/ai/conversation/AiRateLimitRecovery.kt — read the source
AiRequestLedger.kt
The on-disk accounting record for every AI4 model request: which endpoint answered, how many tokens it billed, how long it took and how it ended, with none of the prompt and none of the reply.
The OpenAI Responses bridge and the Anthropic messages bridge both write through it, so a
session's whole model spend lands in one place whatever provider served it. Lines are appended as
JSON under ~/.afield/logs/ai_usage, redirected by the afield.ai.usageLog.path system property
or the AFIELD_AI_USAGE_LOG_PATH environment variable. A file name carries the date, the process
id and a uuid minted per JVM, so two Field processes logging at once never share a file or
contend for a lock.
Four events describe one request. attempt_started opens each attempt; response_usage records
the billed token counts, normalised so that input means the whole prompt for either provider and
cached input is part of it; retry_wait marks a backoff between attempts; and
attempt_finished carries the elapsed time, the error type, the HTTP status and whichever
rate-limit headers the provider returned. Usage is recorded before the response is validated,
because a stream that failed partway can still have billed.
Writing is best effort. A directory that cannot be written prints one warning to stderr and the request carries on, since losing accounting is not a reason to lose the answer.
src/jvmMain/kotlin/afield/ai/conversation/AiRequestLedger.kt — read the source
AiSessionTree.kt
A box's AI4 conversation recorded as a tree of turns beside its markdown transcript, so a turn can be rewound to or edited and re-sent without losing the abandoned branch.
AiSessionTree reads and writes the ai4_session JSONL sidecar and reconciles it with
ai4_chat_history, which remains what the panel shows. AiImageBlobs moves inline images out to
<document>/ai4_blobs/ as content-addressed references, and FieldBuildInfo identifies the Field
build a turn's checkpoint records.
src/jvmMain/kotlin/afield/ai/conversation/AiSessionTree.kt — read the source
AiSessionTree
A box's AI conversation as a tree, not a list
AiImageBlobs
Images in session-tree markdown, stored once per document rather than once per line and per backup.
| Declaration | Summary |
|---|---|
fun extract(markdown: String, box: Box): String |
|
fun inline(markdown: String, box: Box): String |
FieldBuildInfo
Which Field this is: the checkout's commit, whether the tree was dirty, and a hash of the diff — what a capability checkpoint records so a transcript says which code its tools ran on. Read once, in the background, from git in the working directory (a packaged build may ship field-build-info.properties instead, which wins). Not a Gradle-generated resource: one that changes on every commit would make every test task out of date.
| Declaration | Summary |
|---|---|
data class Info(val sha: String, val dirty: Boolean, val diffSha: String, val source: String) |
|
fun current(): Info |
AiStatePruning.kt
Housekeeping for the box states AI4 conversations save under <document>/ai4_blobs/state/.
AiStatePruning.sweepOrphans removes states no session tree refers to, and Box.pruneAiStates
trims old states along each branch on request, reporting by default rather than deleting.
src/jvmMain/kotlin/afield/ai/conversation/AiStatePruning.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.pruneAiStates(keepPerBranch: Int = 3, dryRun: Boolean = true): AiStatePruning.Report? |
Free disk taken by this document's saved AI4 box states, keeping the latest keepPerBranch along every branch of every conversation and the state at every fork. A turn whose state is removed can still be rewound to, but its box is not restored. Reports what it would remove unless dryRun is false. |
AiStatePruning
Keeping <document>/ai4_blobs/state/ (the box states AI4 conversations save after each request that changes them) from growing without bound, without deleting a state a turn can still be restored to.
| Declaration | Summary |
|---|---|
data class Report(val kept: Int, val removed: List<File>, val dryRun: Boolean) |
What a sweep or prune found: the files it removed (or would remove), and the bytes they hold. |
const val GRACE_MILLIS |
A state file younger than this may belong to a request still writing its turn, so it is never swept. |
fun referenced(sessions: List<AiSessionTree.Session>): Set<String> |
Every state hash any of sessions refers to. |
fun retained(sessions: List<AiSessionTree.Session>, keepPerBranch: Int): Set<String> |
The hashes Box.pruneAiStates keeps: along the path to every tip, the last keepPerBranch distinct after states of each extension key, and every state recorded at a node with more than one child. |
fun sweep(folder: File, keep: Set<String>, dryRun: Boolean, graceMillis: Long = GRACE_MILLIS, now: Long = System.currentTimeMillis()): Report |
The state files in folder whose hash is not in keep, older than graceMillis; deleted unless dryRun. |
fun sweepOrphans(box: Box, dryRun: Boolean = false): Report? |
Delete the states in box's document that no session tree refers to. |
AiToastPolicy.kt
Which AI4 log lines become a toast on the canvas, and which stay in the log.
A turn logs a great deal, and almost none of it is addressed to the person watching. Only events
carrying the ai.user channel are candidates, which is what userNotice writes; everything else
a bridge or a tool logs is diagnostics, and the log viewer is where it belongs.
Candidates are then thinned twice. The same text repeated inside fifteen seconds is dropped, and
an ordinary notice arriving within five seconds of the last one is dropped as well, so a burst of
activity shows one line rather than six. A notice also carrying warning or error skips the
five-second gate and is held back only by the repeat test.
toolActivity is the other half of the policy. It maps a handful of tool names to one short
phrase each, "Checking the view…" or "Updating the document…", and says nothing for the rest:
reads, discovery and unfamiliar tools are already visible in the chat panel and the tool pool,
and a tool's implementation name is not a description of what was asked for.
src/jvmMain/kotlin/afield/ai/conversation/AiToastPolicy.kt — read the source
AiToolFailure.kt
What a chat turn does when a tool throws. Three answers live here: which failures end the turn rather than becoming the call's output, what the model reads in place of a result, and what is written back for a call the turn ended before answering.
The provider's thread has to hold an output for every call it made, so a turn that stops mid-round still writes one for each call left outstanding; without it, every later turn in that chat is refused for a function call with no output.
src/jvmMain/kotlin/afield/ai/conversation/AiToolFailure.kt — read the source
AiTurnCancellation.kt
Telling a stopped AI4 turn apart from one that failed.
Stopping a turn arrives as a CancellationException or an InterruptedException, but rarely at
the top of the throwable: a future, a coroutine or a reflective tool call wraps it, sometimes
several layers down, so the type that was thrown says nothing by itself. The one function here
walks the cause chain for either of those two, guarding against a cycle on the way.
It is asked wherever the agent loop would otherwise swallow an exception and carry on. Saving and restoring a conversation's branch state both do that, and both rethrow instead when this answers yes, so a turn the person stopped is not written into the transcript as a step that failed.
src/jvmMain/kotlin/afield/ai/conversation/AiTurnCancellation.kt — read the source
AiTurnInbox.kt
The queue for messages a person sends to a box's chat while its AI turn is still running.
The bridge hands waiting messages to the model at the next tool-round boundary, and whatever is left
when the turn ends becomes the box's next turn. See AiTurnInbox.
src/jvmMain/kotlin/afield/ai/conversation/AiTurnInbox.kt — read the source
AiTurnInbox
Messages a person sends to a box's chat while that box's AI turn is still running.
| Declaration | Summary |
|---|---|
data class Pending(val shown: String, val forModel: String, val images: List<String> = emptyList(), val assist: AiAssist? = null, val session: io.ktor.websocket.WebSocketSession? = null, val fromField: Boolean = false) |
|
var onDelivered: ((List<Pending>) -> Unit)? |
Called, on the bridge's thread, with the messages it has just handed to the model. |
var onQueued: (() -> Unit)? |
Called by whoever queued a message, after offer and outside any lock, so the turn can show it. |
fun offer(message: Pending): Boolean |
Queue message; false once the turn has ended and taken what was left. |
fun queued(): List<Pending> |
|
fun queuedMarkdown(): String |
The waiting messages as transcript blocks, to show after the live turn; empty when none wait. |
fun drainForModel(): List<Pending> |
Take everything waiting, for the bridge to send now, and report it delivered. |
fun close(): List<Pending> |
The turn has ended: refuse further messages and return those never delivered, for the next turn. |
AiWorldUndo.kt
Which boxes an abandoned turn created can be deleted without taking anyone's later work with them.
The world undo after a rewind (ai-chat-rewind-world) undoes the abandoned turns' property edits strictly
(an edit changed since is skipped) and deletes the boxes they created. A person may since have typed into
one of those boxes, moved it, or wired it to their own; the panel promises that anything changed since is
left alone. So every turn records a fingerprint of each box its conversation has created, as the turn
starts and as it ends (AiSessionTree.TurnMeta.createdBefore / createdAfter), and a box is deleted only
when it is exactly as the last abandoned turn left it, and nobody changed it between abandoned turns either.
A fingerprint covers the box's name, frame, links to other boxes and every property that is saved with the
document, apart from the chat's own (ai4_*). Values are written out typed and quoted, lists in their own
order (their order can be a polygon's vertices or an authored sequence) and only sets and maps sorted, so
two different values never print alike. The one normalisation is for LOADER_DEFAULTS, properties that
loading a document fills in when absent, so an untouched box still matches its own fingerprint after its
document is reopened.
src/jvmMain/kotlin/afield/ai/conversation/AiWorldUndo.kt — read the source
AiWorldUndo
Fingerprints the boxes a chat turn created and decides which of them a world undo may delete.
| Declaration | Summary |
|---|---|
fun fingerprint(box: Box): String |
A 16-hex-digit SHA-256 prefix over box's name, frame, links and saved properties, excluding ai4_*. |
fun fingerprints(boxes: Map<String, Box>, uids: Collection<String>): Map<String, String> |
Fingerprints of the live boxes among uids. |
data class Plan(val delete: List<Box>, val keep: List<Pair<Box, String>>) |
The outcome of plan: boxes to delete, and boxes to keep paired with the reason they stay. |
fun plan(abandoned: List<JsonObject>, boxes: Map<String, Box>): Plan |
For abandoned (turn nodes, in the order they happened), the boxes they created that may be deleted, and those that must stay, each with the reason. |
ContextPipeline.kt
The parts an AI4 turn's prompt is built from, tagged with how long each is kept and whether it changes between turns.
A TurnContext holds a turn's ContextFragments and the request that follows them; each fragment
carries a FragmentRetention (stored with the message, or sent for this turn only) and a Stability
(cacheable across turns, or not). The provider bridges decide where each kind goes, and
TurnContext.legacyPrompt rebuilds the single-string prompt for bridges that send one.
ContextReport is what /context shows about a turn, and ContextPipeline.enabledByDefault reads the
afield.ai4.ephemeralInstructions switch.
src/jvmMain/kotlin/afield/ai/conversation/ContextPipeline.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun keptFor(placement: String, retention: String, stability: String): Kept |
Sent with this turn's requests and then dropped. |
fun ContextReport.toMarkdown(): String |
A context report as the markdown /context shows: one row per fragment, largest parts easy to spot. |
FragmentRetention
What a turn tells the model, as parts rather than one string
Constants: DURABLE, EPHEMERAL.
Stability
Constants: STABLE, VOLATILE.
ContextFragment
| Declaration | Summary |
|---|---|
val source: String |
Where it came from, as /context shows it: core.contract, tools.graph, guides.catalogue, lessons, ... |
val text: String |
The text, including the separator that joins it to the fragment before (so a join is a plain concat). |
val retention: FragmentRetention |
|
val stability: Stability |
|
val chars: Int |
|
val approxTokens: Int |
A rough token count (four characters a token); for showing proportions, never for billing. |
val sha8: String |
TurnContext
data class TurnContext(val fragments: List<ContextFragment>, val rest: String)
One turn's prompt in parts: the fragments of instructions and request, then rest — what follows them in the user's message (the prompt itself, the code an editor turn rewrites).
| Declaration | Summary |
|---|---|
fun legacyPrompt(): String |
Exactly what a turn sent before fragments existed: every fragment, then a blank line, then rest. |
fun durablePrompt(): String |
The user message to store: the durable fragments and rest. |
fun ephemeral(stability: Stability): List<ContextFragment> |
|
fun ephemeralText(stability: Stability): String |
The ephemeral fragments of one stability, joined, with the leading separator dropped. |
fun allEphemeralText(): String |
Stable first, then volatile: the order that keeps a cached prefix valid longest. |
fun instructionHash(): String |
A hash of the ephemeral instructions, for checkpoints and for telling two turns' instructions apart. |
fun report(placement: String): ContextReport |
ContextFragmentInfo
What /context shows and the tool I/O log records: fragment metadata, never the text.
| Declaration | Summary |
|---|---|
val source: String |
|
val retention: String |
|
val stability: String |
|
val chars: Int |
|
val approxTokens: Int |
|
val sha8: String |
ContextReport
DynamicToolInfo
data class DynamicToolInfo(val name: String, val box: String, val sourceRevision: String)
ContextPipeline
AFIELD_AI4_EPHEMERAL_INSTRUCTIONS=off (or -Dafield.ai4.ephemeralInstructions=off) puts every turn back on TurnContext.legacyPrompt; an agent task's context=legacy does the same for one task (the A/B arm).
| Declaration | Summary |
|---|---|
val enabledByDefault: Boolean |
Kept
enum class Kept(val label: String)
Where a fragment ends up, given where the bridge put it (ContextReport.placement). The fragment classes describe intent; the provider decides retention, and these differ: OpenAI's conversations live on its side, so anything sent inside one stays there.
Constants: EVERY_TURN, ONCE, STORED, EVERY_REQUEST, THIS_TURN.
IdeChatHelpers.kt
The entry points an editor or chat panel calls to run one AI4 turn against a box's code.
IdeChatHelpers wraps an LlmBridge. IdeChatHelpers.chatFile, IdeChatHelpers.chatInline and
IdeChatHelpers.chatError build the turn's instructions and tool context, run it through the bridge,
and return an IdeChatHelpers.HighLevelResult with the edited code, commentary and tool transcript;
IdeChatHelpers.chatGeneral runs a chat turn and returns the bridge's own result.
src/jvmMain/kotlin/afield/ai/conversation/IdeChatHelpers.kt — read the source
IdeChatHelpers
Runs AI4 editor and chat turns through one LlmBridge.
Instructions.kt
The instruction text AI4 sends the model with each turn, per kind of turn.
InstructionMode names the kinds (general, inline, error, chat). buildInstructionFragments assembles
a turn's instructions as ContextFragments from the base contract, the tool guidance and the document's
context, and buildDynamicInstructions joins them into one string. generalInstructions,
inlineInstructions, errorInstructions and generalChatInstructions are the per-mode texts.
src/jvmMain/kotlin/afield/ai/conversation/Instructions.kt — read the source
Top-level functions
InstructionMode
Constants: General, Inline, Error, Chat.
TurnSuspension.kt
How a tool result ends a model's turn early so the conversation can wait for study builds without spending model calls.
A tool result implementing SuspendsTurn carries a TurnSuspension: the AwaitedBuild jobs and a
deadline. The bridge stops after the current round and keeps that round's tool outputs as a
SuspendedRound, which the resuming turn sends ahead of its own prompt.
src/jvmMain/kotlin/afield/ai/conversation/TurnSuspension.kt — read the source
SuspendsTurn
A tool result that ends the model's turn when suspension is set: the bridge finishes the round it is in, makes no further model request, and returns the suspension in OpenAiIdeCodexBridge.SendResult. Someone else resumes the conversation later (StudyBuildAwaits for AwaitStudyBuildsRequest), so the time spent waiting costs no model calls.
| Declaration | Summary |
|---|---|
val suspension: TurnSuspension? |
TurnSuspension
What a suspended turn waits for: study-build jobs and how long it may wait for them, or the person's answer to a question (AskUserQuestionRequest), which has no deadline: their next message is the answer.
| Declaration | Summary |
|---|---|
val jobs: List<AwaitedBuild> |
|
val deadlineMs: Long |
Epoch milliseconds after which the conversation resumes whatever the jobs' state; 0 with no jobs. |
val question: AskedQuestion? |
|
fun merge(other: TurnSuspension): TurnSuspension |
One round may await more than once (two runners): wait for every job, until the later deadline. The first question stands. |
fun describe(): String |
For logs: what the turn waits for. |
AskedQuestion
data class AskedQuestion(val question: String, val choices: List<String> = emptyList())
A question the agent asked the person, and the answers it suggests (possibly none).
AwaitedBuild
One awaited attempt: the runner (its box and name), the job, and what to call it in the chat.
SuspendedRound
data class SuspendedRound(val outputs: List<PendingToolOutput>)
The tool outputs of the round that suspended a turn, which the provider has not seen yet. The next turn sends them first: on OpenAI's chained responses its previous_response_id is still the response that made these calls, and every call must have an output before anything else can follow it; on Anthropic, whose history this bridge keeps, the tool_use blocks need their tool_results in the next user message.
PendingToolOutput
A finished call's output as the provider will receive it. isError marks a failed call (Anthropic).
ChatArchive.kt
The chat archive: a saved AI4 conversation, read from disk as a tree
An AI4 chat is saved as two box properties (AiBoxConversation, AiSessionTree):
- ai4_chat_history, the transcript the panel shows: the current branch only, images inline.
- ai4_session, the session tree: every turn as a node with a parent, including turns rewound away, with
a timestamp, the model, token usage and images as ai4img: references into <document>/ai4_blobs/.
This reads both from a document folder without opening the document. No Field server or box is involved,
so it is safe to point at a document someone is working in. The tree is the source, and the transcript is
used to check it: transcript text past the tree's head (a verdict appended by an agent task, an
interrupted turn) becomes an unrecorded node, and a transcript that no longer starts with the head (a
hand edit) becomes an unrecorded root. That is what AiSessionTree would record the next time it reconciles the box.
Addresses
Every block has an address, <nodeId>.b<index>: the session-tree node that added it, and its position in
that node's markdown. Node ids are written once and never reused, so an address stays valid as the chat
grows, and a block in an abandoned branch has one just as a block on the current branch does. Summaries
written over a chat (lenses) cite these addresses.
src/jvmMain/kotlin/afield/ai/conversation/chatlog/ChatArchive.kt — read the source
ChatArchive
Reads the saved AI4 chats of a document folder from disk, without opening the document.
| Declaration | Summary |
|---|---|
const val HISTORY |
|
const val SESSION |
|
class StoredChat(val documentDir: File, val boxId: String, val boxName: String?, val history: String, val session: String) |
A chat as stored: one box's transcript and session tree. |
fun chats(documentDir: File): List<StoredChat> |
Every box in documentDir with a saved AI4 chat. |
fun tree(chat: StoredChat, families: AiChatToolFamilies = ChatToolFamilies.default): ChatTree |
|
fun lensTree(chat: StoredChat, nodeId: String, title: String, markdown: String, families: AiChatToolFamilies = ChatToolFamilies.default): ChatTree |
A lens read as a chat: one node, nodeId, holding the lens's blocks, so a lens can be outlined, read, cited and written over like any chat. Its pictures are the chat's, so chat is the chat at the bottom of the chain. |
ChatCall
data class ChatCall(val call: AiChatToolCall, val family: String)
A tool call with the family AiChatToolFamilies puts it in.
ChatImageEntry
data class ChatImageEntry(val image: AiChatImage, val resolved: Boolean?)
| Declaration | Summary |
|---|---|
val image: AiChatImage |
|
val resolved: Boolean? |
For an ai4img: reference, whether its blob is on disk; true for an inline image; null otherwise. |
ChatBlockEntry
One block of a chat with what the archive knows about it. kind is user, reply, tool (one call; a tool program's own calls are in calls), build (a study-build status line), failure (the turn failed), status (progress with no tool) or queued.
| Declaration | Summary |
|---|---|
val address: String |
|
val node: String |
|
val turn: Int? |
For a user block on the current branch, its turn number from 1. |
val role: String |
|
val kind: String |
|
val title: String |
|
val call: ChatCall? |
|
val calls: List<ChatCall> |
|
val images: List<ChatImageEntry> |
|
val chars: Int |
|
val model: afield.ai.conversation.AiChatModelIdentity? |
The model a reply records (<<model:…>>), so a re-rendered block keeps its badge. |
val sources: List<String> |
For a block of a lens, the stretches it stands for (<<source:…>>). |
val text: String |
The block's markdown, images as references; left out of the index file. |
val allCalls: List<ChatCall> |
The call itself and a program's calls: every tool call this block records. |
val failed: Boolean |
ChatNode
A session-tree node. type is turn, append, import or, for transcript text the tree has not recorded yet, unrecorded. onHead says whether it is on the current branch.
| Declaration | Summary |
|---|---|
val id: String |
|
val parent: String? |
|
val type: String |
|
val ts: Long? |
|
val onHead: Boolean |
|
val user: String? |
|
val model: String? |
|
val inputTokens: Long? |
|
val outputTokens: Long? |
|
val rounds: Int? |
|
val blocks: List<ChatBlockEntry> |
|
val chat: String? |
In a series (ChatlogStore), the chat this node comes from. |
ChatTree
| Declaration | Summary |
|---|---|
val document: String |
|
val box: String |
|
val boxName: String? |
|
val head: String? |
|
val nodes: List<ChatNode> |
|
fun node(id: String): ChatNode? |
|
fun children(id: String?): List<ChatNode> |
|
val roots: List<ChatNode> |
|
val tips: List<ChatNode> |
Tips of every branch: nodes nothing else continues from. |
val headPath: List<ChatNode> |
Nodes along the current branch, root first. |
fun block(address: String): ChatBlockEntry? |
|
fun pathTo(id: String): List<ChatNode> |
Nodes from the root to id, root first. |
fun span(from: String, to: String): List<ChatBlockEntry>? |
The blocks from from to to inclusive, in reading order along the branch that leads to to: so a range may start on the shared trunk and end on an abandoned branch. Null when either end does not exist or from does not come before to on that branch. |
val headBlocks: List<ChatBlockEntry> |
The current branch's blocks in reading order. |
ChatToolFamilies
The family table the archive uses unless given another: the ai4-chat-tool-families.json resource.
| Declaration | Summary |
|---|---|
val default: AiChatToolFamilies by lazy |
ChatArchiveCli.kt
Chat archive statistics from the command line
ChatArchiveCli reads saved AI4 chats from document folders through ChatArchive and prints a ChatStats
row per chat (shape, tool calls by family, images, tokens), as a table or JSON, or prints the outline and read
views an agent would see (ChatLogViews). It never opens a document in Field.
src/jvmMain/kotlin/afield/ai/conversation/chatlog/ChatArchiveCli.kt — read the source
ChatStats
Counts over one chat tree: its shape, its tool calls by family, its pictures, its cost.
ChatArchiveCli
afield.ai.conversation.chatlog.ChatArchiveCli: read saved AI4 chats from document folders and report on them. It reads only; it never opens a document in Field.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
|
fun table(stats: List<ChatStats>): String |
ChatLensViewer.kt
The lens viewer
A lens beside the chat it summarizes, both drawn by the chat panel's renderer, with a band from each lens
block to the stretch of the chat it stands for (chatLens.js). A box shows one with
showChatLens("house-machine-10", "storyline")
in a box whose document has run registerChatlogTools: the viewer reads chats and lenses through that
document's ChatlogStore, over the routes in ChatlogRoutes.kt, which answer only for documents that
registered one.
src/jvmMain/kotlin/afield/ai/conversation/chatlog/ChatLensViewer.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.showChatLens(chat: String, lens: String, height: Int = 900, width: Int? = null) |
Show lens beside chat in this box's lens output channel. chat may itself be a lens (<chat>#<lens>): the viewer then shows every level of the chain, each beside the one it summarizes. |
ChatLensViewer
Holds each registering document's ChatlogStore and builds the payload the lens viewer page fetches.
| Declaration | Summary |
|---|---|
fun register(document: String, store: ChatlogStore) |
Called by registerChatlogTools: document is the registering document's folder. |
fun payload(document: String, chat: String, lens: String): JsonObject |
What the viewer page fetches: one column per level of the chain, top first. lens over chat gives the lens and the chat; over <chat>#<lens> it gives three columns, and so on down to the chat itself. Every block carries its address, and lens blocks their citations, so the page can draw a band between each pair of neighbouring columns. |
ChatLogViews.kt
What an agent reads of a chat
Text views of a ChatTree for AI4 agents that write lenses (registerChatlogTools). Every view names blocks by
address, so what the agent reads is what it cites. Tool activity reads as round lines in the panel's own
words (AiChatRound); images read as their captions and ai4img: references, never as bytes.
src/jvmMain/kotlin/afield/ai/conversation/chatlog/ChatLogViews.kt — read the source
ChatLogViews
The outline and read views of a ChatTree, as text an agent can cite by block address.
| Declaration | Summary |
|---|---|
fun outline(tree: ChatTree, families: AiChatToolFamilies = ChatToolFamilies.default): String |
The map of a chat: every user turn with its time, the request in full, and what followed: replies as their first line, rounds as round lines, images as captions. Then each abandoned branch the same way. |
data class Page(val text: String, val next: String?) |
One page of read: the text, and where to continue if it stopped early. |
fun read(tree: ChatTree, from: String?, to: String?, detail: String = "text", maxChars: Int = 20_000, families: AiChatToolFamilies = ChatToolFamilies.default): Page |
The chat as text from from to to (the whole current branch when both are null), each block headed by its address. text gives requests and replies in full and rounds as round lines; full adds every tool block's own text. Stops before maxChars and says where to continue. |
data class Hit(val address: String, val kind: String, val snippet: String) |
|
fun search(tree: ChatTree, query: String, family: String? = null, limit: Int = 40): List<Hit> |
Blocks on any branch whose text contains query (case-insensitive), optionally only tool calls of family. |
data class ImageEntry(val address: String, val ref: String?, val caption: String?, val conclusion: String?) |
|
fun images(tree: ChatTree, from: String? = null, to: String? = null): List<ImageEntry> |
The images from from to to (the whole tree when both are null): where each sits, its reference, what the tool was looking for, and the agent's next words about it (the rest of its block, or the next reply). |
fun lensProblems(tree: ChatTree, lens: String): List<String> |
A lens's problems. Every block must cite what it stands for with a <<source:…>> line directly under its <<you>>/<<ai>>/<<summary>> line, and every citation must resolve to a stretch of the chat. A source line anywhere else (above the marker, at the end of the block before) is refused, because it would cite nothing and show as text. |
fun lensStats(tree: ChatTree, lens: String): Map<String, Any> |
How much of the chat a lens covers, and how much shorter it is than what it cites. |
fun withImageUrls(markdown: String, url: (String) -> String): String |
markdown with every ai4img: reference replaced by the URL url gives its blob name (<sha>.<ext>). |
fun addressedTranscript(blocks: List<ChatBlockEntry>): String |
blocks as chat markup again, each with an <<at:address>> line, so the chat panel's renderer draws them as the panel would and tags every block with where it came from (the lens viewer's line ends). |
ChatlogRoutes.kt
The lens viewer's two read routes (ChatLensViewer). Both answer only for a document that registered a chat
archive, and only to this machine and this site: chats can hold private work, and the archive may be another
workspace than the server's. Pictures are served only by blob name (<sha16>.<ext>) from the chat's own
ai4_blobs/.
src/jvmMain/kotlin/afield/ai/conversation/chatlog/ChatlogRoutes.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Route.installChatlogRoutes() |
Mounts /api/chatlog/lens, which answers ChatLensViewer.payload as JSON, and /api/chatlog/blob, which serves one picture from a chat's own blob directory. Called once while the server builds its routing. |
ChatlogTools.kt
Chat-log tools: AI4 agents that read chats and write lenses
A box hands its document's AI4 agents a chat archive by running, once:
registerChatlogTools(File("/path/to/workspace"))
Agents then see chatlog.list, chatlog.outline, chatlog.read, chatlog.search, chatlog.images and
lens.write, lens.read, lens.list. The archive is only read (see ChatArchive); the chats can live in
another workspace, including one a person is working in.
A lens is a shorter chat written over a longer one: the same <<you>>/<<ai>> markup, so the chat panel
renders it, with a <<source:a..b>> line at the top of each block saying which stretch of the chat it
stands for. Lenses are saved in lensDir, by default this document's lenses/ folder, as
<chat>/<name>.md beside a <name>.json record of the brief that made it.
Chats are named <document> when the document has one chat, otherwise <document>/<box name or id>. A lens is
itself a chat, named <chat>#<lens>: the tools outline, read and cite it like any other, so a lens can be written
over a lens (house-machine-10#episodes), and the viewer shows the whole chain.
A series runs several chats end to end, as one chat: series:<glob> takes every chat whose name matches
(series:house-machine-*), and series:<glob>@<lens> takes each one's lens of that name instead, skipping chats
that have none. A series is outlined, read and cited like a chat (its addresses are its chats' own), so a lens
over a series (series:house-machine-*@storyline#arc) tells the story across them.
src/jvmMain/kotlin/afield/ai/conversation/chatlog/ChatlogTools.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.registerChatlogTools(vararg roots: File, lensDir: File? = null) |
ChatlogStore
class ChatlogStore(val roots: List<File>, val lensDir: File)
Finds chats under roots, keeps each tree until its files change, and keeps lenses under lensDir.
Logging.kt
The channel-tagged log events the AI tooling writes, and the sinks that receive them.
A LogEvent carries a channel (a comma-separated set of tags such as info,ai.toolio), a
message, and optional block indent and boundary markers. LogDispatcher fans each event out to
every installed LogSink; LogDispatcher.withSink attaches one for the length of a block.
Sinks
StdoutLog— prints to the terminal, skipping channels muted byConsoleMute.NotifyLog— prints likeStdoutLogand also raises a clipped, de-duplicated toast.SilentLog— drops everything.
ChannelMatch is the rule all channel selectors share: a selector matches when each of its tags
is among the channel's.
File traces
TraceLogger appends sectioned, truncated text to a trace file when afield.ai4.trace (or
AFIELD_AI4_TRACE) is set; 1/true selects ~/.afield/logs/ai4_trace.log. AiToolIoLogger
writes one JSONL file per tool session under ~/.afield/logs/ai4_tool_io, on unless
afield.ai4.toolIoLog (or AFIELD_AI4_TOOL_IO_LOG) is 0, false or off.
src/jvmMain/kotlin/afield/ai/diagnostics/Logging.kt — read the source
Used in: Logging: channels, sinks and the console, Visualizers, Displays, and Little Instruments, Agent transcript gallery
Top-level functions
| Declaration | Summary |
|---|---|
fun LogSink.log(channel: String, message: String, indent: String = "", blockId: String? = null, boundary: BlockBoundary? = null) |
|
inline fun <R> LogDispatcher.withSink(sink: LogSink, block: () -> R): R |
Runs block with sink attached, and detaches it again however block ends — normally, by an early return, or on a throw. |
LogEvent
One log line: its channel tags, message, time, and the block indent and boundary it sits in.
| Declaration | Summary |
|---|---|
val channel: String |
|
val message: String |
|
val timestamp: Instant |
|
val indent: String |
|
val blockId: String? |
|
val boundary: BlockBoundary? |
BlockBoundary
Constants: BEGIN, END.
LogSink
| Declaration | Summary |
|---|---|
fun log(event: LogEvent) |
LogDispatcher
ChannelMatch
The one rule every channel selector follows — the console mute set, the pinned console and the LogStream filter: a channel is a comma-separated set of tags, and a selector matches when every one of its tags is among the channel's. So boxes matches info,boxes and boxes,debug; info,boxes matches boxes,info and info,boxes,slow; kotlin does not match kotlinjs. Tags compare exactly, case included, as written at the call site.
| Declaration | Summary |
|---|---|
fun tags(channel: String): Set<String> |
|
fun matches(channel: String, selector: String): Boolean |
ConsoleMute
Channels suppressed from the console — both the plain StdoutLog and the interactive PinnedConsole honor this. Muted events still reach every other sink (e.g. the LogStream web widget), so nothing is lost; they're just too chatty for the terminal. Default mutes the chatty background channels (info,boxes, info,persist, info,ai.boxnames, debug,boxes). Override with -Dafield.log.mute=... or AFIELD_LOG_MUTE=... (semicolon-separated selectors, matched by ChannelMatch: boxes mutes every channel tagged boxes); set to a blank/empty value to mute nothing.
| Declaration | Summary |
|---|---|
val channels: Set<String> |
|
fun isMuted(channel: String): Boolean |
StdoutLog
| Declaration | Summary |
|---|---|
override fun log(event: LogEvent) |
NotifyLog
| Declaration | Summary |
|---|---|
override fun log(event: LogEvent) |
SilentLog
| Declaration | Summary |
|---|---|
override fun log(event: LogEvent) |
TraceLogger
class TraceLogger private constructor(private val path: Path, private val maxChars: Int)
| Declaration | Summary |
|---|---|
fun log(section: String, payload: String) |
AiToolIoLogger
| Declaration | Summary |
|---|---|
data class Session(val id: String, val path: Path) |
|
fun begin(fields: Map<String, Any?>): Session |
|
fun event(session: Session, event: String, fields: Map<String, Any?> = emptyMap()) |
Clip.kt
CLIP, as a source of directions in meaning.
CLIP embeds images and text into one 512-dimensional space. Two
separate ONNX graphs — text_model.onnx and vision_model.onnx — each emit an already-projected
512-vector, so no head has to be reimplemented here.
That gives three uses:
- Zero-shot classification with no training. Encode a handful of prompts once, cosine them
against the live frame, take the best. See
ClipVector.classify. - Identity by similarity. A stored vector plus a threshold is a recogniser.
- A knob whose units are meaning.
clipEncode("storm") - clipEncode("calm")is a direction, and projecting the current frame onto it is a continuous parameter that tracks a semantic axis rather than a pixel statistic. SeeClipVector.axisTo.
Encode prompts once, into a val, and the text tower stays out of the per-frame path.
Cosine values are not comparable across modalities
This is why ClipVector.classify ranks prompts rather than thresholding a bare cosine. Measured on this checkpoint:
- text against text sits in a narrow high cone —
cos("storm","calm") = 0.881,cos("a photo of a cat","a photo of a dog") = 0.931. Nothing is ever near 0. - image against text sits far lower — around
0.21for every prompt tried, because the two towers occupy offset regions of the sphere (the "modality gap").
So 0.21 does not mean "unrelated" and 0.88 does not mean "the same". Only rank and
difference carry information: compare one image against several prompts, or track how a
projection moves over time. An absolute threshold on a raw cosine does not transfer between prompts or scenes.
Cost
Text is ≈5ms per prompt, after the one-off cost of loading the encoder. Vision is ≈36ms at
224x224 on a CPU provider, far cheaper than DepthAnything, but still not free, so drive it from a worker if it is per-frame.
The text tower is ClipText and the vision tower is ClipImage.
src/jvmMain/kotlin/afield/ai/inference/onnx/Clip.kt — read the source
Used in: Depth and CLIP: neural vision in a box
Top-level functions
| Declaration | Summary |
|---|---|
fun clipEncode(text: String): ClipVector |
Encode a prompt to a unit-length 512-vector. |
fun clipEncodeImage(image: java.awt.image.BufferedImage): ClipVector |
Encode a picture to a unit-length 512-vector, in the same space as clipEncode's prompts. |
fun clipEncodeImage(file: java.io.File): ClipVector |
clipEncodeImage for an image file: anything javax.imageio reads (JPEG, PNG, BMP, GIF). |
Clip
Shared constants and helpers for the two CLIP towers, ClipText and ClipImage.
| Declaration | Summary |
|---|---|
const val DIMENSION |
The dimensionality of the shared embedding space for this checkpoint. |
const val IMAGE_SIZE |
Side length the vision tower requires; ViT-B/32's position embeddings are fixed at 7x7+1. |
val CLIP_MEAN |
CLIP's own channel means — not ImageNet's, which DepthAnything uses. |
val CLIP_STD |
CLIP's own channel standard deviations. |
const val LOGIT_SCALE |
The trained temperature for CLIP logits, logit_scale.exp(). |
val TEXT_MODEL |
Text tower: input_ids to a projected 512-vector. |
val VISION_MODEL |
Vision tower: pixel_values to a projected 512-vector. |
val TOKENIZER |
The BPE tokenizer, as a plain file rather than a model. |
ClipVector
class ClipVector(val values: FloatArray)
A point, or a direction, in CLIP's 512-dimensional space.
| Declaration | Summary |
|---|---|
val norm: Float |
Euclidean length. 1.0, to within float error, for anything an encoder returned. |
fun normalized(): ClipVector |
Unit-length copy, or a copy of this if the length is too small to divide by. |
fun dot(other: ClipVector): Float |
Inner product. Equal to the cosine when both sides are unit length. |
fun cosine(other: ClipVector): Float |
Cosine similarity, normalising both sides first. |
operator fun plus(other: ClipVector) |
|
operator fun minus(other: ClipVector) |
|
operator fun times(scale: Float) |
|
infix fun axisTo(other: ClipVector): ClipVector |
The unit direction from this towards other — a semantic axis. |
fun project(axis: ClipVector): Float |
Scalar projection of this onto axis, assuming axis is unit length. |
fun classify(candidates: Map<String, ClipVector>, logitScale: Float = Clip.LOGIT_SCALE): List<Pair<String, Float>> |
Softmax over labelled candidates — the correct way to ask "which of these?". |
fun best(candidates: Map<String, ClipVector>): Pair<String, Float>? |
The best-matching label, or null for no candidates. Shorthand for classify.first(). |
fun toBase64(): String |
Little-endian fp32, base64 encoded — 2732 characters. |
override fun toString(): String |
ClipText
The text tower.
| Declaration | Summary |
|---|---|
fun tokenize(text: String): LongArray |
Token ids the tokenizer produces for text, including CLIP's start and end markers. |
fun encode(text: String): ClipVector |
Encode one prompt to a unit-length 512-vector. |
fun encodeAll(texts: List<String>): Map<String, ClipVector> |
encode for several prompts, sharing the loaded model. Sequential — the model is the cost. |
ClipImage
class ClipImage(val provider: OnnxRuntime.OnnxProvider = OnnxRuntime.OnnxProvider.AUTO)
The vision tower.
| Declaration | Summary |
|---|---|
val provider: OnnxRuntime.OnnxProvider |
|
val activeProvider: String |
The provider actually in force. |
var lastInferenceMs: Long |
Wall time of the last encode, in milliseconds. |
fun encode(rgba: ByteBuffer, srcW: Int, srcH: Int): ClipVector |
Encode one frame to a unit-length 512-vector. |
ClipSmokeTest.kt
src/jvmMain/kotlin/afield/ai/inference/onnx/ClipSmokeTest.kt — read the source
ClipSmokeTest
CLIP end to end: both towers, the tokenizer, and the vector algebra on top of them.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
DepthAnything.kt
Monocular depth, from any image, with no depth camera.
Depth Anything V2 Small is the least demanding useful vision model there is: one image in, one single-channel map out, no anchors, no non-maximum suppression, no decode step and no state between frames, so what it exercises is the ONNX plumbing that harder models reuse.
val depth = DepthAnything(width = 266, height = 266)
depth.infer(webcamRgbaBuffer, srcW = 640, srcH = 480)
// depth.depthFp16 now holds width*height fp16 values in [0,1]
The output is inverse relative depth, in arbitrary units
predicted_depth is not
metres and not normalised. On the synthetic frame OnnxSmokeTest uses, the raw range at 518x518 is
[1.0353, 2.8187] and at 266x266 it is [1.2826, 1.9854] — both asserted there against a Python
onnxruntime reference. The range moves
with the content and the resolution, and larger means nearer.
So a usable texture needs a normalisation window, and choosing it per-frame from that frame's own
min and max makes the image flicker: a hand entering shot rescales the entire background.
DepthAnything instead tracks the window with an exponential moving average
(DepthAnything.adaptation) and normalises
against the smoothed window, which is the cheap version of the one-euro filter that anything
body-tracked eventually needs. DepthStats reports both the raw and the smoothed window so a box
can watch that decision rather than guess at it.
Buffers keep their identity
DepthAnything.depthFp16 and DepthAnything.depthRaw are allocated once and refilled in place. That is not just to avoid
garbage: afield.graphics.texture.StreamingTexture captures its source buffer at construction and
re-reads it every frame, so the buffer has to keep the same identity for the texture to keep
working. Uploading to Vulkan is then:
val tex = StreamingTexture(
depth.depthFp16, depth.width, depth.height,
imageFormat = VK10.VK_FORMAT_R16_SFLOAT
)
fp16 is the output format because a single-channel depth map has nowhere near 16 bits of real
precision in it, and R16_SFLOAT halves the upload against R32_SFLOAT for no visible loss.
Cost
Inference is not frame-rate on a CPU execution provider: ≈388ms at 518x518 and ≈101ms at
266x266 on a 10-core Apple Silicon machine, and see OnnxRuntime for why CoreML does not fix that.
Cost is roughly quadratic in resolution, which makes DepthAnything.width and DepthAnything.height
the main lever. Anything interactive should drive this through DepthAnythingWorker rather than calling
DepthAnything.infer on the render thread.
src/jvmMain/kotlin/afield/ai/inference/onnx/DepthAnything.kt — read the source
Used in: Depth and CLIP: neural vision in a box
DepthAnything
Monocular depth estimation with Depth Anything V2 Small, normalised into a reusable fp16 buffer.
| Declaration | Summary |
|---|---|
val width: Int |
|
val height: Int |
|
val model: OnnxModel |
|
val provider: OnnxRuntime.OnnxProvider |
|
var adaptation: Float |
|
var invert: Boolean |
|
val activeProvider: String |
The provider actually in force, which may not be the one requested. |
val depthRaw: FloatBuffer |
The most recent depth map in the model's own units. Valid until the next infer. |
val depthFp16: ByteBuffer |
The most recent depth map as fp16 in [0,1], ready to upload as VK_FORMAT_R16_SFLOAT. |
var framesInferred: Long |
How many frames have been through infer. |
var lastInferenceMs: Long |
Wall time of the last infer call, in milliseconds, inference and conversion together. |
fun infer(rgba: ByteBuffer, srcW: Int, srcH: Int): DepthStats |
Run one frame. |
fun resetAdaptation() |
Forget the normalisation window, so the next frame sets it from its own range. |
DepthStats
The normalisation window one DepthAnything.infer used.
| Declaration | Summary |
|---|---|
val rawMin: Float |
|
val rawMax: Float |
|
val windowMin: Float |
|
val windowMax: Float |
|
val settled: Boolean |
True once the smoothed window has caught up with this frame's raw range. |
DepthAnythingWorker.kt
DepthAnything on its own thread, so a 100–400ms inference cannot stall the renderer.
Depth Anything Small costs ≈101ms at 266x266 and ≈388ms at 518x518 on a CPU execution provider; calling
DepthAnything.infer from a render loop turns 60fps into 2.6fps. So the renderer gets staleness instead of
latency: the most recent depth map that happens to be finished, which at 266x266 is six frames old at 60fps and looks
fine for displacement, parallax and depth-keyed compositing, and looks like lag for anything a hand is steering
directly.
// once
val depth = DepthAnythingWorker(width = 266, height = 266)
val tex = StreamingTexture(depth.depthFp16, depth.width, depth.height,
imageFormat = VK10.VK_FORMAT_R16_SFLOAT)
// per frame
depth.submit(webcam.frameBuffer, 640, 480) // returns immediately, drops old frames
if (depth.consume()) tex.recordUpload(commandBuffer)
Frames are dropped, not queued
DepthAnythingWorker.submit overwrites any frame that has not been picked up yet. A queue would be wrong here: it
would grow without bound whenever inference is slower than capture, which is always, and every frame it held would be
more stale than the one behind it. Dropping means the model is always working on the newest frame available when it
became free.
Why consume copies
DepthAnythingWorker.depthFp16 has a fixed identity because StreamingTexture captures its source buffer once, at
construction. The worker therefore cannot write into it directly — the renderer could be reading it mid-write and
would show a torn map with a visible seam. Instead the worker publishes into an internal buffer, and
DepthAnythingWorker.consume copies that into DepthAnythingWorker.depthFp16 under the lock, on the calling thread.
The copy is ~537KB at 518x518, tens of microseconds, and it is the only work the render thread does.
src/jvmMain/kotlin/afield/ai/inference/onnx/DepthAnythingWorker.kt — read the source
DepthAnythingWorker
Runs DepthAnything on a background thread and hands the renderer the newest finished depth map.
| Declaration | Summary |
|---|---|
val width: Int |
|
val height: Int |
|
val activeProvider: String |
The provider actually in force. |
val depthFp16: ByteBuffer |
The most recently consumed depth map, fp16 in [0,1], width*height*2 bytes. |
var framesSubmitted: Long |
Frames handed to submit, including ones later dropped. |
var framesInferred: Long |
Frames actually inferred. The difference from framesSubmitted is the drop count. |
var framesDropped: Long |
Frames overwritten in staging because inference was still busy when a newer one arrived. |
var lastInferenceMs: Long |
Wall time of the most recent inference, in milliseconds. |
var lastStats: DepthStats? |
Normalisation window of the most recent inference, or null before the first one completes. |
var lastError: Throwable? |
Whatever the worker thread last failed with, if anything. |
fun submit(rgba: ByteBuffer, srcW: Int, srcH: Int) |
Offer a frame. Returns immediately. |
fun consume(): Boolean |
Publish a finished depth map into depthFp16, if one is waiting. |
override fun close() |
Stop the worker thread and release the model's buffers. The cached session is left alone. |
Lpips.kt
LPIPS: how different two pictures look, as a learned perceptual distance.
0 is identical, and larger is more different; for natural images most distances fall between 0
and about 0.8. It is Zhang et al.'s "Learned Perceptual Image Patch Similarity" (CVPR 2018,
richzhang/PerceptualSimilarity, BSD-2),
version 0.1 with the AlexNet backbone: both images go through AlexNet's five convolution stages,
each stage's activations are normalised per position, and the squared differences are weighted
by a linear layer trained on human judgements of which of two distortions looks closer to the
original. So it compares edges, blobs and textures where they are, not pixel colours.
val d = Lpips.distance(imgFromFile("a.png"), imgFromFile("b.png"))
val ds = Lpips.distances(candidates, target) // many against one, the target computed once
Both images must be the same size, at least 32 pixels on each side, RGB or grey in 0..1.
Size matters
AlexNet's first stage has stride 4 and the network halves twice more, so at 32x32 the three
deepest stages are 1x1: they still see the image, but only as a whole. Measured on fixed
pairs (see tools/export_lpips_onnx.py), a yellow disc against a colour ramp is 0.42 at 32
pixels, 0.68 at 64 and 0.83 at 128; the same pair, the same verdict, a compressed scale.
Compare distances made at one size with each other, not across sizes.
Where the model comes from
Field exports its own AlexNet LPIPS ONNX file: tools/export_lpips_onnx.py builds it from the lpips package on PyPI (0.1.4, which ships
the trained linear weights) and torchvision's ImageNet AlexNet, checks the ONNX graph against the
PyTorch module (agreement 1.2e-7 on twelve pairs), and installs a 9.9MB file in the
OnnxModel cache. Lpips.ensureModel runs that script through uv the first time it is needed — a
one-off of about a minute that downloads PyTorch if uv has not cached it — and checks the result
against Lpips.MODEL's pinned sha256, which two exports with the pinned package versions reproduced
byte for byte.
Its gradient
Lpips.distanceAndGradientPlanar also returns d distance / d candidate pixel, from a second exported
graph (Lpips.GRAD_MODEL) that the same script builds and checks against PyTorch's autograd. That is
what lets a fit descend on LPIPS: afield.lang.stacksl.cpu.StackSLFit seeds its per-pixel tapes with
it.
src/jvmMain/kotlin/afield/ai/inference/onnx/Lpips.kt — read the source
Lpips
LPIPS perceptual distance between images, with an optional gradient, from an exported ONNX model.
| Declaration | Summary |
|---|---|
val MODEL |
The exported model: candidate [N,3,H,W], reference [N or 1,3,H,W] in, distanceN`` out. |
val GRAD_MODEL |
The gradient model: the same inputs, distanceN` andgradient [N,3,H,W]out, the gradient being ddistancei/ dcandidateion the0..1` pixels. |
const val MIN_SIDE: Int |
Smallest side the network accepts: AlexNet's deepest stage is 1x1 at 32. |
val EXPORT_ARGS: List<String> |
The command ensureModel runs, with the package versions the pinned digest was made with. |
val GRAD_EXPORT_ARGS: List<String> |
The command ensureGradModel runs: EXPORT_ARGS' environment, the gradient graph only. |
fun ensureModel(): Path |
The model file, exporting it first if it is not in the cache, and checked against MODEL's sha256 once per process. |
fun ensureGradModel(): Path |
The gradient model file, exported on first use and checked against GRAD_MODEL's sha256, exactly as ensureModel does for the forward model. |
val session: OnnxRuntime.LoadedSession |
The cached session. |
val gradSession: OnnxRuntime.LoadedSession |
The cached session of the gradient model. |
fun distance(a: Img, b: Img): Double |
LPIPS distance between a and b, which must be the same size. |
fun distances(candidates: List<Img>, reference: Img): DoubleArray |
LPIPS distance from each of candidates to reference; all must be the same size. |
fun distancesPlanar(candidates: FloatArray, n: Int, width: Int, height: Int, reference: FloatArray): DoubleArray |
The same, on planar data already laid out as the network wants it: candidates is n x 3 x height x width floats in 0..1, reference is one 3 x height x width image. For callers scoring thousands of renders, who should not pay for an Img per render. |
fun distanceAndGradientPlanar(candidate: FloatArray, width: Int, height: Int, reference: FloatArray): Pair<Double, FloatArray> |
The LPIPS distance from candidate to reference and its gradient with respect to every candidate value: planar 3 x height x width floats in 0..1, as distancesPlanar takes them. The gradient has the candidate's layout. |
fun distancesAndGradientsPlanar(candidates: FloatArray, n: Int, width: Int, height: Int, references: FloatArray, m: Int = 1): Pair<DoubleArray, FloatArray> |
Batched distanceAndGradientPlanar: n candidates against references of batch m, which is 1 (one broadcast reference) or n (one each, as the regions of a tiled image are). The gradients come back concatenated, n x 3 x height x width. |
fun conform(img: Img, width: Int, height: Int): Img |
img resampled to width x height and expanded to RGB: the usual way to bring two pictures to one size before comparing them. |
LpipsSmokeTest.kt
src/jvmMain/kotlin/afield/ai/inference/onnx/LpipsSmokeTest.kt — read the source
LpipsSmokeTest
Lpips, end to end on the CPU, against PyTorch.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
OnnxRuntime.kt
ONNX models, running inside Field.
ONNX Runtime reaches the JVM through Microsoft's own JNI artifact —
com.microsoft.onnxruntime:onnxruntime, already on the classpath because
afield.ai.retrieval.SemanticRetrievalService embeds text. OnnxRuntime caches each session in a JVM singleton,
so re-running a box does not rebuild it.
Session construction is the expensive half of ONNX — hundreds of
milliseconds to several seconds, because the graph is parsed, fused and specialised for the
execution provider. Frame-time inference is the cheap half. A box that says depthAnything() and
gets re-evaluated on every keystroke must not pay session construction on every keystroke, and a
recurrent model (RVM, Silero) must not lose its hidden state either.
Field recompiles the box, not the application. The session map lives in an object loaded by the
application class loader, so the map outlives every recompilation of the code that reads it — the
same discipline afield.audio.synth.FaustEngine uses for compiled DSP. Key a session by something
stable (its OnnxRuntime.SessionSpec, i.e. file plus provider plus thread counts) and hot-reload becomes free.
Build an OrtSession yourself in box code and every keystroke costs a rebuild.
Measured costs
Apple Silicon, 10 cores, Depth Anything V2 Small at 518x518. These numbers set the defaults:
- CoreML does not help this model. The provider claims 43 of 848 nodes, across 19 partitions;
the boundary crossings cost about what the accelerated nodes save (≈470ms vs ≈388ms for plain
CPU — i.e. CoreML is slower).
OnnxRuntime.OnnxProvider.AUTOtherefore resolves to CPU. CoreML is still worth requesting for transformer encoders, where the partition count is small. - fp16 weights are slower than fp32 on CPU, ≈462ms vs ≈388ms, because every operator upcasts. fp16's win is download size (50MB vs 99MB), not speed. Pick it to save disk, not time.
- Pinning intra-op threads costs throughput.
setIntraOpNumThreads(4)measured ≈457ms against ≈388ms for the runtime's own default.OnnxRuntime.SessionSpec.intraOpThreadsdefaults to 0, meaning "let ORT decide". Set it only when you have measured that the render thread is actually starving.
The general shape: nothing on a CPU execution provider is frame-rate. 388ms is 2.6fps. Inference
belongs on its own thread with the renderer reading the most recent finished result — see
DepthAnythingWorker — and resolution is a far bigger lever than any provider flag, because cost
is roughly quadratic in it.
What is not here
The Java API (1.27.0) has no IoBinding. Outputs are therefore always copies, and zero-copy CUDA/Vulkan interop is not
reachable from Java today without dropping to Panama downcalls against the same loaded library.
That is fine for everything here: a 518x518 fp32 depth map is 1.07MB, and the copy is invisible
beside a 388ms inference.
Model download and caching is OnnxModel; DepthAnything is a capability built on top.
src/jvmMain/kotlin/afield/ai/inference/onnx/OnnxRuntime.kt — read the source
Used in: Depth and CLIP: neural vision in a box
OnnxRuntime
The process-wide ONNX Runtime environment and its cache of sessions keyed by OnnxRuntime.SessionSpec.
| Declaration | Summary |
|---|---|
val env: OrtEnvironment by lazy |
The process-global environment. |
val liveSessionCount: Int |
How many sessions are currently cached; useful in tests to prove reuse actually happens. |
enum class OnnxProvider |
Which execution provider to ask for. |
data class SessionSpec(val model: Path, val provider: OnnxProvider = OnnxProvider.AUTO, val intraOpThreads: Int = 0, val interOpThreads: Int = 0) |
Everything that changes the identity of a session, and therefore the cache key. |
class LoadedSession internal constructor(val spec: SessionSpec, val session: OrtSession, val provider: String) |
A session plus the provider it actually got, as opposed to the one that was asked for. |
fun session(spec: SessionSpec): LoadedSession |
The cached session for spec, building it on first request. |
fun session(model: Path, provider: OnnxProvider = OnnxProvider.AUTO): LoadedSession |
Convenience for the common case: a model file and nothing else to say about it. |
fun evict(spec: SessionSpec): Boolean |
Drop and close one cached session. |
fun closeAll() |
Close every cached session. For shutdown and for tests that want a cold measurement. |
OnnxModel
A model that can be fetched once and then found again.
| Declaration | Summary |
|---|---|
val id: String |
|
val url: String |
|
val fileName: String |
|
val sha256: String |
|
val note: String |
|
val localPath: Path |
Where this model lives once fetched, whether or not it has been. |
val isPresent: Boolean |
True when the weights are already on disk, so ensureLocal will not touch the network. |
fun ensureLocal(): Path |
The local file, downloading it if absent. |
fun session(provider: OnnxRuntime.OnnxProvider = OnnxRuntime.OnnxProvider.AUTO): OnnxRuntime.LoadedSession |
The cached session for this model, fetching the weights first if needed. |
OnnxSmokeTest.kt
src/jvmMain/kotlin/afield/ai/inference/onnx/OnnxSmokeTest.kt — read the source
OnnxSmokeTest
The ONNX binding, end to end, on the CPU.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
AI4FilestoreCli.kt
A command-line tool that uploads files to OpenAI through the content-hash cache and chats over them.
main reads --file, --dir, --vfs-root and --vfs-file targets (run it with --help for the
full list), uploads each through OpenAiFileStoreCache so unchanged content keeps its file ID, and
creates a vector store and file batch for the results when there are any. It then opens an interactive
chat thread through afield.ai.models.OpenAiIdeCodexBridge, with file search when a vector store was
made and workspace symbol search when the Kotlin source index has definitions. The cache directory
defaults to FilestoreDir; the API key comes from --api-key or OPENAI_API_KEY.
src/jvmMain/kotlin/afield/ai/knowledge/AI4FilestoreCli.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
VectorStoreCreateResponse
data class VectorStoreCreateResponse(val id: String)
VectorStoreFileBatchCreateResponse
data class VectorStoreFileBatchCreateResponse(val id: String)
VectorStoreFileBatchGetResponse
| Declaration | Summary |
|---|---|
val id: String |
|
val status: String? |
|
val lastError: LastError? |
LastError
data class LastError(val message: String? = null)
AI4_filestore.kt
A local cache that maps file contents to OpenAI file IDs, so the same bytes are uploaded once per API key.
OpenAiFileStoreCache keys each record by a SHA-256 of the content and a peppered hash of the API key
(the key itself is never stored), keeps the database as JSON, and writes it atomically under a
read-write lock. uploadIfNeeded returns an existing file ID when the content is already known, and
DuplicatePolicy decides what a second upload of known content does. OpenAiFileUploader is the
interface an upload goes through; OkHttpOpenAiFileUploader is a minimal multipart client for
/v1/files, and uploadVirtualFiles pushes Field virtual files through the same path.
OpenAiKnowledgeStore is a second, registry-style store over the Files and Vector Stores REST endpoints.
The command-line driver for the cache is AI4FilestoreCli.kt.
src/jvmMain/kotlin/afield/ai/knowledge/AI4_filestore.kt — read the source
Top-level functions
Sha256
SHA-256 digests as lowercase hex strings.
| Declaration | Summary |
|---|---|
fun hex(bytes: ByteArray): String |
|
fun hexUtf8(s: String): String |
DuplicatePolicy
Constants: REUSE_EXISTING, APPEND, OVERWRITE, ERROR.
CachePutResult
If contentHash already exists for this keyHash, throw.
| Declaration | Summary |
|---|---|
val contentHash: String |
|
val keyHash: String |
|
val record: FileRecord |
|
val action: String |
CacheLookupResult
| Declaration | Summary |
|---|---|
val contentHash: String, val keyHash: String, val records: List<FileRecord> |
FileRecord
| Declaration | Summary |
|---|---|
val fileId: String |
|
val fileName: String |
|
val purpose: String |
|
val byteCount: Long |
|
val createdAtEpochMs: Long |
FileStoreCacheDb
OpenAiFileStoreCache
OpenAiFileUploader
fun interface OpenAiFileUploader
| Declaration | Summary |
|---|---|
fun upload(openAiApiKey: String, fileName: String, contentBytes: ByteArray, purpose: String): String |
Upload bytes as a File and return the OpenAI file id (e.g. "file-..."). |
OkHttpOpenAiFileUploader
Minimal multipart uploader for /v1/files.
| Declaration | Summary |
|---|---|
override fun upload(openAiApiKey: String, fileName: String, contentBytes: ByteArray, purpose: String): String |
VirtualUploadResult
data class VirtualUploadResult(val file: VirtualTextFile, val result: CachePutResult)
| Declaration | Summary |
|---|---|
val file: VirtualTextFile |
|
val result: CachePutResult |
CanonicalDoc
| Declaration | Summary |
|---|---|
val fileName: String, val content: String, val contentType: String |
CanonicalBundle
data class CanonicalBundle(val name: String, val version: String, val docs: List<CanonicalDoc>)
| Declaration | Summary |
|---|---|
val name: String, val version: String, val docs: List<CanonicalDoc> |
OpenAiLocalRegistry
| Declaration | Summary |
|---|---|
val schemaVersion: Int |
KeyState
| Declaration | Summary |
|---|---|
val filesBySha256: MutableMap<String, UploadedFileRecord> |
|
val vectorStoresByBundle: MutableMap<String, VectorStoreRecord> |
UploadedFileRecord
| Declaration | Summary |
|---|---|
val fileId: String, val fileName: String, val purpose: String, val bytes: Int, val createdAtEpochSec: Long |
VectorStoreRecord
data class VectorStoreRecord(val vectorStoreId: String, val createdAtEpochSec: Long)
| Declaration | Summary |
|---|---|
val vectorStoreId: String, val createdAtEpochSec: Long |
OpenAiKnowledgeStore
FilestoreDir.kt
Where Field's derived completion indexes live, resolved in one place for every index that needs it.
KotlinSourceIndex, afield.ai.tools.GlslIncludeLibraryIndex and the generated-completion-doc cache all read
their directory from FilestoreDir. The index is per checkout because KotlinSourceIndex.runSync indexes
src/{jvmMain,personal,generatedLinalg}/kotlin relative to user.dir: a single machine-wide directory
would let two checkouts, a docs build or a sibling worktree overwrite each other's index. Completion
ranking reads that index (a candidate's category comes from
KotlinSourceIndex.getCompletionCategoryForSymbol), so a shared directory would also make a test measure
whichever Field ran last rather than its own input.
Resolution order, highest first:
-Dafield.filestore.dir=<path>, which the build forwards to Gradle test workers along with the rest of theafield.namespace, so a test can pin a fixture index.AFIELD_FILESTORE_DIR=<path>, the same for a shell or a CI job.<user.dir>/.field/filestore, the per-checkout default.
On first use of (3) in a checkout that has none, the legacy ~/.afield/filestore is copied in if it
exists. Those caches are expensive to rebuild: filestore_cache.json maps content hashes to OpenAI file
IDs, and discarding it re-uploads every source file. The copy happens once, is logged, and is
best-effort; a failure leaves an empty directory, which means a slow first run, not a wrong answer.
FilestoreDirTest exercises the default, the explicit pin and the empty-directory case. See dev_docs/HERMETIC_COMPLETION_INPUTS.md.
src/jvmMain/kotlin/afield/ai/knowledge/FilestoreDir.kt — read the source
FilestoreDir
Resolves the filestore directory: system property, then environment variable, then the checkout default.
| Declaration | Summary |
|---|---|
const val PROPERTY |
|
const val ENV |
|
val legacyGlobal: Path |
The machine-wide ~/.afield/filestore, which FilestoreDir copies from and never deletes. |
val path: Path |
The directory itself. Read late rather than captured in a val, so that a test pinning it after some object's <clinit> has run is still obeyed; a value captured at class-load time would keep answering with whatever was resolved first. |
fun resolve(name: String): Path |
|
fun ensure(): Path |
Create it if it is not there, and return it. Callers that write should use this. |
fun <T> withDirectory(dir: Path, block: () -> T): T |
Run block with the filestore pinned to dir — how a test hands the completion path a known index instead of whatever the machine last wrote. |
fun describe(): String |
What was resolved and why, for a banner or a bug report. |
deepsets.kt
A learned two-body interaction: the acceleration on a particle as a function of the neighbours around it, fitted by autodiff from examples of any rule that can already be evaluated.
flowMatch learns v(x, t): where a particle should go given only where it is. That can carry
a cloud onto a shape, and it is blind to the other particles. An interaction is the other kind
of rule:
a_i = phi( v_i, SUM_j psi(x_j - x_i, v_j - v_i), MEAN_j psi(...) )
two small networks and a reduction over neighbours. This is the Deep Sets form, and its
aggregation is permutation-invariant over any number of neighbours, so one set of weights works
for a particle with three neighbours and a particle with three hundred. Classical boids is one
particular choice of psi and phi, so the family contains it.
Why both a sum and a mean
Reynolds separation is a sum: being crowded by twenty neighbours pushes harder than being
crowded by two. Alignment and cohesion are means, since they are averages of neighbour
velocity and position. A mean-only aggregator cannot represent separation at any width, and a
sum-only one makes alignment scale with crowding, so phi is fed both, at a cost of
messageWidth extra inputs. DeepSetsWeights.sumScale keeps the summed branch in a sane range.
The feature map is where the physics goes
psi is not fed the raw offset. It is fed interactionFeatures: the offset and velocity
difference, plus the unit direction scaled by a small radial basis, u*p and u*p^2, where
p is a bounded proximity that rises as neighbours get close.
Separation goes as 1/r over a four-hundred-fold range of r, and a tanh network cannot
form the product of a direction and a steep radial profile out of Cartesian components at any
practical width. Alignment and cohesion are plain means, and linear in the inputs, so they
survive the raw encoding. Measured by ablation against the flocking rule: fed raw Cartesian
offsets the model reproduces those two to a relative error of 0.036 and separation only to
0.78, which is barely better than predicting the average.
A radial interaction is a direction times a profile, so handing the network u already
multiplied by a couple of profile terms states the form of a two-body law rather than
transcribing boids. The profile's coefficients, the velocity dependence, and how the summed and
meaned branches combine are all still learned.
Trained by afield.math.autodiff.ADTape, not by Smile
Smile's MLP cannot express this: the gradient has to flow back through a sum over a
variable-length neighbour set, and psi is applied once per neighbour with shared weights.
autodiff.kt is reverse-mode over a tape, which handles it without knowing anything about
networks, since the tape records whatever the forward pass did. Cost is one forward and one
backward sweep per batch whatever the neighbour counts are.
The weights come out as MlpWeights
Both halves are packaged in the same flat layout the flow-matching work already uses, so
MlpWeights.glsl emits them for the GPU unchanged and the parity machinery in
four.neural-field-parity.smoke applies to them without modification.
src/jvmMain/kotlin/afield/ai/learning/deepsets.kt — read the source
Used in: Neural particle systems
Top-level functions
DeepSetsWeights
A trained interaction in the plain Cartesian form: psi and phi, the width of the message between them, and the five scales that normalise a neighbourhood into the networks and the answer back out to world units. EquivariantWeights is the same model constrained so that rotating a neighbourhood rotates the answer with it.
| Declaration | Summary |
|---|---|
val psi: MlpWeights |
Applied per neighbour. Inputs (dx, dy, dvx, dvy) normalised; outputs messageWidth. |
val phi: MlpWeights |
Applied once. Inputs (vx, vy, sum…, mean…) normalised; outputs a normalised acceleration. |
val messageWidth: Int |
How wide a single neighbour's message is. |
val radius: Float |
Interaction radius, in world units. Offsets are divided by this. |
val velocityScale: Float |
Velocity differences are divided by this. |
val accelScale: Float |
The output is multiplied by this to get a world-units acceleration. |
val sumScale: Float |
The summed branch is divided by this, so a typical crowd lands near 1. |
val minSeparation: Float |
Closest approach used by the proximity feature — the same floor the rule itself uses. |
val parameterCount: Int |
How many weights and biases in total. |
fun evaluate(vx: Float, vy: Float, neighbours: List<FloatArray>): Pair<Float, Float> |
The acceleration on a particle with velocity (vx, vy) given its neighbours, in fp32 and in the same order a shader would accumulate it — the reference the GPU is held to. |
override fun toString(): String |
Both network shapes and the parameter total. The GLSL emitter writes this line into the shader as a comment, so a generated source names the weights it was generated from. |
InteractionExample
One training example: a neighbourhood and the acceleration the rule being imitated produces.
DeepSetsOptions
The knobs trainDeepSets takes.
| Declaration | Summary |
|---|---|
val steps: Int |
Adam steps to take. Each one calls examples for a fresh batch. |
val batch: Int |
How many examples a batch is expected to hold. trainDeepSets divides its loss by the size of the list examples actually returned and only checks this is positive; trainEquivariant divides by this number, so a batch of a different size there rescales the gradient. |
val messageWidth: Int |
How many numbers psi emits per neighbour, and so how wide the summed and the meaned branch phi reads each are. trainEquivariant reads it as a count of vector channels instead. |
val psiHidden: IntArray |
Hidden layer widths of the per-neighbour network. tanh between them, linear out. |
val phiHidden: IntArray |
Hidden layer widths of the combining network. |
val learningRate: Double |
Adam's rate at the first step. |
val finalLearningRate: Double |
Decayed linearly to this by the last step. Same reasoning as FlowMatchOptions. |
val seed: Long |
Seeds the Xavier initialisation, and nothing else: examples draws its own randomness. |
val onProgress: ((Int, Double) -> Unit)? |
Called with the step number and that step's loss, or null to train silently. |
val progressEvery: Int |
How often onProgress fires, in steps. The last step reports only if it lands on one. |
EquivariantWeights
A learned interaction that is rotationally equivariant by construction — a(R x) = R a(x) holds exactly, not approximately.
| Declaration | Summary |
|---|---|
val psi: MlpWeights |
Invariants to 2 * channels scalars: a_k then b_k. |
val phi: MlpWeights |
Aggregate invariants to 2 * channels + 1 scalars: the sum weights, mean weights, and v. |
val channels: Int |
How many vector channels are aggregated. |
val radius: Float |
Interaction radius, in world units. Neighbour distances are divided by this. |
val velocityScale: Float |
Divides velocity differences, and the centre particle's own speed, into the invariants. |
val accelScale: Float |
The output is multiplied by this to get a world-units acceleration. |
val sumScale: Float |
The summed channels are divided by this, so a typical crowd lands near 1. |
val minSeparation: Float |
Closest approach used by the proximity invariant, the same floor the rule itself uses. |
val parameterCount: Int |
How many weights and biases in total. |
fun evaluate(vx: Float, vy: Float, neighbours: List<FloatArray>): Pair<Float, Float> |
The acceleration, in world units. Equivariant to fp32. |
override fun toString(): String |
Both network shapes, the channel count and the parameter total. The GLSL emitter writes this line into the shader as a comment. |
flowmatching.kt
Flow matching over 2D and 3D point clouds — flowMatch trains a velocity field that carries one
distribution onto another, and the field it returns can be sampled, integrated part-way, or drawn.
The idea is small enough to state completely. Pick a point x0 from where you are starting and a
point x1 from where you want to end up, and declare that the straight line between them is the
path a particle should take: at time t it is at xt = (1-t)·x0 + t·x1, travelling at x1 - x0.
Now fit a network v(x, t) to that velocity by least squares over many random (x0, x1, t)
triples. No two straight lines agree where they cross, so the fit cannot reproduce any single one
of them; what it converges to is the average velocity through each point at each time, and
integrating that average from t = 0 to t = 1 transports the whole starting distribution onto
the whole target one. That is the entire trick, and it is why there is no adversary, no sampler in
the training loop, and nothing to diverge — the loss is a plain regression MSE.
val target = flowSamples(myLine, 600, jitter = 4.0) // sampled FLine + noise
val field = flowMatch(target, options = FlowMatchOptions(steps = 6000))
val cloud = field.sample(600) // fresh points from the same shape
val threads = field.paths(80) // the trajectories, as FLines
With a from cloud supplied it becomes a morph instead of a generator, and
FlowMatchField.transport runs the ODE only part-way: transport(a, to = 0.5) is a genuine
half-way shape rather than a vertex-wise average of two point lists.
val morph = flowMatch(to = flowSamples(lineB, 600), from = flowSamples(lineA, 600))
val halfway = morph.transport(flowSamples(lineA, 600), to = 0.5)
Coordinates
Training happens in a normalised frame — the target cloud (both clouds, for a morph) is centred
and isotropically scaled to roughly unit variance — because a Gaussian source has to be
commensurate with the data for any of this to work, and FLine coordinates are usually in the
hundreds. The scaling is a single factor rather than per-axis, so it cannot squash a shape's aspect
ratio. FlowMatchField converts at its boundary, so everything you pass in and get back is in the
coordinates you started with.
2D and 3D are distinguished by the data, not by a flag: a cloud whose z never varies is trained
as a 2D problem and its points come back on that same z plane. Pass FlowMatchOptions.dimension
to override. It matters — a 2D shape trained as 3D gets a Gaussian's worth of spread in z that
the field then has to learn to collapse.
What this is not
- Not a big model. The default is two hidden layers of 64
leakyunits trained by RMSProp, which is right for a few thousand points in two or three dimensions and nothing like enough for images. Training is single-threaded and per-sample (Smile's MLP has no batched matmul), so cost scales withsteps × batch; see the timings inFlowMatchingTest. - Not able to change topology. Integrating a velocity field is a continuous invertible map, so
it cannot tear a loop open or join two strands into one — a flow can only deform. With
FlowCoupling.PAIREDthis stops being an abstraction and starts being the dominant error term: asking a straight segment to become a closed ring makes the mean landing error fifteen times worse than asking a small circle to do it, because the two ends of the segment are pulled to the same place and their paths have to cross. Give a closed target a closed source. - Not exact. The field is an average over crossing paths, so samples land near the target
distribution, not on it. Sharp corners smooth off, and thin structure needs more
stepsand morehiddenbefore it resolves.FlowMatchField.lossis the training MSE, which measures the velocity fit and not the quality of the samples — judge those by looking, or withchamferDistance. - Not ordered.
FlowMatchField.samplereturns an unordered cloud. Recovering an FLine from it is a separate problem;FlowMatchField.transportsidesteps it by moving points you already have, which keeps whatever order they came in. - Not conditional. There is no class label or text prompt input — one field carries one distribution onto one other.
src/jvmMain/kotlin/afield/ai/learning/flowmatching.kt — read the source
Used in: Flow Matching, Neural particle systems, A Generator, Graded by Datalog
Top-level functions
| Declaration | Summary |
|---|---|
fun flowMatch(to: List<Vec3>, from: List<Vec3>? = null, options: FlowMatchOptions = FlowMatchOptions()): FlowMatchField |
Trains a velocity field that carries from onto to, and returns it. |
fun flowMatch(line: FLine, samples: Int = 600, jitter: Double? = null, options: FlowMatchOptions = FlowMatchOptions()): FlowMatchField |
Trains a field that generates points shaped like line — the common case, spelled out. |
fun flowSamples(line: FLine, count: Int = 600, jitter: Double = 0.0, seed: Long? = null): List<Vec3> |
Samples count points evenly along line's arc length, displaced by Gaussian noise of standard deviation jitter. |
fun contourComponents(lines: List<FLine>, total: Int = 3500, minimum: Int = 24, jitter: Double = 0.0, seed: Long? = null): List<List<Vec3>> |
Splits lines into their closed subpaths and samples each one in arc-length order, in proportion to its length — the components mixtureSource wants, prepared the way that matters. |
fun mixtureSource(components: List<List<Vec3>>, shrink: Double = 1.0, jitter: Double = 0.0, alignPhase: Boolean = true, seed: Long? = null): FlowPairing |
Builds a source cloud whose topology matches the target's: one closed curve per component, in point-for-point correspondence with the component it has to become. |
fun chamferDistance(a: List<Vec3>, b: List<Vec3>): Double |
Symmetric mean nearest-neighbour distance between two point clouds — how far apart two shapes are, in input coordinates. |
VectorMLP
A multi-output regression MLP, which is the one thing Smile's own smile.regression.MLP will not do — it fixes the output layer at a single neuron and predict returns a Double.
| Declaration | Summary |
|---|---|
val inputs: Int |
Width of the input vector. |
val outputs: Int |
Width of the predicted vector. |
val activation: String |
The hidden activation, kept because exportWeights has to name it and Smile does not. |
fun predict(x: DoubleArray, into: DoubleArray = DoubleArray(outputs)): DoubleArray |
Runs the network forward, writing outputs values into into, which is returned. |
fun accumulate(x: DoubleArray, y: DoubleArray): Double |
Adds one sample's gradient to the batch, returning that sample's squared error. |
fun step(batchSize: Int) |
Applies the accumulated gradient of a batch of batchSize samples and advances the clock. |
fun configure(learningRate: Double, momentum: Double, rho: Double, epsilon: Double = 1e-7) |
Configures RMSProp, momentum and learning rate together, the combination flowMatch uses. |
fun configure(learningRate: TimeFunction, momentum: Double, rho: Double, epsilon: Double = 1e-7) |
As configure, but with a learning-rate schedule — TimeFunction.linear(from, steps, to) and friends, indexed by the step count. |
fun parameterCount(): Int |
How many parameters — weights plus biases — the network is fitting. |
fun exportWeights(): MlpWeights |
A flat snapshot of every weight and bias, in the layout MlpWeights documents — which is what afield.ai.learning.glsl turns into a shader. |
FlowCoupling
How flowMatch decides which x0 travels to which x1.
Constants: INDEPENDENT, MINIBATCH_OT, PAIRED.
FlowIntegrator
Which integrator FlowMatchField uses to follow the velocity field.
Constants: EULER, MIDPOINT.
FlowMatchOptions
The knobs flowMatch takes, bundled so that a call that only wants to change one of them does not have to name the rest positionally.
| Declaration | Summary |
|---|---|
val steps: Int |
Gradient steps to take. The dominant cost and the dominant quality lever. |
val batch: Int |
Samples per gradient step. |
val hidden: IntArray |
Hidden layer widths. |
val activation: String |
Hidden activation: leaky, relu, tanh, sigmoid or linear. |
val learningRate: Double |
RMSProp learning rate at the first step. |
val finalLearningRate: Double? |
Learning rate at the last step, decayed linearly from learningRate; null to hold it constant. |
val momentum: Double |
Momentum, or 0 to disable. flowMatch refuses it unless rho is 0. |
val rho: Double |
RMSProp decay, or 0 to fall back to plain SGD. See momentum before changing either. |
val timeFeatures: Int |
How many sin/cos octaves of the time input the network sees, on top of raw t. |
val coupling: FlowCoupling |
How x0 is paired with x1. |
val jitter: Double |
Standard deviation of noise added to each drawn data point, in input coordinates. |
val seed: Long? |
Seed for the training draws. null takes an unpredictable one. |
val dimension: Int? |
Override the inferred 2-or-3 dimensionality. |
val onProgress: ((Int, Double) -> Unit)? |
Called every progressEvery steps with the step index and the running mean loss. |
val progressEvery: Int |
How often onProgress fires. |
override fun equals(other: Any?): Boolean |
Two option sets are equal when they print the same. hidden is an IntArray, which compares by identity, so the equality a data class generates would call two identical settings different. |
override fun hashCode(): Int |
Hashes the same string equals compares, so equal option sets land in the same bucket. |
FlowPairing
class FlowPairing(val from: List<Vec3>, val to: List<Vec3>)
A source cloud and a target cloud of the same length, in correspondence — what FlowCoupling.PAIRED wants, and what mixtureSource builds.
| Declaration | Summary |
|---|---|
val from: List<Vec3> |
Where the particles start. The i-th point travels to to[i](workspace.md#stdlib-kt--i). |
val to: List<Vec3> |
Where they end up. |
val size: Int |
How many pairs. |
FlowMatchField
A trained velocity field, as returned by flowMatch — the thing you integrate.
| Declaration | Summary |
|---|---|
val dimension: Int |
2 or 3 — whether z is part of the problem. |
val losses: DoubleArray |
The mean squared velocity error of every training step, in order. |
val loss: Double |
The training loss of the final step. |
fun loss(count: Int): Double |
Mean training loss over the last count steps — steadier to read than loss alone. |
val hasSourceCloud: Boolean |
Whether this field was trained to morph from a supplied cloud rather than from a Gaussian. |
val parameterCount: Int |
How many weights and biases the field is made of. |
val network: MlpWeights |
The trained network itself, flattened for a shader — see MlpWeights. |
fun export(): FlowMatchExport |
Everything a shader needs to reproduce velocity — the network plus the three things around it: how wide the position is, how t is encoded, and the frame the training happened in. |
fun velocity(at: Vec3, t: Double): Vec3 |
The velocity the field predicts at at and time t, in input coordinates. |
fun sample(count: Int, steps: Int = 32, integrator: FlowIntegrator = FlowIntegrator.MIDPOINT, seed: Long? = null): List<Vec3> |
Draws count points from the source and follows the field to t = 1. |
fun transport(points: List<Vec3>, from: Double = 0.0, to: Double = 1.0, steps: Int = 32, integrator: FlowIntegrator = FlowIntegrator.MIDPOINT): List<Vec3> |
Carries points along the field from time from to time to. |
fun paths(count: Int, steps: Int = 32, integrator: FlowIntegrator = FlowIntegrator.MIDPOINT, seed: Long? = null): List<FLine> |
The trajectories of count fresh samples, one FLine each, from t = 0 to t = 1. |
fun pathsFrom(points: List<Vec3>, from: Double = 0.0, to: Double = 1.0, steps: Int = 32, integrator: FlowIntegrator = FlowIntegrator.MIDPOINT): List<FLine> |
The trajectories of points specifically, from time from to time to — see paths. |
corpora.kt
Synthetic drawings to teach a learned pen: Hershey plotter fonts and four drawing generators,
and a writer that turns a list of drawings into a corpus python/fieldpen can train on.
Every generator draws in the order a hand (or a plotter) would, which is the one thing a pen
needs from its training data and the one thing a scanned drawing cannot supply. PenCorpus
resamples each drawing exactly the way Pen.feed reads a line, so a pen trained on a corpus sees
the same spacing when it is later shown a line. Tests: PenCorporaTest.
src/jvmMain/kotlin/afield/ai/learning/pen/corpora.kt — read the source
Used in: Teaching a pen, A pen that sees
Top-level functions
| Declaration | Summary |
|---|---|
fun curliness(line: FLine, scale: Double = 8.0, step: Double = 0.35, shortest: Int = 6): Double |
Mean absolute turn per unit length along line's marks, a rough measure of how curly it is, taken after resampling the way a pen reads a line (PenCorpus.strokes). Measured on the raw points instead, it mostly counts the small jitter between close points, and choosing by it picks the shakiest lines rather than the curliest. Marks of fewer than shortest resampled points are left out, so a line of dots does not count as curly either. |
fun PenModel.probe(drawings: List<FLine>, labels: List<List<Int>>, scale: Double = 8.0, step: Double = 0.35): List<Pair<Int, Double>> |
Which memory cells track a label: show model each of drawings (a fresh pen per drawing), and correlate every cell's output with the label of the subpath being drawn, labels[d](workspace.md#stdlib-collections-kt--s) for subpath s of drawing d. Returns every cell with its correlation, strongest (positive or negative) first. |
fun following(line: FLine, within: Double = 8.0, recent: Int = 8, scale: Double = 8.0, step: Double = 0.35): Double |
How much of line runs alongside ink it drew earlier: the share of its points (resampled the way a pen reads a line) with an earlier point, more than recent points back, within within drawing units. Hatching scores high; a single wandering line scores near zero. |
fun joins(line: FLine, within: Double = 4.0, recent: Int = 8, scale: Double = 8.0, step: Double = 0.35): Double |
How many of line's strokes end on other ink: the share of its subpaths whose last point is within within drawing units of a point of any earlier subpath, or of this one more than recent points back. A web of cracks scores high; strokes that stop in mid-air score low. |
HersheyFont
class HersheyFont private constructor(val name: String, private val glyphs: List<Glyph>)
One of the Hershey fonts: single-stroke vector lettering from 1967, drawn as a pen plotter draws it, stroke by stroke.
| Declaration | Summary |
|---|---|
fun strokes(c: Char): List<List<IntArray>>? |
The strokes of c in font units (y down, about 32 units from ascender to descender), or null. |
fun text(text: String, size: Double = 24.0, at: Vec3 = Vec3(0f, 0f, 0f)): FLine |
text as one FLine, a subpath per stroke, starting at at. size is the height of a capital in drawing units. |
PenDrawings
Generators of ordered drawings for training a pen. Each takes a seed and returns one drawing at a size that suits the default scale of 8 drawing units per model unit.
| Declaration | Summary |
|---|---|
fun word(random: Random, length: Int = random.nextInt(2, 8)): String |
A made-up word of length letters, alternating consonants and vowels often enough to look like one. |
fun hershey(seed: Long, font: String = "scripts", words: Int = 5, size: Double = 22.0): FLine |
A line of made-up words in a Hershey font: words words, capitals size units tall. |
fun harmonograph(seed: Long, radius: Double = 70.0, turns: Double = 14.0): FLine |
A harmonograph: two damped pendulums per axis, summed, drawn as one long decaying stroke. The frequencies sit near small whole-number ratios, which is what makes the figure close up. |
fun plant(seed: Long, generations: Int = 4, height: Double = 140.0): FLine |
A branching plant from an L-system, drawn by a turtle in one unbroken stroke: each branch is drawn out to its tip and back down to its fork, the way a hand draws a twig. |
fun truchet(seed: Long, columns: Int = 9, rows: Int = 5, cell: Double = 18.0): FLine |
Truchet tiles: a grid of squares, each holding two quarter-circle arcs one way or the other, whose arcs join into long winding curves. Each curve is one stroke, traced end to end. |
fun boids(seed: Long, count: Int = 10, steps: Int = 160, size: Double = 160.0): FLine |
Flocking trails: a dozen boids that steer towards each other, align and keep apart, wandering a box. Each boid's path is one stroke, drawn one boid after another. |
fun michaux(seed: Long, columns: Int = 2, lines: Int = 14, length: Double = 200.0, gap: Double = 5.0): FLine |
After Henri Michaux's mescaline drawings: columns of fine, trembling lines, each laid down beside the last at a small gap and following its undulation, a little changed, so the field accumulates. A column is one unbroken stroke going down, turning, and coming back up beside itself, the way hatching is drawn; now and then a pass leans into its neighbour and touches it. |
fun cracks(seed: Long, size: Double = 110.0, count: Int = 26): FLine |
A web of cracks: a wobbling outline, then crack after crack, each starting on the ink near the last one and wandering until it meets a line, where it stops. Every crack ends in a join: the thing a sensing pen can learn and a blind one cannot. |
fun echoes(seed: Long, guides: Int = 3, passes: Int = 7, gap: Double = 5.0): FLine |
Echo hatching: a few guide strokes (a stroke of a Hershey letter, a smooth curve, or a closed blob, each at a random angle), each followed by passes laid alongside it at a small gap, back and forth in one unbroken stroke with a tremor, outwards from the guide. A pass is cut short where it would run into other ink, so hatching stops at the lines around it. |
fun twoHands(seed: Long, words: Int = 6, hands: MutableList<Int>? = null): FLine |
A line in two hands: words that are either chains of small loops (hand 0) or sharp zigzags (hand 1), the hand changing every word or two. hands gets the hand of each word, one entry per subpath of the result, which is what a probe needs to find the cell that tracks it. |
PenCorpus
A corpus of drawings for python/fieldpen: one JSON file per drawing, split into train/ and valid/ under a folder.
| Declaration | Summary |
|---|---|
fun write(folder: String, drawings: List<FLine>, scale: Double = 8.0, step: Double = 0.35, validEvery: Int = 10): Int |
Write drawings under folder, every validEveryth one into valid/. Returns the number of points written. |
fun strokes(line: FLine, scale: Double = 8.0, step: Double = 0.35): List<List<Vec3>> |
line's subpaths resampled every step model units: what the pen is shown. |
PenCorpora
Writes the corpora Field's bundled synthetic pens were trained on: run-jvm-main afield.ai.learning.pen.PenCorpora --arg <folder> makes one sub-folder per corpus, which tools/train_pen_corpora.py then trains and exports.
| Declaration | Summary |
|---|---|
fun all(): Map<String, () -> List<FLine>> |
Every corpus by name: the drawings to write. |
fun rounder(candidates: Int = 2400, keep: Int = 300): List<FLine> |
The handwriting pen's own output, kept only where it is curliest: 2400 short unprimed scribbles of 120 points, the top eighth by curliness. Fine-tuning on it is a pen learning a taste from its own work. |
fun main(args: Array<String>) |
guide.kt
Guided sampling for a learned pen: the pen proposes several next points and a PenGuide picks
one. The pen keeps its hand (its tremor, its rhythm, its habits) while the guide decides where it
goes. FollowGuide keeps a pen a set distance from the ink beside it, which is hatching.
Tests: PenHatchTest.
src/jvmMain/kotlin/afield/ai/learning/pen/guide.kt — read the source
Used in: A pen that sees
PenGuide
Chooses among a pen's proposals for its next point; set one on PenLine.guide.
| Declaration | Summary |
|---|---|
val candidates: Int |
How many proposals the pen makes each point. |
fun score(from: Vec3, to: Vec3, lift: Boolean, penUp: Boolean): Double |
How bad a proposal is, lower is better: a step from from to to in drawing units, after which the pen lifts if lift. penUp says whether this step is a move rather than a mark. |
fun drawn(at: Vec3, lift: Boolean) |
The pen drew the chosen point; a guide that tracks ink records it here. |
FollowGuide
Keeps a pen gap drawing units from the ink beside it, without crossing any: hatching.
| Declaration | Summary |
|---|---|
val gap: Double |
|
override val candidates: Int |
|
val recent: Int |
|
val reach: Double |
|
val liftCost: Double |
|
val longest: Double |
|
fun see(lines: List<FLine>, spacing: Double = 2.0) |
Ink the pen should keep its distance from, visible at once: the drawing being hatched. |
override fun drawn(at: Vec3, lift: Boolean) |
|
override fun score(from: Vec3, to: Vec3, lift: Boolean, penUp: Boolean): Double |
hatch.kt
Hatching around a drawing with a sensing pen: every stroke of the drawing gets passes of the pen's own trembling line laid beside it, which keep clear of the other strokes and of the hatching already done.
A pen cannot remember which strokes it has not hatched yet, so Hatcher chooses: it hands the
pen one stroke at a time as a finished primer, with everything else on the page as
surroundings, and moves on when the pen has drawn enough or wandered off. Tests: PenHatchTest.
src/jvmMain/kotlin/afield/ai/learning/pen/hatch.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun hatch(lines: List<FLine>, model: StrokeModel = PenModel.bundled("echoes"), passes: Int = 6, seed: Long = 0, controls: PenControls = PenControls(temperature = 1.0, noise = 1.0), gap: Double? = 4.0): List<FLine> |
Hatch around lines with a sensing pen and return the hatching, one FLine per stroke: Hatcher run to the end. |
Hatcher
Hatches lines stroke by stroke with model, grow points at a time, so a box can watch it.
| Declaration | Summary |
|---|---|
val model: StrokeModel |
|
val passes: Int |
|
val scale: Double |
|
var controls: PenControls |
|
val seed: Long |
|
val step: Double |
|
val wander: Double |
|
val gap: Double? |
|
val hatching: MutableList<FLine> |
The hatching so far, one FLine per stroke started. |
val done: Boolean |
True once every stroke has been hatched. |
val current: FLine? |
The stroke being hatched now, or null when done. |
fun grow(steps: Int = 8): Boolean |
Draw up to steps more points; returns false once everything is hatched. |
fun finish(): List<FLine> |
Hatch everything; returns hatching. |
pen.kt
A learned pen: a recurrent network that has watched people write and draws the next point.
PenModel is a one-layer (or deeper) LSTM whose output is a mixture of 2D Gaussians for the next
pen displacement plus a probability that the pen lifts. PenModel.distill is the handwriting model
from Carter, Ha, Johnson and Olah, Experiments in Handwriting with a Neural Network (Distill,
2016), bundled with Field. PenModel.load reads a model retrained or fine-tuned with the optional
Python package python/fieldpen and exported with fieldpen.export_for_field.
The everyday call is continueWith: hand it an FLine and get back the next few hundred points
the pen would have drawn. Pen is the state underneath, for when you want to feed, step, fork
or edit the pen yourself. Tests: PenModelTest.
src/jvmMain/kotlin/afield/ai/learning/pen/pen.kt — read the source
Used in: A learned pen, Teaching a pen, A pen that sees
Top-level functions
| Declaration | Summary |
|---|---|
fun FLine.continueWith(model: StrokeModel = PenModel.distill, steps: Int = 300, scale: Double = 8.0, controls: PenControls = PenControls(), seed: Long = 0, step: Double = 0.35, liftAtEnd: Boolean = false): FLine |
Continue this line with a learned pen: the next steps points it would draw, as a new FLine that starts where this one ends. |
fun FLine.futuresWith(model: StrokeModel = PenModel.distill, count: Int = 16, steps: Int = 60, scale: Double = 8.0, controls: PenControls = PenControls(), seed: Long = 1, step: Double = 0.35): List<FLine> |
Several continuations of this line at once: count different futures of steps points, each a fresh FLine from this line's end. Futures share the pen's reading of the line and differ only in the random choices after it. |
StrokeModel
Anything that can continue a stroke one displacement at a time.
| Declaration | Summary |
|---|---|
fun pen(seed: Long = 0): StrokePen |
A fresh pen that has seen nothing, sampling with a generator seeded by seed. |
StrokePen
The running state of a StrokeModel: everything it has drawn or been shown so far.
| Declaration | Summary |
|---|---|
fun feed(dx: Double, dy: Double, lift: Boolean) |
Show the pen a displacement in model units; lift means the pen comes up after this point. |
fun next(controls: PenControls = PenControls()): PenStep |
Sample the next displacement and feed it back, as though the pen had drawn it. |
fun fork(seed: Long): StrokePen |
An independent copy: the same memory, its own random generator seeded by seed. |
PenStep
data class PenStep(val dx: Double, val dy: Double, val lift: Boolean)
One step of a pen, in model units: move by (dx, dy), then lift if lift.
PenControls
How a pen samples its next step.
| Declaration | Summary |
|---|---|
val temperature: Double |
|
val noise: Double |
|
val liftBias: Double |
|
val driftX: Double |
|
val driftY: Double |
PenDistribution
A pen's prediction for its next step: a mixture of 2D Gaussians and a lift probability.
| Declaration | Summary |
|---|---|
val weights: DoubleArray |
|
val meanX: DoubleArray |
|
val meanY: DoubleArray |
|
val sigmaX: DoubleArray |
|
val sigmaY: DoubleArray |
|
val correlation: DoubleArray |
|
val liftProbability: Double |
|
val size: Int |
The number of mixture components. |
fun toDrawing(at: Vec3, scale: Double) |
The same distribution, moved to at and scaled by scale: drawing coordinates. |
PenMoment
One point a PenLine drew: from from to to in drawing units, as a mark or (when mark is false) a move with the pen up. liftProbability is the chance the pen gave of lifting after this point, read before it was drawn. cells and hidden are the pen's memory just after the point, kept only when the PenLine was made with recordMemory = true.
| Declaration | Summary |
|---|---|
val from: Vec3 |
|
val to: Vec3 |
|
val mark: Boolean |
|
val lifted: Boolean |
|
val liftProbability: Double |
|
val cells: FloatArray? |
|
val hidden: FloatArray? |
PenModel
The weights of an LSTM pen model, shared by every Pen made from it.
| Declaration | Summary |
|---|---|
val hidden: Int |
|
val mixtures: Int |
|
val layers: Int |
|
val inputs: Int |
3 for a pen that reads only its own displacements, 3 + sectors + 2 for one that also sees the ink around it (PenSense), in sectors directions out to senseRadius model units, ignoring its last senseRecent points. |
val sectors: Int |
|
val senseRadius: Double |
|
val senseRecent: Int |
|
override fun pen(seed: Long): Pen |
|
fun pen(primer: FLine, scale: Double = 8.0, step: Double = 0.35, seed: Long = 0): Pen |
A pen that has already been shown primer (see Pen.feed for scale and step). |
Pen
class Pen internal constructor(val model: PenModel, seed: Long) : StrokePen
The state of one PenModel: its memory, where it is, whether its pen is up, and its random generator.
| Declaration | Summary |
|---|---|
var sense: PenSense? |
The ink a sensing pen sees; null for a pen that does not sense. |
var x0 |
Pen position in model units, the sum of every displacement fed so far. |
var y0 |
The y of x0. |
var penUp |
Whether the pen is off the paper, so the next step is a move rather than a mark. |
var count |
Steps fed or drawn, not counting the start-of-sequence step. |
override fun feed(dx: Double, dy: Double, lift: Boolean) |
|
fun addInk(points: List<Pair<Double, Double>>) |
Let a sensing pen see ink it did not draw: points in model units, in the pen's own coordinates (its first point is the origin). Does nothing for a pen that does not sense. |
fun feed(line: FLine, scale: Double = 8.0, step: Double = 0.35, liftAtEnd: Boolean = false) |
Show the pen line, so that what it draws next continues it. |
override fun next(controls: PenControls): PenStep |
|
fun propose(controls: PenControls = PenControls()): PenStep |
A sample of the next step, as next would draw it, without drawing it: the pen is unchanged apart from its random generator. Calling this several times and then feeding one is how a PenGuide chooses among proposals. |
override fun fork(seed: Long): Pen |
|
fun distribution(controls: PenControls = PenControls()): PenDistribution |
The pen's prediction for its next step, in model units relative to where it is: one weight, mean, spread and correlation per mixture component, with controls' temperature applied to the weights and its noise to the spreads, as next would sample them. |
val liftProbability: Double |
The probability that the pen lifts after its next step, before any PenControls.liftBias. |
fun cells(layer: Int = model.layers - 1): FloatArray |
A copy of layer layer's cell memory (the LSTM's c), one value per cell. |
fun hidden(layer: Int = model.layers - 1): FloatArray |
A copy of layer layer's hidden output (the LSTM's h). |
fun setCell(cell: Int, value: Double, layer: Int = model.layers - 1) |
Overwrite one memory cell, the Distill article's fourth experiment. The change shows up from the step after next: the head has already read the current output. |
fun rawOutput(): FloatArray |
The head's raw output for the next step (see PenModel for its layout). |
PenLine
A line that a pen keeps drawing: the primer you gave it, then everything the pen added.
| Declaration | Summary |
|---|---|
val model: StrokeModel |
|
val scale: Double |
|
var controls: PenControls |
|
val recordMemory: Boolean |
Also keep each step's memory (cells and hidden) in history, for colouring by a cell. |
var pen: StrokePen |
The pen underneath, for Pen.setCell or forking by hand. |
val line: FLine |
The primer followed by what has been drawn so far. |
val drawn: FLine |
Only the pen's own contribution, without the primer. |
val history: MutableList<PenMoment> |
One PenMoment per point the pen has drawn, oldest first. |
val tip: Vec3 |
Where the pen is now, in drawing units. |
val penUp: Boolean |
Whether the pen is off the paper, so its next point starts a new stroke. |
fun grow(steps: Int = 1): FLine |
Draw steps more points onto line (and drawn); returns line. |
var guide: PenGuide? |
Steers the pen without taking its hand: each point, the pen proposes PenGuide.candidates next steps (by forking itself) and the guide's choice is the one drawn. null, the default, lets the pen draw as it would. |
fun distribution(): PenDistribution? |
What the pen expects next, in drawing units around tip: its mixture of Gaussians, with controls' temperature and noise applied. null if the pen is not a PenModel's. |
fun futures(count: Int = 16, steps: Int = 40, seed: Long = 1): List<FLine> |
Ask what the pen might do next without letting it do it: count independent continuations of steps points each, from here. line and the pen are unchanged. |
fun sense(lines: List<FLine>, step: Double = 0.35) |
Let a sensing pen see lines from now on, as ink already on the page: other strokes to join, follow or stop at. Resampled at the pen's own spacing. Does nothing for a pen that does not sense. |
fun lift() |
Make the pen lift now, so the next point starts a new stroke. |
penviz.kt
Pictures of what a learned pen is thinking: its next-step distribution as ellipses, a stroke coloured by one memory cell or drawn thick where the pen is sure to stay down, and the density of hundreds of possible futures as an image.
Everything here reads a PenLine: its PenLine.history for what it has drawn and
PenLine.distribution for what it expects next. Tests: PenVizTest.
src/jvmMain/kotlin/afield/ai/learning/pen/penviz.kt — read the source
Used in: A learned pen, Teaching a pen, A pen that sees
Top-level functions
| Declaration | Summary |
|---|---|
fun PenDistribution.ellipses(sigmas: Double = 2.0, minWeight: Double = 0.01, color: Vec4 = Vec4(0.55f, 0.85f, 1f, 1f)): List<FLine> |
One ellipse per mixture component, at sigmas standard deviations, with opacity following the component's weight; components lighter than minWeight are left out. Read it as "where the next point will land": a tight cluster is a pen that knows, a spread of faint rings one that is choosing. |
fun PenLine.coloredBy(bands: Int = 16, low: Vec4 = Vec4(0.2f, 0.45f, 1f, 1f), high: Vec4 = Vec4(1f, 0.55f, 0.2f, 1f), range: ClosedFloatingPointRange<Double>? = null, thickness: Double = 1.5, value: (PenMoment) -> Double): List<FLine> |
The pen's marks, split into bands FLines by value, each coloured between low and high. range is the span of values mapped onto the colours; by default, the values' own. |
fun PenLine.coloredByCell(cell: Int, bands: Int = 16, thickness: Double = 1.5, range: ClosedFloatingPointRange<Double>? = -1.0..1.0): List<FLine> |
The pen's marks coloured by memory cell cell's output (the LSTM's h): blue where it is low, orange where it is high. range is the span of outputs the colours cover, by default all of it, -1 to 1; null spreads them over the values this cell actually took, for a cell that never moves far. Needs a PenLine made with recordMemory = true. |
fun PenLine.widthByLift(bands: Int = 12, thinnest: Double = 0.3, thickest: Double = 4.0, color: Vec4 = Vec4(0.9f, 0.92f, 1f, 1f), smoothing: Int = 4): List<FLine> |
The pen's marks drawn thick where it was sure the pen would stay on the paper and thin where it expected to lift: the lift probability read as pressure. It was never trained on pressure, so this is the model's confidence, not a hand's. Widths follow the logarithm of the probability, averaged over smoothing points either side and spread over this line's own range of it. |
fun PenLine.liveliestCells(count: Int = 12): List<Int> |
The count memory cells whose output changed most while drawing, most variable first: the place to start looking for a cell that means something. Needs recordMemory = true. |
fun PenLine.futuresDensity(area: DoubleArray? = null, count: Int = 400, steps: Int = 60, width: Int = 1040, height: Int = 520, seed: Long = 1, ink: Vec4 = Vec4(1f, 0.85f, 0.3f, 1f)): ByteArray |
Where the pen might go: count futures of steps points from here, accumulated into an image of width by height pixels over area (drawing coordinates, x, y, w, h), on a log scale so a single stray future still shows. line and extra are drawn on top in ink. Returns PNG bytes for display(...). |
fun framing(lines: List<FLine>, aspect: Double): DoubleArray |
A rectangle (x, y, w, h) around lines's points, padded by a tenth, widened or heightened to aspect (width over height) so nothing is stretched. Ignores the farthest 1% of points, so one wild future does not shrink everything else. |
fun PenLine.tail(points: Int = 20): FLine |
The last points points the pen drew, as an FLine ending at PenLine.tip: the context for a close-up. |
fun PenLine.closeUp(points: Int = 8): PenCloseUp |
This moment, as a PenCloseUp over the last points points. |
fun sideBySide(groups: List<List<FLine>>, gap: Double = 12.0, y: Double = 0.0): List<FLine> |
groups laid out left to right, each moved so it starts gap after the previous one ends and sits with its top at y: small multiples that never overlap, whatever their sizes. |
fun stacked(groups: List<List<FLine>>, gap: Double = 12.0, x: Double = 0.0): List<FLine> |
groups stacked top to bottom, each starting gap below the previous one's lowest point. |
fun PenLine.seeing(color: Vec4 = Vec4(1f, 0.6f, 0.3f, 1f)): List<FLine> |
What a sensing pen sees from where it is now: a faint circle as far as it can see, and a ray in each direction that has ink in reach, out to the nearest ink there, brighter the closer it is. Empty for a pen that does not sense. |
Top-level values
| Declaration | Summary |
|---|---|
val PenDistribution.uncertainty: Double |
How unsure a distribution is, in squared drawing units: the spread of where the next point could land, counting both each component's own spread and how far apart the components sit. Use it to find the moments worth a close-up. |
DensityImage
class DensityImage(val width: Int, val height: Int, val area: DoubleArray)
Accumulates FLines into a grid of counts over a region of drawing coordinates and tone-maps them into an image: futuresDensity's canvas, usable on its own for any pile of lines.
| Declaration | Summary |
|---|---|
fun add(line: FLine, weight: Float = 1f) |
Add line's marks, one unit of ink per pixel of length, anti-aliased. |
fun toneMapped(): IntArray |
Counts as ARGB pixels: black through blue to white, logarithmically. |
fun ink(line: FLine, color: Vec4, pixels: IntArray) |
Draw line solidly over pixels in color. |
PenCloseUp
class PenCloseUp(val tail: FLine, val penUp: Boolean, val distribution: PenDistribution)
One moment of a PenLine: its last few points, whether its pen was up, and what it expected next.
| Declaration | Summary |
|---|---|
fun lines(color: Vec4 = Vec4(0.85f, 0.9f, 1f, 1f)): List<FLine> |
The tail in color and the distribution's ellipses. |
sense.kt
What a sensing pen sees of the ink around it: the Kotlin half of python/fieldpen/sense.py,
which has the canonical definition. PenModelTest holds the two together.
At each point the pen sees every point drawn before it except its own last recent, plus any
ink added from outside with addInk. Around the pen, sectors equal wedges of direction (sector
k covers atan2(dy, dx) in [-pi + k * 2pi / sectors, -pi + (k + 1) * 2pi / sectors)) each
report 1 - d / radius for their nearest visible point within radius, or 0. Two more values
follow, the way to the middle of all the visible ink, tanh((cx - x) / CENTRE_SCALE) and the same
for y: what is close, then where the mass of the drawing is. Units are model units throughout.
src/jvmMain/kotlin/afield/ai/learning/pen/sense.kt — read the source
PenSense
class PenSense(val sectors: Int, val radius: Double, val recent: Int)
The ink a pen has drawn or been shown, kept in a grid of cells radius wide so that a look around the pen touches only the nine cells near it.
| Declaration | Summary |
|---|---|
fun add(x: Double, y: Double) |
Record the point the pen has just reached, and make the points recent back visible. |
fun addInk(x: Double, y: Double) |
Add ink the pen did not draw, visible at once: lines around it it should sense. |
fun features(x: Double, y: Double, out: FloatArray, offset: Int) |
Write what the pen sees from (x, y) into out from offset, size values. |
fun copy(): PenSense |
An independent copy, for a forked pen. |
fun points(): List<Pair<Double, Double>> |
Every point the pen has drawn or been shown, in model units: for drawing what it senses. |
Ai4Installer.kt
Installs Field's AI assistant: the websocket handlers behind the box and document chat, and the code tools the model calls.
installAi4 registers the ai-assist, ai-chat-* and related message handlers, picks an
LlmBridge for the configured provider, and keeps one conversation thread per chat key.
Ai4CodeToolProviders implements the tools that read, edit, compile and evaluate box code,
shared by the chat and the MCP backdoor. The top-level helpers (uniqueBoxOrRefuse,
setupSnapshot, clipToolText, primitiveToolValue) turn boxes and runtime values into
tool results.
src/jvmMain/kotlin/afield/ai/models/Ai4Installer.kt — read the source
Top-level functions
FileRef
data class FileRef(val document: String, val uid: String, val property: String)
AmbiguousBoxNameException
Thrown when a box name an agent supplied matches more than one box.
SetupSnapshot
| Declaration | Summary |
|---|---|
val preamble: String |
|
val preambleCache: String |
|
val intro: String |
|
val introCache: String |
|
val attic: String |
|
val atticCache: String |
Ai4CodeToolProviders
Box-scoped Kotlin code tools (CompileKotlin, EvaluateKotlin), extracted from the ai-assist chat handler so the OpenAI harness and the MCP backdoor share one implementation. Context is supplied via four closures; everything else resolves to the promoted top-level helpers above. refResolver replaces the chat handler's exec-coupled requestedRefOrCurrent — the backdoor passes an explicit-target resolver with no "current box" fallback.
| Declaration | Summary |
|---|---|
val evaluateKotlinExpression: (EvaluateKotlinExpressionRequest) -> EvaluateKotlinExpressionResult |
|
val compileKotlin: (CompileKotlinRequest) -> CompileKotlinResult |
Ai4ModelDefaults.kt
src/jvmMain/kotlin/afield/ai/models/Ai4ModelDefaults.kt — read the source
AnthropicBridge.kt
The Anthropic (Claude) implementation of LlmBridge, running Field's agent loop over the Messages API.
It uses the same ToolRegistry and tool definitions as OpenAiIdeCodexBridge. The Messages API
keeps no server-side conversation, so AnthropicBridge holds each thread's history locally and
marks the system prompt and tool list for prompt caching.
src/jvmMain/kotlin/afield/ai/models/AnthropicBridge.kt — read the source
AnthropicBridge
Anthropic (Claude) implementation of LlmBridge, mirroring OpenAiIdeCodexBridge's agent loop over the Messages API.
AnthropicCredentials.kt
Credential + client construction for the Anthropic (Claude) provider.
Field's enterprise setup can't mint a raw ANTHROPIC_API_KEY, so the default
path here authenticates the way the official clients do: a short-lived OAuth
bearer token minted from an ant auth login profile via
ant auth print-credentials --access-token, sent as Authorization: Bearer
plus the anthropic-beta: oauth-2025-04-20 header. A real API key
(ANTHROPIC_API_KEY) or a pre-supplied bearer token (ANTHROPIC_AUTH_TOKEN)
are honored first when present, so nothing here is ant-only.
This mirrors openAiClientFromFieldConfig / activeAiProviderRuntimeConfig
for the OpenAI/OpenRouter providers; the client it returns exposes the same
client.messages() surface the AnthropicBridge drives.
src/jvmMain/kotlin/afield/ai/models/AnthropicCredentials.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun configuredAnthropicProfile(): String? |
The ant profile to bind to, if any: env → ~/.afield/anthropic-profile.txt → active/default. |
fun configuredAnthropicBaseUrl(): String? |
Base URL override: env → ai-models.propertiesanthropicBaseUrl`` → null (SDK default, api.anthropic.com). |
fun invalidateAnthropicOAuthToken() |
Force a fresh token on the next resolve — call this after a 401 from the API. |
fun resolveAnthropicAuth(forceRefresh: Boolean = false): AnthropicAuth |
Resolve auth for a request. Precedence: explicit API key → explicit bearer token → ant OAuth profile. Only the OAuth/bearer paths set AnthropicAuth.isOAuth. |
fun anthropicClientFromFieldConfig(forceRefresh: Boolean = false): AnthropicClient |
|
fun anthropicStatus(): AnthropicStatus |
Non-throwing status for the /welcome screen. Reports whether we can currently obtain a credential; the token mint is cached so repeat calls are cheap. |
Top-level values
| Declaration | Summary |
|---|---|
const val ANTHROPIC_API_KEY_ENV |
|
const val ANTHROPIC_AUTH_TOKEN_ENV |
|
const val ANTHROPIC_BASE_URL_ENV |
|
const val ANTHROPIC_DEFAULT_BASE_URL |
Where the SDK sends requests when configuredAnthropicBaseUrl is null. |
const val ANTHROPIC_PROFILE_ENV |
|
const val AFIELD_ANT_BIN_ENV |
MissingAnthropicCredentialException
class MissingAnthropicCredentialException(message: String) : IllegalStateException(message)
AnthropicAuth
data class AnthropicAuth(val token: String, val source: String, val isOAuth: Boolean)
Resolved Anthropic auth for one request. isOAuth selects the header shape: bearer token + oauth beta header vs. x-api-key.
| Declaration | Summary |
|---|---|
val token: String |
|
val source: String |
|
val isOAuth: Boolean |
AnthropicStatus
| Declaration | Summary |
|---|---|
val available: Boolean |
|
val source: String |
|
val detail: String |
|
val baseUrl: String |
|
val profile: String |
LlmBridge.kt
The interface the IDE agent loop talks to, with one implementation per provider.
Both the OpenAI/OpenRouter engine (OpenAiIdeCodexBridge, via OpenAiLlmBridge)
and the Anthropic engine (AnthropicBridge) implement this, so IdeChatHelpers
and the installer can drive whichever provider is selected on /welcome without
caring which one is behind the interface.
The state/result types are the ones OpenAiIdeCodexBridge already defines — they
carry no OpenAI-SDK types in their signatures, so they double as the neutral
currency here. The typealiases below give them provider-neutral names without moving
the declarations their callers already reference.
src/jvmMain/kotlin/afield/ai/models/LlmBridge.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun configuredAiReasoningEffort(): LlmReasoningEffort |
A provider-scoped local preference; explicit per-call choices still take precedence. |
fun createLlmBridge(model: String = Ai4ModelDefaults.flagshipModel(), log: LogSink = StdoutLog, reasoningEffort: LlmReasoningEffort = configuredAiReasoningEffort()): LlmBridge |
Provider-aware factory: builds the agent engine matching the /welcome provider setting. Shared by the IDE installer and the docs-search bridge so provider selection lives in exactly one place. |
fun createIdeBridge(log: LogSink = StdoutLog): LlmBridge |
The bridge every AI4 chat uses: the configured provider at the flagship model. The box panel and the document chat build theirs here, and so does a live AgentTask, so a task runs on exactly the engine a person's chat would. |
LlmThreadState
typealias LlmThreadState = OpenAiIdeCodexBridge.ThreadState
LlmSendResult
typealias LlmSendResult = OpenAiIdeCodexBridge.SendResult
LlmProgressUpdate
typealias LlmProgressUpdate = OpenAiIdeCodexBridge.ProgressUpdate
LlmToolTranscriptEntry
typealias LlmToolTranscriptEntry = OpenAiIdeCodexBridge.ToolTranscriptEntry
LlmReasoningEffort
Constants: LOW, MEDIUM, HIGH.
LlmBridge
OpenAiLlmBridge
class OpenAiLlmBridge(private val inner: OpenAiIdeCodexBridge) : LlmBridge
Adapter that presents the existing (untouched) OpenAiIdeCodexBridge as an LlmBridge. Overrides carry no default values (Kotlin forbids that on an override); the defaults live on the interface and reach every call site typed as LlmBridge. A blank startThread instruction defers to the inner bridge's own default so existing behaviour is preserved exactly.
LocalAiConnection.kt
The connection check behind the local-model panel on Field's welcome page.
Field asks the endpoint for its OpenAI-style model list at /models, from the server JVM, so no code
is sent and no inference runs. The answer says whether the endpoint replied, which model ids it lists,
and whether the one chosen in the panel is among them; a non-200 reply, or a body that is not a model
list, fails with that as the reason rather than as a missing model. A saved key is reused only for the
endpoint it was saved against, and a cloud credential is never offered to a local one.
src/jvmMain/kotlin/afield/ai/models/LocalAiConnection.kt — read the source
LocalAiContextRecovery.kt
Recovery from a local model that cannot fit the conversation: when a self-hosted, OpenAI-compatible provider rejects a request for exceeding its context window, Field shortens the history and retries rather than failing the turn.
isLocalContextRecoveryFailure recognises that case from the exception chain, including the empty
answer some servers return in place of an error, and returns false for a cancelled turn or a response
that was already partly delivered. shortenLocalHistory then drops older exchanges until the request
is about half its original size, measured in characters, while keeping the seeded start of the
conversation, the current prompt, and any tool call whose output is still pending; it adds a
developer-role note saying that older exchanges were omitted and that completed tool work has not been
replayed. Two rounds are allowed, and the failure after them is LocalContextRecoveryExhausted.
src/jvmMain/kotlin/afield/ai/models/LocalAiContextRecovery.kt — read the source
LocalAiFeedback.kt
src/jvmMain/kotlin/afield/ai/models/LocalAiFeedback.kt — read the source
OpenAiCredentials.kt
Where Field's AI features find their provider, API key, endpoint and model names.
Each setting is read from an environment variable first and then from a file under
~/.afield/: the provider (AFIELD_AI_PROVIDER, else ai-provider.txt, defaulting to
openai), the OpenAI key (OPENAI_PLATFORM_KEY or OPENAI_API_KEY, else
openai-platform-key.txt), the OpenRouter key and the per-provider model settings. The
/welcome page writes the files through saveAiSettings and saveOpenAiPlatformKey, and
aiProviderConfigRevision changes on each saveAiSettings so cached clients know to rebuild.
activeAiProviderRuntimeConfig resolves all of it into the one AiProviderRuntimeConfig a
bridge is built from.
src/jvmMain/kotlin/afield/ai/models/OpenAiCredentials.kt — read the source
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
const val OPENAI_PLATFORM_KEY_ENV |
Where Field's AI features find their provider, API key, endpoint and model names. |
const val OPENAI_API_KEY_ENV |
|
const val OPENROUTER_API_KEY_ENV |
|
const val AFIELD_AI_PROVIDER_ENV |
|
const val AFIELD_AI_BASE_URL_ENV |
|
const val OPENROUTER_BASE_URL_ENV |
|
const val LOCAL_AI_BASE_URL_ENV |
|
const val LOCAL_AI_API_KEY_ENV |
|
const val LOCAL_AI_DEFAULT_BASE_URL |
MissingOpenAiCredentialException
class MissingOpenAiCredentialException(message: String) : IllegalStateException(message)
OpenAiCredentialStatus
| Declaration | Summary |
|---|---|
val configured: Boolean |
|
val source: String |
|
val configPath: String |
AiModelSettings
| Declaration | Summary |
|---|---|
val flagshipModel: String |
|
val fastModel: String |
|
val fallbackModel: String |
|
val reasoningEffort: String |
AiProviderStatus
AiSettingsSaveRequest
AiProviderRuntimeConfig
| Declaration | Summary |
|---|---|
val provider: String |
|
val apiKey: String |
|
val source: String |
|
val baseUrl: String |
|
val modelSettings: AiModelSettings |
|
val isOpenRouter: Boolean |
|
val usesStatelessResponses: Boolean |
OpenAiIdeCodexBridge.kt
The OpenAI implementation of LlmBridge: Field's agent loop over the Responses API.
Each turn sends the conversation and the shared tool definitions, runs the function calls the
model returns through the ToolRegistry, and feeds the outputs back until the model answers in
text. Against OpenAI the history lives in a server-side conversation; against OpenRouter, which
has none, the bridge keeps it locally per thread. Rate-limit waits, suspended turns and the
request ledger are handled here too.
src/jvmMain/kotlin/afield/ai/models/OpenAiIdeCodexBridge.kt — read the source
OpenAiIdeCodexBridge
The OpenAI implementation of LlmBridge: Field's agent loop over the Responses API.
ScriptedLlmBridge.kt
An LlmBridge that replays a fixed script of tool calls instead of asking a model.
Tests and the no-credentials mode of agent tasks use it to drive the real tool dispatch;
ScriptedLlmBridge documents the script format.
src/jvmMain/kotlin/afield/ai/models/ScriptedLlmBridge.kt — read the source
ScriptedLlmBridge
An LlmBridge with no model behind it: it replays a script of tool calls and a final answer through the real ToolRegistry, exactly as a provider bridge would, and records what it was given.
StatelessHistory.kt
Keeping a stateless provider's conversation (local models, OpenRouter) inside its context window.
A stateless provider is sent its whole conversation on every request, so the conversation is ours to size, and an item count does not size it: every turn's user message carries the full instructions, and each of those copies stays in history. Three things keep it inside:
settle, once per turn boundary: the finished turn's prompt is swapped for its durable text (the request without the instructions, which the newest message always carries), and older tool outputs and images are cut down. Done once, so the history stays a stable prefix for the server's prompt cache.dropOldestTurns, when an estimate says the next request will not fit: whole turns go from the front, always at a user message, so no function call loses its output.contextOverflowreads the window out of the server's own refusal, in llama.cpp's wording ("request (65833 tokens) exceeds the available context size (65536 tokens)") or vLLM's, which calibrates the estimate; the bridge then fits the request and retries.
src/jvmMain/kotlin/afield/ai/models/StatelessHistory.kt — read the source
VoiceTranscriber.kt
Local speech-to-text for AI4 push-to-talk.
The browser records while the push-to-talk key is held and sends the clip as ai-transcribe;
this hands it to python/fieldvoice/whisper_worker.py, a Python process started on first use and
kept alive so the model loads once (large-v3-turbo on a GPU, small.en on a CPU). The worker
is an optional dependency (uv sync --inexact --group voice); without it the reply says how to
install it.
The interpreter is FIELD_VOICE_PYTHON if set, otherwise the one Field's Python boxes resolved
(the same venv the voice group is installed into), otherwise the checkout's .venv
(Scripts\python.exe on Windows).
One clip is transcribed at a time, on a dedicated thread: a second press queues behind the first.
src/jvmMain/kotlin/afield/ai/models/VoiceTranscriber.kt — read the source
VoiceTranscriber
The push-to-talk transcriber: owns the Whisper worker process and the thread clips queue on.
| Declaration | Summary |
|---|---|
fun install() |
Registers the ai-transcribe and ai-transcribe-warm handlers the browser's recorder calls. |
MarkdownChunker.kt
Markdown cut into the pieces semantic search indexes and an embedder can read whole.
Four things go wrong when a page is cut on its # lines and indexed raw, and each has an answer here.
A # line inside a code fence is a comment rather than a heading, so headings count only outside
fences. The generated reference pages carry their <span class=…> markup into the indexed text, so
markup outside code is reduced to the text it wraps. A section longer than the window the embedder
reads (around 512 tokens, which DEFAULT_MAX_CHARS approximates) loses its end, so a long section is
split on paragraph boundaries. And a section alone says nothing about where it sits ("Kotlin side", of
what?), so every chunk carries a breadcrumb of page title, parent headings and its own.
src/jvmMain/kotlin/afield/ai/retrieval/MarkdownChunker.kt — read the source
RelatedContextDiscovery.kt
Suggests the guides and example files most related to a source path, for AI tools that hand an agent a symbol or file.
RelatedContextDiscovery.forPath scores the Markdown files and the .kt/.kts files under an
examples/ directory beneath src, dev_docs, public and docs in the checkout against the
path's directory and camel-case name tokens, and returns up to six of each, best first. The file list is walked once and each path's answer is
cached. An afield:// path gets the connected compute-and-render example.
src/jvmMain/kotlin/afield/ai/retrieval/RelatedContextDiscovery.kt — read the source
RelatedContextHints
Up to six related guide paths and six example paths, best match first.
| Declaration | Summary |
|---|---|
val guides: List<String> |
|
val examples: List<String> |
RelatedContextDiscovery
object RelatedContextDiscovery
Process-wide related-context lookup rooted at the current working directory.
| Declaration | Summary |
|---|---|
fun forPath(rawPath: String?): RelatedContextHints |
RetrievalEval.kt
How well documentation search answers the questions the repository already asks of it.
Queries and answers come from two sources the authors wrote for readers rather than for a benchmark:
the text of every cross-link between authored docs pages ([drawing with FLines](fline_examples.md)
is a query whose answer is fline_examples.md; the standard anchor-text method), and the why text
of each docs: entry in the agent transcripts (dev_docs/agents/transcripts/<name>.yaml), each of
which says what a page explains. An anchor is left out when its text is generic (here, this page,
api) or points at three or more different pages; the page a link sits on is not counted against it,
since it contains the anchor text by construction.
src/jvmMain/kotlin/afield/ai/retrieval/RetrievalEval.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
Score documentation search on RetrievalEval.cases. Run from a checkout with an isolated index: -Dafield.semanticSearch.cacheRoot=<dir with config.json + models> and -Dafield.semanticSearch.indexPath=<dir>; -Dafield.semanticSearch.corpora=documentation keeps the build to minutes. Arguments: rebuild to (re)build first; then fusion modes to compare (default: hybrid lexical dense). Writes per-case ranks to build/retrieval-eval/<mode>.tsv. |
RetrievalEval
Query sets built from the repository's own prose, and the hit@1, hit@5 and MRR over them.
| Declaration | Summary |
|---|---|
data class Case(val set: String, val query: String, val pages: Set<String>, val excludePage: String = "") |
|
fun anchorCases(docsRoot: File): List<Case> |
Cross-links between authored pages (not the generated reference, not assets). |
fun whyCases(transcriptsDir: File): List<Case> |
docs: [{page, why}] in the agent transcripts: what a page explains, in its author's words. |
data class Lesson(val title: String, val files: List<String>, val summary: String) |
|
fun graphicsLessons(index: File): List<Lesson> |
The graphics curriculum's lessons from its generated index: the lesson table and the reverse API index. |
fun tutorialCases(checkout: Path): List<Case> |
Tutorial search, from the transcripts' docs links: the title a docs page's own author gave it is a query, and the tutorials that link to that page answer it. The summaries indexed for tutorials leave these links out. |
fun cases(checkout: Path): List<Case> |
|
data class Score(val set: String, val n: Int, val hit1: Int, val hit5: Int, val mrr: Double) |
|
fun rank(case: Case, results: List<String>): Int? |
Rank of the first result on an answering page, counting pages once and skipping Case.excludePage. search returns result paths relative to the checkout (public/docs/...), best first. |
fun score(cases: List<Case>, search: (String) -> List<String>): Pair<List<Score>, List<Pair<Case, Int?>>> |
SemanticRetrievalService.kt
Hybrid semantic search over Field's own source, the open workspace and the documentation, backed by a local Lucene index and an ONNX embedding model.
Each indexed item is a SemanticCapsule carrying an intent text, an implementation text and a
lexical text. SemanticRetrievalService.query runs BM25 and symbol matches alongside vector
nearest-neighbour searches on the intent and implementation embeddings, fuses the ranks, and
filters by corpus (field-internals, workspace, documentation), path, kind, document, box
and property.
Models and cache
OnnxSemanticEmbedder implements SemanticEmbedder with a Hugging Face tokenizer and ONNX
Runtime. The model presets download from Hugging Face through
SemanticRetrievalService.downloadPreset; models, settings and the index live under
~/.afield/semantic-search unless afield.semanticSearch.cacheRoot (or
AFIELD_SEMANTIC_SEARCH_CACHE_ROOT) says otherwise.
Rebuilding
SemanticRetrievalService.rebuild and SemanticRetrievalService.startRebuild rebuild the index;
SemanticRetrievalService.startIdleRebuildWatcher rebuilds when workspace documents change.
src/jvmMain/kotlin/afield/ai/retrieval/SemanticRetrievalService.kt — read the source
SemanticRetrievalStatus
SemanticRetrievalConfig
| Declaration | Summary |
|---|---|
val enabled: Boolean |
|
val presetId: String |
|
val modelPath: String |
|
val tokenizerPath: String |
|
val dimension: Int |
|
val maxTokens: Int |
|
val queryPrefix: String |
|
val documentPrefix: String |
SemanticRetrievalModelPreset
SemanticRetrievalFileState
| Declaration | Summary |
|---|---|
val path: String |
|
val exists: Boolean |
|
val sizeBytes: Long |
|
val lastModifiedEpochMs: Long |
SemanticRetrievalSettingsStatus
SemanticRetrievalBuildStatus
| Declaration | Summary |
|---|---|
val running: Boolean |
|
val status: String |
|
val startedAtEpochMs: Long |
|
val finishedAtEpochMs: Long |
|
val message: String |
|
val step: String |
|
val indexedCapsules: Int |
SemanticRetrievalBuildResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val status: SemanticRetrievalStatus |
|
val rebuild: SemanticRetrievalBuildStatus |
SemanticRetrievalQueryRequest
SemanticRetrievalQueryResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val status: SemanticRetrievalStatus |
|
val results: List<SemanticRetrievalHit> |
SemanticRetrievalHit
SemanticRetrievalModelInfo
| Declaration | Summary |
|---|---|
val backend: String |
|
val dimension: Int |
|
val modelPath: String |
|
val tokenizerPath: String |
|
val queryPrefix: String |
|
val documentPrefix: String |
|
val message: String |
SemanticRetrievalDownloadRequest
data class SemanticRetrievalDownloadRequest(val presetId: String = "granite-97m-r2-quantized")
| Declaration | Summary |
|---|---|
val presetId: String |
SemanticRetrievalConfigSaveRequest
SemanticRetrievalDeleteCacheRequest
data class SemanticRetrievalDeleteCacheRequest(val target: String = "index")
| Declaration | Summary |
|---|---|
val target: String |
SemanticCapsule
| Declaration | Summary |
|---|---|
val id: String |
|
val scale: String |
|
val corpus: String |
|
val kind: String |
|
val symbol: String |
|
val title: String |
|
val path: String |
|
val line: Int |
|
val endLine: Int |
|
val parentId: String |
|
val documentName: String |
|
val boxName: String |
|
val boxId: String |
|
val property: String |
|
val language: String |
|
val signature: String |
|
val documentation: String |
|
val metadata: String |
|
val source: String |
|
fun intentText(): String |
|
fun implementationText(): String |
|
fun lexicalText(): String |
SemanticEmbedder
interface SemanticEmbedder : AutoCloseable
| Declaration | Summary |
|---|---|
val dimension: Int |
|
val backend: String |
|
fun encodeQueries(texts: List<String>): List<FloatArray> |
|
fun encodeDocuments(texts: List<String>): List<FloatArray> |
SemanticRetrievalService
object SemanticRetrievalService
OnnxSemanticEmbedder
AgentExtensions.kt
agentExtension { } lets a box extend its document's AI agent, beyond the single tool
aiTool { } gives it. An extension can register tools, context the agent should know before
every turn, a look at — or a veto over — every tool call, and slash commands in the chat.
Pi (earendil-works/pi) has extensions with this shape; what
Field adds is that they are live-coded. Every part is an ownership in the afield.runtime.lifecycle.LifecycleLedger, owned
by the run that registered it, so re-running the box replaces its whole extension at once, a run that
stops registering a part drops it, and deleting the box retires it.
agentExtension("studio") {
context("tempo") { "The piece is at ${bpm} bpm; keep edits in time." }
beforeTool({ it == "DeleteBoxRequest" }) { refuse("Ask before deleting boxes in this document.") }
afterTool { call, result -> if (!result.ok) println("${call.name} failed: ${result.summary}") }
command("tempo", "show the tempo") { "**${bpm} bpm**" }
tool("studio.bpm") { describe("The tempo."); action { bpm } }
}
What the hooks may do
They run on the agent's thread, with a deadline (HOOK_DEADLINE_MS); a hook that throws or overruns is
treated as "proceed" and reported, because a broken observer must not stop the agent. A beforeTool
hook sees the call's name, arguments, origin (the model, a tool program, the backdoor) and declared
effects, and answers proceed() or refuse(message) — the model then gets the message as the tool's
result. An afterTool hook sees a one-line summary and whether the call succeeded; it cannot change the
result. Context arrives in the instructions as document-authored data, framed as such, at most
CONTEXT_CHARS_EACH characters each and CONTEXT_CHARS_TOTAL in all.
Extensions are per document: they apply to AI conversations about that document only.
src/jvmMain/kotlin/afield/ai/tools/AgentExtensions.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.agentExtension(id: String = "default", build: AgentExtensionBuilder.() -> Unit) |
Registers this box's agent extension id, replacing whatever the box's previous run registered under it. |
Top-level values
| Declaration | Summary |
|---|---|
const val HOOK_DEADLINE_MS |
|
const val CONTEXT_CHARS_EACH |
|
const val CONTEXT_CHARS_TOTAL |
AgentExtensionBuilder
class AgentExtensionBuilder internal constructor(private val box: Box, private val id: String)
| Declaration | Summary |
|---|---|
fun tool(name: String, build: BoxToolBuilder.() -> Unit): BoxTool |
A box-authored tool, exactly as aiTool makes one. |
fun context(name: String, changesOften: Boolean = true, body: (ContextTurnView) -> String?) |
Text the agent should have before each turn — computed when the turn starts, so it can describe the document as it is. changesOften = false lets the bridges cache it with the stable instructions. Return null or blank to say nothing this turn. |
fun beforeTool(match: (String) -> Boolean = { true }, mandatory: Boolean = false, timeoutMs: Long = HOOK_DEADLINE_MS, body: ToolCallView.() -> ToolDecision) |
Look at a tool call before it runs, and let it ToolCallView.proceed or ToolCallView.refuse it. |
fun afterTool(match: (String) -> Boolean = { true }, body: (ToolCallView, ToolResultView) -> Unit) |
Look at a tool call after it has run. Observe only. |
fun command(name: String, help: String = "", body: (CommandScope) -> String) |
A chat command, /name args; the returned markdown is shown in the panel, not saved. |
fun branchState(name: String, save: (java.io.File) -> Unit, restore: (java.io.File?) -> Unit, onConflict: BranchConflict = BranchConflict.KEEP, resumeOnOpen: Boolean = false) |
State of this box's that belongs to the conversation rather than the document — a canvas the agent paints on, a simulation it is steering. Field calls save (into a file it gives you) before and after every chat turn in this document, keeps each snapshot with that turn in the chat's session tree, and calls restore when the conversation moves: rewinding to a message restores the state from before that message, switching branch the state at that branch's end. restore gets null where the conversation has no snapshot (before the extension existed): start fresh, or keep what you have. The file is deleted when restore returns, so read it there. |
ContextTurnView
data class ContextTurnView(val mode: String, val request: String, val focusBox: String?)
What a context hook is told about the turn about to start.
ToolCallView
| Declaration | Summary |
|---|---|
val name: String |
|
val argumentsJson: String |
|
val origin: String |
MODEL, PROGRAM or BACKDOOR. |
val effects: Set<String> |
The effects the tool declares, by name (MUTATE_DOCUMENT, ...); UNDECLARED when it said nothing. |
fun proceed(): ToolDecision |
|
fun refuse(message: String): ToolDecision |
ToolResultView
data class ToolResultView(val ok: Boolean, val summary: String, val elapsedMs: Long)
CommandScope
data class CommandScope(val args: String, val chatBox: Box?)
ToolDecision
| Declaration | Summary |
|---|---|
data object Proceed : ToolDecision |
|
data class Refuse(val message: String) : ToolDecision |
AgentExtensionRegistry
AiStateBlobs
Branch-state snapshots (AgentExtensionBuilder.branchState), stored once per content: <document>/ai4_blobs/state/ for a document that writes to disk, a per-process temporary folder otherwise.
| Declaration | Summary |
|---|---|
fun put(file: java.io.File, box: Box): String |
Keep file's bytes, gzipped, under their content hash, and return the hash. A paint canvas's state is tens of megabytes and its ground texture does not compress much (49 MB to 28 MB measured), so each turn that changes it costs that much disk. The first put in a document each session also sweeps the states no conversation refers to (AiStatePruning.sweepOrphans). |
fun get(sha: String, box: Box): java.io.File? |
The state stored under sha, unpacked into a temporary file the caller deletes. Null when no such state was stored; throws when it is there but cannot be read (a damaged file). |
BranchConflict
What AgentExtensionBuilder.branchState does when the state changed outside the conversation since it last saw it.
Constants: KEEP, RESTORE.
AgentToolExtensions.kt
The registration point through which code outside the AI core, including a document's own boxes, adds tools to the AI4 agents.
AgentToolExtensions.register takes a request class, an availability check and a handler;
registering the same tool name again in the same scope replaces the handler. A result type that
implements SummarisesForToolLog supplies its own one-line summary for the tool log.
src/jvmMain/kotlin/afield/ai/tools/AgentToolExtensions.kt — read the source
AgentToolExtensions
How code outside core adds tools to the AI4 agents.
| Declaration | Summary |
|---|---|
class Registration<T : Any> internal constructor(val toolClass: Class<T>, val available: (ToolExecutionContext) -> Boolean, val handler: (ToolExecutionContext, T) -> Any, val scope: Any? = null, val meta: ToolMeta? = null) |
One registered tool: its request class, its availability gate, its handler and its scope. |
fun registeredNames(): Set<String> |
The tool names currently registered here (request class simple names). |
fun <T : Any> register(toolClass: Class<T>, available: (ToolExecutionContext) -> Boolean = { true }, scope: Any? = null, meta: ToolMeta? = null, handler: (ToolExecutionContext, T) -> Any) |
Add a tool, replacing any previous registration of the same name, and push it to every agent that is already running. |
fun unregister(toolClass: Class<*>) |
Drop a tool, in every scope. Mostly for tests, and for a live-coded registration being retired. |
fun unregister(name: String, scope: Any? = ALL_SCOPES) |
Drop a tool by the name the model sees, for a box retiring a registration whose request class it no longer has in scope — which, after an edit that renamed or deleted the class, is normal. With scope, only that scope's registration goes; the others keep answering. |
fun registeredToolNames(): List<String> |
Every registered tool's name, once each, for diagnostics and for a status readout. |
fun isAvailable(ctx: ToolExecutionContext, name: String): Boolean |
True if name is registered here and its gate passes under ctx. |
fun availableToolNames(ctx: ToolExecutionContext): List<String> |
The names of every extension tool available under ctx, sorted. |
fun execute(ctx: ToolExecutionContext, name: String, argsJson: String): Any |
Runs an extension tool by name with JSON arguments, exactly as a model's tool call would. |
fun describe(ctx: ToolExecutionContext): List<String> |
MCP-style descriptors for the extension tools available under ctx — {name, description, inputSchema}. |
SummarisesForToolLog
interface SummarisesForToolLog
A tool result that can describe itself in one line for the tool I/O log.
| Declaration | Summary |
|---|---|
fun toolLogSummary(): String |
One line for the tool I/O log — see the interface docs for what belongs in it. |
AgentToolRegistrations.kt
src/jvmMain/kotlin/afield/ai/tools/AgentToolRegistrations.kt — read the source
Ai4BackdoorTools.kt
The AI4 tool surface as an external agent sees it: the same registry the chat panel uses, wired to one attached document, with every target named outright.
Building a context for a session assembles what a tool call will need: compiling and evaluating Kotlin against a box, reading, writing and replaying operation macros, semantic retrieval over the indexed corpora, text and symbol search across the workspace, the runtime providers for shader reload and render-window capture, whatever tools the document's own boxes declare, and the agent memory kept per document. The backdoor and the MCP bridge both call it, and so do the tests that pin a tool's answer.
Targeting is the one place it differs from the chat panel. A chat tool may mean "the box whose
chat is open"; here a box is named by uid, by a name unique in the document, or by an afield://
reference, and there is no current-box fallback. A name matching two boxes is refused with the
count rather than resolved to either, because replaying a macro over the wrong box is the one
failure here that edits a document. A name matching nothing still falls through to the literal
uid, which is how a document root reaches its box and how a typo earns the provider's own
message. Passing a focus box names the box whose chat is to be played, which is how a backdoor
call reproduces what the panel would have answered.
Workspace text search is rooted at the attached document, the workspace and this checkout's
src, never at the process working directory, and refuses to run on Field's Runloop because it
reads disk.
src/jvmMain/kotlin/afield/ai/tools/Ai4BackdoorTools.kt — read the source
Ai4RenderCapture.kt
src/jvmMain/kotlin/afield/ai/tools/Ai4RenderCapture.kt — read the source
Ai4RuntimeToolProviders.kt
The five AI4 tools that need the running Field rather than the saved document: reload a box's shaders, capture the render window, list a box's Kotlin context variables, query the code-use index, and search workspace symbols.
The in-Field chat panel and the backdoor registry share all five and differ only in what they hand the constructor. The caller supplies how a box reference resolves and how to look a box up by uid, which is what lets the panel mean "the box whose chat is open" where the backdoor insists on an explicit target. The panel also supplies a staged-source hook, so a shader reload compiles the text the editor is showing rather than the text last written to the box; the backdoor leaves that argument out and compiles what the box holds.
Shader reload is the largest of the five, because one box can carry several shader programs at
once and a request may not say which to rebuild. An explicit mode wins outright. Failing that the
property being reloaded implies one: vertex and fragment mean graphics, compute means
compute, task and mesh mean mesh, and the ray-tracing stages mean ray. Failing that too, the
box is asked which programs it actually has and each is rebuilt, which is what auto and an
absent mode both come to.
src/jvmMain/kotlin/afield/ai/tools/Ai4RuntimeToolProviders.kt — read the source
AiToolUseImprovementMonitor.kt
The runner behind the AI tool-use improvement task, which summarises the AI4 tool input/output logs and starts the Gradle task that proposes fixes, optionally in a generated worktree.
Logs are read from ~/.afield/logs/ai4_tool_io (or AFIELD_AI4_TOOL_IO_LOG_PATH); the report,
dry-run patch and transcript are written under this checkout's build/.
src/jvmMain/kotlin/afield/ai/tools/AiToolUseImprovementMonitor.kt — read the source
AiToolUseImprovementMonitor
object AiToolUseImprovementMonitor
AskUserTools.kt
Asking the person a question mid-task: AskUserQuestionRequest ends the turn with a TurnSuspension carrying
the question. The box chat ends its transcript with a question row (AiChatBlock.questionRow), which the panel
shows with a button per suggested answer; the person's next message, a button's or typed, is the answer, and
the next turn opens with this call's output ahead of it, as any suspended turn does.
src/jvmMain/kotlin/afield/ai/tools/AskUserTools.kt — read the source
AskUserQuestionRequest
Asking the person a question mid-task: AskUserQuestionRequest ends the turn with a TurnSuspension carrying the question. The box chat ends its transcript with a question row (AiChatBlock.questionRow), which the panel shows with a button per suggested answer; the person's next message, a button's or typed, is the answer, and the next turn opens with this call's output ahead of it, as any suspended turn does.
| Declaration | Summary |
|---|---|
val question: String |
|
val choices: List<String> |
AskUserQuestionResult
AskUserQuestionRequest's result. A set suspension ends the turn; the model reads only the rest.
| Declaration | Summary |
|---|---|
override fun toolLogSummary() |
AttachmentExcerpt.kt
Reading a long attachment one window at a time, under the guards every attachment shares.
The slice is taken after ordinary resolution, so an attachment already in the turn's context and one
the document's own resolver fetches are treated alike. The window is bounded (2 to 65536 characters)
and has to start inside the content. The content's SHA-256 comes back as sourceHash, and a later
read that passes it back is refused when the source has changed since, with a message asking for a
fresh read from the start rather than a splice of two versions. A find string moves the window to
the first match at or after the offset, with a little room before it, and an empty window with
searchMatched false says there was none. An offset that would split a surrogate pair is refused,
and a computed start or end that would is moved back one, so a window never ends mid-character.
nextOffset is null on the last one.
src/jvmMain/kotlin/afield/ai/tools/AttachmentExcerpt.kt — read the source
BoxAiTools.kt
A box contributes tools to its document's AI agents with aiTool(name) { }, an idempotent
Kotlin DSL that serves both the in-app chat and external MCP agents. A tool is a
suspend block that runs with the box in scope, can span many frames, and has its
arguments/description/return value marshaled automatically.
val a = aiTool("rectsl.run") {
describe("Compile & run RectSL in this document's window, capture PNGs")
val source by param<String>("RectSL program text")
val times by param<List<Double>>("capture times (s)", default = listOf(0.0, 1.0))
action { // suspend; box in scope; may call frame()/delay()
val d = afield.lang.rectsl.RectAnimationOracle.compile(source())
if (!d.ok) return@action toolError(d.diagnostics)
"compiled ${source().length} chars, would capture at ${times()}"
}
}
// Test it yourself, same marshaling the agent uses — positional & type-coerced:
start { println(a("wall 1 1 ; rect 0 0 1 1 fill", listOf(0.0, 0.5))) }
The returned handle is directly suspend-invocable from box code — positional
(a(src, times), coerced by declared param order) or named (a.call("source" to src))
— so you can exercise a tool without going through an agent. See BoxTool.
Registration is a side effect of running box code: re-running a box replaces its tools
by name (idempotent); a re-run that no longer registers one, or retiring the box, drops it —
see DocumentToolRegistry for the lifecycle.
src/jvmMain/kotlin/afield/ai/tools/BoxAiTools.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.aiTool(name: String, build: BoxToolBuilder.() -> Unit): BoxTool |
Registers (or replaces) the tool name owned by this box and returns its BoxTool handle. |
BoxToolBuilder
class BoxToolBuilder internal constructor(val box: Box, val name: String)
| Declaration | Summary |
|---|---|
val box: Box |
|
val name: String |
|
fun describe(text: String) |
Human-readable description shown to the agent. |
fun namespace(name: String) |
The catalogue namespace; defaults to the part of the name before its first . (rectsl.run → rectsl), else doc. |
fun exposure(exposure: ToolExposure) |
How the model meets this tool (ToolExposure): DIRECT (the default) sends its schema with every request; DEFERRED lists it by name and sends the schema only once the model asks; PROGRAM leaves it to tool programs. |
fun effects(vararg effects: ToolEffect) |
What calling it does (ToolEffect); effects() with nothing says "none". Undeclared by default. A tool declaring MUTATE_DOCUMENT is refused on a read-only document without its action running. |
inline fun <reified T> param(description: String = "", default: Any? = NoDefault): PendingParam<T> |
Declare a parameter. Use with by so the argument name is taken from the property: val source by param<String>("RectSL program text"). The JSON type is derived from T (reusing the same emitter as the static tool schemas). Supply default = … to make the argument optional. |
fun action(body: suspend BoxToolScope.() -> Any?) |
The tool body. Runs on the runloop with the box in scope; may call frame()/delay(). |
fun test(name: String, arguments: Map<String, Any?> = emptyMap(), verify: (Any) -> Unit) |
Attach an explicit, executable example to this tool. Tests run only when requested. Supply ordinary arguments; verify must throw when the returned result is wrong. A test may call a mutating tool: run those tests in a disposable project. |
fun testFailure(name: String, arguments: Map<String, Any?> = emptyMap(), messageContains: String) |
Attach an example that must reject its arguments with a specific diagnostic. messageContains must occur in the thrown exception message or structured tool error. An unexpected success or unrelated error fails the test; cancellation always propagates. Like test, this invokes the real tool and should use a disposable project for mutations. |
PendingParam
Intermediate returned by BoxToolBuilder.param; provideDelegate captures the property name.
| Declaration | Summary |
|---|---|
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): BoxToolArg<T> |
BoxToolArg
An argument accessor. Inside action { } call it like a function — source() — to read the current invocation's (coerced) value. Reads a coroutine/thread-local set for the duration of the call, so it is safe across frame() suspensions.
| Declaration | Summary |
|---|---|
val description: String |
|
operator fun getValue(thisRef: Any?, property: KProperty<*>): BoxToolArg<T> |
|
operator fun invoke(): T |
BoxToolScope
class BoxToolScope internal constructor(val box: Box, val ctx: ToolExecutionContext?)
Receiver of a tool's action { } body.
| Declaration | Summary |
|---|---|
val box: Box |
|
val ctx: ToolExecutionContext? |
The chat/MCP execution context when driven by an agent; null for direct box-code calls. |
val thisBox: Box |
|
suspend fun frame() |
Advance one runloop frame (yields the continuation to resume next frame). |
suspend fun delay(ms: Long) |
Wait ms real milliseconds, then settle onto the next frame. |
fun toolError(vararg messages: String): BoxToolError |
Return a structured error to the agent (also usable as a normal return value). |
fun toolError(messages: List<String>): BoxToolError |
|
fun images(dataUrls: List<String>, note: String = ""): BoxToolImages |
Return one or more images to the conversation. Return the result from action { }; the chat loop attaches the images as visible content and lets the model inspect them. Each entry must be a data:image/...;base64,... URL (see afield.media.image.util.PngEncoder or a captured JPEG). The base64 payload is kept out of the tool's JSON output (only note and count are serialized) so it does not bloat the transcript. |
fun image(dataUrl: String, note: String = ""): BoxToolImages |
BoxToolError
data class BoxToolError(val messages: List<String>, val error: Boolean = true)
A structured tool error, serialized back to the agent.
BoxToolImages
Image(s) produced by a tool, injected into the conversation as visible content by the chat loop (mirrors the shader-reload visual-check path). The base64 URLs are JsonIgnored so the tool's JSON function-output stays small; only note and count are serialized.
| Declaration | Summary |
|---|---|
val note: String |
|
val count: Int |
|
override val toolImages: List<ToolImage> |
Carried by both bridges and the MCP backdoor through the shared CarriesToolImages path. |
BoxTool
| Declaration | Summary |
|---|---|
val name: String |
|
val description: String |
|
val boxId: String |
|
val meta: ToolMeta |
|
val sourceRevision: String |
A hash of the registering box's code when it registered this, for capability checkpoints. |
val source: String |
Exact provider source at registration; a later source edit cannot relabel old evidence. |
val sourceHash: String |
|
val diagnostics: ToolDiagnostics |
Local diagnostic records, shared by chat, MCP and direct Kotlin calls. |
val parametersSchema: JsonObject by lazy |
JSON-Schema for the parameters (kotlinx form), reusing the static-tool emitter. |
val wireName: String |
|
fun toFunctionTool(): FunctionTool |
Build the SDK descriptor, using a portable alias when the authored name needs one. |
suspend fun call(argsJson: String, ctx: ToolExecutionContext? = null): Any |
Core execution: bind+coerce argsJson, then run the body. suspend. |
suspend operator fun invoke(vararg positional: Any?): Any |
Terse positional call from box code: a(src, listOf(0.0)). Values are coerced by declared parameter order and arity-checked. suspend — invoke from a coroutine (e.g. inside start { } / frames { }). |
suspend fun call(vararg named: Pair<String, Any?>): Any |
Named call from box code: a.call("source" to src). suspend. |
suspend fun test(): List<ToolTestOutcome> |
Run the attached examples through the same binding/execution path as AI4. |
DocumentToolRegistry
Document-scoped registry of box-authored tools. A tool registered by any box in a document is visible to every agent anchored in that document — the in-app AI4 chat and external MCP users alike. Keyed by document path; tools remember their owning box id so clearBox can drop them on box disposal.
| Declaration | Summary |
|---|---|
const val AI_TOOL_KIND |
The afield.runtime.lifecycle.LifecycleLedger kind of a box-authored tool registration. |
fun register(documentKey: String, tool: BoxTool) |
Register (upsert by name) — re-running a box replaces its prior tool of the same name. |
fun toolsFor(documentKey: String): List<BoxTool> |
All tools for a document, sorted by name (stable for schemas/tests). |
fun get(documentKey: String, name: String): BoxTool? |
|
fun clearBox(documentKey: String, boxId: String) |
Drop every tool owned by boxId (call on box dispose/stop). |
fun clearBox(box: Box) |
Drop tools owned by box across whatever document it belongs to. |
fun remove(documentKey: String, name: String) |
|
fun clearDocument(documentKey: String) |
DocsSearchBridge.kt
The search engine behind the documentation panel, which retrieves from Field's local index and answers through the selected language model.
DocsSearchBridge holds the conversation state and runs each query; retrieval stays local, and
only the synthesis step goes to the provider chosen on /welcome.
src/jvmMain/kotlin/afield/ai/tools/DocsSearchBridge.kt — read the source
DocsSearchBridge
Docs-search engine for the documentation iframe.
| Declaration | Summary |
|---|---|
data class SearchState(val thread: LlmThreadState) |
Opaque to the installer; carries the synthesis thread whose history the bridge maintains. |
data class SearchResult(val success: Boolean, val text: String, val newState: SearchState) |
|
fun startConversation(): SearchState |
|
fun search(state: SearchState, query: String, onTextDelta: ((String) -> Unit)? = null): SearchResult |
DocsSearchInstaller.kt
The server side of the documentation search box: the docs_search_input Field message handler.
The prefix of Message.uid before _ is the browser session key. One
DocsSearchBridge.SearchState is retained per key for the lifetime of this installer; there is no
eviction and no reset message, though every state is dropped when the AI provider settings change.
Work runs on the process-wide IO scope and replies are marshalled through inMain. Phase 1 is
provisional HTML and phase 20 is terminal HTML. The incoming phase is not consulted.
src/jvmMain/kotlin/afield/ai/tools/DocsSearchInstaller.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun installDocsSearch() |
Installs the docs_search_input handler, which answers a query with provisional then final HTML. |
DocumentGuideTools.kt
The AI4 tools for a document's Markdown guides: the durable notes and handoffs kept in the document's own
folder, its docs/ or its guides/.
An agent can list, read, search, create, patch and append them. Writes are revision-checked: a patch or append names the revision from its last read and fails on a conflict instead of overwriting another writer, and create never overwrites. Mutations are refused on a read-only document.
src/jvmMain/kotlin/afield/ai/tools/DocumentGuideTools.kt — read the source
DocumentGuideListRequest
data class DocumentGuideListRequest(val unused: Int = 0)
List the document's guides, with their titles and links, and no contents.
DocumentGuideReadRequest
Read a window of one guide's lines. The revision it returns covers the whole file, not the window, so it is still the right revision to quote back on a write.
DocumentGuideSearchRequest
data class DocumentGuideSearchRequest(val query: String, val path: String = "", val limit: Int = 20)
Search the guides for literal text, case-insensitively. Each hit carries the heading it falls under and a link to that section, so an answer can cite where it came from.
DocumentGuideCreateRequest
data class DocumentGuideCreateRequest(val path: String, val contents: String)
Write a new guide. A path that already exists is a conflict, never an overwrite.
DocumentGuidePatchRequest
Replace one exact passage of a guide. oldText has to occur exactly once, and an empty newText deletes the passage rather than leaving a gap.
DocumentGuideAppendRequest
data class DocumentGuideAppendRequest(val path: String, val revision: String, val text: String)
Add text to the end of a guide, verbatim: any blank line or heading it needs has to be in the text.
GuideToolEntry
data class GuideToolEntry(val path: String, val title: String, val link: String)
One guide in a listing: its path in the document, its title, and a field://guide/ link to open it.
GuideSearchHit
One matching line, with the heading above it and a link that opens the guide at that section.
DocumentGuideToolResult
What every guide tool answers with; which fields are filled depends on which tool answered.
| Declaration | Summary |
|---|---|
val success: Boolean |
False when the operation failed or conflicted, with the reason in message. |
val message: String |
One sentence about what happened, and what this result writes to the tool log. |
val document: String |
The document folder the guides live in. |
val readOnly: Boolean |
Whether this document refuses writes, answered on every call rather than only on a refused one. |
val path: String |
The guide the call was about, where it was about one. |
val link: String |
A field://guide/ link to that guide. |
val revision: String |
The revision of the whole file, which a patch or append has to quote back. |
val contents: String |
The lines read, to 32000 characters. |
val startLine: Int |
The first line contents holds, counting from one. |
val endLine: Int |
The last line it holds. |
val totalLines: Int |
How many lines the whole guide has, of which contents is a window. |
val truncated: Boolean |
True when the guide runs past endLine, or when contents was cut at 32000 characters. |
val entries: List<GuideToolEntry> |
Every guide in the document, for a listing. |
val hits: List<GuideSearchHit> |
The matching lines, for a search, up to the limit it was given. |
val conflict: Boolean |
True when a write was refused because the guide had changed, or already existed. |
override fun toolLogSummary(): String |
The message, which is already one line. |
DocumentGuideTools
Shared by every chat provider and the MCP/backdoor registry. No ambient session lookup.
| Declaration | Summary |
|---|---|
fun list(): DocumentGuideToolResult |
Every guide in the document, with titles and links and no contents. |
fun read(req: DocumentGuideReadRequest): DocumentGuideToolResult |
A window of one guide's lines, at most 500 of them and at most 32000 characters, with the revision of the whole file. |
fun search(req: DocumentGuideSearchRequest): DocumentGuideToolResult |
Lines containing a literal query, case-insensitively, across every guide or inside one. Each hit carries the heading above it and a link to that section. |
fun create(req: DocumentGuideCreateRequest) |
Write a guide that does not exist yet; an existing path comes back as a conflict. |
fun patch(req: DocumentGuidePatchRequest) |
Replace one passage that occurs exactly once, in a guide still at the revision given. |
fun append(req: DocumentGuideAppendRequest) |
Add text to the end of a guide still at the revision given. |
DocumentSourceTools.kt
The AI4 tools that read and patch the live source of a box, navigate the editor to it, and export a snapshot of part of the graph.
DocumentSourceReadRequest reports the live, saved and last executed revisions of one property;
DocumentSourcePatchRequest replaces it only when the revision still matches;
DocumentBoxNavigateRequest opens the editor, box or output channel; and
DocumentSourceExportRequest writes a snapshot with provenance warnings. None of them launches
a box.
src/jvmMain/kotlin/afield/ai/tools/DocumentSourceTools.kt — read the source
Used in: AI4's built-in tools
DocumentSourceReadRequest
data class DocumentSourceReadRequest(val uid: String, val property: String = "code")
Read one box property as it stands in memory, with the revisions of what is on disk and of what last ran beside it. Those three disagreeing is the ordinary state of a box being edited.
DocumentSourcePatchRequest
Replace one box property, if its live revision is still the one the last read reported. A property that changed in between comes back with DocumentSourceResult.conflict set and nothing written.
DocumentBoxNavigateRequest
Bring a box, its source or one of its output channels into view in the person's own window. It waits five seconds for a frontend to confirm, and reports that the surface was opened rather than what is in it.
DocumentSourceExportRequest
Write the live source and topology of some boxes, and any guides named with them, into a new directory as an installable snapshot. The directory has to be new, and has to sit outside the document being exported.
DocumentSourceResult
What a read or a patch answers with: one property's text, and where else a copy of it exists.
GlslIncludeLibraryIndex.kt
GlslIncludeLibraryIndex scans directories of GLSL include files, Lygia by default plus any roots
named in AFIELD_GLSL_INCLUDE_DIRS or repositories in AFIELD_GLSL_INCLUDE_REPOS, and records each
file's include path, content hash and declared functions and macros in glsl_include_index.json
under the AI filestore, so an agent can find which include defines a function.
AFIELD_GLSL_INCLUDE_INDEX=off disables it and AFIELD_GLSL_INCLUDE_DEFAULTS=off drops the Lygia
default.
src/jvmMain/kotlin/afield/ai/tools/GlslIncludeLibraryIndex.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun syncGlslIncludeLibraryIndex() |
GlslIncludeSymbolRecord
data class GlslIncludeSymbolRecord(val name: String, val kind: String)
One function or macro declared in an include file.
| Declaration | Summary |
|---|---|
val name: String |
|
val kind: String |
GlslIncludeFileRecord
| Declaration | Summary |
|---|---|
val includePath: String |
|
val localPath: String |
|
val contentHash: String |
|
val bytes: Long |
|
val symbols: List<GlslIncludeSymbolRecord> |
GlslIncludeLibraryRecord
GlslIncludeIndexDb
| Declaration | Summary |
|---|---|
val version: Int |
|
val updatedAtEpochMs: Long |
|
val libraries: List<GlslIncludeLibraryRecord> |
GlslIncludeLibraryIndex
object GlslIncludeLibraryIndex
Indexes local or managed directories of GLSL include files for AI grounding.
OutputIframeAiTools.kt
src/jvmMain/kotlin/afield/ai/tools/OutputIframeAiTools.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.exposeOutputIframeTools(channel: String, name: String = channel): List<BoxTool> |
Opt this box's named output iframe into AI4's tool catalogue for this document. |
PresentationTools.kt
The AI4 tools for a document's slides: read and revise an editable slide file, and inspect or drive a presentation that box code has already built.
Those are two different decks. PresentationReadRequest and PresentationWriteSlideRequest work on a
.slides.json file in the document's folder, one Markdown slide at a time, and a write into an existing
file carries the revision from the read it was based on: a file that changed in between fails the write
instead of overwriting the other writer. PresentationInspectRequest and PresentationControlRequest work on the
live slide show of a box that built a presentation in Kotlin — inspect reports its steps, current cue and
checkpoint, control navigates it. Control can run a step's own callbacks, so what it reports is the state
of the JVM rather than proof that an audience saw anything.
src/jvmMain/kotlin/afield/ai/tools/PresentationTools.kt — read the source
PresentationReadRequest
Read a slide file's outline, and one slide's Markdown when slideId names one.
PresentationWriteSlideRequest
Write one slide. A slide whose id the file already has is replaced where it stands; a new id is inserted after afterId, or appended when that is empty.
PresentationInspectRequest
data class PresentationInspectRequest(val unused: Int = 0)
List the presentations this document's boxes have built, with their host box UIDs. A box whose slide show is a guide rather than a presentation is left out.
PresentationControlRequest
data class PresentationControlRequest(val hostUid: String, val command: String)
Navigate a built presentation by its host box UID: next, previous, refresh, present, stop, reset-checkpoint, run-cue, or a zero-based slide number. A box that holds a guide rather than a presentation is refused, as is a slide number past the end.
PresentationOutlineEntry
One slide in an outline: its id, its title, the section path it sits under, and its reveal cues.
PresentationEditResult
What a read or a write answers with: the outline either way, and the whole slide when one was named.
| Declaration | Summary |
|---|---|
val outline: List<PresentationOutlineEntry> |
A page of the deck's outline, one entry per slide, in the order the file keeps them. |
val slide: PresentationSlide? |
The whole slide, when the call named one: the slide a read asked for, or the one a write just saved. |
val totalSlides: Int |
How many slides the whole file holds, of which outline is a page. |
val nextOffset: Int? |
The offset of the next page of outline, or null once it reaches the last slide. |
PresentationTools
One document's authoring surface; shared by chat providers and the backdoor registry.
| Declaration | Summary |
|---|---|
fun read(req: PresentationReadRequest): PresentationEditResult |
Read a page of one slide file's outline, with the revision a later write will have to match. |
fun write(req: PresentationWriteSlideRequest): PresentationEditResult |
Save one slide into the file, replacing a slide of the same id or inserting a new one. An empty revision starts a fresh deck; any other revision has to be the one the file still carries. |
fun inspect(): Any |
The presentation state of every box in the session that has built one, read on the Runloop thread. |
fun control(req: PresentationControlRequest): Any |
Send one navigation command to a built presentation and answer with the state it reached. Both this and inspect have to be called off the Runloop, since they step onto it to reach the slide show. |
RepoCommandTools.kt
Bounded commands for a headless AI4 job
The repo command tool lets a job that already has RepoFileTools run a named command in its
checkout, so it can reproduce a failure and confirm a fix (the landing queue's fixer needs both).
It is not a shell. A job lists the names it allows (RepoCommandScope.allowed); each name is an
argv template (RepoCommandTemplates) whose free slots take only what that slot's check admits
(a Gradle task name, a tools/test_*.py path the job may read, a git revision). The process:
- is started from an argv list (no shell, no interpolation), with the job's root as its cwd and stdin closed;
- gets the Field JVM's environment minus every
GIT_*variable,VIRTUAL_ENV(uv would warn and ignore it) and the verification-lock owner marker, plusAFIELD_VERIFICATION_LOCK=fail,GIT_TERMINAL_PROMPT=0andGIT_PAGER=cat; - is refused outright while another live process holds the root's
.field-backdoor/verification.lock(unless that process is an ancestor of this JVM, i.e. the job's own runner); - is stopped with its whole process tree (TERM, then KILL after a grace) at its wall-clock cap. A Gradle daemon in that tree is left alone: daemons are shared by every checkout, and killing the client cancels its build;
- has stdout and stderr merged and capped: the first
HEAD_BYTESand the last bytes up to the cap are kept, the middle is dropped and counted.
One command runs at a time per job.
src/jvmMain/kotlin/afield/ai/tools/RepoCommandTools.kt — read the source
RepoCommandScope
What one job may run, and how long and how loud each run may be.
| Declaration | Summary |
|---|---|
val allowed: Set<String> |
|
val maxTimeoutSec: Int |
|
val maxOutputBytes: Int |
RepoRunCommandRequest
| Declaration | Summary |
|---|---|
val command: String |
|
val args: List<String> |
|
val timeoutSec: Int |
RepoCommandResult
RepoCommandTemplate
One allowed command: a fixed argv prefix, a check that turns the model's args into the rest, and its default timeout.
| Declaration | Summary |
|---|---|
val name: String |
|
val summary: String |
|
val defaultTimeoutSec: Int |
|
val build: (args: List<String>, files: RepoFileTools) -> List<String> |
RepoCommandTemplates
The command names a job can allow. FIXER_SET is what the landing queue's fixer gets.
| Declaration | Summary |
|---|---|
val ALL: Map<String, RepoCommandTemplate> |
|
val FIXER_SET: List<String> |
Reproduce and confirm, nothing that changes git state: what field_agent.headless_argv("field", edits=True) allows. |
RepoCommandTools
class RepoCommandTools(val files: RepoFileTools, val scope: RepoCommandScope)
The command tool over one job's RepoFileTools.
| Declaration | Summary |
|---|---|
val runs: MutableList<String> |
Every run, in order, for the job's report: name args -> exit (or timeout). |
fun describeAllowed(): String |
The lines the job's prompt shows: one per allowed command. |
fun run(req: RepoRunCommandRequest): RepoCommandResult |
RepoFileTools.kt
The repo tools read, list, search, patch and create files in one checkout, for a job that hands
AI4 work a coding agent would otherwise do (a docs-notes revision, a landing question). A job
supplies a RepoScope: the root, the globs it may read and the globs it may write. Every path is
relative to the root with / separators; an absolute path, a .. segment, a symbolic link
anywhere on the way, .git, or a path no glob admits is refused with the reason. The tools are
offered only when a job wires a scope (ToolExecutionContext.repoFiles); a chat never sees them.
src/jvmMain/kotlin/afield/ai/tools/RepoFileTools.kt — read the source
Used in: AI4's built-in tools
RepoScope
What one job may touch. Globs are java.nio globs over the root-relative path: ** crosses directories.
RepoReadFileRequest
| Declaration | Summary |
|---|---|
val path: String |
|
val startLine: Int |
|
val endLine: Int |
RepoListFilesRequest
data class RepoListFilesRequest(val glob: String = "**")
RepoSearchRequest
| Declaration | Summary |
|---|---|
val query: String |
|
val glob: String |
|
val regex: Boolean |
|
val ignoreCase: Boolean |
|
val maxMatches: Int |
RepoPatchFileRequest
RepoWriteFileRequest
RepoFileResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val path: String |
|
val text: String |
|
val totalLines: Int |
|
val truncated: Boolean |
RepoMatch
data class RepoMatch(val path: String, val line: Int, val text: String)
RepoListResult
RepoSearchResult
RepoPathRefused
class RepoPathRefused(message: String) : IllegalArgumentException(message)
A path the scope refuses; the message is what the model sees.
RepoFileTools
class RepoFileTools(val scope: RepoScope)
The repo tools over one RepoScope. Every call re-checks its path: nothing is cached between calls.
| Declaration | Summary |
|---|---|
val written: MutableList<String> |
Paths this job wrote, in order, for the job's report. |
fun resolve(raw: String, write: Boolean): Path |
raw as a confined absolute path, or RepoPathRefused. |
fun read(req: RepoReadFileRequest): RepoFileResult |
|
fun list(req: RepoListFilesRequest): RepoListResult |
|
fun search(req: RepoSearchRequest): RepoSearchResult |
|
fun patch(req: RepoPatchFileRequest): RepoFileResult |
|
fun write(req: RepoWriteFileRequest): RepoFileResult |
StateTools.kt
The AI4 tools that list, read, show and fork the saved histories of a document, over the same providers the States browser shows a person.
StateListRequest, StateReadRequest, StateShowRequest and StateForkRequest are the tools,
and StateToolResult is their answer. None of them restores a state or runs code; forking is
offered only by a history that reports canFork.
src/jvmMain/kotlin/afield/ai/tools/StateTools.kt — read the source
Used in: AI4's built-in tools
StateListRequest
List the histories, or the saved states inside one of them. With history empty the answer's StateToolResult.histories names every history this document has; with it set, StateToolResult.nodes is a page of that history's states.
StateReadRequest
Read one saved state's text, a page at a time. The whole text is hashed into StateToolResult.revision; passing that revision back on a later page makes a provider whose data changed mid-read fail rather than stitch two versions together.
StateShowRequest
data class StateShowRequest(val history: String, val state: String = "")
Reveal a history, and optionally one of its states, in the person's own state browser. This is the one tool here that needs a connected frontend: with none, it answers unsuccessfully within twenty seconds.
StateForkRequest
data class StateForkRequest(val history: String, val state: String, val name: String)
Branch from a saved state under a new name, leaving the active state where it is. Only a history whose provider supplies a fork can do this, which is what StateHistoryInfo reports as canFork.
StateHistoryInfo
data class StateHistoryInfo(val id: String, val name: String, val canFork: Boolean)
One history in a listing: its exact id, the label the browser shows, and whether it can fork.
StateToolResult
What all four state tools answer with. Most fields are empty for most calls: a listing fills histories or nodes, a read fills contents and the paging fields, and a fork fills state with the new child's ID.
| Declaration | Summary |
|---|---|
val success: Boolean |
False when the operation failed, with the reason in message. |
val message: String |
One sentence about what happened, and the summary this result writes to the tool log. |
val history: String |
The history the call resolved to, as its exact ID rather than the name that was asked for. |
val state: String |
The saved state the call resolved to, or the new child's ID after a fork. |
val title: String |
That state's title, to 180 characters, or the branch name a fork was given. |
val histories: List<StateHistoryInfo> |
Every history in the document, for a listing that named none. |
val nodes: List<StateTreeNode> |
One page of a history's saved states, each with its parents, titles cut to 180 characters. |
val current: String? |
The ID of the state the history currently sits on, when its provider reports one. |
val note: String |
The provider's own note about the history, to 1000 characters. |
val canFork: Boolean |
Whether this history can fork, repeated here so a read answers the question too. |
val total: Int |
How many histories or states matched before paging. |
val nextOffset: Int? |
The offset of the next page of histories or nodes, or null at the end. |
val contents: String |
One page of the saved state's text. |
val revision: String |
A hash of the whole saved text, to pass back on the next page. |
val startChar: Int |
The character index contents starts at. |
val totalChars: Int |
The length of the whole saved text, of which contents is a window. |
val nextChar: Int? |
Where the next page of contents starts, or null at the end of the text. |
override fun toolLogSummary() |
The message, which is what the tool log keeps rather than any state text. |
StateTreeBrowser.kt
The States browser of AI4: how a box exposes a saved history with stateTree, and the read-only view of it shared by the browser and by chat references.
A provider supplies a snapshot, a reader for one saved state and, optionally, a fork callback.
StateTreeBrowser pages and bounds the text it shows, and never restores a state.
src/jvmMain/kotlin/afield/ai/tools/StateTreeBrowser.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.stateTree(name: String, label: String, read: () -> StateTreeSnapshot, inspect: (String) -> String, fork: ((String, String) -> String)?) |
Expose an existing history to AI4's States browser. Callbacks only read saved data; they must not restore state or run admission checks. read returns metadata; inspect reads one exact saved ID. Optional fork creates a named saved child and returns its ID without activation or restoration. Leave it null for histories that cannot safely retain branches. Re-running replaces this named provider and the run's lifecycle owns its registration. |
fun Box.stateTree(name: String, label: String, read: () -> StateTreeSnapshot, inspect: (String) -> String) |
Read-only provider overload; keeps the inspector usable as a trailing lambda. |
fun Box.stateTree(name: String, label: String, study: SourceStudy<*>) |
Browse a SourceStudy without invoking its compiler or candidate checks. |
StateTreeBrowser
Read-only projection shared by the browser and its chat references. No navigation or restoration.
| Declaration | Summary |
|---|---|
fun read(boxes: Collection<Box>, request: StateTreeRequest): StateTreeReply |
ToolMeta.kt
Tool metadata answers three questions beyond the schema: which namespace a tool belongs to, how the
model discovers it (ToolExposure), and what calling it does (ToolEffect). Both of the last two
follow Pi (earendil-works/pi). Exposure decides whether a tool's schema is sent with every request or
only on demand. Field uses effects for three things rather than for confirmation dialogs: a central
refusal of document mutations on a read-only document, eval checks
(effects-absent:FILESYSTEM_WRITE), and the catalogue line that tells a model which calls are cheap
looks and which change things.
Metadata comes from AgentToolMeta on the request class, from aiTool { namespace(); exposure();
effects() } for a box-authored tool, or from AgentToolExtensions.register(meta = ...). A class with
none gets ToolMeta.DEFAULT: namespace core, DIRECT, ToolEffect.UNDECLARED — undeclared rather
than "no effects", so an eval asserting an effect is absent fails on a tool that never said.
src/jvmMain/kotlin/afield/ai/tools/ToolMeta.kt — read the source
ToolExposure
How a model discovers a tool: schema up front, by program, on request, or never.
Constants: DIRECT, PROGRAM, DEFERRED, HIDDEN.
ToolEffect
Never shown to a model, nor over MCP; callable only by Field itself and the backdoor.
Constants: READ_DOCUMENT, MUTATE_DOCUMENT, EXECUTE_CODE, CONTROL_RUNTIME, CAPTURE_IMAGE, GPU, FILESYSTEM_READ, FILESYSTEM_WRITE, NETWORK, AGENT_STATE, UNDECLARED.
AgentToolMeta
The tool never said. Distinct from an empty set, which means "no effects".
| Declaration | Summary |
|---|---|
val namespace: String |
|
val exposure: ToolExposure |
|
val effects: Array<ToolEffect> |
ToolMeta
| Declaration | Summary |
|---|---|
val namespace: String |
|
val exposure: ToolExposure |
|
val effects: Set<ToolEffect> |
|
val mutates: Boolean |
|
val readOnly: Boolean |
Only looks: every effect is a read or a picture. What MCP calls readOnlyHint. |
CallOrigin
Who is calling: the model directly, a tool program, or Field's backdoor / MCP face.
Constants: MODEL, PROGRAM, BACKDOOR.
ToolFace
Which descriptor list ToolRegistry.describe builds: what a model is shown, or what MCP clients are.
Constants: MODEL, MCP, PROGRAM.
DescribeToolsRequest
| Declaration | Summary |
|---|---|
val namespace: String |
|
val names: List<String> |
DescribeToolsResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val tools: List<DescribedTool> |
|
override fun toolLogSummary(): String |
DescribedTool
ToolRunloopDispatch.kt
The check that turns a deadlocking aiTool call into an error message.
A tool body runs on the Runloop's main thread, so a caller that is already blocking that thread inside
runBlocking can never let the body run: the call would wait for a thread that is waiting for it.
Giving the runBlocking a Dispatchers.IO context moves the body but not the blocked caller, so that
case is checked as well, by looking for runBlocking on the owning thread's stack. Both messages name
the two ways of calling a tool that do work: directly from AI4, or inside start { ... } in box code.
src/jvmMain/kotlin/afield/ai/tools/ToolRunloopDispatch.kt — read the source
Tools.kt
The request and result classes of the tools Field's AI agent can call, and the registry that dispatches a call by name to its handler.
Each tool is a Kotlin data class: its simple name is the tool name, @JsonClassDescription and
@JsonPropertyDescription are what the model reads, and AgentToolMeta records the tool's group
and effects. The file covers attachments and definitions, Kotlin compile and evaluate, shader
reload, render-window capture, macros, code-use queries, and the agent's scratchpad and checklist
(AgentMemoryState). A result that carries pictures implements CarriesToolImages, so the images
travel as images rather than as base64 text inside the JSON output.
ToolSchema emits the JSON Schema for a request class from Kotlin reflection alone, skipping
@JsonIgnore and placeholder unused fields; openAiTool builds the OpenAI function tool and
keeps nullable arguments optional. AgentToolSchemaTest checks that OpenAI accepts the argument
schema of every registered tool.
src/jvmMain/kotlin/afield/ai/tools/Tools.kt — read the source
Used in: AI4's built-in tools
Top-level functions
| Declaration | Summary |
|---|---|
fun virtualFilesToAttachments(files: List<afield.document.workspace.VirtualTextFile>): List<ContextAttachment> |
|
fun validateNoMarkers(code: String) |
Commentary
data class Commentary(val text: String)
Commentary tool: model uses this for non-code notes/questions/alternatives. You collect these separately and keep code output clean.
ContextAttachment
Represents a file/language/code triple that can be attached to a request. Attachments are retrieved via tools rather than stuffed into the prompt body.
| Declaration | Summary |
|---|---|
val fileName: String |
|
val language: String |
|
val code: String |
|
val description: String |
ListAttachmentsRequest
data class ListAttachmentsRequest(val unused: Int = 0)
Tool: list available attachment filenames (+ languages) for this request.
ListAttachmentsResult
data class ListAttachmentsResult(val attachments: List<AttachmentInfo>)
AttachmentInfo
data class AttachmentInfo(val file: String, val language: String, val description: String = "")
AttachmentRequest
Tool: read a bounded excerpt of an attachment or resolvable source.
| Declaration | Summary |
|---|---|
val file: String |
|
val offset: Int |
|
val maxChars: Int |
|
val expectedSourceHash: String |
|
val find: String |
AttachmentResponse
| Declaration | Summary |
|---|---|
val file: String |
|
val language: String |
|
val content: String |
|
val relatedGuides: List<String> |
|
val relatedExamples: List<String> |
|
val offset: Int |
|
val totalChars: Int |
|
val sourceHash: String |
|
val nextOffset: Int? |
|
val isExcerpt: Boolean |
|
val searchMatched: Boolean? |
|
val matchOffset: Int? |
|
val startLine: Int? |
1-based line of offset and of matchOffset: offsets are not line numbers, so cite these. |
val matchLine: Int? |
|
val nearMatches: List<String> |
When find missed: up to eight line: text rows containing its longest identifier. |
ListDefinitionsRequest
data class ListDefinitionsRequest(val unused: Int = 0)
Tool: list all definitions visible from the current Kotlin file (incl shaders).
ListDefinitionsResult
data class ListDefinitionsResult(val definitions: List<DefinitionInfo>)
DefinitionInfo
ListContentsRequest
data class ListContentsRequest(val unused: Int = 0)
Tool: get full contents of text properties for the current box/file context.
ListContentsResult
PreamblePropertyHint
| Declaration | Summary |
|---|---|
val property: String |
|
val receiver: String |
|
val delegate: String |
|
val type: String |
|
val boxUid: String |
|
val boxName: String |
|
val relation: String |
|
val graphRelevant: Boolean |
ListContentEntry
| Declaration | Summary |
|---|---|
val fileRef: String |
|
val property: String |
|
val language: String |
|
val content: String |
|
val boxUid: String |
|
val boxName: String |
|
val relation: String |
ListKotlinContextVariablesRequest
data class ListKotlinContextVariablesRequest(val target: String = "", val maxValueChars: Int = 300)
| Declaration | Summary |
|---|---|
val target: String |
|
val maxValueChars: Int |
KotlinContextVariableInfo
ListKotlinContextVariablesResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val target: String |
|
val boxUid: String |
|
val boxName: String |
|
val generation: Int |
|
val message: String |
|
val variables: List<KotlinContextVariableInfo> |
EvaluateKotlinExpressionRequest
| Declaration | Summary |
|---|---|
val target: String |
|
val expression: String |
|
val renderHtml: Boolean |
|
val maxChars: Int |
EvaluateKotlinExpressionResult
EditFileRequest
data class EditFileRequest(val file: String, val newContent: String)
Tool: edit another file by name (your integration decides how to apply this).
EditFileResponse
data class EditFileResponse(val success: Boolean, val message: String? = null)
CompileKotlinRequest
Agentic verification tools ---------------------------------------------
| Declaration | Summary |
|---|---|
val file: String |
|
val source: String? |
|
val runSetupBlocks: Boolean |
CompileDiagnostic
| Declaration | Summary |
|---|---|
val severity: String |
|
val message: String |
|
val line: Int |
|
val column: Int |
|
val endLine: Int |
|
val endColumn: Int |
|
val code: String? |
SetupPropertyChange
| Declaration | Summary |
|---|---|
val property: String |
|
val changed: Boolean |
|
val cacheChanged: Boolean |
|
val beforeChars: Int |
|
val afterChars: Int |
CompileKotlinResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val file: String |
|
val message: String |
|
val diagnostics: List<CompileDiagnostic> |
|
val setupChanges: List<SetupPropertyChange> |
ReloadShadersRequest
| Declaration | Summary |
|---|---|
val target: String |
|
val mode: String |
|
val visualCheck: Boolean |
ShaderReloadError
data class ShaderReloadError(val property: String, val line: Int = -1, val message: String)
| Declaration | Summary |
|---|---|
val property: String |
|
val line: Int |
|
val message: String |
ReloadShadersResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val target: String |
|
val mode: String |
|
val message: String |
|
val reloaded: List<String> |
|
val errors: List<ShaderReloadError> |
|
val visualCheckAttached: Boolean |
|
val visualCheckMessage: String |
|
val visualDataUrl: String |
|
override val toolImages: List<ToolImage> |
Derived from visualDataUrl rather than passed separately, so "was a picture attached" has one source of truth. |
CaptureRenderWindowRequest
Looking at the render window ----------------------------------------------
| Declaration | Summary |
|---|---|
val target: String |
|
val uid: String |
|
val settleMs: Int |
|
val note: String |
CaptureRenderWindowResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val title: String |
|
val width: Int |
|
val height: Int |
|
val settledMs: Int |
|
val dataUrl: String |
|
override val toolImages: List<ToolImage> |
|
override fun toolLogSummary(): String |
One line for the tool I/O log, and specifically including images=. |
MacroListRequest
data class MacroListRequest(val unused: Int = 0)
Macro transcript tools ---------------------------------------------------
MacroListResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val macros: List<String> |
MacroReadRequest
data class MacroReadRequest(val name: String)
MacroReadResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val name: String |
|
val content: String |
|
val operationCount: Int |
MacroWriteRequest
data class MacroWriteRequest(val name: String, val content: String)
| Declaration | Summary |
|---|---|
val name: String |
|
val content: String |
MacroWriteResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val name: String |
|
val operationCount: Int |
MacroRunRequest
| Declaration | Summary |
|---|---|
val name: String |
|
val direction: String |
|
val targetBoxes: List<String> |
|
val strict: Boolean |
MacroRunResult
CodeUseQueryRequest
Code-use telemetry tools -------------------------------------------------
CodeUseQueryResult
CodeUseCallersForSourceLineRequest
| Declaration | Summary |
|---|---|
val sourcePath: String |
|
val line: Int |
|
val limit: Int |
CodeUseReferencesForCodeRequest
data class CodeUseReferencesForCodeRequest(val code: String, val limit: Int = 5)
| Declaration | Summary |
|---|---|
val code: String |
|
val limit: Int |
CodeUseHotSymbolsRequest
data class CodeUseHotSymbolsRequest(val window: String = "all", val limit: Int = 20)
| Declaration | Summary |
|---|---|
val window: String |
|
val limit: Int |
CodeUseEarlyDocumentSymbolsRequest
| Declaration | Summary |
|---|---|
val document: String |
|
val earlyWindowMs: Long |
|
val limit: Int |
CodeUseRelatedSourceForCodeRequest
| Declaration | Summary |
|---|---|
val code: String |
|
val limit: Int |
|
val callersPerSymbol: Int |
|
val sourceRadiusLines: Int |
CodeUseSemanticAnalyzeCodeRequest
| Declaration | Summary |
|---|---|
val code: String |
|
val sourceName: String |
|
val limit: Int |
|
val callersPerSymbol: Int |
|
val sourceRadiusLines: Int |
SemanticRetrievalSearchRequest
Semantic retrieval tools ------------------------------------------------
SemanticRetrievalStatusRequest
data class SemanticRetrievalStatusRequest(val unused: Int = 0)
SemanticRetrievalRebuildRequest
data class SemanticRetrievalRebuildRequest(val unused: Int = 0)
SemanticRetrievalToolResult
ScratchpadReadRequest
data class ScratchpadReadRequest(val unused: Int = 0)
Long-running task tools --------------------------------------------------
ScratchpadUpdateRequest
data class ScratchpadUpdateRequest(val operation: String = "append", val text: String = "")
| Declaration | Summary |
|---|---|
val operation: String |
|
val text: String |
ScratchpadResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val text: String |
|
val charCount: Int |
ChecklistReadRequest
data class ChecklistReadRequest(val unused: Int = 0)
ChecklistUpdateRequest
| Declaration | Summary |
|---|---|
val operation: String |
|
val itemId: String |
|
val text: String |
|
val status: String |
|
val items: List<ChecklistItemInput> |
ChecklistItemInput
data class ChecklistItemInput(val text: String, val status: String = "pending", val id: String = "")
| Declaration | Summary |
|---|---|
val text: String |
|
val status: String |
|
val id: String |
ChecklistItem
data class ChecklistItem(val id: String, val text: String, val status: String = "pending")
| Declaration | Summary |
|---|---|
val id: String |
|
val text: String |
|
val status: String |
ChecklistResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val items: List<ChecklistItem> |
AgentMemoryState
| Declaration | Summary |
|---|---|
fun beginTurn() |
Called when an agent turn starts. |
fun checklistSummary(): String? |
"2 of 6 done, 1 in progress", plus "; not updated this turn" when it is stale; null when empty. |
data class Snapshot(val scratchpad: String, val checklistJson: String) |
The scratchpad and checklist as they are, for the session tree: memory is branch state, so a rewound conversation gets back the memory of the turn it was rewound to (AiSessionTree). |
fun snapshot(): Snapshot |
|
fun restore(snapshot: Snapshot) |
|
fun readScratchpad(): ScratchpadResult |
|
fun updateScratchpad(request: ScratchpadUpdateRequest): ScratchpadResult |
|
fun readChecklist(): ChecklistResult |
|
fun updateChecklist(request: ChecklistUpdateRequest): ChecklistResult |
|
fun checklistMarkdown(): String |
ToolImage
data class ToolImage(val dataUrl: String, val caption: String = "")
One image for the model to look at, as a data:image/…;base64,… URL.
| Declaration | Summary |
|---|---|
val dataUrl: String |
|
val caption: String |
CarriesToolImages
A tool result that has something for the model to see, not just to read.
| Declaration | Summary |
|---|---|
val toolImages: List<ToolImage> |
The pictures, excluded from this result's JSON on purpose — see the interface docs. |
WorkspaceSymbolSearch
Example tool (optional) -------------------------------------------------
WorkspaceSymbolSearchResult
WorkspaceSymbolMatch
GetUtcTime
data class GetUtcTime(val format: String = "yyyy-MM-dd HH:mm:ss")
CLI example tools --------------------------------------------------------
Remember
data class Remember(val key: String, val value: String)
Recall
data class Recall(val key: String)
ToolExecutionContext
| Declaration | Summary |
|---|---|
val attachments: List<ContextAttachment> |
|
val resolveAttachment: ((String) -> AttachmentResponse?)? |
|
val listDefinitions: (() -> ListDefinitionsResult)? |
|
val listFileContents: (() -> ListContentsResult)? |
|
val listKotlinContextVariables: ((ListKotlinContextVariablesRequest) -> ListKotlinContextVariablesResult)? |
|
val evaluateKotlinExpression: ((EvaluateKotlinExpressionRequest) -> EvaluateKotlinExpressionResult)? |
|
val editOtherFile: ((EditFileRequest) -> EditFileResponse)? |
|
val compileKotlin: ((CompileKotlinRequest) -> CompileKotlinResult)? |
|
val reloadShaders: ((ReloadShadersRequest) -> ReloadShadersResult)? |
|
val captureRenderWindow: ((CaptureRenderWindowRequest) -> CaptureRenderWindowResult)? |
|
val listMacros: ((MacroListRequest) -> MacroListResult)? |
|
val readMacro: ((MacroReadRequest) -> MacroReadResult)? |
|
val writeMacro: ((MacroWriteRequest) -> MacroWriteResult)? |
|
val runMacro: ((MacroRunRequest) -> MacroRunResult)? |
|
val codeUseQuery: ((CodeUseQueryRequest) -> CodeUseQueryResult)? |
|
val semanticRetrievalSearch: ((SemanticRetrievalSearchRequest) -> SemanticRetrievalToolResult)? |
|
val semanticRetrievalStatus: (() -> SemanticRetrievalToolResult)? |
|
val semanticRetrievalRebuild: (() -> SemanticRetrievalToolResult)? |
|
val agentMemory: AgentMemoryState? |
|
val documentGuides: DocumentGuideTools? |
|
val workspaceSymbolSearch: ((WorkspaceSymbolSearch) -> WorkspaceSymbolSearchResult)? |
|
val workspaceSearch: ((WorkspaceSearchRequest) -> WorkspaceSearchResult)? |
|
val repoFiles: RepoFileTools? |
The repo tools over one checkout (RepoFileTools); only a headless job with a repo scope wires it. |
val repoCommands: RepoCommandTools? |
The repo command tool (RepoCommandTools) over repoFiles' root; only a job that allows commands wires it. |
val focus: ToolFocus? |
What this conversation is about, when it is about one box: see ToolFocus. |
val dynamicTools: List<BoxTool> |
Document-authored tools contributed by boxes via the aiTool DSL. These are resolved per-request (the anchoring document's DocumentToolRegistry snapshot) and layered on top of the static built-ins by ToolRegistry: they surface on both the OpenAI chat path (ToolRegistry.addAllTo/ToolRegistry.execute) and the provider-neutral MCP face (ToolRegistry.describe/ToolRegistry.executeByName). A dynamic tool whose name collides with a built-in is ignored (built-ins win). |
val fieldGraph: afield.ai.agent.FieldGraphTools? |
Native box graph + execution tools (NewBox, RunBox, ReadBoxOutput, ...) for one document. null hides them: only contexts that mean to let the agent edit and run the document wire it. |
val withheld: Set<String> |
Tool names this conversation must not see or call, whatever their gates say. The registry enforces it everywhere (schemas, MCP descriptors, dispatch), so an A/B arm that withholds a tool cannot leak it through a second path. An eval's programs=off arm withholds RunToolProgramRequest. |
val progress: ((String) -> Unit)? |
Progress lines from inside a long tool — a tool program reports each nested call through it, so the panel shows program: RunBox Cell03 (4/12) rather than one silent spinner. The bridges wire it to their onProgress; null where nobody is watching. |
val promoted: MutableSet<String>? |
Deferred tools this conversation has loaded with DescribeToolsRequest — the bridges pass their thread's own set, so a load lasts for the conversation (and, with branching, for its branch). Null where there is no conversation to remember it in. |
val reportProgress: ((LlmProgressUpdate) -> Unit)? |
Structured progress from native tools, scoped to this conversation. |
val inbox: AiTurnInbox? |
Messages the person sends while this turn runs. The bridges drain it after each tool round and send what it held as a user message alongside the tool results; null where nobody can write in. |
val suspendable: Boolean |
Whether a tool may end this turn to wait (SuspendsTurn). Only a conversation that something will resume says so: the box chat's general turn, which saves the suspension with the chat. Elsewhere (the editor kinds, the document chat, a headless task, MCP) the waiting tools are not offered. |
fun documentReadOnly(): Boolean |
Whether the document this conversation acts on refuses edits: the session's flag, or Field's global one. |
ToolFocus
data class ToolFocus(val document: String, val box: afield.document.graph.Box, val property: String)
The document, box and property a conversation is focused on — the box whose AI4 chat is open.
TutorialCodeTools.kt
Finding tutorial code: a worked example of box code to start from.
Field ships about 260 tutorial documents: the materialized agent transcripts under
public/docs/assets/agent_transcripts/<name>/ and the graphics curriculum in
public/docs/assets/canonical_graphics/. Semantic search indexes them box by box as its tutorials corpus,
the way it indexes a workspace; this tool is a query on that corpus, grouped by document (by lesson for the
graphics curriculum, whose boxes are its lessons), with each document's description and docs links from its
transcript and the code files to read. A match names files rather than quoting them: read one with
AttachmentRequest to see the box code itself.
src/jvmMain/kotlin/afield/ai/tools/TutorialCodeTools.kt — read the source
TutorialCodeSearchRequest
Ask for tutorials by what they do. The answer is a TutorialCodeSearchResult.
TutorialCodeMatch
One tutorial that matched, with the files a reader would open next.
| Declaration | Summary |
|---|---|
val name: String |
The tutorial document's name, or canonical_graphics and the lesson box's name for one lesson. |
val description: String |
What the document's transcript says it shows, to 400 characters, empty when it says nothing. |
val boxes: List<String> |
Names of the boxes inside it that matched, up to six. |
val docs: List<String> |
The docs pages it illustrates, each as page: why from the transcript, up to four. |
val codeFiles: List<String> |
Paths of its box code and shader files, up to eight, for AttachmentRequest to read. |
TutorialCodeSearchResult
The tool's answer: success is false both for a failed search and for a search that matched nothing.
WorkspaceSearch.kt
WorkspaceSearchRequest is the agent tool that finds literal text in exactly one
WorkspaceSearchScope: the current document's files, its history, the whole workspace, or Field's
own Kotlin source. It reads one bounded file at a time under byte, match and time limits, and the
WorkspaceSearchResult says whether it is complete and, if not, why it stopped and what it skipped.
There is no regex or shell syntax, and unsaved editor state is not searched.
src/jvmMain/kotlin/afield/ai/tools/WorkspaceSearch.kt — read the source
WorkspaceSearchScope
enum class WorkspaceSearchScope
The one tree a search covers.
Constants: DOCUMENT_FILES, DOCUMENT_HISTORY, WORKSPACE_FILES, FIELD_KOTLIN_SOURCE, FIELD_DOCS.
WorkspaceSearchScopeDeserializer
class WorkspaceSearchScopeDeserializer : JsonDeserializer<WorkspaceSearchScope>()
Jackson otherwise accepts enum ordinals, which could silently widen a search.
| Declaration | Summary |
|---|---|
override fun deserialize(parser: JsonParser, context: DeserializationContext): WorkspaceSearchScope |
WorkspaceSearchRequest
| Declaration | Summary |
|---|---|
val scope: WorkspaceSearchScope |
|
val query: String |
|
val pathPrefix: String |
|
val extensions: List<String> |
|
val ignoreCase: Boolean |
|
val maxMatches: Int |
WorkspaceSearchStop
enum class WorkspaceSearchStop
Constants: MATCH_LIMIT, RESPONSE_LIMIT, ENTRY_LIMIT, DEPTH_LIMIT, INPUT_LIMIT, DEADLINE, CANCELLED.
WorkspaceSearchMatch
| Declaration | Summary |
|---|---|
val path: String |
|
val line: Int |
|
val column: Int |
|
val excerpt: String |
|
val excerptStartColumn: Int |
|
val excerptClipped: Boolean |
WorkspaceSearchSkips
| Declaration | Summary |
|---|---|
val oversized: Int |
|
val nonText: Int |
|
val unreadable: Int |
|
val changed: Int |
WorkspaceSearchResult
WorkspaceSymbolSearchSupport.kt
src/jvmMain/kotlin/afield/ai/tools/WorkspaceSymbolSearchSupport.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun searchWorkspaceSymbols(index: KotlinSourceIndexDb?, rawQuery: String, limit: Int, includeRelatedContext: Boolean = false): WorkspaceSymbolSearchResult |
ToolEvidence.kt
Complete tool results that are too large for a transcript, kept whole outside the model's context.
A program's nested calls are recorded in the turn's transcript and the tool I/O log, but a result of
tens of kilobytes would make both unreadable, and cutting it at a fixed length leaves invalid JSON
that hides exactly the part of a failure the next agent needs. So a nested result over
ToolProgramRunner.MAX_NESTED_RESULT_CHARS is stored here whole, content-addressed, and the transcript
carries a small, valid stub with its id, SHA-256 and size; ReadToolEvidenceRequest reads it back.
store is replaceable: a host with its own evidence store (a study's build records, say) can put
these there instead. The default keeps them gzipped beside the tool I/O log,
~/.afield/logs/ai4_tool_io/evidence/ (AFIELD_AI4_TOOL_EVIDENCE_PATH or
-Dafield.ai4.toolEvidence.path to move it). Nothing deletes them yet.
src/jvmMain/kotlin/afield/ai/tools/program/ToolEvidence.kt — read the source
ToolEvidence
The evidence store for oversized nested tool results: store puts and gets them by content id.
| Declaration | Summary |
|---|---|
data class Ref(val id: String, val sha256: String, val bytes: Int) |
Where a result was put: id is what ReadToolEvidenceRequest takes; sha256 and bytes are of the UTF-8 text. |
interface Store |
|
class FileStore(private val dir: File) : Store |
gzip files named by content hash in dir. |
const val ID_PREFIX |
|
var store: Store |
|
fun sha256(bytes: ByteArray): String |
ReadToolEvidenceRequest
| Declaration | Summary |
|---|---|
val id: String |
|
val offset: Int |
|
val limit: Int |
ReadToolEvidenceResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val id: String |
|
val bytes: Int |
|
val sha256: String |
|
val offset: Int |
|
val text: String |
|
val more: Boolean |
|
val message: String |
ToolProgramRunner.kt
The sandbox a tool program runs in: one RunToolProgramRequest's JavaScript, in a fresh GraalJS
context that can reach the conversation's tools and nothing else. RunToolProgramRequest's own
file overview says what a program is for; this file is how it is kept to its tools.
The boundary
Only strings cross it. The guest gets six host functions (__call, __out, __progress, __show,
__describe, __settle) and one JSON string of tool names; a prelude builds the friendly
tools.X(...), tools.X.raw(...), check(...), progress(...), text(...) and show(...) surface from
those in JS. The context runs under SandboxPolicy.CONSTRAINED with no IO, no threads, no processes,
no native access, no polyglot access, no environment and no host class lookup, and carries a statement
limit, so a program cannot reach a Java object even by accident.
Ending a program
A program ends by settling its promise (stage ok; or tool-failed for an uncaught failing call,
check-failed for an uncaught check, runtime for any other error), by a syntax error, by running out
of statements, calls or time, or because its turn was cancelled. Time and cancellation are a watchdog's job:
it closes the context from its own thread, which Graal honours the next time the program thread is in
guest code, so a tool call already running is allowed to finish. A cancellation is rethrown as a
CancellationException so the turn ends rather than reporting a tool error, matching every bridge's
isAiTurnCancellation handling.
Threads
The program runs on the calling thread, which is the agent loop's: an IO thread for a panel turn, a
daemon for an agent task, the backdoor's scope for MCP. Never the Runloop — the graph tools a program
calls hop onto the Runloop and wait for it, which would deadlock there, so run refuses.
src/jvmMain/kotlin/afield/ai/tools/program/ToolProgramRunner.kt — read the source
ToolProgramTools.kt
RunToolProgramRequest is one tool whose argument is a short JavaScript program, so a model that
would have spent twelve rounds calling twelve tools spends one. The program can
call every other tool the conversation has, as a function — tools.RunBox({box: "Cell03"}) — and
only what it returns, or prints with text(...), goes back to the model, plus a one-line record per
call. The intermediate results stay in the program. The idea is Pi's "codemode"
(earendil-works/pi); the mapping onto Field is in dev_docs/ai/AI4_PI_LESSONS.md.
The saving is in what a round costs. A model working through tools one call per round pays for every round twice: a request carrying the whole conversation so far, and a result that lands in that conversation for good. For "run each of these twelve boxes and tell me which ones fail" that is twelve rounds and twelve results the model reads in full, when what it needed was three names.
What a program can reach
Only the tools, and only through one string-in, string-out bridge: the runner builds a
GraalJS context with no host access, no IO, no threads, no processes, no environment and a
statement limit, so there is no Java.type, no require, no fetch and no timers. A tool call
from a program goes through ToolRegistry.executeByName — the same dispatcher a model's own call
goes through, with the same gates — so a program can do nothing the conversation could not do one
call at a time, and every edit it makes is recorded exactly as the direct call would record it.
A program is not a transaction. One that fails halfway leaves its earlier edits in place; they are recorded and undoable like any other agent edit, but nothing rolls them back.
Why JavaScript and not the macro YAML
What makes this pay is the middle of the program — filter, loop, branch on a result — and the OperationTranscript YAML has no data flow at all: it is the durable, reversible record of edits, and the edits a program makes still land there. Kotlin would be the house language, but every program would pay a K2 compile, and box code can reach the whole JVM. JS is what models write fluently as glue, and GraalJS is already on the classpath.
src/jvmMain/kotlin/afield/ai/tools/program/ToolProgramTools.kt — read the source
Top-level values
| Declaration | Summary |
|---|---|
const val RUN_TOOL_PROGRAM |
RunToolProgramRequest
The RunToolProgram tool: a JavaScript program that calls the conversation's other tools and returns one result.
| Declaration | Summary |
|---|---|
val code: String |
|
val purpose: String |
|
val timeoutSeconds: Double |
|
val maxCalls: Int |
ProgramToolCall
One tool call a program made, as the model sees it: enough to tell what happened, never the result itself.
| Declaration | Summary |
|---|---|
val n: Int |
|
val tool: String |
|
val args: String |
|
val ok: Boolean |
|
val elapsedMs: Long |
|
val summary: String |
|
val error: String? |
|
val evidence: String? |
Set when the result was too large to keep in the transcript: read it whole with ReadToolEvidenceRequest. |
NestedToolCall
One nested call in full, for transcripts and logs rather than the model: the bridges expand these into child afield.ai.models.LlmToolTranscriptEntrys with ids <programCallId>/<n>, so the tool I/O log, the eval's tool-call records and AgentLessons' misuse count all see what the program did.
| Declaration | Summary |
|---|---|
val n: Int |
|
val tool: String |
|
val argumentsJson: String |
|
val resultJson: String |
|
val summary: String |
|
val ok: Boolean |
|
val elapsedMs: Long |
|
val resultBytes: Int |
Size and SHA-256 of the complete result, whether or not resultJson holds all of it. |
val resultSha256: String |
|
val evidence: String? |
Where the complete result is when resultJson is only a stub (ToolEvidence). |
CarriesNestedToolCalls
interface CarriesNestedToolCalls
A tool result made of other tool calls. Implemented by RunToolProgramResult; read by the bridges.
| Declaration | Summary |
|---|---|
val nestedCalls: List<NestedToolCall> |
RunToolProgramResult
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val stage: String |
ok, tool-failed, check-failed, runtime, syntax, timeout, statement-limit, call-cap or unsettled. |
val message: String |
|
val output: String |
What the program passed to text(...), one line per call, at most 16000 characters. |
val returned: JsonNode? |
What the program returned, or {"truncated": N, "preview": ...} when it was too large. |
val calls: List<ProgramToolCall> |
|
val totalCalls: Int |
|
val failedCalls: Int |
|
val elapsedMs: Long |
|
val truncated: Boolean |
True when output, the returned value, the call list or a nested result was cut to fit. |
val imagesShown: Int |
|
val nestedClipped: Int |
Nested results kept whole in ToolEvidence because they were too large for the transcript. |
override fun toolLogSummary(): String |
AiToolUseImprovementCli.kt
A command-line task in which a model reads Field's recent AI tool I/O logs and edits the AI4 tool descriptions and prompts that caused the trouble it finds.
main runs it; Gradle's improveAiToolUse and improveAiToolUseDryRun tasks launch it. It
summarises the newest logs under ~/.afield/logs/ai4_tool_io/ (--log-dir, or
AFIELD_AI4_TOOL_IO_LOG_PATH), attaches a manifest of the source tree, and gives the model a small
tool set over it, including ReplaceInFileRequest for exact snippet edits. It writes a Markdown
report and a transcript of the run. With --dry-run the edits go to an in-memory overlay and come
out as a patch file instead of touching the source. Logs of this task's own runs are left out of the
review (isToolUseImprovementLog).
src/jvmMain/kotlin/afield/ai/tools/tooluse/AiToolUseImprovementCli.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun isToolUseImprovementLog(path: Path): Boolean |
Whether a tool I/O log is one of this task's own runs — its session_start carries the file key cli://ai-tool-use-improvement. A review leaves those out, so that a window of recent logs holds the agent work being reviewed rather than earlier passes of the reviewer. main also turns this run's own logging off, so this filter applies to logs already on disk. |
fun main(args: Array<String>) |
ReplaceInFileRequest
| Declaration | Summary |
|---|---|
val file: String |
|
val oldText: String |
|
val newText: String |
|
val expectedOccurrences: Int |
ReplaceInFileResponse
| Declaration | Summary |
|---|---|
val success: Boolean |
|
val message: String |
|
val file: String |
|
val occurrences: Int |
|
val newContentChars: Int |
stippletransport.kt
Runs a learned stipple transport on the CPU: a small SiLU network, trained by the optional
PyTorch tool in optional/transport-flow, that moves points from a uniform source rectangle
toward an image's density.
loadStippleTransport reads the exported JSON model. StippleTransport.transport and
StippleTransport.flowMap integrate points through the learned velocity field (Euler or
midpoint steps), StippleTransport.pathsFrom returns each point's path as an FLine, and
StippleTransport.sourceParticles draws the source points. A temporal model also takes a movie
time, so you can draw source points once and re-transport the same list for each frame.
StippleTransportTest covers loading and integration.
src/jvmMain/kotlin/afield/geometry/spatial/stippletransport.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun loadStippleTransport(file: File): StippleTransport |
Loads the optional PyTorch stipple trainer's portable field.transport-flow.v1 model. |
fun loadStippleTransport(path: String): StippleTransport |
Convenience spelling of loadStippleTransport for a filename. |
StippleTransport
class StippleTransport private constructor(private val data: StippleTransportData)
A reusable density-conditioned coordinate warp, independent of its training stipples.
| Declaration | Summary |
|---|---|
val temporal: Boolean |
Whether the network has a fourth input for movie time. |
val frameTimes: List<Double> |
Discrete movie times used for training, copied so callers cannot alter the model. |
val parameterCount: Int |
Number of learned scalar weights and biases. |
val sourceHalfExtent: List<Double> |
Half-width and half-height of the uniform canonical source rectangle. |
fun velocity(at: Vec3, transportTime: Double, movieTime: Double = 0.0): Vec3 |
Predicts (vx,vy,0) at any point, at two independent times. |
fun transport(points: List<Vec3>, transportTime: Double = 1.0, movieTime: Double = 0.0, steps: Int = 32, fromTransportTime: Double = 0.0, integrator: FlowIntegrator = FlowIntegrator.MIDPOINT): List<Vec3> |
Integrates arbitrary particles, curve vertices or lattice samples. No training-point lookup. fromTransportTime can exceed transportTime for inversion; both can overshoot 0..1. |
fun flowMap(at: Vec3, transportTime: Double = 1.0, movieTime: Double = 0.0, steps: Int = 32): Vec3 |
Single-point flow map, with the same semantics as transport. |
fun pathsFrom(points: List<Vec3>, transportTime: Double = 1.0, movieTime: Double = 0.0, steps: Int = 32, fromTransportTime: Double = 0.0, integrator: FlowIntegrator = FlowIntegrator.MIDPOINT): List<FLine> |
Traces supplied geometry in transport time, one FLine per input point. |
fun sourceParticles(count: Int, seed: Long = 1L, grid: Boolean = false): List<Vec3> |
Draw once and retain the returned list for persistent movie identity. |
fun unsafeBlendWeights(other: StippleTransport, amount: Double): StippleTransport |
Deliberately unsafe weight interpolation; matching metadata does not align hidden neurons. Prefer conditioning one shared model on movie time. Sequential fine-tuning gives checkpoints shared ancestry but still supplies no guarantee. amount is not clamped. |
neuralparticles.kt
Taking a small trained network to the GPU: MlpWeights is a flat, layer-major snapshot of a
multilayer perceptron's weights, and MlpWeights.glsl emits a GLSL function that evaluates the
same network out of a storage buffer.
What that buys is a particle whose update rule was trained rather than written. flowMatch
(see flowmatching.kt) already fits a velocity field v(x, t) in a couple of seconds of pure
Kotlin; a compute shader stepping a buffer of particles can evaluate that field per particle per
frame, provided the two sides agree about what the network is. This file is that agreement,
and afield.examples.four.NeuralFieldParitySmoke measures that it holds on real hardware.
Nothing here is particle-specific: a net that takes a position and returns a velocity, a net that takes a neighbour offset and returns an interaction term, and a net that takes anything and returns anything are all the same object. The particle system is what consumes it.
Two comparisons, not one
There are two distinct errors between "the network Smile trained" and "the network the GPU runs", they have very different sizes, and conflating them makes the unavoidable one look like a bug.
- Precision. Smile evaluates in
Double; a shader evaluates infloat. Agreement is therefore limited to fp32 by construction — a few parts in a million, relative, and rather more for an output that is a cancelling sum.MlpWeights.evaluateis the fp32 reference in Kotlin, so this error can be measured on its own with no GPU present at all. - Transcription. The order of the layers, the orientation of each weight matrix, the slope of the leaky ReLU, whether the output layer has an activation. Every one of these is either exactly right or grossly wrong, and a grossly wrong one still produces smooth, plausible, entirely fictional output.
So the GPU is compared against MlpWeights.evaluate, fp32 against fp32, where the only
remaining freedom is the order the hardware chooses to accumulate a dot product in; and
evaluate is compared against Smile separately. A single comparison against Double would
have to be loose enough to absorb the precision gap, which is a tolerance wide enough to hide a
transposed matrix in a small network.
The weight layout
MlpWeights.flat runs layer by layer from the first hidden layer to the output layer. Each
layer contributes its weight matrix, row-major with one row per output neuron, then its bias
vector. So for a layer with n outputs over p inputs, the weight from input i to output o
is at weightOffset(layer) + o * p + i, and its bias is at biasOffset(layer) + o. The input
layer contributes nothing — it holds no weights.
This is the layout a shader wants to read, and it is also the layout Smile happens to store.
The two are not relied on to coincide: VectorMLP.exportWeights in flowmatching.kt reads
element by element through DenseMatrix.get(row, column) rather than copying memory, so a
change of storage order upstream cannot silently transpose the network.
src/jvmMain/kotlin/afield/simulation/particles/neuralparticles.kt — read the source
Used in: Neural particle systems
Top-level functions
| Declaration | Summary |
|---|---|
fun MlpWeights.blendWith(other: MlpWeights, t: Float): MlpWeights |
Two networks of the same shape, averaged weight by weight — (1-t)·this + t·other. |
fun FlowMatchExport.blendWith(other: FlowMatchExport, t: Float): FlowMatchExport |
MlpWeights.blendWith for a whole field, carrying the training frame and source with it. |
fun MlpWeights.glsl(name: String = "neuralField"): String |
GLSL that evaluates this network out of a storage buffer, as a buffer_reference type and a function named name. |
fun FlowMatchExport.glsl(name: String = "neuralField"): String |
GLSL for the whole field: the network from MlpWeights.glsl, the time encoding, and a neuralVelocity that converts in and out of the training frame. |
MlpWeights
A flat, layer-major snapshot of a trained multilayer perceptron: the layer widths, the hidden activation, and one FloatArray holding every weight and bias.
| Declaration | Summary |
|---|---|
val inputs: Int |
Width of the input vector. |
val outputs: Int |
Width of the output vector. |
val hidden: IntArray |
Hidden layer widths, in order. |
val activation: MlpActivation |
The activation applied after every hidden layer. The output layer is always linear. |
val flat: FloatArray |
Every weight and bias, laid out as described above. |
val layerWidths: IntArray |
The width of each weight-bearing layer's output: the hidden widths, then outputs. |
val layerInputs: IntArray |
The width of each weight-bearing layer's input: inputs, then the hidden widths. |
val layerCount: Int |
How many weight-bearing layers there are — one more than the number of hidden layers. |
fun weightOffset(layer: Int): Int |
Index into flat of the first weight of layer, which is layer's row 0, column 0. |
fun biasOffset(layer: Int): Int |
Index into flat of the first bias of layer. |
fun evaluate(x: FloatArray, into: FloatArray = FloatArray(outputs)): FloatArray |
The network's output for x, in Float, accumulated in exactly the order glsl accumulates it — bias first, then one multiply-add per input in index order. |
val parameterCount: Int |
How many weights and biases this network is, which is flat.size. |
override fun toString(): String |
MlpActivation
enum class MlpActivation(val smileName: String)
The hidden activations this file can evaluate on both sides of the CPU/GPU line.
Constants: LEAKY, RELU, TANH, SIGMOID, LINEAR.
| Declaration | Summary |
|---|---|
val smileName: String |
The name smile.base.mlp.Layer.builder knows this activation by. |
fun apply(v: Float): Float |
This activation applied to one value, in Float. |
FlowMatchExport
A trained velocity field packaged for a shader: the network, plus the three things around it that decide what its numbers mean.
| Declaration | Summary |
|---|---|
val network: MlpWeights |
The trained network. Its inputs are dimension positions followed by the time encoding. |
val dimension: Int |
2 or 3 — how many position components the network takes. |
val timeFeatures: Int |
How many sin/cos octaves of t follow raw t in the input vector. |
val center: FloatArray |
The centre of the frame training happened in, in input coordinates. |
val scale: Float |
The isotropic scale of that frame. One number, not one per axis. |
val source: FloatArray? |
The cloud the field was trained to start from, interleaved by dimension and already in the normalised frame — or null if it was trained from a standard Gaussian. |
val sourceCount: Int |
How many points are in source, or 0 if the field starts from a Gaussian. |
override fun toString(): String |
MlpAgreement
The largest relative disagreement between two evaluations of the same network, and the input it happened at — the statistic both halves of the parity test report.
| Declaration | Summary |
|---|---|
val worstRelative: Double |
The largest relative disagreement seen. |
val worstAbsolute: Double |
The largest absolute disagreement seen, in output units. |
val worstAt: FloatArray |
The input at which worstRelative occurred. |
val comparisons: Int |
How many outputs were compared in total. |
override fun toString(): String |
neuralparticles_system.kt
A particle system driven by a trained velocity field: ShaderNeuralParticles integrates a
buffer of particles along the field in a compute pass and draws the same allocation as points,
with no copy between the two.
neuralparticles.kt settles what a network is on both sides of the CPU/GPU line and emits
the GLSL that evaluates one; this file is what consumes that GLSL. The chain from a target
shape to a moving cloud is flowMatch, then FlowMatchField.export(), then
ShaderNeuralParticles and installInto. afield.examples.four.NeuralParticlesSmoke is the
GPU smoke over that whole path, and the neural_particles agent transcript is that chain as
three boxes.
Two of the design decisions reach the caller, so they are stated here rather than only on the declarations that implement them:
- Positions are stored in the training frame, not in the coordinates the field was
authored in, because integrating in input coordinates would convert into the frame and back
out again on every step to reach the same answer. So
NeuralParticleSettings'sviewScaleandviewCenterare in normalised units, where a scale of about 2.6 frames a cloud of roughly unit variance. - A particle is travelling a journey, not wandering a force field. A flow is a map: each
particle goes from one specific source point to wherever the field takes it by
t = 1, then starts again. That is why it carries astartand at, and whyresethas to seed from the field's own source cloud whenever the field has one.
Which settings are live follows from where each one is read. particleCount sizes the particle
buffer, the compute dispatch and the point mesh at construction, so it cannot be raised
afterwards, and name is written into the module and buffer names there. phaseSpread,
sourceJitter and randomSeed are read by reset, which fills the buffer, so changing one
takes a reset call. Everything else is read through a push-constant lambda every frame and
takes effect on the next one, including stepsPerCycle and dwellCycles.
src/jvmMain/kotlin/afield/simulation/particles/neuralparticles_system.kt — read the source
Used in: Neural particle systems
NeuralParticleSettings
The knobs ShaderNeuralParticles takes.
ShaderNeuralParticles
A particle system whose velocity field is a trained network rather than a written rule.
| Declaration | Summary |
|---|---|
val export: FlowMatchExport |
The trained field, from FlowMatchField.export(). |
val renderer: Renderer |
The renderer both passes are built against; the box's own by default. |
val settings |
The settings configure produced, kept live. |
val particleBuffer: FrameBufferGroup |
The particle state. Read by both passes, owned by neither. |
val weightBuffer: FrameBufferGroup |
The network's weights, swappable between frames without recompiling anything. |
val computeModule: ComputeModulePointer |
The integrator. |
val drawModule: ModulePointer |
The draw. |
val computeSource: String |
The compute shader source, as installed — editable. |
val vertexSource: String |
The vertex shader source, as installed — editable. |
val fragmentSource: String |
The fragment shader source, as installed — editable. |
fun reset(seed: Long = settings.randomSeed) |
Redraws every particle's source point and restarts the cycle. |
fun updateWeights(network: MlpWeights) |
Replaces the weights in place, with no recompilation — the network may be retrained live. |
fun installInto(renderTarget: RenderTarget = renderer.renderList) |
Adds both passes to renderTarget's renderer: the integrator, then the draw. |
fun removeFrom(renderTarget: RenderTarget = renderer.renderList) |
Takes both passes back out again. |