Enhance shared agent configuration and bridge diagnostics

This commit is contained in:
Yu-ga
2026-05-20 23:08:09 +09:00
parent 33333fe2aa
commit 192c3a6459
38 changed files with 1260 additions and 91 deletions
+8 -9
View File
@@ -34,15 +34,14 @@
> <key>`; the default model name is `hermes-agent`. Connection test
> calls `GET /v1/models` and falls back to `/health`.
>
> **Hermes-Relay feature parity.** WakeHermesClaw mirrors the patterns in
> [hermes-relay](https://codename-11.github.io/hermes-relay/):
> 6-character pairing codes + deep-link QR payload (`agentvoice://pair?...`),
> multi-endpoint candidates (LAN + Tailscale + public) raced in parallel on
> every connect, per-capability TTL grants ("approve · 10 min / 1 hour /
> until revoked") with a destructive-verb override, `/revoke` and `/grants`
> endpoints, an Accessibility Bridge (tap / swipe / Home / Back / window
> describe — sideload-only), notifications.active.list backed by the
> existing NotificationListenerService, and a build-flag (`IS_SIDELOAD`)
> **WakeHermesClaw shared agent controls.** The app includes 6-character
> bridge pairing codes, deep-link setup payloads, multi-endpoint candidates
> (LAN + VPN + public) raced in parallel on connect, per-capability TTL grants
> ("approve · 10 min / 1 hour / until revoked") with a destructive-verb
> override, `/revoke` and `/grants` endpoints, an Accessibility Bridge
> (tap / swipe / Home / Back / window describe — sideload-only),
> notifications.active.list backed by the existing NotificationListenerService,
> and a build-flag (`IS_SIDELOAD`)
> that gates Accessibility + SMS for a Play-track build.
>
> **Wear OS** uses its own per-watch backend URL + auth token. Point the
@@ -80,6 +80,8 @@ import com.openclaw.assistant.ui.chat.ChatMessage
import com.openclaw.assistant.gateway.AgentInfo
import com.openclaw.assistant.backend.BackendRepository
import com.openclaw.assistant.backend.BackendType
import com.openclaw.assistant.backend.HermesConfigApi
import com.openclaw.assistant.backend.HermesModelOption
import com.openclaw.assistant.ui.theme.OpenClawAssistantTheme
import androidx.compose.material3.TextButton
import kotlinx.coroutines.launch
@@ -874,7 +876,7 @@ fun PendingToolsIndicator(toolCalls: List<String>) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ChatSettingsDialog(
uiState: ChatUiState,
@@ -890,8 +892,11 @@ fun ChatSettingsDialog(
var pendingSelectedId by remember(selectedId) { mutableStateOf(selectedId) }
val selectedBackend = enabledBackends.firstOrNull { it.id == pendingSelectedId } ?: primary
var modelName by remember(selectedBackend?.id, selectedBackend?.modelName) {
mutableStateOf(selectedBackend?.modelName?.ifBlank { null } ?: "default")
mutableStateOf(selectedBackend?.modelName?.ifBlank { null } ?: defaultModelFor(selectedBackend?.type))
}
var modelStatus by remember(selectedBackend?.id) { mutableStateOf<String?>(null) }
var hermesModels by remember(selectedBackend?.id) { mutableStateOf<List<HermesModelOption>>(emptyList()) }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
@@ -940,16 +945,83 @@ fun ChatSettingsDialog(
)
}
if (selectedBackend?.type == BackendType.HERMES_API_SERVER) {
if (selectedBackend != null) {
HorizontalDivider()
Text(
text = when (selectedBackend.type) {
BackendType.HERMES_API_SERVER -> "Hermes Model"
BackendType.OPENCLAW_GATEWAY -> "OpenClaw Model"
BackendType.OPENCLAW_HTTP -> "OpenClaw API Model"
},
style = MaterialTheme.typography.titleSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
)
OutlinedTextField(
value = modelName,
onValueChange = { modelName = it },
label = { Text(stringResource(R.string.chat_settings_hermes_model)) },
supportingText = { Text(stringResource(R.string.chat_settings_hermes_model_help)) },
label = { Text("Model") },
supportingText = {
Text(
when (selectedBackend.type) {
BackendType.HERMES_API_SERVER -> "Saved for chat and can also be applied to Hermes."
BackendType.OPENCLAW_GATEWAY -> "Sent with gateway chat requests when supported."
BackendType.OPENCLAW_HTTP -> "Sent as the OpenAI-compatible model field."
},
)
},
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (selectedBackend.type == BackendType.HERMES_API_SERVER) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
val target = selectedBackend.copy(modelName = modelName.trim().ifBlank { "default" })
scope.launch {
modelStatus = "Loading models..."
runCatching { HermesConfigApi().fetchCatalog(target) }
.onSuccess { catalog ->
hermesModels = catalog.models
catalog.config?.model?.takeIf { it.isNotBlank() }?.let { modelName = it }
modelStatus = "Loaded ${catalog.models.size} model${if (catalog.models.size == 1) "" else "s"}"
}
.onFailure { modelStatus = "Could not load models: ${it.message ?: it.javaClass.simpleName}" }
}
},
) { Text("Load Models") }
OutlinedButton(
onClick = {
val target = selectedBackend.copy(modelName = modelName.trim().ifBlank { "default" })
scope.launch {
modelStatus = "Applying to Hermes..."
runCatching { HermesConfigApi().updateModel(target, modelName) }
.onSuccess { state ->
val saved = target.copy(modelName = state.model ?: modelName.trim().ifBlank { "default" })
repo.upsert(saved)
modelName = saved.modelName ?: "default"
modelStatus = "Hermes model updated: ${saved.modelName}"
}
.onFailure { modelStatus = "Could not update Hermes: ${it.message ?: it.javaClass.simpleName}" }
}
},
enabled = modelName.isNotBlank(),
) { Text("Apply to Hermes") }
}
if (hermesModels.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
hermesModels.take(8).forEach { option ->
AssistChip(
onClick = { modelName = option.id },
label = { Text(option.id, style = MaterialTheme.typography.labelSmall) },
)
}
}
}
}
modelStatus?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}
},
@@ -958,8 +1030,8 @@ fun ChatSettingsDialog(
onClick = {
ChatBackendTarget.set(pendingSelectedId)
val target = selectedBackend
if (target?.type == BackendType.HERMES_API_SERVER) {
repo.upsert(target.copy(modelName = modelName.trim().ifBlank { "default" }))
if (target != null) {
repo.upsert(target.copy(modelName = modelName.trim().ifBlank { defaultModelFor(target.type) }))
}
onDismiss()
},
@@ -975,6 +1047,13 @@ fun ChatSettingsDialog(
)
}
private fun defaultModelFor(type: BackendType?): String = when (type) {
BackendType.OPENCLAW_GATEWAY,
BackendType.OPENCLAW_HTTP -> "openclaw"
BackendType.HERMES_API_SERVER,
null -> "default"
}
@Composable
private fun ChatTargetRadioRow(
selected: Boolean,
@@ -183,7 +183,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.abortChat()
}
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>, modelName: String? = null) {
runtime.sendChat(message = message, thinking = thinking, attachments = attachments, modelName = modelName)
}
}
@@ -7,6 +7,9 @@ import androidx.core.os.LocaleListCompat
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.openclaw.assistant.backend.BackendMigration
import com.openclaw.assistant.backend.AgentDiagnostics
import com.openclaw.assistant.bridge.BridgeActivityLog
import com.openclaw.assistant.bridge.WakeLockManager
import com.openclaw.assistant.data.SettingsRepository
import com.openclaw.assistant.node.NodeRuntime
import java.security.Security
@@ -68,6 +71,9 @@ class OpenClawApplication : Application() {
} catch (e: Throwable) {
Log.w("OpenClawApp", "Backend migration skipped: ${e.message}")
}
AgentDiagnostics.initialize(this)
BridgeActivityLog.initialize(this)
WakeLockManager.initialize(this)
}
private fun applySavedAppLocale() {
@@ -43,6 +43,7 @@ class OpenClawClient() {
sessionId: String,
authToken: String? = null,
agentId: String? = null,
modelName: String? = null,
attachments: List<Pair<String, String>> = emptyList()
): Result<OpenClawResponse> = withContext(Dispatchers.IO) {
if (httpUrl.isBlank()) {
@@ -62,7 +63,7 @@ class OpenClawClient() {
try {
// OpenAI Chat Completions format for /v1/chat/completions
val requestBody = JsonObject().apply {
addProperty("model", "openclaw")
addProperty("model", modelName?.trim()?.takeIf { it.isNotBlank() } ?: "openclaw")
addProperty("user", sessionId)
val messagesArray = JsonArray()
val userMessage = JsonObject().apply {
@@ -24,9 +24,8 @@ data class AgentBackendConfig(
val updatedAt: Long = System.currentTimeMillis(),
/**
* Additional endpoints to race alongside [baseUrl] at connect time.
* Inspired by Hermes-Relay's "LAN + Tailscale + public URLs" model: the
* client tries all candidates in parallel on every connect and on every
* network change, using whichever responds first.
* Additional routes such as LAN, VPN, and public URLs. The client tries all
* candidates in parallel on connect and uses the first reachable route.
*/
val secondaryUrls: List<String> = emptyList(),
/**
@@ -36,6 +35,12 @@ data class AgentBackendConfig(
*/
val terminalUrl: String? = null,
val terminalSessionToken: String? = null,
/** Optional cross-backend agent/profile label selected by the user. */
val agentContextName: String? = null,
/** Optional model/personality/profile hint shown in shared Agent Context UI. */
val agentContextDetail: String? = null,
/** Optional preferred endpoint role, such as lan, vpn, or public. */
val preferredEndpointRole: String? = null,
) {
val hermesMode: HermesMode
get() = if (useRunsApi) HermesMode.RUNS_API else HermesMode.CHAT_COMPLETIONS
@@ -0,0 +1,99 @@
package com.openclaw.assistant.backend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
data class AgentContextInspection(
val contextName: String?,
val contextDetail: String?,
val summary: String,
)
/**
* Best-effort read-only inspector for agent metadata exposed by Hermes-like
* API servers. Every endpoint is optional; unsupported servers simply return a
* short "not available" summary.
*/
class AgentContextInspector(
private val httpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build(),
private val json: Json = Json { ignoreUnknownKeys = true; isLenient = true },
) {
suspend fun inspect(config: AgentBackendConfig): AgentContextInspection = withContext(Dispatchers.IO) {
val baseUrl = config.baseUrl?.trim()?.takeIf { it.isNotBlank() }
?: return@withContext AgentContextInspection(null, null, "No base URL configured")
val apiBase = HermesUrl.apiBase(baseUrl)
val headers = config.apiKeyOrToken?.takeIf { it.isNotBlank() }?.let { mapOf("Authorization" to "Bearer $it") }.orEmpty()
val profiles = getJson("$apiBase/api/profiles", headers)
val configJson = getJson("$apiBase/api/config", headers)
val skillsJson = getJson("$apiBase/api/skills", headers)
val soulJson = getJson("$apiBase/api/profiles/default/soul", headers)
val memoryJson = getJson("$apiBase/api/profiles/default/memory", headers)
val profileName = firstProfileName(profiles)
?: config.agentContextName
?: config.modelName
val model = firstProfileModel(profiles) ?: config.modelName
val personality = configJson?.let(::defaultPersonality)
val skillsCount = countArray(skillsJson, "skills")
val memoryCount = countArray(memoryJson, "entries")
val soulState = soulJson?.jsonObject?.get("exists")?.jsonPrimitive?.contentOrNull
val detailParts = listOfNotNull(
model?.takeIf { it.isNotBlank() }?.let { "model: $it" },
personality?.takeIf { it.isNotBlank() }?.let { "personality: $it" },
skillsCount?.let { "skills: $it" },
memoryCount?.let { "memory: $it" },
soulState?.let { "SOUL: $it" },
)
AgentContextInspection(
contextName = profileName?.takeIf { it.isNotBlank() },
contextDetail = detailParts.joinToString(" · ").ifBlank { null },
summary = if (profiles == null && configJson == null && skillsJson == null && soulJson == null && memoryJson == null) {
"No optional agent metadata endpoints responded. Manual context fields can still be used."
} else {
detailParts.joinToString("\n").ifBlank { "Agent metadata endpoint responded, but no displayable fields were found." }
},
)
}
private fun getJson(url: String, headers: Map<String, String>): JsonObject? = runCatching {
val builder = Request.Builder().url(url).get()
headers.forEach { (k, v) -> builder.header(k, v) }
httpClient.newCall(builder.build()).execute().use { response ->
if (!response.isSuccessful) return@runCatching null
val body = response.body?.string()?.takeIf { it.isNotBlank() } ?: return@runCatching null
json.parseToJsonElement(body).jsonObject
}
}.getOrNull()
private fun firstProfileName(obj: JsonObject?): String? {
val array = obj?.get("profiles") as? JsonArray ?: obj?.get("items") as? JsonArray ?: return null
return array.firstOrNull()?.jsonObject?.get("name")?.jsonPrimitive?.contentOrNull
}
private fun firstProfileModel(obj: JsonObject?): String? {
val array = obj?.get("profiles") as? JsonArray ?: obj?.get("items") as? JsonArray ?: return null
return array.firstOrNull()?.jsonObject?.get("model")?.jsonPrimitive?.contentOrNull
}
private fun defaultPersonality(obj: JsonObject): String? {
val display = obj["display"]?.jsonObject
return display?.get("personality")?.jsonPrimitive?.contentOrNull
?: obj["personality"]?.jsonPrimitive?.contentOrNull
}
private fun countArray(obj: JsonObject?, key: String): Int? =
obj?.get(key)?.jsonArray?.size
}
@@ -0,0 +1,166 @@
package com.openclaw.assistant.backend
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@Serializable
data class AgentDiagnosticSnapshot(
val backendId: String,
val backendName: String,
val backendType: String,
val totalMessages: Int = 0,
val streamsCompleted: Int = 0,
val streamsErrored: Int = 0,
val streamsCancelled: Int = 0,
val totalInputChars: Long = 0,
val totalOutputChars: Long = 0,
val averageTimeToFirstTokenMs: Long = 0,
val averageCompletionMs: Long = 0,
val lastHealthOk: Boolean? = null,
val lastHealthLatencyMs: Long? = null,
val lastError: String? = null,
val updatedAtMs: Long = 0,
)
/**
* Small local-only diagnostics store shared by Hermes and OpenClaw backends.
* It stores counts/status only; never message text, screen text, tokens, or
* profile document contents.
*/
object AgentDiagnostics {
private const val PREFS_NAME = "openclaw.agent.diagnostics"
private const val KEY_SNAPSHOTS = "snapshots.v1"
private val _snapshots = MutableStateFlow<List<AgentDiagnosticSnapshot>>(emptyList())
val snapshots: StateFlow<List<AgentDiagnosticSnapshot>> = _snapshots.asStateFlow()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Volatile private var prefs: SharedPreferences? = null
fun initialize(context: Context) {
if (prefs != null) return
synchronized(this) {
if (prefs != null) return
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
_snapshots.value = decode(prefs?.getString(KEY_SNAPSHOTS, null).orEmpty())
}
}
fun recordHealth(context: Context, backend: AgentBackendConfig, ok: Boolean, latencyMs: Long?, error: String? = null) {
initialize(context)
update(backend) { current ->
current.copy(
lastHealthOk = ok,
lastHealthLatencyMs = latencyMs,
lastError = error?.take(160),
updatedAtMs = System.currentTimeMillis(),
)
}
}
fun beginMessage(context: Context, backend: AgentBackendConfig, inputChars: Int): ActiveRun {
initialize(context)
return ActiveRun(context.applicationContext, backend, inputChars.coerceAtLeast(0), System.currentTimeMillis())
}
fun clear(context: Context) {
initialize(context)
_snapshots.value = emptyList()
prefs?.edit { remove(KEY_SNAPSHOTS) }
}
class ActiveRun internal constructor(
private val context: Context,
private val backend: AgentBackendConfig,
private val inputChars: Int,
private val startedAtMs: Long,
) {
private var firstTokenAtMs: Long? = null
private var outputChars: Int = 0
fun onToken(chars: Int) {
if (firstTokenAtMs == null) firstTokenAtMs = System.currentTimeMillis()
outputChars += chars.coerceAtLeast(0)
}
fun complete(finalOutputChars: Int? = null) {
val now = System.currentTimeMillis()
val ttft = (firstTokenAtMs ?: now) - startedAtMs
val completion = now - startedAtMs
val out = (finalOutputChars ?: outputChars).coerceAtLeast(0)
initialize(context)
update(backend) { current ->
val newMessages = current.totalMessages + 1
val newCompleted = current.streamsCompleted + 1
current.copy(
totalMessages = newMessages,
streamsCompleted = newCompleted,
totalInputChars = current.totalInputChars + inputChars,
totalOutputChars = current.totalOutputChars + out,
averageTimeToFirstTokenMs = rollingAverage(current.averageTimeToFirstTokenMs, current.streamsCompleted, ttft),
averageCompletionMs = rollingAverage(current.averageCompletionMs, current.streamsCompleted, completion),
lastError = null,
updatedAtMs = now,
)
}
}
fun error(message: String?) {
initialize(context)
update(backend) { current ->
current.copy(
streamsErrored = current.streamsErrored + 1,
lastError = message?.take(160),
updatedAtMs = System.currentTimeMillis(),
)
}
}
fun cancelled() {
initialize(context)
update(backend) { current ->
current.copy(
streamsCancelled = current.streamsCancelled + 1,
updatedAtMs = System.currentTimeMillis(),
)
}
}
}
private fun rollingAverage(previousAverage: Long, previousCount: Int, next: Long): Long {
val count = previousCount.coerceAtLeast(0)
return ((previousAverage * count) + next) / (count + 1)
}
private fun update(backend: AgentBackendConfig, block: (AgentDiagnosticSnapshot) -> AgentDiagnosticSnapshot) {
val current = _snapshots.value
val index = current.indexOfFirst { it.backendId == backend.id }
val base = if (index >= 0) {
current[index]
} else {
AgentDiagnosticSnapshot(
backendId = backend.id,
backendName = backend.displayName,
backendType = backend.type.name,
)
}
val updated = block(base.copy(backendName = backend.displayName, backendType = backend.type.name))
val next = if (index >= 0) current.toMutableList().also { it[index] = updated } else current + updated
_snapshots.value = next.sortedByDescending { it.updatedAtMs }
prefs?.edit { putString(KEY_SNAPSHOTS, encode(_snapshots.value)) }
}
private fun encode(items: List<AgentDiagnosticSnapshot>): String =
json.encodeToString(ListSerializer(AgentDiagnosticSnapshot.serializer()), items)
private fun decode(raw: String): List<AgentDiagnosticSnapshot> = runCatching {
json.decodeFromString(ListSerializer(AgentDiagnosticSnapshot.serializer()), raw)
}.getOrDefault(emptyList())
}
@@ -0,0 +1,128 @@
package com.openclaw.assistant.backend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
data class HermesModelOption(
val id: String,
val description: String? = null,
)
data class HermesConfigState(
val model: String?,
val provider: String?,
val apiMode: String?,
val baseUrl: String?,
)
data class HermesModelCatalog(
val config: HermesConfigState?,
val models: List<HermesModelOption>,
val providers: List<String>,
)
class HermesConfigApi(
private val httpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build(),
private val json: Json = Json { ignoreUnknownKeys = true; isLenient = true },
) {
suspend fun fetchCatalog(config: AgentBackendConfig): HermesModelCatalog = withContext(Dispatchers.IO) {
val current = fetchConfig(config)
val catalog = getJson(config, HermesUrl.availableModelsUrl(requireBaseUrl(config)))
HermesModelCatalog(
config = current,
models = parseModels(catalog),
providers = parseProviders(catalog),
)
}
suspend fun fetchConfig(config: AgentBackendConfig): HermesConfigState? = withContext(Dispatchers.IO) {
getJson(config, HermesUrl.configUrl(requireBaseUrl(config)))?.let(::parseConfig)
}
suspend fun updateModel(config: AgentBackendConfig, model: String): HermesConfigState = withContext(Dispatchers.IO) {
val body = buildJsonObject { put("model", model.trim()) }.toString().toRequestBody(JSON_MEDIA)
val request = authed(config, Request.Builder().url(HermesUrl.configUrl(requireBaseUrl(config))).patch(body)).build()
httpClient.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw IllegalStateException("HTTP ${response.code}: ${extractError(text).ifBlank { response.message }}")
}
parseConfig(json.parseToJsonElement(text).jsonObject)
}
}
private fun getJson(config: AgentBackendConfig, url: String): JsonObject? {
val request = authed(config, Request.Builder().url(url).get()).build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val text = response.body?.string()?.takeIf { it.isNotBlank() } ?: return null
return json.parseToJsonElement(text).jsonObject
}
}
private fun authed(config: AgentBackendConfig, builder: Request.Builder): Request.Builder {
config.apiKeyOrToken?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", "Bearer $it") }
return builder
}
private fun requireBaseUrl(config: AgentBackendConfig): String =
config.baseUrl?.takeIf { it.isNotBlank() } ?: throw IllegalStateException("Hermes backend has no base URL")
private fun parseConfig(obj: JsonObject): HermesConfigState = HermesConfigState(
model = obj["model"]?.jsonPrimitive?.contentOrNull,
provider = obj["provider"]?.jsonPrimitive?.contentOrNull,
apiMode = obj["api_mode"]?.jsonPrimitive?.contentOrNull,
baseUrl = obj["base_url"]?.jsonPrimitive?.contentOrNull,
)
private fun parseModels(obj: JsonObject?): List<HermesModelOption> {
val array = obj?.get("models") as? JsonArray ?: return emptyList()
return array.mapNotNull { item ->
when (val value = item) {
is JsonPrimitive -> value.contentOrNull?.let { HermesModelOption(it) }
is JsonObject -> {
val id = value["id"]?.jsonPrimitive?.contentOrNull ?: value["model"]?.jsonPrimitive?.contentOrNull
id?.takeIf { it.isNotBlank() }?.let {
HermesModelOption(
id = it,
description = value["description"]?.jsonPrimitive?.contentOrNull,
)
}
}
else -> null
}
}
}
private fun parseProviders(obj: JsonObject?): List<String> {
val array = obj?.get("providers")?.jsonArray ?: return emptyList()
return array.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
}
private fun extractError(text: String): String = runCatching {
val obj = json.parseToJsonElement(text).jsonObject
obj["error"]?.jsonPrimitive?.contentOrNull.orEmpty()
}.getOrDefault(text).take(300)
private companion object {
val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
}
}
@@ -18,9 +18,8 @@ import java.util.concurrent.TimeUnit
* in parallel and returns the first one to respond successfully to
* `GET /v1/models` (with `/health` fallback).
*
* Modelled after Hermes-Relay's "LAN + Tailscale + public URLs" pattern: every
* connect picks the lowest-latency reachable endpoint, so the same paired
* backend works at home, on a train, or behind a VPN without reconfiguration.
* Races LAN, VPN, and public URLs so the same configured backend works at
* home, on a train, or behind a VPN without manual reconfiguration.
*
* The racer never falls back to a non-2xx endpoint — auth failure on the
* fastest endpoint is still preferred over silently using a slow stale one.
@@ -24,6 +24,9 @@ internal object HermesUrl {
fun chatCompletionsUrl(base: String) = "${normalizeBase(base)}/chat/completions"
fun modelsUrl(base: String) = "${normalizeBase(base)}/models"
fun apiBase(base: String) = normalizeBase(base).removeSuffix("/v1")
fun configUrl(base: String) = "${apiBase(base)}/api/config"
fun availableModelsUrl(base: String) = "${apiBase(base)}/api/available-models"
fun healthUrl(base: String): String {
val n = normalizeBase(base)
return "$n/health"
@@ -66,6 +66,7 @@ class OpenClawHttpAdapter(override val config: AgentBackendConfig) : AgentClient
sessionId = options.sessionId ?: "agent-voice",
authToken = config.apiKeyOrToken?.takeIf { it.isNotBlank() },
agentId = options.extra["agentId"]?.takeIf { it.isNotBlank() },
modelName = config.modelName?.takeIf { it.isNotBlank() },
)
result.fold(
onSuccess = { response ->
@@ -40,7 +40,7 @@ object PrimaryBackendDispatcher {
} ?: return null
return when (target.type) {
BackendType.HERMES_API_SERVER,
BackendType.OPENCLAW_HTTP -> sendViaAgentClient(target, userText, sessionId, agentId)
BackendType.OPENCLAW_HTTP -> sendViaAgentClient(context, target, userText, sessionId, agentId)
BackendType.OPENCLAW_GATEWAY -> sendViaGateway(context, target, userText)
}
}
@@ -62,6 +62,7 @@ object PrimaryBackendDispatcher {
): Reply? = sendPrimary(context, userText)
private suspend fun sendViaAgentClient(
context: Context,
target: AgentBackendConfig,
userText: String,
sessionId: String?,
@@ -69,24 +70,40 @@ object PrimaryBackendDispatcher {
): Reply {
val client = AgentClientFactory.create(target)
val collected = StringBuilder()
client.sendMessage(
messages = listOf(AgentMessage.user(userText)),
options = AgentSendOptions(
sessionId = sessionId,
stream = target.useStreaming,
extra = mapOf("agentId" to agentId.orEmpty()),
),
).collect { event ->
when (event) {
is AgentEvent.TokenDelta -> collected.append(event.text)
is AgentEvent.MessageDelta -> collected.append(event.text)
is AgentEvent.Completed -> {
if (collected.isEmpty()) collected.append(event.finalText)
val run = AgentDiagnostics.beginMessage(context, target, userText.length)
try {
client.sendMessage(
messages = listOf(AgentMessage.user(userText)),
options = AgentSendOptions(
sessionId = sessionId,
stream = target.useStreaming,
extra = mapOf("agentId" to agentId.orEmpty()),
),
).collect { event ->
when (event) {
is AgentEvent.TokenDelta -> {
collected.append(event.text)
run.onToken(event.text.length)
}
is AgentEvent.MessageDelta -> {
collected.append(event.text)
run.onToken(event.text.length)
}
is AgentEvent.Completed -> {
if (collected.isEmpty()) collected.append(event.finalText)
run.complete(collected.length)
}
is AgentEvent.ToolProgress -> com.openclaw.assistant.ui.backend.ToolProgressFeed.push(event)
is AgentEvent.Error -> {
run.error(event.message)
throw RuntimeException("${target.displayName} error: ${event.message}", event.cause)
}
else -> Unit
}
is AgentEvent.ToolProgress -> com.openclaw.assistant.ui.backend.ToolProgressFeed.push(event)
is AgentEvent.Error -> throw RuntimeException("Hermes error: ${event.message}", event.cause)
else -> Unit
}
} catch (e: Throwable) {
run.error(e.message ?: e.javaClass.simpleName)
throw e
}
if (collected.isBlank()) {
throw IllegalStateException("${target.displayName} returned an empty response. Check the backend model/provider configuration.")
@@ -104,8 +121,14 @@ object PrimaryBackendDispatcher {
throw IllegalStateException("OpenClaw Gateway is not connected")
}
val run = AgentDiagnostics.beginMessage(context, target, userText.length)
val assistantCountBefore = runtime.chatMessages.value.count { it.role == "assistant" }
runtime.sendChat(message = userText, thinking = "low", attachments = emptyList())
runtime.sendChat(
message = userText,
thinking = "low",
attachments = emptyList(),
modelName = target.modelName?.takeIf { it.isNotBlank() },
)
val responseText: String? = try {
withTimeout<String>(60_000L) {
@@ -131,10 +154,13 @@ object PrimaryBackendDispatcher {
}
if (responseText.isNullOrBlank()) {
run.error("No reply before timeout")
throw IllegalStateException(
"OpenClaw Gateway accepted the message, but the agent did not return a reply. Check the host OpenClaw agent/model authentication."
)
}
run.onToken(responseText.length)
run.complete(responseText.length)
return Reply(text = responseText, sourceDisplayName = target.displayName)
}
}
@@ -15,7 +15,7 @@ import java.security.MessageDigest
* that host MUST match.
*
* This is a defence against MITM on a paired backend's public URL — the
* exact threat hermes-relay calls out with "TOFU cert pinning".
* exact threat handled with TOFU cert pinning.
*
* Pin format follows [CertificatePinner.pin] — `sha256/<base64-encoded-hash>`.
*/
@@ -5,7 +5,11 @@ import android.content.Context
import com.openclaw.assistant.BuildConfig
import com.openclaw.assistant.bridge.accessibility.AgentVoiceAccessibilityService
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
@@ -54,6 +58,133 @@ object ScreenSwipeCapability : BridgeCapability {
}
}
object ScreenLongPressCapability : BridgeCapability {
override val name = "screen.long_press"
override val description = "Long-press a point on the screen"
override val group = "accessibility"
override val riskLevel = RiskLevel.MEDIUM
override fun isAvailable(context: Context) = a11yAvailable()
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
val x = arguments["x"]?.jsonPrimitive?.content?.toFloatOrNull()
val y = arguments["y"]?.jsonPrimitive?.content?.toFloatOrNull()
val ms = arguments["durationMs"]?.jsonPrimitive?.content?.toLongOrNull() ?: 600L
if (x == null || y == null) return buildJsonObject { put("ok", false); put("reason", "x,y required") }
val ok = AgentVoiceAccessibilityService.get()?.performLongPress(x, y, ms) ?: false
return buildJsonObject { put("ok", ok) }
}
}
object ScreenDragCapability : BridgeCapability {
override val name = "screen.drag"
override val description = "Drag from (x1,y1) to (x2,y2)"
override val group = "accessibility"
override val riskLevel = RiskLevel.MEDIUM
override fun isAvailable(context: Context) = a11yAvailable()
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject =
ScreenSwipeCapability.execute(context, arguments)
}
object ScreenFindNodesCapability : BridgeCapability {
override val name = "screen.find_nodes"
override val description = "Find visible accessibility nodes by text, class, or clickable state"
override val group = "accessibility"
override val riskLevel = RiskLevel.LOW
override fun isAvailable(context: Context) = a11yAvailable()
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
val nodes = AgentVoiceAccessibilityService.get()?.findNodes(
text = arguments["text"]?.jsonPrimitive?.content,
className = arguments["className"]?.jsonPrimitive?.content,
clickable = arguments["clickable"]?.jsonPrimitive?.content?.toBooleanStrictOrNull(),
limit = arguments["limit"]?.jsonPrimitive?.content?.toIntOrNull() ?: 20,
).orEmpty()
return buildJsonObject {
put("nodes", buildJsonArray {
nodes.forEach { node ->
add(buildJsonObject {
put("nodeId", node.nodeId)
put("text", node.text ?: "")
put("contentDescription", node.contentDescription ?: "")
put("className", node.className ?: "")
put("viewId", node.viewId ?: "")
put("bounds", node.bounds.toJson())
put("clickable", node.clickable)
put("longClickable", node.longClickable)
put("scrollable", node.scrollable)
put("editable", node.editable)
put("enabled", node.enabled)
})
}
})
}
}
}
object ScreenDescribeNodeCapability : BridgeCapability {
override val name = "screen.describe_node"
override val description = "Describe one visible accessibility node by nodeId"
override val group = "accessibility"
override val riskLevel = RiskLevel.LOW
override fun isAvailable(context: Context) = a11yAvailable()
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
val nodeId = arguments["nodeId"]?.jsonPrimitive?.content?.trim().orEmpty()
val node = AgentVoiceAccessibilityService.get()?.describeNode(nodeId)
return if (node == null) {
buildJsonObject { put("found", false); put("nodeId", nodeId) }
} else {
buildJsonObject {
put("found", true)
put("nodeId", node.nodeId)
put("text", node.text ?: "")
put("contentDescription", node.contentDescription ?: "")
put("className", node.className ?: "")
put("viewId", node.viewId ?: "")
put("bounds", node.bounds.toJson())
put("clickable", node.clickable)
put("longClickable", node.longClickable)
put("scrollable", node.scrollable)
put("editable", node.editable)
put("enabled", node.enabled)
}
}
}
}
object ScreenHashCapability : BridgeCapability {
override val name = "screen.hash"
override val description = "Return a stable hash of visible accessibility content"
override val group = "accessibility"
override val riskLevel = RiskLevel.LOW
override fun isAvailable(context: Context) = a11yAvailable()
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
val hash = AgentVoiceAccessibilityService.get()?.screenHash()
?: return buildJsonObject { put("ok", false); put("reason", "accessibility off") }
return buildJsonObject {
put("hash", hash.hash)
put("nodeCount", hash.nodeCount)
put("truncated", hash.truncated)
}
}
}
object ScreenDiffCapability : BridgeCapability {
override val name = "screen.diff"
override val description = "Compare the current screen hash with a previous hash"
override val group = "accessibility"
override val riskLevel = RiskLevel.LOW
override fun isAvailable(context: Context) = a11yAvailable()
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
val previous = arguments["previousHash"]?.jsonPrimitive?.content.orEmpty()
val hash = AgentVoiceAccessibilityService.get()?.screenHash()
?: return buildJsonObject { put("ok", false); put("reason", "accessibility off") }
return buildJsonObject {
put("changed", hash.hash != previous)
put("hash", hash.hash)
put("nodeCount", hash.nodeCount)
put("truncated", hash.truncated)
}
}
}
object ScreenHomeCapability : BridgeCapability {
override val name = "screen.home"
override val description = "Press the Home key via Accessibility Bridge"
@@ -87,20 +218,34 @@ object ScreenWindowDescribeCapability : BridgeCapability {
override suspend fun execute(context: Context, arguments: JsonObject): JsonObject {
val svc = AgentVoiceAccessibilityService.get()
?: return buildJsonObject { put("ok", false); put("reason", "accessibility off") }
val root = svc.rootInActiveWindow
val snapshot = svc.readScreenSnapshot()
val first = snapshot.nodes.firstOrNull()
return buildJsonObject {
put("ok", root != null)
put("packageName", root?.packageName?.toString() ?: "")
put("contentDescription", root?.contentDescription?.toString() ?: "")
put("text", root?.text?.toString() ?: "")
put("ok", true)
put("packageName", snapshot.packageName ?: "")
put("contentDescription", first?.contentDescription ?: "")
put("text", first?.text ?: "")
put("nodeCount", snapshot.nodes.size)
put("truncated", snapshot.truncated)
}
}
}
object A11yCapabilities {
val all: List<BridgeCapability> = listOf(
ScreenTapCapability, ScreenSwipeCapability,
ScreenTapCapability, ScreenSwipeCapability, ScreenLongPressCapability, ScreenDragCapability,
ScreenHomeCapability, ScreenBackCapability,
ScreenWindowDescribeCapability,
ScreenWindowDescribeCapability, ScreenFindNodesCapability, ScreenDescribeNodeCapability,
ScreenHashCapability, ScreenDiffCapability,
)
}
private fun com.openclaw.assistant.bridge.accessibility.BoundsSnapshot.toJson(): JsonObject =
buildJsonObject {
put("left", left)
put("top", top)
put("right", right)
put("bottom", bottom)
put("centerX", centerX)
put("centerY", centerY)
}
@@ -0,0 +1,69 @@
package com.openclaw.assistant.bridge
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@Serializable
data class BridgeActivityEntry(
val id: String,
val capability: String,
val riskLevel: String,
val status: String,
val message: String?,
val timestampMs: Long,
)
object BridgeActivityLog {
private const val PREFS_NAME = "openclaw.bridge.activity"
private const val KEY_ENTRIES = "entries.v1"
private const val MAX_ENTRIES = 100
private val _entries = MutableStateFlow<List<BridgeActivityEntry>>(emptyList())
val entries: StateFlow<List<BridgeActivityEntry>> = _entries.asStateFlow()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Volatile private var prefs: SharedPreferences? = null
fun initialize(context: Context) {
if (prefs != null) return
synchronized(this) {
if (prefs != null) return
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
_entries.value = decode(prefs?.getString(KEY_ENTRIES, null).orEmpty())
}
}
fun record(context: Context, capability: String, riskLevel: RiskLevel?, status: String, message: String? = null) {
initialize(context)
val entry = BridgeActivityEntry(
id = "${System.currentTimeMillis()}-$capability",
capability = capability,
riskLevel = riskLevel?.name?.lowercase().orEmpty(),
status = status,
message = message?.take(180),
timestampMs = System.currentTimeMillis(),
)
val next = (listOf(entry) + _entries.value).take(MAX_ENTRIES)
_entries.value = next
prefs?.edit { putString(KEY_ENTRIES, encode(next)) }
}
fun clear(context: Context) {
initialize(context)
_entries.value = emptyList()
prefs?.edit { remove(KEY_ENTRIES) }
}
private fun encode(entries: List<BridgeActivityEntry>): String =
json.encodeToString(ListSerializer(BridgeActivityEntry.serializer()), entries)
private fun decode(raw: String): List<BridgeActivityEntry> = runCatching {
json.decodeFromString(ListSerializer(BridgeActivityEntry.serializer()), raw)
}.getOrDefault(emptyList())
}
@@ -14,7 +14,7 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
/**
* Media / system capabilities that round out the Hermes-Relay tool surface
* Media / system capabilities that round out the WakeHermesClaw tool surface
* count to ~18. Each one is risk-tiered and reports `isAvailable` honestly
* based on the runtime permissions it actually holds.
*/
@@ -39,9 +39,8 @@ class MobileBridgeConfig internal constructor(private val prefs: SharedPreferenc
/**
* Package names that `apps.launch` will refuse to start. Mirrors the
* Hermes-Relay "per-app blocklist" concept: a user can shield banking,
* password, or work-profile apps even when `apps.launch` is otherwise
* granted.
* Per-app blocklist: a user can shield banking, password, or work-profile
* apps even when `apps.launch` is otherwise granted.
*/
private val _packageBlocklist = MutableStateFlow(loadBlocklist())
val packageBlocklist: StateFlow<Set<String>> = _packageBlocklist.asStateFlow()
@@ -228,18 +228,21 @@ open class MobileBridgeServer(
val allowed = config.allowedCapabilityGroups.value
val cap = registry.byName(capabilityName)
if (cap == null || !cap.isAvailable(context) || cap.group !in allowed) {
BridgeActivityLog.record(context, capabilityName, cap?.riskLevel, "blocked", "unsupported or disabled")
return HttpResponse(200, errorEnvelope(requestId, "unsupported_capability", "Capability is not supported"))
}
if (requiresApproval(cap)) {
val approved = approvalGate(requestId, capabilityName, arguments)
if (!approved) {
BridgeActivityLog.record(context, capabilityName, cap.riskLevel, "denied", "User denied or approval timed out")
return HttpResponse(200, errorEnvelope(requestId, "approval_denied", "User denied or approval timed out"))
}
}
return try {
val result = cap.execute(context, arguments)
BridgeActivityLog.record(context, capabilityName, cap.riskLevel, "completed")
HttpResponse(200, buildJsonObject {
put("requestId", requestId)
put("status", "completed")
@@ -247,6 +250,7 @@ open class MobileBridgeServer(
put("error", kotlinx.serialization.json.JsonNull)
}.toString())
} catch (e: Exception) {
BridgeActivityLog.record(context, capabilityName, cap.riskLevel, "failed", e.message ?: e.javaClass.simpleName)
HttpResponse(200, errorEnvelope(requestId, "execution_failed", e.message ?: "Unknown error"))
}
}
@@ -262,9 +266,8 @@ open class MobileBridgeServer(
* [BridgeApprovalRegistry]; tests substitute a deterministic gate.
*/
internal open suspend fun approvalGate(requestId: String, capability: String, arguments: kotlinx.serialization.json.JsonObject): Boolean {
// Honour outstanding grants — these are Hermes-Relay style "per-channel
// grants with user-chosen TTLs". A destructive verb forces a fresh
// prompt regardless of the grant.
// Honour outstanding grants with user-chosen TTLs. A destructive verb
// forces a fresh prompt regardless of the grant.
val destructive = com.openclaw.assistant.bridge.grants.DestructiveVerbs.isDestructive(capability)
if (!destructive && com.openclaw.assistant.bridge.grants.BridgeGrants.isGranted(capability)) return true
runCatching { BridgeApprovalNotifier.notify(context, requestId, capability) }
@@ -15,7 +15,7 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Mirrors Hermes-Relay's "notifications" surface. Reads the current active
* Reads the current active
* notifications via [OpenClawNotificationListenerService] (which Android
* grants only after the user has explicitly enabled
* "Notification access" for WakeHermesClaw).
@@ -0,0 +1,64 @@
package com.openclaw.assistant.bridge
import android.content.Context
import android.os.PowerManager
import android.util.Log
object WakeLockManager {
private const val TAG = "BridgeWakeLock"
private const val LOCK_TAG = "WakeHermesClaw::BridgeAction"
private const val TIMEOUT_MS = 10_000L
private val lock = Any()
private var count = 0
@Volatile private var wakeLock: PowerManager.WakeLock? = null
fun initialize(context: Context) {
synchronized(lock) {
if (wakeLock != null) return
val pm = context.applicationContext.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG).apply {
setReferenceCounted(false)
}
}
}
fun <T> withWakeLock(block: () -> T): T {
val acquired = acquire()
return try {
block()
} finally {
if (acquired) release()
}
}
fun acquire(): Boolean {
val wl = wakeLock ?: return false
synchronized(lock) {
if (count == 0) {
try {
wl.acquire(TIMEOUT_MS)
} catch (t: Throwable) {
Log.w(TAG, "acquire failed: ${t.message}")
return false
}
}
count += 1
return true
}
}
fun release() {
val wl = wakeLock ?: return
synchronized(lock) {
if (count <= 0) {
count = 0
return
}
count -= 1
if (count == 0) {
runCatching { if (wl.isHeld) wl.release() }
}
}
}
}
@@ -2,12 +2,16 @@ package com.openclaw.assistant.bridge.accessibility
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Rect
import android.graphics.Path
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import com.openclaw.assistant.bridge.WakeLockManager
import java.security.MessageDigest
/**
* Accessibility Bridge Hermes-Relay's "the agent reads your screen and acts
* on it: tap, type, swipe, screenshots, clipboard, media, notifications".
* Accessibility Bridge the agent reads the visible screen and acts on it:
* tap, type, swipe, screenshots, clipboard, media, notifications.
*
* The user must explicitly enable WakeHermesClaw in Android Settings
* Accessibility before any of the screen.* capabilities can run. The service
@@ -42,15 +46,64 @@ class AgentVoiceAccessibilityService : AccessibilityService() {
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* no-op: pull-driven service */ }
fun performTap(x: Float, y: Float): Boolean {
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y + 1f) }
val stroke = GestureDescription.StrokeDescription(path, 0L, 50L)
return dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
return WakeLockManager.withWakeLock {
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y + 1f) }
val stroke = GestureDescription.StrokeDescription(path, 0L, 50L)
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
}
fun performSwipe(x1: Float, y1: Float, x2: Float, y2: Float, durationMs: Long): Boolean {
val path = Path().apply { moveTo(x1, y1); lineTo(x2, y2) }
val stroke = GestureDescription.StrokeDescription(path, 0L, durationMs)
return dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
return WakeLockManager.withWakeLock {
val path = Path().apply { moveTo(x1, y1); lineTo(x2, y2) }
val stroke = GestureDescription.StrokeDescription(path, 0L, durationMs.coerceAtLeast(50L))
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
}
fun performLongPress(x: Float, y: Float, durationMs: Long): Boolean {
return WakeLockManager.withWakeLock {
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y + 1f) }
val stroke = GestureDescription.StrokeDescription(path, 0L, durationMs.coerceAtLeast(350L))
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
}
fun readScreenSnapshot(): ScreenSnapshot {
val roots = snapshotRoots()
return try {
ScreenSnapshot.fromRoots(roots)
} finally {
roots.forEach { runCatching { it.recycle() } }
}
}
fun findNodes(text: String?, className: String?, clickable: Boolean?, limit: Int): List<ScreenNodeSnapshot> {
val needle = text?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
val klass = className?.trim()?.takeIf { it.isNotEmpty() }
return readScreenSnapshot().nodes.filter { node ->
val hay = listOfNotNull(node.text, node.contentDescription).joinToString(" ").lowercase()
(needle == null || needle in hay) &&
(klass == null || node.className == klass) &&
(clickable == null || node.clickable == clickable)
}.take(limit.coerceIn(1, 50))
}
fun describeNode(nodeId: String): ScreenNodeSnapshot? =
readScreenSnapshot().nodes.firstOrNull { it.nodeId == nodeId }
fun screenHash(): ScreenHash {
val snapshot = readScreenSnapshot()
return ScreenHash(
hash = snapshot.computeHash(),
nodeCount = snapshot.nodes.size,
truncated = snapshot.truncated,
)
}
private fun snapshotRoots(): List<AccessibilityNodeInfo> {
val multi = runCatching { windows.mapNotNull { it.root } }.getOrDefault(emptyList())
return if (multi.isNotEmpty()) multi else listOfNotNull(rootInActiveWindow)
}
companion object {
@@ -59,3 +112,116 @@ class AgentVoiceAccessibilityService : AccessibilityService() {
fun isRunning(): Boolean = instance != null
}
}
data class ScreenSnapshot(
val packageName: String?,
val rootBounds: BoundsSnapshot,
val nodes: List<ScreenNodeSnapshot>,
val truncated: Boolean = false,
) {
fun computeHash(): String {
val joined = nodes.joinToString("\u001e") { node ->
listOf(
node.className.orEmpty(),
node.text.orEmpty(),
node.contentDescription.orEmpty(),
node.viewId.orEmpty(),
"${node.bounds.left},${node.bounds.top},${node.bounds.right},${node.bounds.bottom}",
).joinToString("|")
}
return MessageDigest.getInstance("SHA-256")
.digest(joined.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}
companion object {
private const val MAX_NODES = 512
fun fromRoots(roots: List<AccessibilityNodeInfo>): ScreenSnapshot {
val nodes = mutableListOf<ScreenNodeSnapshot>()
var truncated = false
var packageName: String? = null
val union = Rect()
var unionSet = false
roots.forEachIndexed { windowIndex, root ->
if (nodes.size >= MAX_NODES) {
truncated = true
return@forEachIndexed
}
if (packageName == null) packageName = root.packageName?.toString()
val rect = Rect()
root.getBoundsInScreen(rect)
if (!unionSet) {
union.set(rect)
unionSet = true
} else {
union.union(rect)
}
walk(root, windowIndex, nodes)
if (nodes.size >= MAX_NODES) truncated = true
}
return ScreenSnapshot(
packageName = packageName,
rootBounds = union.toBoundsSnapshot(),
nodes = nodes.take(MAX_NODES),
truncated = truncated,
)
}
private fun walk(node: AccessibilityNodeInfo?, windowIndex: Int, out: MutableList<ScreenNodeSnapshot>) {
if (node == null || out.size >= MAX_NODES) return
val rect = Rect()
node.getBoundsInScreen(rect)
val text = node.text?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.take(500)
val desc = node.contentDescription?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.take(500)
val interesting = (text != null || desc != null || node.isClickable || node.isLongClickable || node.isScrollable || node.isEditable) &&
rect.width() > 0 && rect.height() > 0
if (interesting) {
out += ScreenNodeSnapshot(
nodeId = "w$windowIndex:${out.size}",
text = text,
contentDescription = desc,
className = node.className?.toString(),
viewId = node.viewIdResourceName,
bounds = rect.toBoundsSnapshot(),
clickable = node.isClickable,
longClickable = node.isLongClickable,
scrollable = node.isScrollable,
editable = node.isEditable,
enabled = node.isEnabled,
)
}
for (i in 0 until node.childCount) {
if (out.size >= MAX_NODES) return
val child = node.getChild(i) ?: continue
try {
walk(child, windowIndex, out)
} finally {
runCatching { child.recycle() }
}
}
}
}
}
data class ScreenNodeSnapshot(
val nodeId: String,
val text: String?,
val contentDescription: String?,
val className: String?,
val viewId: String?,
val bounds: BoundsSnapshot,
val clickable: Boolean,
val longClickable: Boolean,
val scrollable: Boolean,
val editable: Boolean,
val enabled: Boolean,
)
data class BoundsSnapshot(val left: Int, val top: Int, val right: Int, val bottom: Int) {
val centerX: Int get() = (left + right) / 2
val centerY: Int get() = (top + bottom) / 2
}
data class ScreenHash(val hash: String, val nodeCount: Int, val truncated: Boolean)
private fun Rect.toBoundsSnapshot(): BoundsSnapshot = BoundsSnapshot(left, top, right, bottom)
@@ -3,8 +3,7 @@ package com.openclaw.assistant.bridge.grants
import java.util.concurrent.ConcurrentHashMap
/**
* Per-capability grant store with TTL Hermes-Relay calls these
* "per-channel grants, user-chosen TTLs, revocable from any client".
* Per-capability grant store with user-chosen TTLs, revocable from any client.
*
* A grant lets a capability run without re-prompting for [Grant.expiresAtMs]
* milliseconds. Default TTL choices are 10 minutes / 1 hour / "until I revoke"
@@ -37,8 +36,8 @@ object BridgeGrants {
}
/**
* Destructive-verb list. Hermes-Relay enforces an extra "are you sure?"
* prompt when a capability's name (or arguments) match a destructive verb.
* Destructive-verb list. The bridge enforces an extra confirmation prompt when
* a capability's name (or arguments) match a destructive verb.
*
* Matching is case-insensitive substring on the capability name; tools that
* want to bypass the check would have to pick a non-destructive name, which
@@ -5,7 +5,7 @@ import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
/**
* Pairing protocol modelled after Hermes-Relay's `hermes-pair` flow:
* Pairing protocol for trusted local Mobile Bridge clients:
*
* 1. Bridge UI generates a short-lived [Offer] containing a random 6-character
* human-readable code and a one-time pairing nonce. The offer is shown as
@@ -39,7 +39,7 @@ import com.openclaw.assistant.R
import com.openclaw.assistant.bridge.MobileBridgeConfig
/**
* Pairing screen Hermes-Relay style. Generates a short-lived offer and
* Pairing screen. Generates a short-lived offer and
* displays both the 6-character human-typeable code (for a `hermes-pair`
* style CLI) and the full `agentvoice://pair?...` URL (for QR scanners that
* can read it off the screen, or to paste into a desktop pairing tool).
@@ -127,6 +127,7 @@ class ChatController(
message: String,
thinkingLevel: String,
attachments: List<OutgoingAttachment>,
modelName: String? = null,
) {
val trimmed = message.trim()
if (trimmed.isEmpty() && attachments.isEmpty()) return
@@ -182,6 +183,7 @@ class ChatController(
put("sessionKey", JsonPrimitive(sessionKey))
put("message", JsonPrimitive(text))
put("thinking", JsonPrimitive(thinking))
modelName?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(runId))
if (attachments.isNotEmpty()) {
@@ -1104,8 +1104,8 @@ class NodeRuntime(context: Context) {
chat.abort()
}
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>, modelName: String? = null) {
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments, modelName = modelName)
}
private fun handleGatewayEvent(event: String, payloadJson: String?) {
@@ -655,6 +655,28 @@ class OpenClawSession(
}
}
private suspend fun resolveOpenClawGatewayModel(): String? {
val backends = com.openclaw.assistant.backend.BackendRepository.getInstance(context).backends.first()
.filter { it.enabled }
return backends.firstOrNull {
it.isPrimary && it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_GATEWAY
}?.modelName?.takeIf { it.isNotBlank() }
?: backends.firstOrNull {
it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_GATEWAY
}?.modelName?.takeIf { it.isNotBlank() }
}
private suspend fun resolveLegacyOpenClawModel(): String? {
val backends = com.openclaw.assistant.backend.BackendRepository.getInstance(context).backends.first()
.filter { it.enabled }
return backends.firstOrNull {
it.isPrimary && it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_HTTP
}?.modelName?.takeIf { it.isNotBlank() }
?: backends.firstOrNull {
it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_HTTP
}?.modelName?.takeIf { it.isNotBlank() }
}
private var waitPhraseJob: Job? = null
private fun scheduleInitialFillerPhrase() {
@@ -754,7 +776,8 @@ class OpenClawSession(
nodeRuntime.sendChat(
message = message,
thinking = "low",
attachments = emptyList()
attachments = emptyList(),
modelName = resolveOpenClawGatewayModel(),
)
// Wait for a new complete assistant response (timeout 60s).
@@ -828,7 +851,8 @@ class OpenClawSession(
message = message,
sessionId = settings.sessionId,
authToken = settings.authToken.takeIf { it.isNotBlank() },
agentId = agentId
agentId = agentId,
modelName = resolveLegacyOpenClawModel(),
)
cancelWaitPhraseTimer()
@@ -22,6 +22,7 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -37,8 +38,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.openclaw.assistant.backend.AgentBackendConfig
import com.openclaw.assistant.backend.AgentClientFactory
import com.openclaw.assistant.backend.AgentContextInspector
import com.openclaw.assistant.backend.AgentDiagnostics
import com.openclaw.assistant.backend.BackendRepository
import com.openclaw.assistant.backend.BackendType
import com.openclaw.assistant.backend.HermesConfigApi
import com.openclaw.assistant.backend.HermesModelOption
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -77,6 +82,9 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
var port by remember { mutableStateOf(existing?.port?.toString().orEmpty()) }
var useTls by remember { mutableStateOf(existing?.useTls ?: true) }
var modelName by remember { mutableStateOf(existing?.modelName ?: "default") }
var agentContextName by remember { mutableStateOf(existing?.agentContextName.orEmpty()) }
var agentContextDetail by remember { mutableStateOf(existing?.agentContextDetail.orEmpty()) }
var preferredEndpointRole by remember { mutableStateOf(existing?.preferredEndpointRole.orEmpty()) }
var useRunsApi by remember { mutableStateOf(existing?.useRunsApi ?: true) }
var useStreaming by remember { mutableStateOf(existing?.useStreaming ?: true) }
var setPrimary by remember { mutableStateOf(existing?.isPrimary ?: backends.isEmpty()) }
@@ -84,6 +92,8 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
var tailscaleUrl by remember { mutableStateOf(existing?.secondaryUrls?.getOrNull(1).orEmpty()) }
var publicUrl by remember { mutableStateOf(existing?.secondaryUrls?.getOrNull(2).orEmpty()) }
var status by remember { mutableStateOf<String?>(null) }
var hermesModels by remember { mutableStateOf<List<HermesModelOption>>(emptyList()) }
var hermesProviders by remember { mutableStateOf<List<String>>(emptyList()) }
val scope = rememberCoroutineScope()
Scaffold(topBar = { TopAppBar(title = { Text(if (existing == null) androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.add_backend) else androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.av_backends_edit)) }) }) { padding ->
@@ -102,12 +112,67 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
OutlinedTextField(value = displayName, onValueChange = { displayName = it }, label = { Text("Display name") }, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
Text("Agent Context", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(4.dp))
OutlinedTextField(
value = agentContextName,
onValueChange = { agentContextName = it },
label = { Text("Profile / agent name (optional)") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
OutlinedTextField(
value = agentContextDetail,
onValueChange = { agentContextDetail = it },
label = { Text("Model / personality note (optional)") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
OutlinedTextField(
value = preferredEndpointRole,
onValueChange = { preferredEndpointRole = it },
label = { Text("Preferred route label (optional)") },
modifier = Modifier.fillMaxWidth(),
)
if (type == BackendType.HERMES_API_SERVER) {
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = {
val config = buildConfig(
existing = existing,
type = type,
displayName = displayName,
baseUrl = baseUrl,
token = token,
host = host,
port = port,
useTls = useTls,
modelName = modelName,
useRunsApi = useRunsApi,
useStreaming = useStreaming,
isPrimary = setPrimary,
secondaryUrls = listOf(lanUrl, tailscaleUrl, publicUrl).filter { it.isNotBlank() },
agentContextName = agentContextName,
agentContextDetail = agentContextDetail,
preferredEndpointRole = preferredEndpointRole,
)
scope.launch {
status = "Inspecting agent context..."
val inspection = AgentContextInspector().inspect(config)
inspection.contextName?.let { agentContextName = it }
inspection.contextDetail?.let { agentContextDetail = it }
status = inspection.summary
}
}, enabled = baseUrl.isNotBlank()) {
Text("Inspect Agent Context")
}
}
Spacer(Modifier.height(12.dp))
when (type) {
BackendType.HERMES_API_SERVER -> {
OutlinedTextField(value = baseUrl, onValueChange = { baseUrl = it }, label = { Text("Primary URL (e.g. http://host:8642)") }, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
Text("Additional endpoints — raced in parallel on every connect, fastest wins (Hermes-Relay style):", style = MaterialTheme.typography.bodySmall)
Text("Additional endpoints — raced in parallel on every connect, fastest reachable route wins:", style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
OutlinedTextField(value = lanUrl, onValueChange = { lanUrl = it }, label = { Text("LAN URL (optional)") }, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(4.dp))
@@ -119,6 +184,68 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
Spacer(Modifier.height(8.dp))
OutlinedTextField(value = modelName, onValueChange = { modelName = it }, label = { Text(androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.av_import_model)) }, modifier = Modifier.fillMaxWidth())
Text(androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.av_import_model_help), style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, listOf(lanUrl, tailscaleUrl, publicUrl).filter { it.isNotBlank() }, agentContextName, agentContextDetail, preferredEndpointRole)
scope.launch {
status = "Loading Hermes models..."
runCatching { HermesConfigApi().fetchCatalog(config) }
.onSuccess { catalog ->
hermesModels = catalog.models
hermesProviders = catalog.providers
catalog.config?.model?.takeIf { it.isNotBlank() }?.let { modelName = it }
status = buildString {
append("Loaded ${catalog.models.size} model")
if (catalog.models.size != 1) append("s")
catalog.config?.provider?.takeIf { it.isNotBlank() }?.let { append(" · provider: ").append(it) }
}
}
.onFailure { status = "Could not load Hermes models: ${it.message ?: it.javaClass.simpleName}" }
}
}, enabled = baseUrl.isNotBlank()) {
Text("Load Models")
}
OutlinedButton(onClick = {
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, listOf(lanUrl, tailscaleUrl, publicUrl).filter { it.isNotBlank() }, agentContextName, agentContextDetail, preferredEndpointRole)
scope.launch {
status = "Applying model to Hermes..."
runCatching { HermesConfigApi().updateModel(config, modelName) }
.onSuccess { state ->
val saved = config.copy(modelName = state.model ?: modelName.trim().ifBlank { "default" })
repo.upsert(saved)
if (setPrimary) repo.setPrimary(saved.id)
status = "Hermes model updated: ${state.model ?: modelName}"
}
.onFailure { status = "Could not update Hermes model: ${it.message ?: it.javaClass.simpleName}" }
}
}, enabled = baseUrl.isNotBlank() && modelName.isNotBlank()) {
Text("Apply to Hermes")
}
}
if (hermesProviders.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text("Providers: ${hermesProviders.joinToString(", ")}", style = MaterialTheme.typography.bodySmall)
}
if (hermesModels.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
hermesModels.take(8).forEach { option ->
AssistChip(
onClick = { modelName = option.id },
label = {
Text(
listOfNotNull(option.id, option.description?.takeIf { it.isNotBlank() }).joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
)
},
)
}
if (hermesModels.size > 8) {
Text("+${hermesModels.size - 8} more", style = MaterialTheme.typography.bodySmall)
}
}
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = useRunsApi, onCheckedChange = { useRunsApi = it }); Text(androidx.compose.ui.res.stringResource(com.openclaw.assistant.R.string.av_hermes_use_runs_api))
@@ -158,17 +285,18 @@ fun BackendEditorScreen(existingId: String?, onDone: () -> Unit) {
val secondary = listOf(lanUrl, tailscaleUrl, publicUrl).filter { it.isNotBlank() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, secondary)
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, secondary, agentContextName, agentContextDetail, preferredEndpointRole)
repo.upsert(config)
if (setPrimary) repo.setPrimary(config.id)
onDone()
}) { Text("Save") }
Button(onClick = {
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, secondary)
val config = buildConfig(existing, type, displayName, baseUrl, token, host, port, useTls, modelName, useRunsApi, useStreaming, setPrimary, secondary, agentContextName, agentContextDetail, preferredEndpointRole)
scope.launch {
status = "Testing…"
val r = withContext(Dispatchers.IO) { AgentClientFactory.create(config).testConnection() }
AgentDiagnostics.recordHealth(context, config, r.ok, r.latencyMs, if (r.ok) null else r.message)
status = if (r.ok) "${r.message}" else "${r.message}"
}
}) { Text("Test") }
@@ -192,6 +320,9 @@ private fun buildConfig(
useStreaming: Boolean,
isPrimary: Boolean,
secondaryUrls: List<String> = emptyList(),
agentContextName: String = "",
agentContextDetail: String = "",
preferredEndpointRole: String = "",
): AgentBackendConfig {
val base = existing ?: AgentBackendConfig(displayName = displayName, type = type)
return base.copy(
@@ -207,6 +338,9 @@ private fun buildConfig(
useStreaming = useStreaming,
isPrimary = isPrimary,
secondaryUrls = secondaryUrls,
agentContextName = agentContextName.ifBlank { null },
agentContextDetail = agentContextDetail.ifBlank { null },
preferredEndpointRole = preferredEndpointRole.ifBlank { null },
)
}
@@ -47,6 +47,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.openclaw.assistant.backend.AgentBackendConfig
import com.openclaw.assistant.backend.AgentClientFactory
import com.openclaw.assistant.backend.AgentDiagnostics
import com.openclaw.assistant.backend.BackendRepository
import com.openclaw.assistant.backend.BackendType
import com.openclaw.assistant.backend.ConnectionTestResult
@@ -77,7 +78,9 @@ class BackendListViewModel(app: Application) : AndroidViewModel(app) {
fun delete(id: String) = repo.delete(id)
suspend fun testConnection(config: AgentBackendConfig): ConnectionTestResult = withContext(Dispatchers.IO) {
AgentClientFactory.create(config).testConnection()
val result = AgentClientFactory.create(config).testConnection()
AgentDiagnostics.recordHealth(getApplication<Application>().applicationContext, config, result.ok, result.latencyMs, if (result.ok) null else result.message)
result
}
}
@@ -18,8 +18,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Hermes-Relay style "tool-progress cards" feed. Producers (Hermes client
* code) push [AgentEvent.ToolProgress] events here; Chat renders the most
* Shared tool-progress feed. Producers (Hermes/OpenClaw client code) push
* [AgentEvent.ToolProgress] events here; Chat renders the most
* recent few as inline cards above the input so the user can see tool
* activity in real time.
*
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -62,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.openclaw.assistant.R
import com.openclaw.assistant.bridge.BridgeActivityLog
import com.openclaw.assistant.bridge.BridgeApprovalMode
import com.openclaw.assistant.bridge.BridgeBindMode
import com.openclaw.assistant.bridge.MobileBridgeConfig
@@ -84,6 +86,8 @@ fun MobileBridgeSettingsScreen() {
val bindMode by cfg.bindMode.collectAsState()
val approvalMode by cfg.approvalMode.collectAsState()
val allowedGroups by cfg.allowedCapabilityGroups.collectAsState()
LaunchedEffect(Unit) { BridgeActivityLog.initialize(context) }
val activityEntries by BridgeActivityLog.entries.collectAsState()
var portText by remember(port) { mutableStateOf(port.toString()) }
var showToken by remember { mutableStateOf(false) }
var rotated by remember { mutableStateOf(0) }
@@ -206,7 +210,7 @@ fun MobileBridgeSettingsScreen() {
)
Spacer(Modifier.height(10.dp))
FlowChipRow {
listOf("device", "apps", "clipboard.read", "medium").forEach { group ->
listOf("device", "apps", "accessibility", "clipboard.read", "clipboard.write", "media", "notifications", "sms", "contacts", "calendar", "camera").forEach { group ->
FilterChip(
selected = group in allowedGroups,
onClick = {
@@ -269,6 +273,34 @@ fun MobileBridgeSettingsScreen() {
}
}
SettingsCard(title = "Bridge Activity", icon = Icons.Default.CheckCircle) {
Text(
"Recent local capability calls. Arguments, screen text, screenshots, and tokens are not stored.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(10.dp))
if (activityEntries.isEmpty()) {
Text("No bridge activity yet.", style = MaterialTheme.typography.bodySmall)
} else {
activityEntries.take(8).forEach { entry ->
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Text(entry.capability, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
AssistChip(onClick = {}, label = { Text(entry.status) })
}
val detail = listOf(entry.riskLevel.takeIf { it.isNotBlank() }, entry.message).filterNotNull().joinToString(" · ")
if (detail.isNotBlank()) {
Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
OutlinedButton(onClick = { BridgeActivityLog.clear(context) }) {
Text("Clear activity")
}
}
}
if (!com.openclaw.assistant.BuildConfig.IS_SIDELOAD) {
Text(
stringResource(R.string.av_bridge_play_warning),
@@ -536,7 +536,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
nodeRuntime.sendChat(
message = text,
thinking = "low",
attachments = outgoing
attachments = outgoing,
modelName = resolveSelectedOpenClawModel(),
)
} catch (e: Exception) {
pendingNodeChatTts = false
@@ -632,6 +633,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
sessionId = sessionId,
authToken = authToken,
agentId = effectiveAgentId,
modelName = resolveSelectedOpenClawModel(),
attachments = attachments.map { Pair(it.mimeType, it.base64) }
)
@@ -1215,4 +1217,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
agentId = agentId,
)?.text
}
private suspend fun resolveSelectedOpenClawModel(): String? {
val ctx = getApplication<Application>().applicationContext
val backends = com.openclaw.assistant.backend.BackendRepository.getInstance(ctx).backends.first()
.filter { it.enabled }
val overrideId = com.openclaw.assistant.ui.backend.ChatBackendTarget.selectedId.value
val target = if (overrideId != null) {
backends.firstOrNull { it.id == overrideId }
} else {
backends.firstOrNull { it.isPrimary }
}
return target?.takeIf {
it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_GATEWAY ||
it.type == com.openclaw.assistant.backend.BackendType.OPENCLAW_HTTP
}?.modelName?.takeIf { it.isNotBlank() }
}
}
@@ -44,8 +44,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Self-check / diagnostic screen the AgentVoice analogue of Hermes-Relay's
* `hermes-status` command and `/hermes-relay-self-setup` skill. One scrollable
* Self-check / diagnostic screen. One scrollable
* view that probes every load-bearing surface so a user (or Hermes itself
* via the bridge) can answer "is everything wired correctly?" in a glance.
*
@@ -605,7 +605,7 @@ private fun parseHermesRelayJson(obj: JsonObject): PairingPayload? {
modelName = obj["model"]?.jsonPrimitive?.contentOrNull?.trim()?.ifEmpty { "default" } ?: "default",
useRunsApi = obj["runs"]?.jsonPrimitive?.booleanOrNull ?: true,
streaming = obj["streaming"]?.jsonPrimitive?.booleanOrNull ?: true,
displayName = obj["name"]?.jsonPrimitive?.contentOrNull?.trim()?.ifEmpty { null } ?: "Hermes Relay",
displayName = obj["name"]?.jsonPrimitive?.contentOrNull?.trim()?.ifEmpty { null } ?: "Hermes Agent",
),
openClawSetupCode = null,
)
@@ -34,7 +34,7 @@ import kotlin.math.min
import kotlin.math.sin
/**
* "Morphing sphere" voice indicator Hermes-Relay-style organic blob that
* "Morphing sphere" voice indicator organic blob that
* breathes while idle, ripples in response to live microphone amplitude while
* listening, swirls slowly while thinking, and pulses while the assistant
* speaks. Drives off [AssistantState] + an `audioLevel` already normalised
@@ -2,7 +2,7 @@
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeNotificationStateChanged|typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagRequestTouchExplorationMode"
android:accessibilityFlags="flagDefault|flagRequestTouchExplorationMode|flagRetrieveInteractiveWindows"
android:canPerformGestures="true"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
@@ -143,7 +143,7 @@ class PairingUriParserTest {
assertTrue(h.secondaryUrls.isEmpty())
assertEquals("api-key", h.apiKey)
assertEquals(true, h.useRunsApi)
assertEquals("Hermes Relay", h.displayName)
assertEquals("Hermes Agent", h.displayName)
}
@Test fun `Hermes Relay v3 endpoints are imported in priority order`() {