feat(subagents): live active subagents roster and transcript tail (#1089) (#1098)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 11:06:34 +03:00
1 parent 69cd34d289
commit 29bc367aec
13 files changed
+1452 -2

No files matched your search

@@ -0,0 +1,52 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Item returned in the [SubagentListResponse] from `subagent.list` (issue #1089).
*/
@Serializable
data class SubagentListItem(
@SerialName("subagent_id") val subagentId: String = "",
val goal: String? = null,
val status: String? = null,
val model: String? = null,
@SerialName("elapsed_seconds") val elapsedSeconds: Double? = null,
@SerialName("started_at") val startedAt: Double? = null,
@SerialName("parent_id") val parentId: String? = null,
val depth: Int? = null,
@SerialName("delegation_id") val delegationId: String? = null,
@SerialName("tool_count") val toolCount: Int? = null,
@SerialName("last_tool") val lastTool: String? = null,
@SerialName("accepting_steer") val acceptingSteer: Boolean? = null,
)
/**
* Response returned from `subagent.list` (issue #1089).
*/
@Serializable
data class SubagentListResponse(
val subagents: List<SubagentListItem> = emptyList(),
val delegations: List<JsonElement> = emptyList(),
)
/**
* Response returned from `subagent.tail` (issue #1089).
*
* Backend returns either `text` (desktop/TUI gateway) or `tail` (contract).
* [content] returns the authoritative transcript text from whichever field is present.
*/
@Serializable
data class SubagentTailResponse(
@SerialName("subagent_id") val subagentId: String = "",
val tail: String? = null,
val text: String? = null,
val available: Boolean? = null,
val truncated: Boolean? = null,
@SerialName("bytes_read") val bytesRead: Long? = null,
) {
/** Returns authoritative transcript text across backend schema variants. */
fun content(): String = text ?: tail ?: ""
}
@@ -0,0 +1,138 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.model.SubagentListResponse
import com.m57.hermescontrol.data.model.SubagentTailResponse
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
import kotlinx.serialization.serializer
/**
* WebSocket RPC repository for subagent operations: `subagent.list` and `subagent.tail` (issue #1089).
*/
object SubagentRepository {
private const val TAG = "SubagentRepository"
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
/**
* Request the current active subagent roster for a given [sessionId].
*
* Returns null if RPC fails or is unsupported by server.
*/
suspend fun listSubagents(sessionId: String): SubagentListResponse? {
if (sessionId.isBlank()) return null
return try {
val result =
HermesWsClient
.request(
WsMethods.SUBAGENT_LIST,
mapOf("session_id" to sessionId),
).await()
decode<SubagentListResponse>(result)
} catch (e: Exception) {
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
null
}
}
/**
* Request a rolling live transcript tail for [subagentId].
*
* Supplies [sessionId] when available so gateway authority checks pass cleanly.
* Throws or returns null if unavailable.
*/
suspend fun tailSubagent(
sessionId: String,
subagentId: String,
maxBytes: Int = 16384,
): SubagentTailResponse? {
if (subagentId.isBlank()) return null
val params =
mutableMapOf<String, Any>(
"subagent_id" to subagentId,
"max_bytes" to maxBytes,
)
if (sessionId.isNotBlank()) {
params["session_id"] = sessionId
}
val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await()
return decode<SubagentTailResponse>(result)
}
@Suppress("UNCHECKED_CAST")
internal inline fun <reified T> decode(result: Any?): T? {
if (result == null) return null
val element: JsonElement =
when (result) {
is JsonElement -> {
result
}
is Map<*, *> -> {
anyToJsonElement(result)
}
is List<*> -> {
JsonArray(result.map { anyToJsonElement(it) })
}
else -> {
val str = result.toString()
try {
json.parseToJsonElement(str)
} catch (_: Exception) {
JsonPrimitive(str)
}
}
}
return json.decodeFromJsonElement(serializer<T>(), element)
}
@Suppress("UNCHECKED_CAST")
internal fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
)
}
is List<*> -> {
JsonArray(value.map { anyToJsonElement(it) })
}
is String -> {
JsonPrimitive(value)
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
}
}
@@ -83,6 +83,14 @@ object WsMethods {
/** Kill a single background process (scoped to the active session). */
const val PROCESS_KILL = "process.kill"
// ── Subagents (issue #1089) ───────────────────────────────────────────
/** List active subagents and delegations for a session. */
const val SUBAGENT_LIST = "subagent.list"
/** Rolling tail snapshot of a subagent's live execution transcript. */
const val SUBAGENT_TAIL = "subagent.tail"
// ── Billing / subscription (issue #628) ─────────────────────────────
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`