AiToolUseImprovementMonitor.kt
src/jvmMain/kotlin/afield/ai/tools/AiToolUseImprovementMonitor.kt · 469 lines
package and imports — 47 lines, from line 1
package afield.ai.tools
import afield.app.server.home
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with
import afield.ui.output.markdown
import afield.ui.output.md
import afield.ui.output.transcript
import afield.ai.conversation.aiChatCodeBlockHtml
import afield.ai.conversation.aiChatHistoryHtml
import afield.ai.conversation.aiChatMarkdownHtml
import afield.app.server.broadcastWelcomeEvent
import afield.audio.synth.*
import afield.data.table.get
import afield.document.workspace.AgentWorktreeProposal
import afield.document.workspace.AiToolUseImprovementStatus
import afield.document.workspace.AiToolUseImprovementView
import afield.document.workspace.AiToolUseLogSummary
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.tools.automation.AgentWorktreeManager
import afield.tools.docs.format
import afield.ui.output.logs
import afield.ui.output.templates.filter
import afield.ui.output.transcriptHtml
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.UUID
import kotlin.concurrent.thread
import kotlin.io.path.absolutePathString
import kotlin.io.path.fileSize
import kotlin.io.path.getLastModifiedTime
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/.
object AiToolUseImprovementMonitor
object AiToolUseImprovementMonitor {
private const val MAX_LOG_CHARS = 180_000
private const val MAX_ARTIFACT_CHARS = 220_000
private const val BROADCAST_MIN_INTERVAL_MS = 220L
private const val RECENT_LOG_COUNT = 12
private val archiveFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").withZone(ZoneOffset.UTC)
private val lock = Any()
private val logDir: Path = Paths.get(
System.getProperty("afield.ai4.toolIoLog.path")
?: System.getenv("AFIELD_AI4_TOOL_IO_LOG_PATH")
?: Paths.get(System.getProperty("user.home"), ".afield", "logs", "ai4_tool_io").toString()
).toAbsolutePath().normalize()
private val reportPath: Path = Paths.get("build", "ai-tool-use-improvement-report.md").toAbsolutePath().normalize()
private val dryRunPatchPath: Path = Paths.get("build", "ai-tool-use-improvement-dry-run.patch").toAbsolutePath().normalize()
Absolute and always in this checkout's build/, so a proposal running in a generated worktree shows here too.
private val transcriptPath: Path = Paths.get("build", "ai-tool-use-improvement-transcript.md").toAbsolutePath().normalize()
private var runState = MutableRunState(dryRun = true)
private var process: Process? = null
@Volatile
private var lastBroadcastEpochMs = 0L
fun status(): AiToolUseImprovementStatus
fun status(): AiToolUseImprovementStatus =
synchronized(lock) { runState.toStatus(logSummary(), worktreeSummary()) }
fun view(): AiToolUseImprovementView
fun view(): AiToolUseImprovementView {
val patch = artifactText(dryRunPatchPath)
return AiToolUseImprovementView(
viewStamp = viewStamp(),
transcriptHtml = artifactText(transcriptPath).takeIf { it.isNotBlank() }
?.let { aiChatHistoryHtml(it, afield.ai.conversation.chatlog.ChatToolFamilies.default) }.orEmpty(),
reportHtml = artifactText(reportPath).takeIf { it.isNotBlank() }?.let { aiChatMarkdownHtml(it) }.orEmpty(),
patchHtml = patch.takeIf { it.isNotBlank() }
?.let { "<div class=\"ai-chat-markdown\">${aiChatCodeBlockHtml(it, "diff")}</div>" }.orEmpty()
)
}
private fun viewStamp(): String = listOf(transcriptPath, reportPath, dryRunPatchPath).joinToString("|") { path ->
runCatching { "${path.getLastModifiedTime().toMillis()}:${path.fileSize()}" }.getOrDefault("-")
}
fun start(dryRun: Boolean = true, proposalCount: Int = 1): AiToolUseImprovementStatus
body — 45 lines, from line 100
fun start(dryRun: Boolean = true, proposalCount: Int = 1): AiToolUseImprovementStatus {
synchronized(lock) {
if (runState.running) {
return runState.copy(message = "AI tool-use improvement task is already running.")
.toStatus(logSummary(), worktreeSummary())
}
val logs = logSummary()
if (!logs.exists || logs.recentLogCount <= 0) {
runState = MutableRunState(
status = "idle",
dryRun = dryRun,
message = logs.message.ifBlank { "No AI tool I/O logs are ready to review." }
)
return runState.toStatus(logs, worktreeSummary())
}
val taskName = if (dryRun) "improveAiToolUseDryRun" else "improveAiToolUse"
val transcriptArg = "--transcript-file ${transcriptPath.absolutePathString()}"
val appArgs = if (dryRun) {
"--dry-run --verbose --max-log-files $RECENT_LOG_COUNT $transcriptArg"
} else {
"--verbose --max-log-files $RECENT_LOG_COUNT $transcriptArg"
}
// The last run's transcript stays on the page until this one starts writing its own.
runCatching { Files.deleteIfExists(transcriptPath) }
runState = MutableRunState(
running = true,
status = "running",
runId = UUID.randomUUID().toString(),
taskName = taskName,
dryRun = dryRun,
startedAtEpochMs = Instant.now().toEpochMilli(),
proposalCount = if (dryRun) 1 else proposalCount.coerceIn(1, 8),
message = if (dryRun) "Starting $taskName..." else "Starting $taskName in generated worktree(s)..."
)
process = null
appendLogLocked("Starting ./gradlew $taskName --args=\"$appArgs\"\n")
broadcastWelcomeEvent("ai-tool-use-improvement")
thread(name = "ai-tool-use-improvement-monitor", isDaemon = true) {
runProcess(taskName, appArgs, runState.proposalCount)
}
return runState.toStatus(logs, worktreeSummary())
}
}
fun clearReviewed(recentOnly: Boolean = true): AiToolUseImprovementStatus
body — 323 lines, from line 146
fun clearReviewed(recentOnly: Boolean = true): AiToolUseImprovementStatus {
synchronized(lock) {
if (runState.running) {
return runState.copy(message = "AI tool-use improvement task is running; logs were not moved.")
.toStatus(logSummary(), worktreeSummary())
}
if (!Files.isDirectory(logDir)) {
runState = runState.copy(message = "No AI tool I/O log directory found.")
return runState.toStatus(logSummary(), worktreeSummary())
}
val candidates = logFiles().let { files ->
if (recentOnly) {
files.sortedByDescending { runCatching { it.getLastModifiedTime().toMillis() }.getOrDefault(0) }
.take(RECENT_LOG_COUNT)
} else {
files
}
}
if (candidates.isEmpty()) {
runState = runState.copy(message = "No AI tool I/O logs to move.")
return runState.toStatus(logSummary(), worktreeSummary())
}
val archiveDir = logDir
.resolve("archived-reviewed")
.resolve(archiveFormatter.format(Instant.now()))
.toAbsolutePath()
.normalize()
Files.createDirectories(archiveDir)
var moved = 0
candidates.forEach { source ->
val target = uniqueArchiveTarget(archiveDir, source.fileName.toString())
runCatching {
Files.move(source, target)
moved += 1
}
}
runState = runState.copy(
status = "idle",
message = "Moved $moved reviewed AI tool I/O log(s) to ${archiveDir.absolutePathString()}."
)
runState.log.clear()
appendLogLocked(runState.message + "\n")
}
broadcastWelcomeEvent("ai-tool-use-improvement")
return status()
}
private fun runProcess(taskName: String, appArgs: String, proposalCount: Int) {
val projectRoot = File(".").canonicalFile
val totalRuns = if (taskName == "improveAiToolUseDryRun") 1 else proposalCount.coerceIn(1, 8)
var failureCount = 0
var completedCount = 0
try {
repeat(totalRuns) { index ->
val proposal = if (taskName == "improveAiToolUse") {
val created = AgentWorktreeManager.createProposal(
projectRoot = projectRoot,
taskName = "ai-tool-use",
label = "AI tool-use proposal ${index + 1}"
)
synchronized(lock) {
runState = runState.copy(
proposalIds = runState.proposalIds + created.id,
message = "$taskName will run in ${created.path}."
)
}
appendLog("Generated worktree ${created.id}: ${created.path}\n")
created
} else null
val runDirectory = proposal?.path?.let { File(it).canonicalFile } ?: projectRoot
val command = listOf(gradleWrapperCommand(runDirectory), taskName, "--args=$appArgs")
val startedProcess = ProcessBuilder(command)
.directory(runDirectory)
.redirectErrorStream(true)
.start()
synchronized(lock) {
process = startedProcess
runState = runState.copy(
message = if (proposal == null) {
"$taskName is running."
} else {
"$taskName is running in generated worktree ${proposal.id}."
}
)
}
proposal?.let {
AgentWorktreeManager.updateProposal(
projectRoot,
it.id,
"running",
"$taskName is running."
)
}
broadcastWelcomeEvent("ai-tool-use-improvement")
startedProcess.inputStream.reader(StandardCharsets.UTF_8).use { reader ->
val buffer = CharArray(2048)
while (true) {
val read = reader.read(buffer)
if (read < 0) break
appendLog(String(buffer, 0, read))
}
}
val exitCode = startedProcess.waitFor()
completedCount++
synchronized(lock) {
process = null
}
val proposalFinalize = if (proposal != null && exitCode == 0) {
AgentWorktreeManager.finalizeProposalChanges(projectRoot, proposal.id)
.also { result ->
appendLog(
if (result.success) {
"Committed generated worktree ${proposal.id}: ${result.message}\n"
} else {
"Could not commit generated worktree ${proposal.id}: ${result.message}\n"
}
)
}
} else null
proposal?.let {
AgentWorktreeManager.updateProposal(
projectRoot,
it.id,
if (exitCode == 0 && proposalFinalize?.success != false) "ready" else "failed",
if (exitCode == 0) {
proposalFinalize?.message ?: "$taskName finished in this generated worktree."
} else {
"$taskName exited with $exitCode."
},
reportPath = File(runDirectory, "build/ai-tool-use-improvement-report.md").absolutePath,
dryRunPatchPath = File(runDirectory, "build/ai-tool-use-improvement-dry-run.patch").absolutePath
)
}
if (exitCode != 0 || proposalFinalize?.success == false) failureCount++
synchronized(lock) {
runState = runState.copy(
exitCode = exitCode,
message = if (exitCode == 0) {
"$taskName finished run $completedCount of $totalRuns."
} else {
"$taskName run $completedCount of $totalRuns exited with $exitCode."
}
)
trimLogLocked()
}
broadcastWelcomeEvent("ai-tool-use-improvement")
}
synchronized(lock) {
process = null
runState = runState.copy(
running = false,
status = if (failureCount == 0) "succeeded" else "failed",
finishedAtEpochMs = Instant.now().toEpochMilli(),
message = if (failureCount == 0) {
"$taskName finished."
} else {
"$taskName finished with $failureCount failed run(s)."
}
)
trimLogLocked()
}
} catch (t: Throwable) {
synchronized(lock) {
process = null
appendLogLocked("\nFailed to run $taskName: ${t.javaClass.simpleName}: ${t.message.orEmpty()}\n")
runState = runState.copy(
running = false,
status = "failed",
finishedAtEpochMs = Instant.now().toEpochMilli(),
exitCode = -1,
message = "Could not start $taskName: ${t.message.orEmpty()}"
)
}
} finally {
broadcastWelcomeEvent("ai-tool-use-improvement")
}
}
private fun appendLog(text: String) {
synchronized(lock) {
appendLogLocked(text)
trimLogLocked()
}
val now = System.currentTimeMillis()
if (now - lastBroadcastEpochMs >= BROADCAST_MIN_INTERVAL_MS) {
lastBroadcastEpochMs = now
broadcastWelcomeEvent("ai-tool-use-improvement")
}
}
private fun appendLogLocked(text: String) {
runState.log.append(text)
}
private fun trimLogLocked() {
val excess = runState.log.length - MAX_LOG_CHARS
if (excess > 0) runState.log.delete(0, excess)
}
private fun logSummary(): AiToolUseLogSummary {
if (!Files.isDirectory(logDir)) {
return AiToolUseLogSummary(
logDir = logDir.absolutePathString(),
exists = false,
reportPath = reportPath.absolutePathString(),
dryRunPatchPath = dryRunPatchPath.absolutePathString(),
message = "No AI tool I/O log directory found."
)
}
val files = logFiles()
if (files.isEmpty()) {
return AiToolUseLogSummary(
logDir = logDir.absolutePathString(),
exists = true,
reportPath = reportPath.absolutePathString(),
dryRunPatchPath = dryRunPatchPath.absolutePathString(),
message = "AI tool I/O log directory is empty."
)
}
val sorted = files.sortedByDescending { runCatching { it.getLastModifiedTime().toMillis() }.getOrDefault(0) }
val recent = sorted.take(RECENT_LOG_COUNT)
val size = files.sumOf { runCatching { it.fileSize() }.getOrDefault(0) }
val latest = runCatching { sorted.first().getLastModifiedTime().toMillis() }.getOrDefault(0)
return AiToolUseLogSummary(
logDir = logDir.absolutePathString(),
exists = true,
logCount = files.size,
recentLogCount = recent.size,
sizeBytes = size,
lastModifiedEpochMs = latest,
reportPath = reportPath.absolutePathString(),
dryRunPatchPath = dryRunPatchPath.absolutePathString(),
message = "${recent.size} recent AI tool I/O log(s) ready for review."
)
}
private fun worktreeSummary(): List<AgentWorktreeProposal> =
runCatching { AgentWorktreeManager.listProposals(File(".").canonicalFile) }.getOrDefault(emptyList())
private fun logFiles(): List<Path> =
runCatching {
if (!Files.isDirectory(logDir)) return@runCatching emptyList()
Files.list(logDir).use { stream ->
stream
.filter { Files.isRegularFile(it) }
.filter { it.fileName.toString().endsWith(".jsonl", ignoreCase = true) }
.filter { !afield.ai.tools.tooluse.isToolUseImprovementLog(it) }
.toList()
}
}.getOrDefault(emptyList())
private fun uniqueArchiveTarget(directory: Path, fileName: String): Path {
var candidate = directory.resolve(fileName)
if (!Files.exists(candidate)) return candidate
val dot = fileName.lastIndexOf('.')
val stem = if (dot > 0) fileName.substring(0, dot) else fileName
val suffix = if (dot > 0) fileName.substring(dot) else ""
var index = 1
while (Files.exists(candidate)) {
candidate = directory.resolve("$stem-$index$suffix")
index += 1
}
return candidate
}
private fun artifactText(path: Path): String =
runCatching {
if (!Files.isRegularFile(path)) return@runCatching ""
val text = Files.readString(path, StandardCharsets.UTF_8)
if (text.length <= MAX_ARTIFACT_CHARS) {
text
} else {
text.take(MAX_ARTIFACT_CHARS) + "\n\n[truncated ${text.length - MAX_ARTIFACT_CHARS} chars]"
}
}.getOrDefault("")
private fun gradleWrapperCommand(projectRoot: File): String =
if (System.getProperty("os.name").lowercase().contains("windows")) {
File(projectRoot, "gradlew.bat").path
} else {
File(projectRoot, "gradlew").path
}
private data class MutableRunState(
val running: Boolean = false,
val status: String = "idle",
val runId: String = "",
val taskName: String = "",
val dryRun: Boolean = true,
val proposalCount: Int = 1,
val proposalIds: List<String> = emptyList(),
val startedAtEpochMs: Long = 0,
val finishedAtEpochMs: Long = 0,
val exitCode: Int? = null,
val message: String = "",
val log: StringBuilder = StringBuilder()
) {
fun toStatus(logs: AiToolUseLogSummary, worktrees: List<AgentWorktreeProposal>): AiToolUseImprovementStatus =
AiToolUseImprovementStatus(
logs = logs,
worktrees = worktrees,
running = running,
status = status,
runId = runId,
taskName = taskName,
dryRun = dryRun,
startedAtEpochMs = startedAtEpochMs,
finishedAtEpochMs = finishedAtEpochMs,
exitCode = exitCode,
message = message,
logTail = log.toString(),
reportMarkdown = artifactText(reportPath),
dryRunPatch = artifactText(dryRunPatchPath),
transcriptPath = transcriptPath.absolutePathString(),
viewStamp = viewStamp()
)
}
}