Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
3052db7d65
commit
168d27dcca
5 files changed
+156
No files matched your search
@@ -90,6 +90,18 @@ object EventParser {
|
||||
WsEvent.ToolComplete(name, payload, sessionId)
|
||||
}
|
||||
|
||||
"tool.output_risk" -> {
|
||||
val toolId = payload?.get("tool_id") as? String ?: ""
|
||||
val name = payload?.get("name") as? String ?: ""
|
||||
val risk = (payload?.get("risk") as? String)?.lowercase() ?: "low"
|
||||
val redacted = payload?.get("redacted") as? Boolean ?: false
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val findings = (payload?.get("findings") as? List<*>)?.filterIsInstance<String>() ?: emptyList()
|
||||
|
||||
WsEvent.ToolOutputRisk(toolId, name, risk, findings, redacted, sessionId)
|
||||
}
|
||||
|
||||
"clarify.request" -> {
|
||||
// Gateway sends "question"/"choices" — fall back to "text"/"options" for any
|
||||
// older client or test that still uses the legacy field names. (Issue #206)
|
||||
|
||||
@@ -62,6 +62,23 @@ sealed class WsEvent {
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
* Backend flagged tool output as having potential risk (secrets, PII).
|
||||
* Emitted alongside tool.progress — carries risk level, findings, and
|
||||
* whether content was redacted.
|
||||
*
|
||||
* Events: `tool.output_risk`
|
||||
* Payload: `{ tool_id, name, risk, findings[], redacted }`
|
||||
*/
|
||||
data class ToolOutputRisk(
|
||||
val toolId: String = "",
|
||||
val name: String = "",
|
||||
val risk: String = "low",
|
||||
val findings: List<String> = emptyList(),
|
||||
val redacted: Boolean = false,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Interactive ──────────────────────────────────────────────────────
|
||||
|
||||
data class ClarifyRequest(
|
||||
|
||||
@@ -85,6 +85,7 @@ import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.theme.AssistantBubble
|
||||
import com.m57.hermescontrol.theme.AssistantBubbleLight
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.StatusGreen
|
||||
import com.m57.hermescontrol.theme.StatusRed
|
||||
import com.m57.hermescontrol.theme.SystemMessageColor
|
||||
@@ -1893,6 +1894,12 @@ private fun ToolBubble(
|
||||
// ── Header row: icon + tool name ──
|
||||
HeaderRow(message, config, contentColor)
|
||||
|
||||
// ── Security risk chip (tool.output_risk) ──
|
||||
val riskData = message.toolOutputRiskData
|
||||
if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) {
|
||||
SecurityRiskChip(riskData, contentColor)
|
||||
}
|
||||
|
||||
// ── Summary line (always visible when collapsed) ──
|
||||
if (!expanded && parsed?.summaryText != null) {
|
||||
Text(
|
||||
@@ -2069,6 +2076,56 @@ private fun HeaderRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security risk chip for [tool.output_risk] events.
|
||||
*
|
||||
* Shows a compact ⚠ badge when the backend flagged tool output as risky.
|
||||
* Renders in the tool card between the header row and the summary line.
|
||||
*/
|
||||
@Composable
|
||||
private fun SecurityRiskChip(
|
||||
riskData: ToolOutputRiskData,
|
||||
contentColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (chipColor, label) =
|
||||
when {
|
||||
riskData.risk == "high" -> statusColors.error to "Risky output"
|
||||
riskData.risk == "medium" -> statusColors.warning to "Caution"
|
||||
else -> statusColors.warning to "Redacted"
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(top = 4.dp, start = 22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "⚠",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = chipColor,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = chipColor,
|
||||
)
|
||||
if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) {
|
||||
Text(
|
||||
text = "· redacted",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = chipColor.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyButton(
|
||||
visible: Boolean,
|
||||
|
||||
@@ -14,6 +14,17 @@ data class ApprovalInfo(
|
||||
val patternKeys: List<String>?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Risk metadata from the backend's [tool.output_risk] WS event.
|
||||
* Attached to the tool [ChatMessage] so the UI can render a security chip.
|
||||
* Transient — not persisted to SQLite.
|
||||
*/
|
||||
data class ToolOutputRiskData(
|
||||
val risk: String, // "low" | "medium" | "high"
|
||||
val findings: List<String>,
|
||||
val redacted: Boolean,
|
||||
)
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val role: MessageRole,
|
||||
@@ -26,6 +37,12 @@ data class ChatMessage(
|
||||
val approvalInfo: ApprovalInfo? = null,
|
||||
/** Files attached to this message — shown inline in the bubble. */
|
||||
val attachments: List<Attachment>? = null,
|
||||
/**
|
||||
* Risk metadata from [tool.output_risk] WS event.
|
||||
* When risk is "medium"/"high" or redacted is true, the UI shows a ⚠ chip.
|
||||
* Transient — not persisted to SQLite.
|
||||
*/
|
||||
val toolOutputRiskData: ToolOutputRiskData? = null,
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
|
||||
@@ -32,6 +32,7 @@ object ChatWsEventReducer {
|
||||
is WsEvent.MessageDone -> event.sessionId
|
||||
is WsEvent.ToolStart -> event.sessionId
|
||||
is WsEvent.ToolComplete -> event.sessionId
|
||||
is WsEvent.ToolOutputRisk -> event.sessionId
|
||||
is WsEvent.ClarifyRequest -> event.sessionId
|
||||
else -> null
|
||||
}
|
||||
@@ -67,6 +68,8 @@ object ChatWsEventReducer {
|
||||
|
||||
is WsEvent.ToolComplete -> onToolComplete(state, streamingState, event)
|
||||
|
||||
is WsEvent.ToolOutputRisk -> onToolOutputRisk(state, streamingState, event)
|
||||
|
||||
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
|
||||
|
||||
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
||||
@@ -350,6 +353,56 @@ object ChatWsEventReducer {
|
||||
)
|
||||
}
|
||||
|
||||
// ── ToolOutputRisk ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attaches [ToolOutputRiskData] to the matching tool message.
|
||||
*
|
||||
* Finds the last RUNNING tool message with matching [WsEvent.ToolOutputRisk.name].
|
||||
* If no RUNNING message exists (tool already completed), falls back to the last
|
||||
* COMPLETED tool with that name. This covers the case where the risk event arrives
|
||||
* after tool.complete.
|
||||
*/
|
||||
private fun onToolOutputRisk(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
event: WsEvent.ToolOutputRisk,
|
||||
): ReducerResult {
|
||||
val riskData =
|
||||
ToolOutputRiskData(
|
||||
risk = event.risk,
|
||||
findings = event.findings,
|
||||
redacted = event.redacted,
|
||||
)
|
||||
|
||||
val messages = state.messages.toMutableList()
|
||||
|
||||
// Prefer RUNNING tool, fall back to COMPLETED
|
||||
var toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
}
|
||||
if (toolIdx < 0) {
|
||||
toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.COMPLETED
|
||||
}
|
||||
}
|
||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
messages[toolIdx] = messages[toolIdx].copy(toolOutputRiskData = riskData)
|
||||
|
||||
// No persist — risk data is transient (not stored in SQLite)
|
||||
return ReducerResult(
|
||||
state = state.copy(messages = messages),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
// ── ClarifyRequest ────────────────────────────────────────────────
|
||||
|
||||
private fun onClarifyRequest(
|
||||
|
||||
Reference in new issue
Block a user