This commit is contained in:
9 files changed
+942
-90
No files matched your search
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.await
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||
@@ -827,8 +828,10 @@ object HermesWsClient {
|
||||
/**
|
||||
* Send a typed JSON-RPC request and await its deserialized result.
|
||||
*
|
||||
* Params classes are strict writers, result classes are tolerant readers, and
|
||||
* profile is never a field in params (it is injected by WsProfileParams).
|
||||
* Params classes are strict writers; `profile` is a field ONLY on methods
|
||||
* where callers pass an explicit override (e.g. session.create, session.resume);
|
||||
* otherwise it is injected by WsProfileParams (an explicit value wins).
|
||||
* Result classes are tolerant readers.
|
||||
*/
|
||||
suspend fun <P, R> call(
|
||||
method: RpcMethod<P, R>,
|
||||
@@ -836,10 +839,7 @@ object HermesWsClient {
|
||||
timeoutMs: Long = REQUEST_TIMEOUT_MS,
|
||||
suppressErrorEvent: Boolean = false,
|
||||
): R {
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
|
||||
require(encoded is JsonObject) {
|
||||
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
|
||||
}
|
||||
val encoded = encodeParams(method, params)
|
||||
val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent)
|
||||
val result =
|
||||
try {
|
||||
@@ -852,6 +852,17 @@ object HermesWsClient {
|
||||
return OkHttpProvider.json.decodeFromJsonElement(method.result, element)
|
||||
}
|
||||
|
||||
private fun <P, R> encodeParams(
|
||||
method: RpcMethod<P, R>,
|
||||
params: P,
|
||||
): JsonObject {
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
|
||||
require(encoded is JsonObject) {
|
||||
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
/** Complete (or fail) a single pending call and cancel its timer. */
|
||||
private fun resolvePending(
|
||||
id: String,
|
||||
@@ -957,6 +968,21 @@ object HermesWsClient {
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
): String = send(method, params, onSent, true)
|
||||
|
||||
/**
|
||||
* Send a typed fire-and-forget JSON-RPC request.
|
||||
*
|
||||
* Params classes are strict writers; `profile` is a field ONLY on methods
|
||||
* where callers pass an explicit override (session.create, session.resume);
|
||||
* otherwise it is injected by WsProfileParams (an explicit value wins).
|
||||
*
|
||||
* @return the request id used (can be matched against [WsEvent.RpcResult]).
|
||||
*/
|
||||
fun <P, R> send(
|
||||
method: RpcMethod<P, R>,
|
||||
params: P,
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
): String = send(method.name, encodeParams(method, params), onSent)
|
||||
|
||||
private fun send(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
@@ -1065,23 +1091,17 @@ object HermesWsClient {
|
||||
text: String,
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
queued: Boolean = false,
|
||||
): String {
|
||||
val params =
|
||||
buildMap {
|
||||
put("session_id", sessionId)
|
||||
put("text", text)
|
||||
// Explicit queue semantics: the gateway's busy-input policy
|
||||
// forces "run after, never interrupt" when prompt.submit
|
||||
// carries queued=true (hermes-agent methods_prompt.py
|
||||
// _handle_busy_submit) — used by /queue.
|
||||
if (queued) put("queued", true)
|
||||
}
|
||||
return send(
|
||||
method = WsMethods.PROMPT_SUBMIT,
|
||||
params = params,
|
||||
): String =
|
||||
send(
|
||||
method = RpcMethods.PROMPT_SUBMIT,
|
||||
params =
|
||||
PromptSubmitParams(
|
||||
sessionId = sessionId,
|
||||
text = text,
|
||||
queued = queued.takeIf { it },
|
||||
),
|
||||
onSent = onSent,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: redirect the active model turn while it is still generating
|
||||
|
||||
@@ -17,6 +17,33 @@ object RpcMethods {
|
||||
SessionEventsSinceResult.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_CREATE: RpcMethod<SessionCreateParams, SessionCreateResult> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_CREATE,
|
||||
SessionCreateParams.serializer(),
|
||||
SessionCreateResult.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_RESUME: RpcMethod<SessionResumeParams, SessionResumeResult> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_RESUME,
|
||||
SessionResumeParams.serializer(),
|
||||
SessionResumeResult.serializer(),
|
||||
)
|
||||
|
||||
val PROMPT_SUBMIT: RpcMethod<PromptSubmitParams, PromptSubmitResult> =
|
||||
RpcMethod(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
PromptSubmitParams.serializer(),
|
||||
PromptSubmitResult.serializer(),
|
||||
)
|
||||
|
||||
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
|
||||
val all: List<RpcMethod<*, *>> = listOf(SESSION_EVENTS_SINCE)
|
||||
val all: List<RpcMethod<*, *>> =
|
||||
listOf(
|
||||
SESSION_EVENTS_SINCE,
|
||||
SESSION_CREATE,
|
||||
SESSION_RESUME,
|
||||
PROMPT_SUBMIT,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SessionCreateParams(
|
||||
@SerialName("source") val source: String? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
@SerialName("title") val title: String? = null,
|
||||
@SerialName("hidden") val hidden: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionResumeParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PromptSubmitParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("text") val text: String,
|
||||
@SerialName("queued") val queued: Boolean? = null,
|
||||
)
|
||||
|
||||
// tolerant readers: all nullable with null defaults, only fields callers read
|
||||
@Serializable
|
||||
data class SessionCreateResult(
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
@SerialName("stored_session_id") val storedSessionId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionResumeResult(
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PromptSubmitResult(
|
||||
val status: String? = null,
|
||||
)
|
||||
@@ -11,7 +11,9 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -109,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val runtimeSessionId =
|
||||
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
||||
?: resumeSession(sessionId)
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to runtimeSessionId, "text" to replyText),
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
method = RpcMethods.PROMPT_SUBMIT,
|
||||
params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText),
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val entity =
|
||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||
@@ -152,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
private suspend fun resumeSession(storedSessionId: String): String {
|
||||
val profile = AuthManager.activeProfileId.value
|
||||
val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to storedSessionId,
|
||||
"omit_messages" to true,
|
||||
SessionResumeParams(
|
||||
sessionId = storedSessionId,
|
||||
omitMessages = true,
|
||||
profile = profile,
|
||||
)
|
||||
if (!profile.isNullOrBlank()) {
|
||||
params["profile"] = profile
|
||||
}
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SESSION_RESUME,
|
||||
params,
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
).await() as? Map<*, *>
|
||||
HermesWsClient.call(
|
||||
method = RpcMethods.SESSION_RESUME,
|
||||
params = params,
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
)
|
||||
val runtimeSessionId =
|
||||
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
||||
result.sessionId?.takeIf { it.isNotBlank() }
|
||||
?: error("Resume returned no runtime session id")
|
||||
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
|
||||
return runtimeSessionId
|
||||
|
||||
@@ -17,6 +17,9 @@ import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
|
||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -559,17 +562,24 @@ class GroupChatViewModel(
|
||||
|
||||
val title = "Group: $groupName"
|
||||
try {
|
||||
val createParams =
|
||||
buildMap<String, Any> {
|
||||
put("profile", bot.name)
|
||||
put("title", title)
|
||||
put("source", "desktop")
|
||||
put("hidden", true)
|
||||
}
|
||||
val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams)
|
||||
val res = deferred.await()
|
||||
val sessionInfo = extractSessionInfo(res)
|
||||
if (sessionInfo != null) {
|
||||
val res =
|
||||
HermesWsClient.call(
|
||||
method = RpcMethods.SESSION_CREATE,
|
||||
params =
|
||||
SessionCreateParams(
|
||||
source = "desktop",
|
||||
profile = bot.name,
|
||||
title = title,
|
||||
hidden = true,
|
||||
),
|
||||
)
|
||||
val runtimeId = res.sessionId?.takeIf { it.isNotBlank() }
|
||||
if (runtimeId != null) {
|
||||
val sessionInfo =
|
||||
MemberSession(
|
||||
runtimeSessionId = runtimeId,
|
||||
storedSessionId = res.storedSessionId,
|
||||
)
|
||||
memberSessions[bot.name] = sessionInfo
|
||||
return sessionInfo
|
||||
}
|
||||
@@ -701,11 +711,10 @@ class GroupChatViewModel(
|
||||
// turn boundary can be proven for them. They stay uncorrelated
|
||||
// (reply notifications from group turns are never auto-dismissed
|
||||
// from REST hydration) instead of borrowing another turn's bound.
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to runtimeId, "text" to prompt),
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
method = RpcMethods.PROMPT_SUBMIT,
|
||||
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
|
||||
)
|
||||
} catch (submitErr: Exception) {
|
||||
Log.w(
|
||||
"GroupChatViewModel",
|
||||
@@ -735,11 +744,10 @@ class GroupChatViewModel(
|
||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||
|
||||
try {
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to runtimeId, "text" to prompt),
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
method = RpcMethods.PROMPT_SUBMIT,
|
||||
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
|
||||
)
|
||||
} catch (retryErr: Exception) {
|
||||
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
|
||||
return null
|
||||
@@ -1202,19 +1210,4 @@ class GroupChatViewModel(
|
||||
val clean = text.trim().lowercase()
|
||||
return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass."
|
||||
}
|
||||
|
||||
private fun extractSessionInfo(response: Any?): MemberSession? {
|
||||
if (response is Map<*, *>) {
|
||||
val runtimeId =
|
||||
response["session_id"]?.toString()?.trim('\"', ' ')
|
||||
?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ')
|
||||
val storedId =
|
||||
response["stored_session_id"]?.toString()?.trim('\"', ' ')
|
||||
?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ')
|
||||
if (!runtimeId.isNullOrBlank()) {
|
||||
return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user