feat(chat): upgrade tool rendering, add missing renderers, and clamp output budget (#1086)
This commit is contained in:
11 files changed
+640
-9
No files matched your search
@@ -16,12 +16,15 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
@@ -45,7 +48,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -58,6 +63,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
@@ -84,6 +90,7 @@ internal fun composeToolSummaryLines(
|
||||
): Pair<String, String?>? {
|
||||
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
|
||||
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
|
||||
val statsSuffix = view.diffStats?.let { " (+${it.added}/-${it.removed})" } ?: ""
|
||||
|
||||
val firstLine: String
|
||||
val secondLine: String?
|
||||
@@ -93,6 +100,7 @@ internal fun composeToolSummaryLines(
|
||||
if (subtitle != null) {
|
||||
append(subtitle)
|
||||
}
|
||||
append(statsSuffix)
|
||||
view.countLabel?.let { append(" ($it)") }
|
||||
}
|
||||
secondLine = null
|
||||
@@ -100,6 +108,7 @@ internal fun composeToolSummaryLines(
|
||||
firstLine =
|
||||
buildString {
|
||||
append(view.title)
|
||||
append(statsSuffix)
|
||||
view.countLabel?.let { append(" ($it)") }
|
||||
}
|
||||
secondLine = subtitle
|
||||
@@ -233,6 +242,11 @@ internal fun ToolBubble(
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
@@ -301,6 +315,11 @@ internal fun ToolBubble(
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
@@ -383,6 +402,11 @@ private fun ExpandedToolContent(
|
||||
view.stdout?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
@@ -394,6 +418,11 @@ private fun ExpandedToolContent(
|
||||
view.stderr?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
@@ -408,13 +437,27 @@ private fun ExpandedToolContent(
|
||||
text = stringResource(R.string.chat_tool_exit_code, code),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = statusColors.error,
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Generated/resolved image preview ──
|
||||
view.imageUrl?.let { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = view.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
// ── File diff ──
|
||||
if (view.inlineDiff != null) {
|
||||
DiffViewCard(
|
||||
@@ -479,6 +522,11 @@ private fun ExpandedToolContent(
|
||||
if (view.detail.isNotBlank() && view.inlineDiff == null) {
|
||||
Text(
|
||||
text = view.detail,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
|
||||
@@ -131,6 +131,12 @@ object ToolSchemaRegistry {
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Checklist,
|
||||
),
|
||||
"todo_list" to
|
||||
ToolDisplayConfig(
|
||||
name = "todo_list",
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Checklist,
|
||||
),
|
||||
"fact_store" to
|
||||
ToolDisplayConfig(
|
||||
name = "fact_store",
|
||||
@@ -150,6 +156,12 @@ object ToolSchemaRegistry {
|
||||
summaryArgKey = "action",
|
||||
icon = Icons.Filled.Schedule,
|
||||
),
|
||||
"cronjob_manage" to
|
||||
ToolDisplayConfig(
|
||||
name = "cronjob_manage",
|
||||
summaryArgKey = "action",
|
||||
icon = Icons.Filled.Schedule,
|
||||
),
|
||||
"memory" to
|
||||
ToolDisplayConfig(
|
||||
name = "memory",
|
||||
@@ -168,6 +180,12 @@ object ToolSchemaRegistry {
|
||||
summaryArgKey = "action",
|
||||
icon = Icons.Filled.Settings,
|
||||
),
|
||||
"process_manage" to
|
||||
ToolDisplayConfig(
|
||||
name = "process_manage",
|
||||
summaryArgKey = "action",
|
||||
icon = Icons.Filled.Settings,
|
||||
),
|
||||
"skill_manage" to
|
||||
ToolDisplayConfig(
|
||||
name = "skill_manage",
|
||||
@@ -187,6 +205,42 @@ object ToolSchemaRegistry {
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Keyboard,
|
||||
),
|
||||
"browser_scroll" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_scroll",
|
||||
summaryArgKey = "direction",
|
||||
icon = Icons.Filled.TouchApp,
|
||||
),
|
||||
"browser_back" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_back",
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Language,
|
||||
),
|
||||
"browser_press" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_press",
|
||||
summaryArgKey = "key",
|
||||
icon = Icons.Filled.Keyboard,
|
||||
),
|
||||
"browser_get_images" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_get_images",
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Image,
|
||||
),
|
||||
"browser_vision" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_vision",
|
||||
summaryArgKey = "question",
|
||||
icon = Icons.Filled.Visibility,
|
||||
),
|
||||
"browser_console" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_console",
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Terminal,
|
||||
),
|
||||
"browser_cdp" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_cdp",
|
||||
|
||||
@@ -16,6 +16,18 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
internal object ToolJson {
|
||||
private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE)
|
||||
|
||||
const val MAX_TOOL_RENDER_CHARS = 20_000
|
||||
|
||||
fun clampForDisplay(
|
||||
value: String,
|
||||
max: Int = MAX_TOOL_RENDER_CHARS,
|
||||
): String {
|
||||
if (value.length <= max) return value
|
||||
val omitted = value.length - max
|
||||
return value.take(max) +
|
||||
"\n… $omitted more characters truncated — use Copy for the full output."
|
||||
}
|
||||
|
||||
/** First non-blank string value among [keys], trimmed. */
|
||||
fun firstString(
|
||||
record: JsonObject?,
|
||||
@@ -294,7 +306,11 @@ internal object ToolJson {
|
||||
}
|
||||
|
||||
fun durationLabel(result: JsonObject?): String? {
|
||||
val seconds = numberValue(result?.get("duration_s")) ?: return null
|
||||
val seconds =
|
||||
numberValue(result?.get("duration_s"))
|
||||
?: numberValue(result?.get("duration_seconds"))
|
||||
?: numberValue(result?.get("total_duration_seconds"))
|
||||
?: return null
|
||||
|
||||
return formatDurationSeconds(seconds)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer
|
||||
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.BrowserVisionRenderer
|
||||
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.DelegateTaskRenderer
|
||||
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
|
||||
@@ -15,11 +20,13 @@ 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.SearchFilesRenderer
|
||||
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.TextToSpeechRenderer
|
||||
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
|
||||
@@ -44,11 +51,14 @@ internal object ToolRendererRegistry {
|
||||
"terminal" to TerminalRenderer,
|
||||
"execute_code" to TerminalRenderer,
|
||||
"read_terminal" to ReadTerminalRenderer,
|
||||
// Delegation
|
||||
"delegate_task" to DelegateTaskRenderer,
|
||||
// Files
|
||||
"read_file" to ReadFileRenderer,
|
||||
"edit_file" to FileEditRenderer,
|
||||
"patch" to FileEditRenderer,
|
||||
"write_file" to FileEditRenderer,
|
||||
"search_files" to SearchFilesRenderer,
|
||||
// Web
|
||||
"web_search" to WebSearchRenderer,
|
||||
"web_extract" to WebExtractRenderer,
|
||||
@@ -59,14 +69,23 @@ internal object ToolRendererRegistry {
|
||||
"browser_click" to BrowserClickRenderer,
|
||||
"browser_fill" to BrowserTypeRenderer,
|
||||
"browser_type" to BrowserTypeRenderer,
|
||||
"browser_scroll" to BrowserActionRenderer,
|
||||
"browser_back" to BrowserActionRenderer,
|
||||
"browser_press" to BrowserActionRenderer,
|
||||
"browser_get_images" to BrowserImagesRenderer,
|
||||
"browser_vision" to BrowserVisionRenderer,
|
||||
"browser_console" to BrowserConsoleRenderer,
|
||||
// Memory / facts
|
||||
"memory" to MemoryRenderer,
|
||||
"fact_store" to FactStoreRenderer,
|
||||
// Task management
|
||||
"cronjob" to CronjobRenderer,
|
||||
"cronjob_manage" to CronjobRenderer,
|
||||
"todo" to TodoRenderer,
|
||||
"todo_list" to TodoRenderer,
|
||||
"session_search" to SessionSearchRenderer,
|
||||
"process" to ProcessRenderer,
|
||||
"process_manage" to ProcessRenderer,
|
||||
// Skills / tool discovery
|
||||
"skills_list" to SkillsListRenderer,
|
||||
"skill_view" to SkillViewRenderer,
|
||||
@@ -75,6 +94,7 @@ internal object ToolRendererRegistry {
|
||||
// Media
|
||||
"image_generate" to ImageGenerateRenderer,
|
||||
"vision_analyze" to VisionAnalyzeRenderer,
|
||||
"text_to_speech" to TextToSpeechRenderer,
|
||||
// Projects
|
||||
"project_list" to ProjectListRenderer,
|
||||
"project_create" to ProjectMutateRenderer,
|
||||
|
||||
@@ -89,19 +89,24 @@ object ToolViewBuilder {
|
||||
ToolCounts.countLabelFor(call)
|
||||
}
|
||||
|
||||
val clampedDetail = ToolJson.clampForDisplay(detail)
|
||||
val clampedStdout = extras.stdout?.let { ToolJson.clampForDisplay(it) }
|
||||
val clampedStderr = extras.stderr?.let { ToolJson.clampForDisplay(it) }
|
||||
val clampedDiff = extras.inlineDiff?.ifEmpty { null }?.let { ToolJson.clampForDisplay(it) }
|
||||
|
||||
return ToolView(
|
||||
status = status,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
detail = detail,
|
||||
detail = clampedDetail,
|
||||
detailLabel = extras.detailLabel,
|
||||
countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" },
|
||||
durationLabel = ToolJson.durationLabel(call.result),
|
||||
stdout = extras.stdout,
|
||||
stderr = extras.stderr,
|
||||
stdout = clampedStdout,
|
||||
stderr = clampedStderr,
|
||||
exitCode = extras.exitCode,
|
||||
terminalCommand = extras.terminalCommand,
|
||||
inlineDiff = extras.inlineDiff?.ifEmpty { null },
|
||||
inlineDiff = clampedDiff,
|
||||
diffPath = extras.diffPath,
|
||||
diffStats = extras.diffStats,
|
||||
imageUrl = imageUrlFor(call.args, call.result),
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
|
||||
/** `browser_navigate`: hostname titles, including the failed-to-open form. */
|
||||
internal object BrowserNavigateRenderer : ToolRenderer {
|
||||
@@ -104,3 +105,98 @@ internal object BrowserTypeRenderer : ToolRenderer {
|
||||
.ifEmpty { "Filled page input" }
|
||||
}
|
||||
}
|
||||
|
||||
/** browser_scroll / browser_back / browser_press: one-line acks. */
|
||||
internal object BrowserActionRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String? =
|
||||
when (call.name) {
|
||||
"browser_scroll" -> {
|
||||
"Scrolled ${ToolJson.firstString(call.result, listOf("scrolled"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("direction")) }.ifEmpty { "page" }}"
|
||||
}
|
||||
|
||||
"browser_back" -> {
|
||||
val url = ToolJson.firstString(call.result, listOf("url"))
|
||||
if (url.isNotEmpty()) "Went back to ${ToolJson.hostnameOf(url)}" else "Went back"
|
||||
}
|
||||
|
||||
"browser_press" -> {
|
||||
"Pressed ${ToolJson.firstString(call.result, listOf("pressed"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("key")) }}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String = ""
|
||||
|
||||
override fun detail(call: ToolCall): String = ""
|
||||
}
|
||||
|
||||
/** browser_get_images: image inventory of the current page. */
|
||||
internal object BrowserImagesRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String {
|
||||
val n = ToolJson.intValue(call.result?.get("count")) ?: 0
|
||||
return "Found $n image${if (n == 1) "" else "s"}"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
(call.result?.get("images") as? JsonArray)
|
||||
?.take(12)
|
||||
?.mapNotNull { el ->
|
||||
val img = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val src = ToolJson.firstString(img, listOf("src"))
|
||||
val alt = ToolJson.firstString(img, listOf("alt"))
|
||||
listOf(alt, src)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" — ")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { "- $it" }
|
||||
}?.joinToString("\n") ?: ""
|
||||
}
|
||||
|
||||
/** browser_vision: screenshot Q&A — the analysis text is the payload. */
|
||||
internal object BrowserVisionRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Looking at page"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Analyzed page"
|
||||
|
||||
override fun subtitle(call: ToolCall): String =
|
||||
ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("question")), 120)
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
ToolJson
|
||||
.firstString(call.result, listOf("analysis"))
|
||||
.ifEmpty { ToolJson.firstString(call.result, listOf("note")) }
|
||||
}
|
||||
|
||||
/** browser_console: message/error counts, or a JS eval result. */
|
||||
internal object BrowserConsoleRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String? {
|
||||
val result = call.result ?: return null
|
||||
if (result.containsKey("result")) return "Evaluated JS"
|
||||
val msgs = ToolJson.intValue(result["total_messages"]) ?: 0
|
||||
val errs = ToolJson.intValue(result["total_errors"]) ?: 0
|
||||
return "Console: $msgs message${if (msgs == 1) "" else "s"}" +
|
||||
if (errs > 0) ", $errs error${if (errs == 1) "" else "s"}" else ""
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result ?: return ""
|
||||
result["result"]?.let { return it.toString() }
|
||||
val lines =
|
||||
(result["console_messages"] as? JsonArray)?.take(30)?.mapNotNull { el ->
|
||||
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
"[${ToolJson.firstString(m, listOf("type")).ifEmpty { "log" }}] " +
|
||||
ToolJson.compactPreview(ToolJson.firstString(m, listOf("text")), 200)
|
||||
} ?: emptyList()
|
||||
val errors =
|
||||
(result["js_errors"] as? JsonArray)?.take(10)?.mapNotNull { el ->
|
||||
val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
"[error] ${ToolJson.compactPreview(ToolJson.firstString(e, listOf("message")), 200)}"
|
||||
} ?: emptyList()
|
||||
return (errors + lines).joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* `delegate_task`: fan-out of subagent tasks.
|
||||
* Covers joined results, background dispatch handles, and list/steer/stop acks.
|
||||
*/
|
||||
internal object DelegateTaskRenderer : ToolRenderer {
|
||||
private fun goals(call: ToolCall): List<String> {
|
||||
val tasks = call.args?.get("tasks") as? JsonArray
|
||||
val fromTasks =
|
||||
tasks?.mapIndexedNotNull { i, el ->
|
||||
ToolJson
|
||||
.parseMaybeObject(el)
|
||||
?.let { ToolJson.firstString(it, listOf("goal")) }
|
||||
?.ifEmpty { "Task ${i + 1}" }
|
||||
} ?: emptyList()
|
||||
if (fromTasks.isNotEmpty()) return fromTasks
|
||||
return ToolJson
|
||||
.firstString(call.args, listOf("goal"))
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
}
|
||||
|
||||
private fun action(call: ToolCall) = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "spawn" }
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String =
|
||||
when (action(call)) {
|
||||
"list" -> {
|
||||
"Listing subagents"
|
||||
}
|
||||
|
||||
"steer" -> {
|
||||
"Steering subagent"
|
||||
}
|
||||
|
||||
"stop" -> {
|
||||
"Stopping subagent"
|
||||
}
|
||||
|
||||
else -> {
|
||||
val n = goals(call).size.coerceAtLeast(1)
|
||||
"Delegating $n task${if (n == 1) "" else "s"}"
|
||||
}
|
||||
}
|
||||
|
||||
override fun doneTitle(call: ToolCall): String {
|
||||
val result = call.result
|
||||
return when {
|
||||
action(call) == "list" -> {
|
||||
val n = ToolJson.intValue(result?.get("count")) ?: 0
|
||||
"$n subagent${if (n == 1) "" else "s"}"
|
||||
}
|
||||
|
||||
action(call) == "steer" -> {
|
||||
"Steer queued"
|
||||
}
|
||||
|
||||
action(call) == "stop" -> {
|
||||
"Stop requested"
|
||||
}
|
||||
|
||||
ToolJson.firstString(result, listOf("status")) == "dispatched" -> {
|
||||
val n = ToolJson.intValue(result?.get("count")) ?: goals(call).size
|
||||
"Dispatched $n background task${if (n == 1) "" else "s"}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
val results = result?.get("results") as? JsonArray
|
||||
val n = results?.size ?: goals(call).size.coerceAtLeast(1)
|
||||
val failed =
|
||||
results?.count {
|
||||
ToolJson.firstString(ToolJson.parseMaybeObject(it), listOf("status")) == "failed"
|
||||
} ?: 0
|
||||
if (failed > 0) {
|
||||
"Delegated $n tasks · $failed failed"
|
||||
} else {
|
||||
"Delegated $n task${if (n == 1) "" else "s"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String = ToolJson.compactPreview(goals(call).firstOrNull() ?: "", 120)
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
// list action: one row per live subagent
|
||||
(result?.get("subagents") as? JsonArray)?.let { subs ->
|
||||
return subs
|
||||
.mapNotNull { el ->
|
||||
val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val goal = ToolJson.compactPreview(ToolJson.firstString(s, listOf("goal")), 80)
|
||||
val status = ToolJson.firstString(s, listOf("status"))
|
||||
val secs = ToolJson.numberValue(s["running_seconds"])
|
||||
val time = secs?.let { " · ${ToolJson.formatDurationSeconds(it)}" } ?: ""
|
||||
"- [$status] $goal$time"
|
||||
}.joinToString("\n")
|
||||
}
|
||||
// joined spawn: per-task result rows
|
||||
val rows =
|
||||
(result?.get("results") as? JsonArray)?.mapNotNull { el ->
|
||||
val r = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val idx = ToolJson.intValue(r["task_index"]) ?: 0
|
||||
val ok = ToolJson.firstString(r, listOf("status")) != "failed"
|
||||
val marker = if (ok) "[x]" else "[!]"
|
||||
val goal = goals(call).getOrNull(idx) ?: "Task ${idx + 1}"
|
||||
val model = ToolJson.firstString(r, listOf("model"))
|
||||
val dur =
|
||||
ToolJson
|
||||
.numberValue(r["duration_seconds"])
|
||||
?.let { ToolJson.formatDurationSeconds(it) } ?: ""
|
||||
val meta = listOf(model, dur).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
val summary =
|
||||
ToolJson.compactPreview(
|
||||
ToolJson.firstString(r, listOf("summary", "error")),
|
||||
160,
|
||||
)
|
||||
buildString {
|
||||
append("$marker ${ToolJson.compactPreview(goal, 80)}")
|
||||
if (meta.isNotEmpty()) append(" ($meta)")
|
||||
if (summary.isNotEmpty()) append("\n $summary")
|
||||
}
|
||||
} ?: emptyList()
|
||||
if (rows.isNotEmpty()) return rows.joinToString("\n")
|
||||
// dispatched handle / steer / stop acks
|
||||
val note = ToolJson.firstString(result, listOf("note"))
|
||||
val goalsArr =
|
||||
(result?.get("goals") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||
?.joinToString("\n") { "- ${ToolJson.compactPreview(it, 100)}" } ?: ""
|
||||
return listOf(goalsArr, note).filter { it.isNotEmpty() }.joinToString("\n\n")
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,32 @@ internal object VisionAnalyzeRenderer : ToolRenderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `text_to_speech`: audio synthesis. Success payload carries file_path(s), provider, chunk_count. */
|
||||
internal object TextToSpeechRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Generating speech"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Generated speech"
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val provider = ToolJson.firstString(call.result, listOf("provider"))
|
||||
val chunks = ToolJson.intValue(call.result?.get("chunk_count"))
|
||||
val parts =
|
||||
listOfNotNull(
|
||||
provider.takeIf { it.isNotEmpty() },
|
||||
chunks?.takeIf { it > 1 }?.let { "$it chunks" },
|
||||
)
|
||||
return parts
|
||||
.joinToString(" · ")
|
||||
.ifEmpty { ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 120) }
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val text = ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 400)
|
||||
val path = ToolJson.firstString(call.result, listOf("file_path", "output_path"))
|
||||
return listOfNotNull(
|
||||
text.takeIf { it.isNotEmpty() }?.let { "“$it”" },
|
||||
path.takeIf { it.isNotEmpty() }?.let { "Saved to $it" },
|
||||
).joinToString("\n\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* `search_files`: ripgrep-backed content/file search.
|
||||
* Handles both result shapes: dense path-grouped `matches_text` string (≥5 hits)
|
||||
* and `matches:[{path,line,content}]` (<5 hits), plus `files:[path]`.
|
||||
*/
|
||||
internal object SearchFilesRenderer : ToolRenderer {
|
||||
private fun pattern(call: ToolCall) = ToolJson.firstString(call.args, listOf("pattern"))
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String? =
|
||||
pattern(call).takeIf { it.isNotEmpty() }?.let { "Searching “${ToolJson.compactPreview(it, 48)}”" }
|
||||
|
||||
override fun doneTitle(call: ToolCall): String? {
|
||||
val p = pattern(call).takeIf { it.isNotEmpty() } ?: return null
|
||||
val total = ToolJson.intValue(call.result?.get("total_count"))
|
||||
val suffix = total?.let { " · $it match${if (it == 1) "" else "es"}" } ?: ""
|
||||
return "Searched “${ToolJson.compactPreview(p, 48)}”$suffix"
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val path = ToolJson.firstString(call.args, listOf("path")).takeIf { it != "." && it.isNotEmpty() } ?: ""
|
||||
val glob = ToolJson.firstString(call.args, listOf("file_glob"))
|
||||
return listOf(path, glob).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result ?: return ""
|
||||
// Dense ≥5-match block: already ripgrep-grouped, use verbatim.
|
||||
val dense = ToolJson.firstString(result, listOf("matches_text"))
|
||||
val body =
|
||||
if (dense.isNotEmpty()) {
|
||||
dense
|
||||
} else {
|
||||
(result["matches"] as? JsonArray)
|
||||
?.mapNotNull { el ->
|
||||
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val path = ToolJson.firstString(m, listOf("path"))
|
||||
val line = ToolJson.intValue(m["line"])
|
||||
val content = ToolJson.firstString(m, listOf("content"))
|
||||
"$path:${line ?: "?"}: ${content.trim()}"
|
||||
}?.joinToString("\n")
|
||||
?: (result["files"] as? JsonArray)
|
||||
?.mapNotNull {
|
||||
(it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
|
||||
}?.joinToString("\n") ?: ""
|
||||
}
|
||||
val hint =
|
||||
if (ToolJson.boolTrue(result["truncated"])) {
|
||||
ToolJson.firstString(result, listOf("_hint")).ifEmpty { "Results truncated" }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return listOf(body, hint).filter { it.isNotEmpty() }.joinToString("\n\n")
|
||||
}
|
||||
|
||||
override fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras = ToolViewExtras(detailLabel = "Matches")
|
||||
}
|
||||
Reference in new issue
Block a user