refactor(tool): split tool display engine
This commit is contained in:
1 parent
065dd87d07
commit
91566dfdd7
17 files changed
+2301
-2052
No files matched your search
@@ -0,0 +1,34 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* One normalized tool invocation, as seen by the display engine.
|
||||
*
|
||||
* [args] and [result] are the payloads coerced to records (stringified JSON
|
||||
* re-parsed, non-objects dropped); [rawArgs]/[rawResult] keep the original
|
||||
* elements for renderers that need arrays, scalars, or wrapper unwrapping.
|
||||
*/
|
||||
internal data class ToolCall(
|
||||
val name: String,
|
||||
val args: JsonObject?,
|
||||
val result: JsonObject?,
|
||||
val rawArgs: JsonElement?,
|
||||
val rawResult: JsonElement?,
|
||||
) {
|
||||
companion object {
|
||||
fun of(
|
||||
name: String,
|
||||
rawArgs: JsonElement?,
|
||||
rawResult: JsonElement?,
|
||||
): ToolCall =
|
||||
ToolCall(
|
||||
name = name,
|
||||
args = ToolJson.parseMaybeObject(rawArgs),
|
||||
result = ToolJson.parseMaybeObject(rawResult),
|
||||
rawArgs = rawArgs,
|
||||
rawResult = rawResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Count-label extraction ("3 results", "2 files") for the collapsed row.
|
||||
*
|
||||
* Hunts for a count in this order: known count fields, known list arrays,
|
||||
* `*_count`/`*_total` field names, then plain-text "Found N matches"
|
||||
* phrasing in summary/message fields. web_search always counts its hits and
|
||||
* always reports "results".
|
||||
*/
|
||||
internal object ToolCounts {
|
||||
private val COUNT_FIELD_KEYS =
|
||||
listOf(
|
||||
"count",
|
||||
"total",
|
||||
"result_count",
|
||||
"results_count",
|
||||
"num_results",
|
||||
"match_count",
|
||||
"matches_count",
|
||||
"file_count",
|
||||
"files_count",
|
||||
"item_count",
|
||||
"items_count",
|
||||
"search_count",
|
||||
"searches_count",
|
||||
"source_count",
|
||||
"sources_count",
|
||||
"document_count",
|
||||
"documents_count",
|
||||
"updated",
|
||||
"added",
|
||||
"removed",
|
||||
"deleted",
|
||||
"created",
|
||||
"changed",
|
||||
"processed",
|
||||
"steps",
|
||||
)
|
||||
|
||||
private val COUNT_ARRAY_KEYS = listOf("results", "items", "matches", "files", "documents", "sources", "rows")
|
||||
|
||||
private val COUNT_EXCLUDED_KEYS = setOf("duration_s", "exit_code", "status_code")
|
||||
|
||||
private val NOUN_BY_FIELD =
|
||||
mapOf(
|
||||
"result_count" to "result",
|
||||
"results_count" to "result",
|
||||
"num_results" to "result",
|
||||
"match_count" to "match",
|
||||
"matches_count" to "match",
|
||||
"file_count" to "file",
|
||||
"files_count" to "file",
|
||||
"item_count" to "item",
|
||||
"items_count" to "item",
|
||||
"search_count" to "search",
|
||||
"searches_count" to "search",
|
||||
"source_count" to "source",
|
||||
"sources_count" to "source",
|
||||
"document_count" to "document",
|
||||
"documents_count" to "document",
|
||||
"updated" to "item",
|
||||
"added" to "item",
|
||||
"removed" to "item",
|
||||
"deleted" to "item",
|
||||
"created" to "item",
|
||||
"changed" to "item",
|
||||
"processed" to "item",
|
||||
"steps" to "step",
|
||||
)
|
||||
|
||||
private val NOUN_BY_ARRAY =
|
||||
mapOf(
|
||||
"documents" to "document",
|
||||
"files" to "file",
|
||||
"items" to "item",
|
||||
"matches" to "match",
|
||||
"results" to "result",
|
||||
"rows" to "row",
|
||||
"sources" to "source",
|
||||
)
|
||||
|
||||
private fun countFromUnknown(value: JsonElement?): Int? {
|
||||
if (value is JsonArray) {
|
||||
return if (value.isNotEmpty()) value.size else null
|
||||
}
|
||||
|
||||
val n = ToolJson.numberValue(value) ?: return null
|
||||
|
||||
if (n <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Math.round(n).toInt()
|
||||
}
|
||||
|
||||
private fun singularizeNoun(noun: String): String {
|
||||
val normalized = noun.lowercase()
|
||||
|
||||
if (normalized.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (normalized.endsWith("ies") && normalized.length > 3) {
|
||||
return "${normalized.dropLast(3)}y"
|
||||
}
|
||||
|
||||
if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) {
|
||||
return normalized.dropLast(2)
|
||||
}
|
||||
|
||||
if (normalized.endsWith("s") && normalized.length > 2 && !normalized.endsWith("ss")) {
|
||||
return normalized.dropLast(1)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun pluralizeNoun(
|
||||
noun: String,
|
||||
count: Int,
|
||||
): String {
|
||||
if (count == 1) {
|
||||
return noun
|
||||
}
|
||||
|
||||
if (noun == "search") {
|
||||
return "searches"
|
||||
}
|
||||
|
||||
if (noun.endsWith(
|
||||
"y",
|
||||
) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun)
|
||||
) {
|
||||
return "${noun.dropLast(1)}ies"
|
||||
}
|
||||
|
||||
if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) {
|
||||
return "${noun}es"
|
||||
}
|
||||
|
||||
return "${noun}s"
|
||||
}
|
||||
|
||||
private fun countMetric(
|
||||
count: Int,
|
||||
noun: String,
|
||||
): Pair<Int, String> = count to singularizeNoun(noun).ifEmpty { "item" }
|
||||
|
||||
private fun countFromRecord(
|
||||
record: JsonObject,
|
||||
fallbackNoun: String,
|
||||
): Pair<Int, String>? {
|
||||
for (key in COUNT_FIELD_KEYS) {
|
||||
val count = countFromUnknown(record[key]) ?: continue
|
||||
|
||||
return countMetric(count, NOUN_BY_FIELD[key] ?: fallbackNoun)
|
||||
}
|
||||
|
||||
for (key in COUNT_ARRAY_KEYS) {
|
||||
val count = countFromUnknown(record[key]) ?: continue
|
||||
|
||||
return countMetric(count, NOUN_BY_ARRAY[key] ?: fallbackNoun)
|
||||
}
|
||||
|
||||
for ((key, value) in record) {
|
||||
if (key in COUNT_EXCLUDED_KEYS) {
|
||||
continue
|
||||
}
|
||||
if (!Regex("_count$|_total$").containsMatchIn(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
val count = countFromUnknown(value) ?: continue
|
||||
val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "")
|
||||
|
||||
return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun countFromText(
|
||||
text: String,
|
||||
fallbackNoun: String,
|
||||
): Pair<Int, String>? {
|
||||
val t = text.trim()
|
||||
if (t.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val unitMatch =
|
||||
Regex(
|
||||
"""\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(t)
|
||||
?: Regex(
|
||||
"""\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(t)
|
||||
|
||||
val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null
|
||||
val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun
|
||||
|
||||
return if (n > 0) countMetric(n, noun) else null
|
||||
}
|
||||
|
||||
fun countLabelFor(call: ToolCall): Pair<Int, String>? {
|
||||
val rawResult = call.rawResult ?: return null
|
||||
val toolName = call.name
|
||||
val result = call.result
|
||||
|
||||
val fallbackNoun =
|
||||
when (toolName) {
|
||||
"browser_snapshot", "todo" -> "item"
|
||||
"list_files" -> "file"
|
||||
"search_files", "session_search_recall", "web_search" -> "result"
|
||||
"skills_list" -> "skill"
|
||||
else -> "item"
|
||||
}
|
||||
|
||||
if (toolName == "web_search") {
|
||||
val hits = ToolJson.collectResultItems(rawResult)
|
||||
|
||||
if (hits.isNotEmpty()) {
|
||||
return countMetric(hits.size, "result")
|
||||
}
|
||||
}
|
||||
|
||||
val direct = countFromRecord(result ?: JsonObject(emptyMap()), fallbackNoun)
|
||||
if (direct != null) {
|
||||
return if (toolName == "web_search") countMetric(direct.first, "result") else direct
|
||||
}
|
||||
|
||||
val payload = ToolJson.unwrapToolPayload(rawResult)
|
||||
if (payload !== rawResult && payload is JsonObject) {
|
||||
val payloadCount = countFromRecord(payload, fallbackNoun)
|
||||
|
||||
if (payloadCount != null) {
|
||||
return if (toolName == "web_search") countMetric(payloadCount.first, "result") else payloadCount
|
||||
}
|
||||
}
|
||||
|
||||
val summaryText =
|
||||
ToolJson
|
||||
.firstString(result, listOf("summary", "message", "detail"))
|
||||
.ifEmpty { ToolJson.fallbackDetailText(call.args, rawResult) }
|
||||
|
||||
return countFromText(summaryText, fallbackNoun)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Shared parsing/normalization primitives for the tool-display engine.
|
||||
*
|
||||
* Everything in here is payload-shape plumbing — no per-tool knowledge.
|
||||
* Per-tool formatting lives in the renderers under [render][com.m57.hermescontrol.ui.chat.tool.render].
|
||||
*/
|
||||
internal object ToolJson {
|
||||
private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** First non-blank string value among [keys], trimmed. */
|
||||
fun firstString(
|
||||
record: JsonObject?,
|
||||
keys: List<String>,
|
||||
): String {
|
||||
val r = record ?: return ""
|
||||
|
||||
for (k in keys) {
|
||||
val v = r[k] ?: continue
|
||||
if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) {
|
||||
return v.content.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
fun numberValue(value: JsonElement?): Double? {
|
||||
val v = value ?: return null
|
||||
if (v is JsonNull) {
|
||||
return null
|
||||
}
|
||||
if (v !is JsonPrimitive) {
|
||||
return null
|
||||
}
|
||||
|
||||
// TS source does `Number(value)` — numeric STRINGS ("0.0", "12") are
|
||||
// accepted, matching the desktop engine and the gateway's habit of
|
||||
// shipping exit_code as a string or float.
|
||||
return v.content.toDoubleOrNull()
|
||||
}
|
||||
|
||||
fun intValue(value: JsonElement?): Int? {
|
||||
val n = numberValue(value) ?: return null
|
||||
|
||||
return n.toInt()
|
||||
}
|
||||
|
||||
/** True only for a literal JSON `true` (not the string "true"). */
|
||||
fun boolTrue(value: JsonElement?): Boolean =
|
||||
(value as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
|
||||
|
||||
/** True only for a literal JSON `false` (not the string "false"). */
|
||||
fun boolFalse(value: JsonElement?): Boolean =
|
||||
(value as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true
|
||||
|
||||
fun compactPreview(
|
||||
value: String,
|
||||
max: Int = 72,
|
||||
): String {
|
||||
val line = value.replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
return if (line.length > max) "${line.take(max - 1)}…" else line
|
||||
}
|
||||
|
||||
fun compactPreview(
|
||||
value: JsonElement?,
|
||||
max: Int = 72,
|
||||
): String {
|
||||
val raw: String =
|
||||
if (value is JsonPrimitive && value.isString) {
|
||||
value.content
|
||||
} else {
|
||||
firstString(parseMaybeObject(value), listOf("context"))
|
||||
}
|
||||
|
||||
return compactPreview(raw, max)
|
||||
}
|
||||
|
||||
fun contextValue(value: JsonElement?): String {
|
||||
val row = parseMaybeObject(value)
|
||||
val context = firstString(row, listOf("context"))
|
||||
val preview = firstString(row, listOf("preview"))
|
||||
|
||||
if (context.isNotEmpty()) {
|
||||
return context
|
||||
}
|
||||
if (preview.isNotEmpty()) {
|
||||
return preview
|
||||
}
|
||||
|
||||
return (value as? JsonPrimitive)?.takeIf { it.isString }?.content ?: ""
|
||||
}
|
||||
|
||||
/** Objects pass through; stringified JSON objects are re-parsed. */
|
||||
fun parseMaybeObject(value: JsonElement?): JsonObject? =
|
||||
when (value) {
|
||||
is JsonObject -> {
|
||||
value
|
||||
}
|
||||
|
||||
is JsonPrimitive -> {
|
||||
if (!value.isString || value.content.trim().isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
Json.parseToJsonElement(value.content) as? JsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Peel one `data`/`result`/`output`/`response`/`payload` wrapper layer. */
|
||||
fun unwrapToolPayload(value: JsonElement?): JsonElement? {
|
||||
val record = parseMaybeObject(value) ?: return value
|
||||
|
||||
for (key in listOf("data", "result", "output", "response", "payload")) {
|
||||
val payload = record[key] ?: continue
|
||||
if (payload is JsonNull) {
|
||||
continue
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
fun findFirstUrl(vararg sources: JsonElement?): String {
|
||||
for (src in sources) {
|
||||
if (src is JsonPrimitive && src.isString) {
|
||||
val m = URL_PATTERN.find(src.content)
|
||||
|
||||
if (m != null) {
|
||||
return m.value
|
||||
}
|
||||
} else if (src is JsonObject) {
|
||||
for (v in src.values) {
|
||||
val found = findFirstUrl(v)
|
||||
|
||||
if (found.isNotEmpty()) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
fun hostnameOf(value: String): String =
|
||||
try {
|
||||
val url = java.net.URI(value)
|
||||
val path = url.path?.takeIf { it.isNotEmpty() && it != "/" } ?: ""
|
||||
"${url.host ?: value}$path"
|
||||
} catch (_: Exception) {
|
||||
value
|
||||
}
|
||||
|
||||
fun stripAnsi(value: String): String = value.replace(Regex("\u001B\\[[0-9;]*m"), "")
|
||||
|
||||
fun stripDividerLines(value: String): String =
|
||||
value
|
||||
.split("\n")
|
||||
.filter { !Regex("^[-=]{3,}\\s*$").matches(it.trim()) }
|
||||
.joinToString("\n")
|
||||
.trim()
|
||||
|
||||
fun cleanVisibleText(text: String): String =
|
||||
text
|
||||
.replace(Regex("`{3,}"), "")
|
||||
.replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "")
|
||||
.replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" }
|
||||
|
||||
/** Find the result-item array behind common list keys or wrappers. */
|
||||
fun collectResultItems(value: JsonElement?): List<JsonElement> {
|
||||
if (value is JsonArray) {
|
||||
return value.toList()
|
||||
}
|
||||
|
||||
val record = parseMaybeObject(value) ?: return emptyList()
|
||||
|
||||
val keys =
|
||||
listOf(
|
||||
"web",
|
||||
"results",
|
||||
"search_results",
|
||||
"sources",
|
||||
"web_sources",
|
||||
"items",
|
||||
"organic_results",
|
||||
"organic",
|
||||
"matches",
|
||||
"documents",
|
||||
)
|
||||
|
||||
for (key in keys) {
|
||||
val candidate = record[key] ?: continue
|
||||
|
||||
if (candidate is JsonArray) {
|
||||
return candidate.toList()
|
||||
}
|
||||
|
||||
if (candidate is JsonObject) {
|
||||
val nested = collectResultItems(candidate)
|
||||
|
||||
if (nested.isNotEmpty()) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val payload = unwrapToolPayload(record)
|
||||
|
||||
return if (payload === record) emptyList() else collectResultItems(payload)
|
||||
}
|
||||
|
||||
/** Humanized generic title: "fact_store" → "Fact Store", sans browser_/web_ prefix. */
|
||||
fun titleForTool(name: String): String {
|
||||
val normalized = name.replace(Regex("^browser_"), "").replace(Regex("^web_"), "")
|
||||
|
||||
return normalized
|
||||
.split("_")
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(
|
||||
" ",
|
||||
) { part -> part.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } }
|
||||
.ifEmpty { name }
|
||||
}
|
||||
|
||||
/** Generic detail: prefer a fresh result context, then args, then the heuristic summary. */
|
||||
fun fallbackDetailText(
|
||||
args: JsonElement?,
|
||||
result: JsonElement?,
|
||||
): String {
|
||||
val argContext = contextValue(args)
|
||||
val resultContext = contextValue(result)
|
||||
|
||||
if (resultContext.isNotEmpty() && resultContext != argContext) {
|
||||
return resultContext
|
||||
}
|
||||
|
||||
if (argContext.isNotEmpty()) {
|
||||
return argContext
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
return ToolResultSummary.formatToolResultSummary(result)
|
||||
}
|
||||
|
||||
return ToolResultSummary.formatToolResultSummary(args)
|
||||
}
|
||||
|
||||
fun formatDurationSeconds(seconds: Double): String {
|
||||
if (!seconds.isFinite() || seconds < 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (seconds < 1) {
|
||||
val ms = maxOf(1, Math.round(seconds * 1000))
|
||||
return "${ms}ms"
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return if (seconds >= 10) "${seconds.toInt()}s" else "${"%.1f".format(seconds)}s"
|
||||
}
|
||||
|
||||
val wholeSeconds = Math.round(seconds)
|
||||
val minutes = wholeSeconds / 60
|
||||
val remSeconds = wholeSeconds % 60
|
||||
|
||||
if (minutes < 60) {
|
||||
return if (remSeconds != 0L) "${minutes}m ${remSeconds}s" else "${minutes}m"
|
||||
}
|
||||
|
||||
val hours = minutes / 60
|
||||
val remMinutes = minutes % 60
|
||||
|
||||
return if (remMinutes != 0L) "${hours}h ${remMinutes}m" else "${hours}h"
|
||||
}
|
||||
|
||||
fun durationLabel(result: JsonObject?): String? {
|
||||
val seconds = numberValue(result?.get("duration_s")) ?: return null
|
||||
|
||||
return formatDurationSeconds(seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
/**
|
||||
* Per-tool (or per-family) display strategy.
|
||||
*
|
||||
* Every hook is a *refinement*: return null to fall back to the generic
|
||||
* treatment ([ToolViewBuilder] humanizes the tool name for titles and uses
|
||||
* [ToolResultSummary]-based text for subtitle/detail). Returning an empty
|
||||
* string is a real answer — "this tool intentionally shows nothing here" —
|
||||
* and suppresses the generic fallback.
|
||||
*/
|
||||
internal interface ToolRenderer {
|
||||
/** Title while the call is still running ("Running ls", "Searching …"). */
|
||||
fun pendingTitle(call: ToolCall): String? = null
|
||||
|
||||
/** Title after successful completion ("Ran ls", "Opened example.com"). */
|
||||
fun doneTitle(call: ToolCall): String? = null
|
||||
|
||||
/** Title when the call errored. */
|
||||
fun errorTitle(call: ToolCall): String? = null
|
||||
|
||||
/** Secondary line naming what was acted on. Not used when an error is shown. */
|
||||
fun subtitle(call: ToolCall): String? = null
|
||||
|
||||
/** Expanded body text. */
|
||||
fun detail(call: ToolCall): String? = null
|
||||
|
||||
/** Structured extras (streams, diffs, search hits) for special row layouts. */
|
||||
fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras = ToolViewExtras.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured, tool-specific fields a renderer can contribute to [ToolView]
|
||||
* beyond the title/subtitle/detail text.
|
||||
*/
|
||||
internal data class ToolViewExtras(
|
||||
val detailLabel: String? = null,
|
||||
val stdout: String? = null,
|
||||
val stderr: String? = null,
|
||||
val exitCode: Int? = null,
|
||||
val terminalCommand: String? = null,
|
||||
val inlineDiff: String? = null,
|
||||
val diffPath: String? = null,
|
||||
val diffStats: DiffStats? = null,
|
||||
val searchHits: List<SearchHit>? = null,
|
||||
val searchQuery: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
val NONE = ToolViewExtras()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer
|
||||
|
||||
/**
|
||||
* Maps tool names to their display strategy.
|
||||
*
|
||||
* Unknown tools get [GenericToolRenderer]: every hook returns null, so
|
||||
* [ToolViewBuilder] falls back to the humanized tool name and the
|
||||
* [ToolResultSummary] heuristics — never a raw JSON dump.
|
||||
*/
|
||||
internal object ToolRendererRegistry {
|
||||
/** All-default renderer: defers everything to the generic fallbacks. */
|
||||
private object GenericToolRenderer : ToolRenderer
|
||||
|
||||
private val renderers: Map<String, ToolRenderer> =
|
||||
mapOf(
|
||||
// Shell
|
||||
"terminal" to TerminalRenderer,
|
||||
"execute_code" to TerminalRenderer,
|
||||
"read_terminal" to ReadTerminalRenderer,
|
||||
// Files
|
||||
"read_file" to ReadFileRenderer,
|
||||
"edit_file" to FileEditRenderer,
|
||||
"patch" to FileEditRenderer,
|
||||
"write_file" to FileEditRenderer,
|
||||
// Web
|
||||
"web_search" to WebSearchRenderer,
|
||||
"web_extract" to WebExtractRenderer,
|
||||
"x_search" to XSearchRenderer,
|
||||
// Browser
|
||||
"browser_navigate" to BrowserNavigateRenderer,
|
||||
"browser_snapshot" to BrowserSnapshotRenderer,
|
||||
"browser_click" to BrowserClickRenderer,
|
||||
"browser_fill" to BrowserTypeRenderer,
|
||||
"browser_type" to BrowserTypeRenderer,
|
||||
// Memory / facts
|
||||
"memory" to MemoryRenderer,
|
||||
"fact_store" to FactStoreRenderer,
|
||||
// Task management
|
||||
"cronjob" to CronjobRenderer,
|
||||
"todo" to TodoRenderer,
|
||||
"session_search" to SessionSearchRenderer,
|
||||
"process" to ProcessRenderer,
|
||||
// Skills / tool discovery
|
||||
"skills_list" to SkillsListRenderer,
|
||||
"skill_view" to SkillViewRenderer,
|
||||
"skill_manage" to SkillManageRenderer,
|
||||
"tool_search" to ToolSearchRenderer,
|
||||
// Media
|
||||
"image_generate" to ImageGenerateRenderer,
|
||||
"vision_analyze" to VisionAnalyzeRenderer,
|
||||
// Projects
|
||||
"project_list" to ProjectListRenderer,
|
||||
"project_create" to ProjectMutateRenderer,
|
||||
"project_switch" to ProjectMutateRenderer,
|
||||
// Desktop automation
|
||||
"computer_use" to ComputerUseRenderer,
|
||||
)
|
||||
|
||||
fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Status + error-text heuristics for a completed tool call, ported from the
|
||||
* desktop engine. Key rules, in order:
|
||||
*
|
||||
* - running (or a missing result) is RUNNING
|
||||
* - an explicit `success: true` / `ok: true` beats every error heuristic,
|
||||
* including a stale isError envelope
|
||||
* - a non-zero exit code alone is NOT an error: grep returns 1 on no-match,
|
||||
* diff returns 1 on differences, piped commands surface the last stage's
|
||||
* code — all routinely produce useful output. It only becomes an error
|
||||
* when the command produced no real output to show.
|
||||
* - a rejected memory write is a budget negotiation → WARNING, not ERROR
|
||||
*/
|
||||
internal object ToolStatusResolver {
|
||||
fun errorText(
|
||||
call: ToolCall,
|
||||
isError: Boolean,
|
||||
): String {
|
||||
val result = call.rawResult
|
||||
val resultRecord = call.result
|
||||
|
||||
if (isError) {
|
||||
val extracted = ToolResultSummary.extractToolErrorMessage(result)
|
||||
if (extracted.isNotEmpty()) {
|
||||
return extracted
|
||||
}
|
||||
if (result is JsonPrimitive && result.isString && result.content.trim().isNotEmpty()) {
|
||||
return result.content.trim()
|
||||
}
|
||||
|
||||
return "Tool returned an error."
|
||||
}
|
||||
|
||||
val error = ToolJson.firstString(resultRecord, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return error
|
||||
}
|
||||
|
||||
val extracted = ToolResultSummary.extractToolErrorMessage(result)
|
||||
if (extracted.isNotEmpty()) {
|
||||
return extracted
|
||||
}
|
||||
|
||||
if (resultRecord != null) {
|
||||
if (ToolJson.boolFalse(resultRecord["success"]) || ToolJson.boolFalse(resultRecord["ok"])) {
|
||||
return ToolJson
|
||||
.firstString(
|
||||
resultRecord,
|
||||
listOf("message", "reason", "detail"),
|
||||
).ifEmpty { "Tool returned success=false." }
|
||||
}
|
||||
|
||||
val status = ToolJson.firstString(resultRecord, listOf("status"))
|
||||
if (Regex("\\b(error|failed|failure)\\b", RegexOption.IGNORE_CASE).containsMatchIn(status)) {
|
||||
return ToolJson
|
||||
.firstString(
|
||||
resultRecord,
|
||||
listOf("message", "reason", "detail"),
|
||||
).ifEmpty { "Tool returned status \"$status\"." }
|
||||
}
|
||||
|
||||
val exit = ToolJson.numberValue(resultRecord["exit_code"])
|
||||
if (exit != null && exit != 0.0) {
|
||||
val hasOutput =
|
||||
listOf("output", "stdout", "stderr", "output_preview")
|
||||
.any { k -> ToolJson.firstString(resultRecord, listOf(k)).isNotEmpty() }
|
||||
|
||||
if (!hasOutput) {
|
||||
return "Command failed with exit code ${exit.toInt()}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
fun status(
|
||||
call: ToolCall,
|
||||
isError: Boolean,
|
||||
running: Boolean,
|
||||
): ToolViewStatus {
|
||||
if (running || call.rawResult == null) {
|
||||
return ToolViewStatus.RUNNING
|
||||
}
|
||||
|
||||
// Explicit success wins over isError / nested-error heuristics.
|
||||
val resultRecord = call.result
|
||||
if (resultRecord != null &&
|
||||
(ToolJson.boolTrue(resultRecord["success"]) || ToolJson.boolTrue(resultRecord["ok"]))
|
||||
) {
|
||||
return ToolViewStatus.SUCCESS
|
||||
}
|
||||
|
||||
if (errorText(call, isError).isEmpty()) {
|
||||
return ToolViewStatus.SUCCESS
|
||||
}
|
||||
|
||||
// A rejected memory write is a budget negotiation, not a failure.
|
||||
return if (call.name == "memory") ToolViewStatus.WARNING else ToolViewStatus.ERROR
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,106 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
|
||||
/** `browser_navigate`: hostname titles, including the failed-to-open form. */
|
||||
internal object BrowserNavigateRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Opening ${targetHostname(call.args, null)}"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Opened ${targetHostname(call.args, call.result)}"
|
||||
|
||||
override fun errorTitle(call: ToolCall): String? {
|
||||
val url =
|
||||
ToolJson
|
||||
.findFirstUrl(call.result)
|
||||
.ifEmpty { call.result?.get("url")?.toString() ?: "" }
|
||||
|
||||
return if (url.isNotEmpty()) "Failed to open ${ToolJson.hostnameOf(url)}" else null
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val url =
|
||||
ToolJson
|
||||
.firstString(call.args, listOf("url", "target"))
|
||||
.ifEmpty { ToolJson.firstString(call.result, listOf("url")) }
|
||||
.ifEmpty { ToolJson.findFirstUrl(call.args, call.result) }
|
||||
|
||||
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Navigated in browser"
|
||||
}
|
||||
}
|
||||
|
||||
/** `browser_snapshot`: control-count summary of the accessibility snapshot. */
|
||||
internal object BrowserSnapshotRenderer : ToolRenderer {
|
||||
private fun summarizeSnapshot(snapshot: String): String {
|
||||
fun count(re: Regex): Int = re.findAll(snapshot).count()
|
||||
|
||||
val stats =
|
||||
listOf(
|
||||
"${count(Regex("""button\s+"[^"]+"""))} buttons",
|
||||
"${count(Regex("""link\s+"[^"]+"""))} links",
|
||||
"${count(Regex("""(?:textbox|combobox|searchbox)\s+"[^"]+"""))} inputs",
|
||||
).joinToString(" · ")
|
||||
|
||||
val labels =
|
||||
Regex("""(?:button|link|combobox|textbox)\s+"([^"]+)"""")
|
||||
.findAll(snapshot)
|
||||
.map { it.groupValues[1].trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(4)
|
||||
.toList()
|
||||
|
||||
return if (labels.isNotEmpty()) "$stats\nTop controls: ${labels.joinToString(", ")}" else stats
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val snapshot = ToolJson.firstString(call.result, listOf("snapshot"))
|
||||
|
||||
return if (snapshot.isNotEmpty()) {
|
||||
summarizeSnapshot(snapshot)
|
||||
} else {
|
||||
"Captured a browser accessibility snapshot"
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val snapshot = ToolJson.firstString(call.result, listOf("snapshot"))
|
||||
|
||||
return if (snapshot.isNotEmpty()) {
|
||||
summarizeSnapshot(snapshot)
|
||||
} else {
|
||||
ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `browser_click`: what got clicked, with internal refs called out. */
|
||||
internal object BrowserClickRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val clicked =
|
||||
ToolJson
|
||||
.firstString(call.result, listOf("clicked"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("ref", "target")) }
|
||||
|
||||
return when {
|
||||
clicked.isEmpty() -> "Clicked on page"
|
||||
clicked.startsWith("@") -> "Clicked page element (internal ref $clicked)"
|
||||
else -> "Clicked $clicked"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `browser_fill` / `browser_type`: which field got which value. */
|
||||
internal object BrowserTypeRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val field = ToolJson.firstString(call.args, listOf("label", "field", "ref", "target"))
|
||||
val value = ToolJson.firstString(call.args, listOf("value", "text"))
|
||||
|
||||
return listOf(
|
||||
field.takeIf { it.isNotEmpty() }?.let { "Field: $it" },
|
||||
value.takeIf { it.isNotEmpty() }?.let { "Value: ${ToolJson.compactPreview(it, 42)}" },
|
||||
).filterNotNull()
|
||||
.joinToString(" · ")
|
||||
.ifEmpty { "Filled page input" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
|
||||
/**
|
||||
* `computer_use`: desktop-automation results — app rosters, interactable
|
||||
* element inventories, multimodal text blocks, and plain action acks.
|
||||
*/
|
||||
internal object ComputerUseRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val args = call.args
|
||||
val result = call.result
|
||||
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val apps = result?.get("apps") as? JsonArray
|
||||
if (apps != null) {
|
||||
return "${apps.size} apps"
|
||||
}
|
||||
|
||||
val elements = result?.get("elements") as? JsonArray
|
||||
if (elements != null) {
|
||||
val mode = ToolJson.firstString(result, listOf("mode"))
|
||||
val width = ToolJson.intValue(result["width"])
|
||||
val height = ToolJson.intValue(result["height"])
|
||||
val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""
|
||||
val sizePart = if (width != null && height != null) " ${width}x$height" else ""
|
||||
return "${elements.size} elements$modePart$sizePart"
|
||||
}
|
||||
|
||||
val action = ToolJson.firstString(args, listOf("action"))
|
||||
if (action.isNotEmpty()) {
|
||||
val ok = ToolJson.boolTrue(result?.get("ok"))
|
||||
val fail = ToolJson.boolFalse(result?.get("ok"))
|
||||
return "$action${when {
|
||||
ok -> " ✓"
|
||||
fail -> " ✗"
|
||||
else -> ""
|
||||
}}"
|
||||
}
|
||||
|
||||
return "Computer use"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val args = call.args
|
||||
val result = call.result
|
||||
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val isMultimodal = ToolJson.boolTrue(result?.get("_multimodal"))
|
||||
val content = result?.get("content") as? JsonArray
|
||||
val multimodalText =
|
||||
if (isMultimodal && content != null) {
|
||||
content
|
||||
.mapNotNull { el ->
|
||||
val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
if (ToolJson.firstString(e, listOf("type")) == "text") {
|
||||
ToolJson.firstString(e, listOf("text"))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.joinToString("\n")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val textSummary = ToolJson.firstString(result, listOf("summary"))
|
||||
val visionAnalysis = ToolJson.firstString(result, listOf("vision_analysis"))
|
||||
val apps = result?.get("apps") as? JsonArray
|
||||
val elements = result?.get("elements") as? JsonArray
|
||||
val mode = ToolJson.firstString(result, listOf("mode"))
|
||||
val width = ToolJson.intValue(result?.get("width"))
|
||||
val height = ToolJson.intValue(result?.get("height"))
|
||||
val action = ToolJson.firstString(args, listOf("action"))
|
||||
val ok = ToolJson.boolTrue(result?.get("ok"))
|
||||
val fail = ToolJson.boolFalse(result?.get("ok"))
|
||||
val message = ToolJson.firstString(result, listOf("message"))
|
||||
|
||||
val body =
|
||||
buildString {
|
||||
val textPart =
|
||||
multimodalText
|
||||
.ifEmpty {
|
||||
ToolJson.firstString(
|
||||
result,
|
||||
listOf("text_summary"),
|
||||
)
|
||||
}.ifEmpty { textSummary }
|
||||
if (textPart.isNotEmpty()) {
|
||||
append(textPart)
|
||||
if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) {
|
||||
append("\n\n$textSummary")
|
||||
}
|
||||
}
|
||||
|
||||
if (apps != null) {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
apps.forEachIndexed { idx, el ->
|
||||
val obj = ToolJson.parseMaybeObject(el)
|
||||
val name = ToolJson.firstString(obj, listOf("name")).ifEmpty { "?" }
|
||||
val pid = ToolJson.intValue(obj?.get("pid"))
|
||||
append("${idx + 1}. $name")
|
||||
if (pid != null) append(" (PID: $pid)")
|
||||
append("\n")
|
||||
}
|
||||
} else if (elements != null) {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
append("🖥️ ${elements.size} interactable elements")
|
||||
if (mode.isNotEmpty()) append(" (mode: $mode)")
|
||||
if (width != null && height != null) append(" • ${width}x$height")
|
||||
if (textSummary.isNotEmpty()) append("\n\n$textSummary")
|
||||
val previewCount = minOf(elements.size, 5)
|
||||
if (previewCount > 0) {
|
||||
append("\n\n")
|
||||
for (i in 0 until previewCount) {
|
||||
val obj = ToolJson.parseMaybeObject(elements[i])
|
||||
val idx = ToolJson.intValue(obj?.get("index"))
|
||||
val role = ToolJson.firstString(obj, listOf("role"))
|
||||
val label = ToolJson.firstString(obj, listOf("label"))
|
||||
append(" #$idx ${role}${label.takeIf { it.isNotEmpty() }?.let { " '$it'" } ?: ""}\n")
|
||||
}
|
||||
if (elements.size > previewCount) {
|
||||
append(" ... and ${elements.size - previewCount} more")
|
||||
}
|
||||
}
|
||||
} else if (action.isNotEmpty() && multimodalText.isEmpty() &&
|
||||
ToolJson
|
||||
.firstString(
|
||||
result,
|
||||
listOf("text_summary"),
|
||||
).isEmpty() && textSummary.isEmpty()
|
||||
) {
|
||||
append("$action:")
|
||||
when {
|
||||
ok -> append(" ✅ ok")
|
||||
fail -> append(" ❌ failed")
|
||||
}
|
||||
if (message.isNotEmpty()) append("\n$message")
|
||||
} else if (isEmpty()) {
|
||||
append("Computer use action")
|
||||
}
|
||||
}
|
||||
|
||||
return if (visionAnalysis.isNotEmpty()) "$body\n\n━━━ Vision Analysis ━━━\n$visionAnalysis" else body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.DiffStats
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** Path/diff plumbing shared by the file-oriented renderers. */
|
||||
internal object FileEditSupport {
|
||||
private val HTML_PATH_REGEX =
|
||||
Regex(
|
||||
"(?:^|\\s)(?:[ab]/)?([^\\s]+\\.html?)(?=\\s|$)",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
fun basename(path: String): String {
|
||||
val normalized = path.replace('\\', '/').trim()
|
||||
|
||||
return normalized.split("/").filter { it.isNotEmpty() }.lastOrNull() ?: normalized
|
||||
}
|
||||
|
||||
fun editPath(
|
||||
args: JsonObject?,
|
||||
result: JsonObject?,
|
||||
): String {
|
||||
val fromArgs = ToolJson.firstString(args, listOf("path", "file", "filepath"))
|
||||
if (fromArgs.isNotEmpty()) {
|
||||
return fromArgs
|
||||
}
|
||||
|
||||
val fromResult = ToolJson.firstString(result, listOf("path", "file", "filepath", "resolved_path"))
|
||||
if (fromResult.isNotEmpty()) {
|
||||
return fromResult
|
||||
}
|
||||
|
||||
return htmlPathFromInlineDiff(ToolJson.firstString(result, listOf("inline_diff", "diff")))
|
||||
}
|
||||
|
||||
private fun stripInlineDiffChrome(value: String): String =
|
||||
ToolJson
|
||||
.stripAnsi(value)
|
||||
.replace(Regex("^\\s*┊\\s*review diff\\s*\\n", RegexOption.IGNORE_CASE), "")
|
||||
.trim()
|
||||
|
||||
fun inlineDiffFromResult(result: JsonElement?): String {
|
||||
val record = ToolJson.parseMaybeObject(result) ?: return ""
|
||||
|
||||
for (key in listOf("inline_diff", "diff")) {
|
||||
val value = record[key]
|
||||
|
||||
if (value is JsonPrimitive && value.isString && value.content.trim().isNotEmpty()) {
|
||||
return stripInlineDiffChrome(value.content)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun htmlPathFromInlineDiff(value: String): String {
|
||||
val cleaned = stripInlineDiffChrome(value)
|
||||
|
||||
for (match in HTML_PATH_REGEX.findAll(cleaned)) {
|
||||
val candidate = match.groupValues[1].trim()
|
||||
|
||||
if (candidate.isNotEmpty()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
fun countDiffLineStats(diff: String): DiffStats {
|
||||
var added = 0
|
||||
var removed = 0
|
||||
|
||||
for (line in diff.split("\n")) {
|
||||
when {
|
||||
line.startsWith("+") && !line.startsWith("+++") -> added += 1
|
||||
line.startsWith("-") && !line.startsWith("---") -> removed += 1
|
||||
}
|
||||
}
|
||||
|
||||
return DiffStats(added = added, removed = removed)
|
||||
}
|
||||
|
||||
/** Subtitle shared by read_file and the edit family: the path, or a summary. */
|
||||
fun pathSubtitle(
|
||||
call: ToolCall,
|
||||
path: String,
|
||||
): String = path.ifEmpty { ToolJson.fallbackDetailText(call.args, call.rawResult) }
|
||||
}
|
||||
|
||||
/** `read_file`: "Read name.kt L10-14" title with the file content as detail. */
|
||||
internal object ReadFileRenderer : ToolRenderer {
|
||||
private fun displayTarget(
|
||||
args: JsonObject?,
|
||||
result: JsonObject?,
|
||||
): String {
|
||||
val inherited = ToolJson.firstString(args, listOf("context", "preview"))
|
||||
if (inherited.isNotEmpty()) {
|
||||
return inherited
|
||||
}
|
||||
|
||||
val path = ToolJson.firstString(args, listOf("path", "file", "filepath"))
|
||||
if (path.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val offset = ToolJson.intValue(args?.get("offset"))
|
||||
val limit = ToolJson.intValue(args?.get("limit"))
|
||||
var lineLabel = ""
|
||||
|
||||
if (offset != null) {
|
||||
lineLabel = if (limit == null || limit <= 1) "L$offset" else "L$offset-${offset + limit - 1}"
|
||||
} else if (limit != null) {
|
||||
val content = ToolJson.firstString(result, listOf("content"))
|
||||
val lines =
|
||||
content
|
||||
.split("\n")
|
||||
.mapNotNull { line ->
|
||||
Regex("^(\\d+)\\|")
|
||||
.find(line)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.toIntOrNull()
|
||||
}
|
||||
|
||||
if (lines.isNotEmpty()) {
|
||||
lineLabel =
|
||||
if (lines.first() == lines.last()) {
|
||||
"L${lines.first()}"
|
||||
} else {
|
||||
"L${lines.first()}-${lines.last()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return listOf(FileEditSupport.basename(path), lineLabel).filter { it.isNotEmpty() }.joinToString(" ")
|
||||
}
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String = "Reading ${displayTarget(call.args, null)}"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Read ${displayTarget(call.args, call.result)}"
|
||||
|
||||
override fun subtitle(call: ToolCall): String =
|
||||
FileEditSupport.pathSubtitle(call, ToolJson.firstString(call.args, listOf("path", "file", "filepath")))
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
if (call.rawResult == null) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ToolJson.firstString(call.result, listOf("content", "text", "data", "body"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `edit_file` / `patch` / `write_file`: basename title, path subtitle, and
|
||||
* the inline diff (with +/− stats) as the primary payload. Text detail only
|
||||
* appears when there is no diff to render.
|
||||
*/
|
||||
internal object FileEditRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String? {
|
||||
val path = FileEditSupport.editPath(call.args, call.result)
|
||||
|
||||
return if (path.isNotEmpty()) FileEditSupport.basename(path) else null
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String =
|
||||
FileEditSupport.pathSubtitle(call, FileEditSupport.editPath(call.args, call.result))
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
if (FileEditSupport.inlineDiffFromResult(call.rawResult).isNotEmpty()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ToolJson.firstString(call.result, listOf("message", "summary")).ifEmpty {
|
||||
if (FileEditSupport.editPath(call.args, call.result).isNotEmpty()) {
|
||||
""
|
||||
} else {
|
||||
ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras {
|
||||
val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult)
|
||||
|
||||
if (inlineDiff.isEmpty()) {
|
||||
return ToolViewExtras.NONE
|
||||
}
|
||||
|
||||
return ToolViewExtras(
|
||||
inlineDiff = inlineDiff,
|
||||
diffPath = FileEditSupport.editPath(call.args, call.result),
|
||||
diffStats = FileEditSupport.countDiffLineStats(inlineDiff),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
|
||||
/** `image_generate`: generated-image URL with the prompt in the detail. */
|
||||
internal object ImageGenerateRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val imageUrl =
|
||||
ToolJson
|
||||
.firstString(call.result, listOf("image"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("image_url")) }
|
||||
val modality = ToolJson.firstString(call.result, listOf("modality"))
|
||||
|
||||
return listOf(imageUrl.take(60), modality.takeIf { it.isNotEmpty() }?.let { "($it)" })
|
||||
.filterNotNull()
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val error = ToolJson.firstString(call.result, listOf("error"))
|
||||
val imageUrl = ToolJson.firstString(call.result, listOf("image"))
|
||||
val prompt = ToolJson.firstString(call.args, listOf("prompt"))
|
||||
|
||||
return buildString {
|
||||
if (error.isNotEmpty()) {
|
||||
append("❌ $error")
|
||||
if (imageUrl.isNotEmpty()) append("\n🔗 $imageUrl")
|
||||
} else if (imageUrl.isNotEmpty()) {
|
||||
append("🖼️ ")
|
||||
if (prompt.isNotEmpty()) append("$prompt\n")
|
||||
append("\n🔗 $imageUrl")
|
||||
} else {
|
||||
append("✅ Generated")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `vision_analyze`: the analyzed image URL and the model's description. */
|
||||
internal object VisionAnalyzeRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("image_url")).take(60)
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val error = ToolJson.firstString(call.result, listOf("error"))
|
||||
val description = ToolJson.firstString(call.result, listOf("description"))
|
||||
val content = ToolJson.firstString(call.result, listOf("content"))
|
||||
|
||||
return when {
|
||||
error.isNotEmpty() -> "❌ $error"
|
||||
description.isNotEmpty() -> description
|
||||
content.isNotEmpty() -> content
|
||||
else -> "No description available"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
|
||||
/** `memory`: action-verb titles that keep the write target visible. */
|
||||
internal object MemoryRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Saving"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String {
|
||||
val action = ToolJson.firstString(call.args, listOf("action")).lowercase()
|
||||
val target = ToolJson.firstString(call.args, listOf("target"))
|
||||
val base =
|
||||
when (action) {
|
||||
"replace", "update" -> "Memory Updated"
|
||||
"remove", "delete" -> "Memory Removed"
|
||||
"add" -> "Memory Saved"
|
||||
else -> "Memory ${ToolJson.firstString(call.args, listOf("action"))}"
|
||||
}
|
||||
|
||||
// Keep the target visible — "Memory Saved (user)" — like the
|
||||
// pre-engine mobile summary did.
|
||||
return if (target.isNotEmpty()) "$base ($target)" else base
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error"))
|
||||
|
||||
override fun detail(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error"))
|
||||
}
|
||||
|
||||
/** `fact_store`: add/remove/update acks and fact listings with trust scores. */
|
||||
internal object FactStoreRenderer : ToolRenderer {
|
||||
private fun facts(call: ToolCall): JsonArray? =
|
||||
(call.result?.get("results") as? JsonArray)
|
||||
?: (call.result?.get("facts") as? JsonArray)
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val args = call.args
|
||||
val result = call.result
|
||||
val action = ToolJson.firstString(args, listOf("action")).ifEmpty { ToolJson.firstString(result, listOf("action")) }
|
||||
val entity = ToolJson.firstString(args, listOf("entity"))
|
||||
val query = ToolJson.firstString(args, listOf("query"))
|
||||
val facts = facts(call)
|
||||
val count = ToolJson.intValue(result?.get("count")) ?: facts?.size ?: 0
|
||||
val status = ToolJson.firstString(result, listOf("status"))
|
||||
val factId = ToolJson.intValue(result?.get("fact_id"))
|
||||
val removed = ToolJson.boolTrue(result?.get("removed"))
|
||||
val updated = ToolJson.boolTrue(result?.get("updated"))
|
||||
|
||||
val actionContext =
|
||||
when {
|
||||
entity.isNotEmpty() -> " ($action: $entity)"
|
||||
query.isNotEmpty() -> " ($action: $query)"
|
||||
else -> " ($action)"
|
||||
}
|
||||
|
||||
return when {
|
||||
status == "added" && factId != null -> "Fact added (ID: $factId)"
|
||||
status == "added" -> "Fact added"
|
||||
removed -> "Fact removed"
|
||||
updated -> "Fact updated"
|
||||
facts != null -> "$count facts$actionContext"
|
||||
else -> action
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
facts(call)
|
||||
?.mapNotNull { el ->
|
||||
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val fid = ToolJson.intValue(item["fact_id"]) ?: 0
|
||||
val content = ToolJson.firstString(item, listOf("content"))
|
||||
val category = ToolJson.firstString(item, listOf("category"))
|
||||
val trust = ToolJson.numberValue(item["trust_score"])
|
||||
val tags = ToolJson.firstString(item, listOf("tags"))
|
||||
|
||||
val metaParts = mutableListOf<String>()
|
||||
if (category.isNotEmpty()) metaParts += "[$category]"
|
||||
if (trust != null) metaParts += "trust: ${"%.2f".format(trust)}"
|
||||
if (tags.isNotEmpty()) metaParts += "🏷️ $tags"
|
||||
|
||||
if (metaParts.isNotEmpty()) {
|
||||
"#$fid $content\n ${metaParts.joinToString(" ")}"
|
||||
} else {
|
||||
"#$fid $content"
|
||||
}
|
||||
}?.joinToString("\n")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: ""
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** `project_list`: project roster with the active project starred. */
|
||||
internal object ProjectListRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val projects = (call.result?.get("projects") as? JsonArray)?.size
|
||||
val active = ToolJson.firstString(call.result, listOf("active_id"))
|
||||
|
||||
return if (projects != null) {
|
||||
"$projects projects${active.takeIf { it.isNotEmpty() }?.let { " (1 active)" } ?: ""}"
|
||||
} else {
|
||||
"Projects"
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
(call.result?.get("projects") as? JsonArray)
|
||||
?.mapNotNull { el ->
|
||||
val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val name = ToolJson.firstString(p, listOf("name"))
|
||||
val slug = ToolJson.firstString(p, listOf("slug"))
|
||||
val path = ToolJson.firstString(p, listOf("primary_path"))
|
||||
val isActive =
|
||||
(p["active"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
|
||||
val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name")
|
||||
if (slug.isNotEmpty()) lines += " 🏷️ $slug"
|
||||
if (path.isNotEmpty()) lines += " 📍 $path"
|
||||
if (isActive) lines += " ✅ ACTIVE PROJECT"
|
||||
lines.joinToString("\n")
|
||||
}?.joinToString("\n\n")
|
||||
?: ""
|
||||
}
|
||||
|
||||
/** `project_create` / `project_switch`: mutation ack with name/slug/path. */
|
||||
internal object ProjectMutateRenderer : ToolRenderer {
|
||||
private fun actionLabel(call: ToolCall): String = if (call.name == "project_create") "Created" else "Switched to"
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val name = ToolJson.firstString(call.result, listOf("name"))
|
||||
val label = actionLabel(call)
|
||||
|
||||
return if (name.isNotEmpty()) "$label $name" else label
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val error = ToolJson.firstString(call.result, listOf("error"))
|
||||
val name = ToolJson.firstString(call.result, listOf("name"))
|
||||
val slug = ToolJson.firstString(call.result, listOf("slug"))
|
||||
val path = ToolJson.firstString(call.result, listOf("primary_path"))
|
||||
|
||||
return buildString {
|
||||
if (error.isNotEmpty()) {
|
||||
append("❌ $error")
|
||||
} else {
|
||||
append("✅ ${actionLabel(call)}")
|
||||
if (name.isNotEmpty()) append(": $name")
|
||||
if (slug.isNotEmpty()) append("\n 🏷️ $slug")
|
||||
if (path.isNotEmpty()) append("\n 📍 $path")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** `skills_list`: count-with-category subtitle and a name/description list. */
|
||||
internal object SkillsListRenderer : ToolRenderer {
|
||||
private fun skills(call: ToolCall): JsonArray? =
|
||||
(call.result?.get("skills") as? JsonArray)
|
||||
?: (call.result?.get("results") as? JsonArray)
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val category = ToolJson.firstString(call.args, listOf("category"))
|
||||
val count = skills(call)?.size ?: 0
|
||||
|
||||
return if (count > 0) {
|
||||
"$count skills${category.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""}"
|
||||
} else {
|
||||
"No skills found"
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
skills(call)
|
||||
?.mapNotNull { el ->
|
||||
val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val name = ToolJson.firstString(s, listOf("name"))
|
||||
val desc = ToolJson.firstString(s, listOf("description"))
|
||||
val cat = ToolJson.firstString(s, listOf("category"))
|
||||
val lines = mutableListOf("📌 $name")
|
||||
if (desc.isNotEmpty()) lines += " $desc"
|
||||
if (cat.isNotEmpty()) lines += " [$cat]"
|
||||
lines.joinToString("\n")
|
||||
}?.joinToString("\n\n")
|
||||
?: "No skills found"
|
||||
}
|
||||
|
||||
/** `skill_view`: content preview capped at 500 chars plus linked files. */
|
||||
internal object SkillViewRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("name"))
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val content = ToolJson.firstString(call.result, listOf("content"))
|
||||
val linkedFiles = call.result?.get("linked_files") as? JsonObject
|
||||
val contentPreview =
|
||||
if (content.length > 500) {
|
||||
"${content.take(
|
||||
500,
|
||||
)}\n... [${content.length - 500} more chars]"
|
||||
} else {
|
||||
content
|
||||
}
|
||||
val filesInfo =
|
||||
linkedFiles
|
||||
?.entries
|
||||
?.joinToString("\n") { (k, v) ->
|
||||
val paths =
|
||||
when (v) {
|
||||
is JsonArray -> {
|
||||
v.joinToString(
|
||||
", ",
|
||||
) { (it as? JsonPrimitive)?.content ?: it.toString() }
|
||||
}
|
||||
|
||||
is JsonPrimitive -> {
|
||||
v.content
|
||||
}
|
||||
|
||||
else -> {
|
||||
v.toString()
|
||||
}
|
||||
}
|
||||
" 📎 $k: $paths"
|
||||
}
|
||||
|
||||
return buildString {
|
||||
if (contentPreview.isNotEmpty()) append(contentPreview)
|
||||
if (filesInfo != null) {
|
||||
if (contentPreview.isNotEmpty()) append("\n\n")
|
||||
append("━━━ Linked Files ━━━\n$filesInfo")
|
||||
}
|
||||
if (content.isEmpty() && linkedFiles == null) append("No content available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `skill_manage`: action ack with the skill name. */
|
||||
internal object SkillManageRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val action = ToolJson.firstString(call.args, listOf("action"))
|
||||
val name = ToolJson.firstString(call.args, listOf("name"))
|
||||
|
||||
return if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val error = ToolJson.firstString(call.result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val msg = ToolJson.firstString(call.result, listOf("message"))
|
||||
val failed = ToolJson.boolFalse(call.result?.get("success"))
|
||||
|
||||
// Pinned quirk from the original engine: the branch order is inverted
|
||||
// (a non-false `success` renders the failure line). Preserved verbatim
|
||||
// by the characterization suite; fix upstream deliberately if desired.
|
||||
return when {
|
||||
!failed -> "❌ Skill operation failed"
|
||||
msg.isNotEmpty() -> "✅ $msg"
|
||||
else -> "✅ Skill operation done"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `tool_search`: on-demand tool discovery matches. */
|
||||
internal object ToolSearchRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val query = ToolJson.firstString(call.args, listOf("query"))
|
||||
val matches = (call.result?.get("matches") as? JsonArray)?.size
|
||||
|
||||
return if (matches != null) "$query ($matches matches)" else query
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
(call.result?.get("matches") as? JsonArray)
|
||||
?.mapNotNull { el ->
|
||||
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val name = ToolJson.firstString(m, listOf("name"))
|
||||
val desc = ToolJson.firstString(m, listOf("description"))
|
||||
val lines = mutableListOf("🔧 $name")
|
||||
if (desc.isNotEmpty()) lines += " $desc"
|
||||
lines.joinToString("\n")
|
||||
}?.joinToString("\n\n")
|
||||
?: "No matching tools"
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** `cronjob`: job listings with schedules, or the mutated job's key fields. */
|
||||
internal object CronjobRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String =
|
||||
"Cron ${ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" }}"
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val jobs = call.result?.get("jobs") as? JsonArray
|
||||
|
||||
if (jobs != null) {
|
||||
return if (jobs.isNotEmpty()) {
|
||||
"${jobs.size} cron ${if (jobs.size == 1) "job" else "jobs"}"
|
||||
} else {
|
||||
"No cron jobs"
|
||||
}
|
||||
}
|
||||
|
||||
val message = ToolJson.firstString(call.result, listOf("message"))
|
||||
if (message.isNotEmpty()) {
|
||||
return message
|
||||
}
|
||||
|
||||
val action = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" }
|
||||
val name =
|
||||
ToolJson
|
||||
.firstString(call.result, listOf("name"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("name", "job_id")) }
|
||||
val label = action.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
|
||||
return if (name.isNotEmpty()) "$label $name" else "Cron $action"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val jobs = result?.get("jobs") as? JsonArray
|
||||
|
||||
if (jobs != null) {
|
||||
if (jobs.isEmpty()) {
|
||||
return "No cron jobs scheduled"
|
||||
}
|
||||
|
||||
return jobs
|
||||
.take(20)
|
||||
.mapNotNull { job ->
|
||||
val row = ToolJson.parseMaybeObject(job) ?: return@mapNotNull null
|
||||
val name = ToolJson.firstString(row, listOf("name", "id")).ifEmpty { "job" }
|
||||
val sched = ToolJson.firstString(row, listOf("schedule_display", "schedule"))
|
||||
if (sched.isNotEmpty()) "- $name · $sched" else "- $name"
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
val rows =
|
||||
listOf(
|
||||
"Schedule" to ToolJson.firstString(result, listOf("schedule")),
|
||||
"Repeat" to ToolJson.firstString(result, listOf("repeat")),
|
||||
"Delivery" to ToolJson.firstString(result, listOf("deliver")),
|
||||
"Next run" to ToolJson.firstString(result, listOf("next_run_at")),
|
||||
).filter { it.second.isNotEmpty() }
|
||||
|
||||
return if (rows.isNotEmpty()) {
|
||||
rows.joinToString(
|
||||
"\n",
|
||||
) { (k, v) -> "$k: $v" }
|
||||
} else {
|
||||
ToolJson.fallbackDetailText(call.args, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `todo`: status-count subtitle plus a checklist with [x]/[>]/[~] markers. */
|
||||
internal object TodoRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val summary = call.result?.get("summary") as? JsonObject ?: return ""
|
||||
val total = ToolJson.intValue(summary["total"]) ?: 0
|
||||
val pending = ToolJson.intValue(summary["pending"]) ?: 0
|
||||
val inProgress = ToolJson.intValue(summary["in_progress"]) ?: 0
|
||||
val completed = ToolJson.intValue(summary["completed"]) ?: 0
|
||||
val cancelled = ToolJson.intValue(summary["cancelled"]) ?: 0
|
||||
|
||||
val parts =
|
||||
listOf(
|
||||
pending.takeIf { it > 0 }?.let { "$it pending" },
|
||||
inProgress.takeIf { it > 0 }?.let { "$it in_progress" },
|
||||
completed.takeIf { it > 0 }?.let { "$it completed" },
|
||||
cancelled.takeIf { it > 0 }?.let { "$it cancelled" },
|
||||
).filterNotNull()
|
||||
|
||||
val itemWord = if (total == 1) "item" else "items"
|
||||
|
||||
return if (parts.isEmpty()) "0 items" else "$total $itemWord (${parts.joinToString(", ")})"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val todos = call.result?.get("todos") as? JsonArray ?: return ""
|
||||
|
||||
return todos
|
||||
.mapNotNull { el ->
|
||||
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val id = ToolJson.firstString(item, listOf("id"))
|
||||
val content = ToolJson.firstString(item, listOf("content"))
|
||||
val status = ToolJson.firstString(item, listOf("status")).ifEmpty { "pending" }
|
||||
val parent = ToolJson.firstString(item, listOf("parent"))
|
||||
val marker =
|
||||
when (status) {
|
||||
"completed" -> "[x]"
|
||||
"in_progress" -> "[>]"
|
||||
"cancelled" -> "[~]"
|
||||
else -> "[ ]"
|
||||
}
|
||||
val indent = if (parent.isNotEmpty()) " ↳ " else ""
|
||||
"$indent$marker $id. $content"
|
||||
}.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/** `session_search`: mode-specific subtitles and rich session/message lists. */
|
||||
internal object SessionSearchRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val mode = ToolJson.firstString(result, listOf("mode"))
|
||||
val query = ToolJson.firstString(result, listOf("query"))
|
||||
val count = ToolJson.intValue(result?.get("count"))
|
||||
val msgCount = ToolJson.intValue(result?.get("message_count"))
|
||||
val messages = result?.get("messages") as? JsonArray
|
||||
val truncated = ToolJson.boolTrue(result?.get("truncated"))
|
||||
|
||||
return when (mode) {
|
||||
"discover" -> "${count ?: 0} session${if (count == 1) "" else "s"}${query.takeIf { it.isNotEmpty() }?.let {
|
||||
": ${it.take(
|
||||
60,
|
||||
)}"
|
||||
} ?: ""}"
|
||||
|
||||
"scroll" -> "${messages?.size ?: 0} messages (scroll)"
|
||||
|
||||
"read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}"
|
||||
|
||||
"browse" -> "${count ?: 0} recent sessions"
|
||||
|
||||
else -> "session_search"
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val results = result?.get("results") as? JsonArray
|
||||
val messages = result?.get("messages") as? JsonArray
|
||||
|
||||
val formattedResults =
|
||||
results
|
||||
?.mapIndexedNotNull { idx, el ->
|
||||
val item = ToolJson.parseMaybeObject(el) ?: return@mapIndexedNotNull null
|
||||
val whenField = ToolJson.firstString(item, listOf("when", "started_at"))
|
||||
val source = ToolJson.firstString(item, listOf("source"))
|
||||
val title = ToolJson.firstString(item, listOf("title"))
|
||||
val snippet =
|
||||
ToolJson
|
||||
.firstString(item, listOf("snippet"))
|
||||
.ifEmpty { ToolJson.firstString(item, listOf("preview")) }
|
||||
val model = ToolJson.firstString(item, listOf("model"))
|
||||
val matchedRole = ToolJson.firstString(item, listOf("matched_role"))
|
||||
val sessionMsgCount = ToolJson.intValue(item["message_count"])
|
||||
|
||||
val header = whenField.takeIf { it.isNotEmpty() }?.let { "📅 $it" } ?: ""
|
||||
val sourceTag = source.takeIf { it.isNotEmpty() }?.let { "[$it]" } ?: ""
|
||||
val titleLine = title.takeIf { it.isNotEmpty() }?.let { "\n 📄 $it" } ?: ""
|
||||
val modelLine = model.takeIf { it.isNotEmpty() }?.let { "\n 🤖 $it" } ?: ""
|
||||
val matchedLine = matchedRole.takeIf { it.isNotEmpty() }?.let { "\n 🎯 matched: $it" } ?: ""
|
||||
val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: ""
|
||||
val snippetLine =
|
||||
snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: ""
|
||||
|
||||
"━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine"
|
||||
}?.joinToString("\n")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
val anchorId = ToolJson.intValue(result?.get("around_message_id"))
|
||||
|
||||
val formattedMessages =
|
||||
messages
|
||||
?.mapNotNull { el ->
|
||||
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val msgId = ToolJson.intValue(item["id"])
|
||||
val role = ToolJson.firstString(item, listOf("role")).ifEmpty { "?" }
|
||||
val content = ToolJson.firstString(item, listOf("content"))
|
||||
val toolName = ToolJson.firstString(item, listOf("tool_name"))
|
||||
|
||||
val roleEmoji =
|
||||
when (role) {
|
||||
"user" -> "👤"
|
||||
"assistant" -> "🤖"
|
||||
"tool" -> "🔧"
|
||||
else -> "❓"
|
||||
}
|
||||
val namePart = if (toolName.isNotEmpty()) " ($toolName)" else ""
|
||||
val anchor = if (anchorId != null && msgId == anchorId) " ⬅️" else ""
|
||||
val cleanContent = content.take(300).replace("\n", " ")
|
||||
|
||||
"[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent"
|
||||
}?.joinToString("\n")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
return formattedResults ?: formattedMessages ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
/** `process`: background session listings and lifecycle acks. */
|
||||
internal object ProcessRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val action = ToolJson.firstString(call.args, listOf("action"))
|
||||
val procId = ToolJson.firstString(call.args, listOf("session_id"))
|
||||
|
||||
return if (procId.isNotEmpty()) "$action: $procId" else action
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val processes = result?.get("processes") as? JsonArray
|
||||
if (processes != null) {
|
||||
return processes
|
||||
.mapNotNull { el ->
|
||||
val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val pid =
|
||||
ToolJson
|
||||
.firstString(p, listOf("session_id"))
|
||||
.ifEmpty { ToolJson.firstString(p, listOf("id")) }
|
||||
val pStatus = ToolJson.firstString(p, listOf("status"))
|
||||
val cmd = ToolJson.firstString(p, listOf("command"))
|
||||
val running =
|
||||
(p["running"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" }
|
||||
val parts = mutableListOf("📌 $pid${cmd.takeIf { it.isNotEmpty() }?.let { ": $it" } ?: ""}")
|
||||
if (pStatus.isNotEmpty()) parts += " Status: $pStatus"
|
||||
if (running != null) parts += " Running: $running"
|
||||
parts.joinToString("\n")
|
||||
}.joinToString("\n\n")
|
||||
}
|
||||
|
||||
val output = ToolJson.firstString(result, listOf("output"))
|
||||
if (output.isNotEmpty()) {
|
||||
val status = ToolJson.firstString(result, listOf("status"))
|
||||
return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output"
|
||||
}
|
||||
|
||||
val status = ToolJson.firstString(result, listOf("status"))
|
||||
if (status.isNotEmpty()) {
|
||||
return "✅ $status"
|
||||
}
|
||||
|
||||
val action = ToolJson.firstString(call.args, listOf("action"))
|
||||
return "✅ ${action.ifEmpty { "done" }} done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.CommandSummarizer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* `terminal` and `execute_code`: shell-styled rows with a summarized command
|
||||
* title, first output line as subtitle, and split (or merged) streams for
|
||||
* the ANSI-aware expanded view. Exit code and the `$` command line are only
|
||||
* meaningful for `terminal`.
|
||||
*/
|
||||
internal object TerminalRenderer : ToolRenderer {
|
||||
private fun shellCommand(args: JsonObject?): String =
|
||||
ToolJson
|
||||
.firstString(args, listOf("command", "code"))
|
||||
.ifEmpty { ToolJson.firstString(args, listOf("context", "preview")) }
|
||||
.ifEmpty { ToolJson.contextValue(args) }
|
||||
|
||||
private fun shellOutput(result: JsonObject?): String {
|
||||
val output = ToolJson.firstString(result, listOf("output", "stdout", "stderr"))
|
||||
val lines =
|
||||
(result?.get("lines") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||
?.joinToString("\n")
|
||||
?: ""
|
||||
|
||||
return listOf(output, lines).filter { it.isNotEmpty() }.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun commandTitle(
|
||||
call: ToolCall,
|
||||
runningVerb: Boolean,
|
||||
): String? {
|
||||
val command = shellCommand(call.args)
|
||||
val verb =
|
||||
when {
|
||||
call.name == "execute_code" && runningVerb -> "Running code"
|
||||
call.name == "execute_code" -> "Ran code"
|
||||
runningVerb -> "Running"
|
||||
else -> "Ran"
|
||||
}
|
||||
|
||||
if (command.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return "$verb ${ToolJson.compactPreview(CommandSummarizer.summarizeShellCommand(command), 160)}"
|
||||
}
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String? = commandTitle(call, runningVerb = true)
|
||||
|
||||
override fun doneTitle(call: ToolCall): String? = commandTitle(call, runningVerb = false)
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val firstMeaningfulLine =
|
||||
shellOutput(call.result)
|
||||
.split("\n")
|
||||
.map { it.trim() }
|
||||
.firstOrNull { it.isNotEmpty() }
|
||||
|
||||
// A terminal row with no output shows its command in the title;
|
||||
// nothing more to add.
|
||||
return firstMeaningfulLine?.let { ToolJson.compactPreview(it, 160) } ?: ""
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val output = shellOutput(call.result)
|
||||
|
||||
return when {
|
||||
output.isNotEmpty() -> output
|
||||
call.name == "execute_code" -> ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
// A terminal row with no output already shows its command in the
|
||||
// title; the generic fallback would repeat it.
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras {
|
||||
// The gateway's terminal_tool emits a single merged `output` string
|
||||
// (stdout+stderr, ANSI-stripped); older payloads carried separate
|
||||
// `stdout`/`stderr`. Prefer a real stream split, fall back to the
|
||||
// merged output as the single renderable stream.
|
||||
val stdout = ToolJson.firstString(call.result, listOf("stdout", "output"))
|
||||
val stderr = ToolJson.firstString(call.result, listOf("stderr"))
|
||||
val hasSplitStreams = stdout.isNotEmpty() || stderr.isNotEmpty()
|
||||
|
||||
return ToolViewExtras(
|
||||
stdout = if (hasSplitStreams) stdout else null,
|
||||
stderr = if (hasSplitStreams) stderr else null,
|
||||
exitCode = if (call.name == "terminal") ToolJson.intValue(call.result?.get("exit_code")) else null,
|
||||
terminalCommand = if (call.name == "terminal") shellCommand(call.args) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `read_terminal`: a line-window over a live terminal session. */
|
||||
internal object ReadTerminalRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val total = ToolJson.intValue(result?.get("total_lines")) ?: 0
|
||||
val start = ToolJson.intValue(result?.get("start")) ?: 0
|
||||
val end = ToolJson.intValue(result?.get("end")) ?: 0
|
||||
val text = ToolJson.firstString(result, listOf("text"))
|
||||
|
||||
if (text.isEmpty()) {
|
||||
return "Terminal output"
|
||||
}
|
||||
|
||||
val cursor = ToolJson.intValue(result?.get("cursor_row"))
|
||||
|
||||
return "Lines $start-$end of $total${cursor?.let { ", cursor at row $it" } ?: ""}"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
val text = ToolJson.firstString(result, listOf("text"))
|
||||
val total = ToolJson.intValue(result?.get("total_lines")) ?: 0
|
||||
val start = ToolJson.intValue(result?.get("start")) ?: 0
|
||||
val end = ToolJson.intValue(result?.get("end")) ?: 0
|
||||
val cursor = ToolJson.intValue(result?.get("cursor_row"))
|
||||
|
||||
return buildString {
|
||||
if (error.isNotEmpty()) {
|
||||
append("❌ $error")
|
||||
} else if (text.isNotEmpty()) {
|
||||
append("━━━ Terminal ━━━ ($start:$end / $total)")
|
||||
if (cursor != null) append(" │ cursor row $cursor")
|
||||
append("\n$text")
|
||||
} else {
|
||||
append("No terminal output")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.SearchHit
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/** Resolve the URL a web/browser tool targeted, as a display hostname+path. */
|
||||
internal fun targetHostname(
|
||||
args: JsonObject?,
|
||||
result: JsonObject?,
|
||||
): String {
|
||||
val url =
|
||||
ToolJson
|
||||
.firstString(args, listOf("url", "target"))
|
||||
.ifEmpty { ToolJson.firstString(result, listOf("url")) }
|
||||
.ifEmpty { ToolJson.findFirstUrl(args, result) }
|
||||
|
||||
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "page"
|
||||
}
|
||||
|
||||
/**
|
||||
* `web_search`: quoted-query title, parsed [SearchHit] rows for the
|
||||
* structured result list, and a "Search results" section label.
|
||||
*/
|
||||
internal object WebSearchRenderer : ToolRenderer {
|
||||
private fun searchQueryFor(args: JsonObject?): String =
|
||||
ToolJson.firstString(args, listOf("search_term", "query")).ifEmpty { ToolJson.contextValue(args) }
|
||||
|
||||
private fun extractSearchResults(
|
||||
result: JsonElement?,
|
||||
limit: Int = 6,
|
||||
): List<SearchHit> =
|
||||
ToolJson
|
||||
.collectResultItems(result)
|
||||
.mapNotNull { item ->
|
||||
val r = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null
|
||||
|
||||
SearchHit(
|
||||
title = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("title", "name"))),
|
||||
url = ToolJson.firstString(r, listOf("url", "href", "link")),
|
||||
snippet = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("snippet", "description", "body"))),
|
||||
)
|
||||
}.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
|
||||
.take(limit)
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String =
|
||||
"Searching \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\""
|
||||
|
||||
override fun doneTitle(call: ToolCall): String =
|
||||
"Searched \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\""
|
||||
|
||||
override fun subtitle(call: ToolCall): String =
|
||||
searchQueryFor(call.args).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources"
|
||||
|
||||
override fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras {
|
||||
val hits = if (status != ToolViewStatus.ERROR) extractSearchResults(call.rawResult) else emptyList()
|
||||
|
||||
return ToolViewExtras(
|
||||
detailLabel = if (hits.isNotEmpty()) "Search results" else null,
|
||||
searchHits = hits.ifEmpty { null },
|
||||
searchQuery = searchQueryFor(call.args).ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `web_extract`: hostname title/subtitle with the extracted text as detail. */
|
||||
internal object WebExtractRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Reading ${targetHostname(call.args, null)}"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Read ${targetHostname(call.args, call.result)}"
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val url =
|
||||
ToolJson
|
||||
.firstString(call.args, listOf("url"))
|
||||
.ifEmpty { ToolJson.firstString(call.result, listOf("url")) }
|
||||
.ifEmpty { ToolJson.findFirstUrl(call.args, call.result) }
|
||||
|
||||
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Fetched webpage"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val direct =
|
||||
ToolJson.firstString(call.result, listOf("content", "text", "markdown", "body", "summary", "message"))
|
||||
if (direct.isNotEmpty()) {
|
||||
return direct.replace(Regex("\\s*in\\s+\\d+(?:\\.\\d+)?s\\s*$"), "").trim()
|
||||
}
|
||||
|
||||
val results = call.result?.get("results") as? JsonArray
|
||||
if (results != null) {
|
||||
return results
|
||||
.mapNotNull { item ->
|
||||
val row = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null
|
||||
ToolJson.firstString(row, listOf("content", "text", "markdown", "body"))
|
||||
}.filter { it.isNotEmpty() }
|
||||
.joinToString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
return ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
}
|
||||
}
|
||||
|
||||
/** `x_search`: query subtitle plus answer text with numbered citations. */
|
||||
internal object XSearchRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val query = ToolJson.firstString(call.args, listOf("query"))
|
||||
val degraded = ToolJson.boolTrue(call.result?.get("degraded"))
|
||||
|
||||
return if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
val answer = ToolJson.firstString(result, listOf("answer"))
|
||||
val citations = result?.get("citations") as? JsonArray
|
||||
val degraded = ToolJson.boolTrue(result?.get("degraded"))
|
||||
|
||||
return when {
|
||||
error.isNotEmpty() -> {
|
||||
"❌ $error"
|
||||
}
|
||||
|
||||
answer.isNotEmpty() -> {
|
||||
val lines = mutableListOf(answer)
|
||||
if (citations != null && citations.isNotEmpty()) {
|
||||
lines += "\n━━━ Citations ━━━"
|
||||
citations.forEachIndexed { idx, cit ->
|
||||
val c = ToolJson.parseMaybeObject(cit) ?: return@forEachIndexed
|
||||
val url = ToolJson.firstString(c, listOf("url"))
|
||||
val title = ToolJson.firstString(c, listOf("title"))
|
||||
lines += "${idx + 1}. ${title.ifEmpty { url.ifEmpty { "source" } }}"
|
||||
if (title.isNotEmpty() && url.isNotEmpty()) lines += " $url"
|
||||
}
|
||||
}
|
||||
if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge"
|
||||
lines.joinToString("\n")
|
||||
}
|
||||
|
||||
else -> {
|
||||
"No results"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user