superintelligence-android / android/app/src/main/java/net/kentonc/si/agent/Tools.kt

raw file 14016 bytes
package net.kentonc.si.agent

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.File
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.util.concurrent.TimeUnit

/**
 * Tools the model can call: filesystem read/search/write and a shell, a port of
 * src/tools.rs. Every tool resolves its path relative to a root directory (the
 * working directory), so the model can't wander off into the rest of the device.
 * Writes and shell execution additionally require approval, supplied by the host
 * (AgentViewModel) as a suspend callback that shows a dialog.
 *
 * Tool results are Strings on purpose: error text is formatted like the Rust
 * original ("Error: ...") because the model reads it, not the UI.
 */
class ToolHost(private val rootDir: File) {

    /** JSON schema list sent with the request, declaring the available tools. */
    val specs: JsonElement = buildJsonArray {
        add(toolSpec("read_file", "Read a UTF-8 text file. Returns the file contents, truncated if long.", listOf(
            prop("path", "File path, relative to the working directory."),
        ), required = listOf("path")))
        add(toolSpec("list_dir", "List the entries of a directory, with sizes and a / suffix on directories.", listOf(
            prop("path", "Directory path, relative to the working directory. Defaults to '.'"),
        )))
        add(toolSpec("search", "Search file contents for a regular expression across the working directory. Returns path:line:match entries.", listOf(
            prop("pattern", "Regular expression to look for."),
            prop("path", "Subdirectory to search. Defaults to '.'"),
            prop("include", "Only search files whose name contains this, e.g. '.rs' or 'main'."),
        ), required = listOf("pattern")))
        add(toolSpec("write_file", "Write text to a file, creating parent directories as needed. Overwrites existing files. Requires user approval.", listOf(
            prop("path", "File path, relative to the working directory."),
            prop("content", "Full contents to write."),
        ), required = listOf("path", "content")))
        add(toolSpec("run_shell", "Run a command with sh (`sh -c`) in the working directory. Requires user approval.", listOf(
            prop("command", "The command line to execute."),
        ), required = listOf("command")))
    }

    /** Run one tool call, returning the text to hand back to the model. */
    suspend fun execute(
        call: ToolCall,
        autoApprove: Boolean,
        confirm: suspend (ToolCall) -> Boolean,
    ): String {
        val args: JsonObject = try {
            RootArgsJson.parseToJsonElement(call.function.arguments.ifBlank { "{}" }) as? JsonObject
                ?: JsonObject(emptyMap())
        } catch (e: Exception) {
            return "Error: arguments were not valid JSON: ${e.message}"
        }

        return try {
            when (call.function.name) {
                "read_file" -> readFile(arg(args, "path"))
                "list_dir" -> listDir(arg(args, "path"))
                "search" -> {
                    val pattern = arg(args, "pattern")
                    if (pattern.isEmpty()) "Error: `pattern` is required."
                    else search(pattern, arg(args, "path"), arg(args, "include"))
                }
                "write_file" -> {
                    if (approved(call, autoApprove, confirm)) writeFile(arg(args, "path"), arg(args, "content"))
                    else "Skipped: the user declined this write. Do not retry it; ask them what they'd prefer."
                }
                "run_shell" -> {
                    val command = arg(args, "command")
                    if (command.trim().isEmpty()) "Error: `command` is required."
                    else if (approved(call, autoApprove, confirm)) runShell(command)
                    else "Skipped: the user declined this command. Do not retry it; describe what you would have run instead."
                }
                else -> "Error: unknown tool `${call.function.name}`."
            }
        } catch (ce: kotlinx.coroutines.CancellationException) {
            throw ce
        } catch (e: Exception) {
            "Error: ${e.message ?: e.javaClass.simpleName}"
        }
    }

    // ─── filesystem ────────────────────────────────────────────────────────────

    private fun readFile(path: String): String {
        if (path.trim().isEmpty()) error("`path` is required")
        val file = resolve(path, mustExist = true)
        val bytes = file.readBytes()
        // Strict decoding so a binary file fails the same way the Rust one does.
        val text = try {
            Charsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT)
                .decode(ByteBuffer.wrap(bytes)).toString()
        } catch (e: Exception) {
            error("${file.path} is not a UTF-8 text file")
        }
        return truncate(text)
    }

    private fun listDir(path: String): String {
        val dir = resolve(path, mustExist = true)
        if (!dir.isDirectory) error("${dir.path} is not a directory")

        val entries = (dir.listFiles() ?: emptyArray())
            .map { Triple(it.name, it.isDirectory, it.length()) }
        if (entries.isEmpty()) return "(empty directory)"

        return entries
            .sortedWith(compareByDescending<Triple<String, Boolean, Long>> { it.second }.thenBy { it.first })
            .take(500)
            .joinToString("\n") { (name, isDir, size) ->
                if (isDir) "$name/" else "$name (${humanSize(size)})"
            }
    }

    private fun writeFile(path: String, content: String): String {
        if (path.trim().isEmpty()) error("`path` is required")
        val file = resolve(path, mustExist = false)
        file.parentFile?.mkdirs()
        file.writeText(content)
        return "Wrote ${humanSize(content.length.toLong())} to ${file.path}."
    }

    // ─── search ────────────────────────────────────────────────────────────────

    /** Directories we never descend into (SKIP_DIRS in tools.rs). */
    private val skipDirs = setOf("target", ".git", "node_modules", "__pycache__", ".venv", "dist", "build")

    private fun search(pattern: String, path: String, include: String): String {
        val regex = Regex(pattern)
        val start = resolve(path, mustExist = true)
        if (!start.isDirectory) error("${start.path} is not a directory")

        val results = ArrayList<String>()
        var filesScanned = 0
        var truncated = false
        val stack = ArrayDeque(listOf(start))

        outer@ while (stack.isNotEmpty()) {
            val dir = stack.removeLast()
            for (entry in dir.listFiles() ?: continue) {
                if (truncated) break@outer
                val name = entry.name
                if (entry.isDirectory) {
                    if (name !in skipDirs && !name.startsWith(".")) stack.addLast(entry)
                    continue
                }
                if (include.isNotEmpty() && !name.contains(include)) continue
                if (entry.length() > 1_000_000) continue  // skip binaries and huge files
                val text = runCatching { entry.readText() }.getOrNull() ?: continue
                filesScanned++
                for ((index, line) in text.lineSequence().withIndex()) {
                    if (regex.containsMatchIn(line)) {
                        if (results.size >= 200) { truncated = true; break@outer }
                        results.add("${entry.path}:${index + 1}:${snippet(line.trim(), 300)}")
                    }
                }
            }
        }

        if (results.isEmpty()) return "No matches for /$pattern/ in $filesScanned file(s)."
        var out = results.joinToString("\n")
        if (truncated) out += "\n… (truncated; narrow the pattern or path)"
        return truncate(out)
    }

    // ─── shell ─────────────────────────────────────────────────────────────────

    private suspend fun runShell(command: String): String = withContext(Dispatchers.IO) {
        val process = ProcessBuilder("sh", "-c", command)
            .directory(rootDir)
            // Android's Redirect has no NULL constant (Java 9's DISCARD
            // arrived much later); INHERIT is harmless: an app process has
            // nothing meaningful on stdin for a shell tool to read.
            .apply { redirectInput(ProcessBuilder.Redirect.INHERIT) }
            .start()

        val stdout = StringBuilder()
        val stderr = StringBuilder()
        // Both pipes drained on threads so a big output can't block the process.
        val outBuf = java.io.ByteArrayOutputStream()
        val errBuf = java.io.ByteArrayOutputStream()
        val t1 = Thread { process.inputStream.use { it.copyTo(outBuf) } }
        val t2 = Thread { process.errorStream.use { it.copyTo(errBuf) } }
        t1.start(); t2.start()

        val finished = process.waitFor(SHELL_TIMEOUT_SECS, TimeUnit.SECONDS)
        if (!finished) {
            process.destroyForcibly()
            t1.join(1000); t2.join(1000)
            error("timed out after ${SHELL_TIMEOUT_SECS}s")
        }
        t1.join(); t2.join()

        var out = ""
        if (outBuf.size() > 0) out += String(outBuf.toByteArray(), Charsets.UTF_8).trimEnd()
        if (errBuf.size() > 0) {
            if (out.isNotEmpty()) out += "\n\n[stderr]"
            out += String(errBuf.toByteArray(), Charsets.UTF_8).trimEnd()
        }
        if (out.isEmpty()) out = "(no output)"
        if (process.exitValue() != 0) out += "\n\n[exit status: ${process.exitValue()}]"
        truncate(out.trim())
    }

    // ─── approval, root resolution, helpers ────────────────────────────────────

    private suspend fun approved(call: ToolCall, autoApprove: Boolean, confirm: suspend (ToolCall) -> Boolean) =
        autoApprove || confirm(call)

    /**
     * Resolve a path against the working directory, refusing to leave it
     * (resolve() in tools.rs).
     */
    private fun resolve(raw: String, mustExist: Boolean): File {
        val root = rootDir.canonicalFile
        val joined = if (raw.trim().isEmpty() || raw.trim() == ".") root
        else {
            val candidate = File(raw)
            if (candidate.isAbsolute) candidate else File(root, raw)
        }

        val resolved = if (mustExist) {
            joined.canonicalFile
        } else {
            // The file may not exist yet, so anchor on the nearest existing ancestor.
            joined.parentFile?.canonicalFile?.resolve(joined.name) ?: joined
        }

        if (resolved.canonicalPath != root.canonicalPath &&
            !resolved.canonicalPath.startsWith(root.canonicalPath + File.separator)
        ) {
            error("${resolved.path} is outside the working directory (${root.path})")
        }
        return resolved
    }

    private fun arg(args: JsonObject, key: String): String =
        (args[key] as? JsonPrimitive)?.content ?: ""

    /** Cut at a char boundary so the result fits, appending a marker. */
    private fun truncate(text: String, max: Int = TOOL_OUTPUT_MAX_CHARS) =
        if (text.length <= max) text else text.take(max) + "\n… (truncated)"

    private fun snippet(text: String, max: Int) =
        if (text.length <= max) text else text.take(max) + "…"

    companion object {
        private const val TOOL_OUTPUT_MAX_CHARS = 16_000
        private const val SHELL_TIMEOUT_SECS = 60L
        private val RootArgsJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
    }
}

/** 500 B -> "500 B", 2048 -> "2.0 KB", etc. (human_size in tools.rs) */
fun humanSize(bytes: Long): String {
    val units = listOf("B", "KB", "MB", "GB", "TB")
    var value = bytes.toDouble()
    var unit = 0
    while (value >= 1024.0 && unit < units.size - 1) { value /= 1024.0; unit++ }
    return if (unit == 0) "$bytes B" else "%.1f %s".format(value, units[unit])
}

/** MIME types accepted for image attachments, keyed by file extension. */
val IMAGE_MIMES = mapOf(
    "png" to "image/png",
    "jpg" to "image/jpeg",
    "jpeg" to "image/jpeg",
    "gif" to "image/gif",
    "webp" to "image/webp",
)

// ─── JSON schema helpers used by `specs` ───────────────────────────────────────

private fun prop(name: String, description: String) = name to buildJsonObject {
    put("type", "string")
    put("description", description)
}

private fun toolSpec(
    name: String,
    description: String,
    props: List<Pair<String, JsonObject>>,
    required: List<String> = emptyList(),
): JsonObject = buildJsonObject {
    put("type", "function")
    put("function", buildJsonObject {
        put("name", name)
        put("description", description)
        put("parameters", buildJsonObject {
            put("type", "object")
            put("properties", JsonObject(props.toMap()))
            if (required.isNotEmpty()) put("required", JsonArray(required.map { JsonPrimitive(it) }))
        })
    })
}