feat: add conversation-scoped yolo controls
This commit is contained in:
@@ -292,6 +292,30 @@ clarifications accept free text.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Auto-approve (YOLO)
|
||||||
|
|
||||||
|
YOLO is always conversation-scoped. It skips ordinary dangerous-command
|
||||||
|
approval prompts for that Hermes session, while Hermes hard deny rules and
|
||||||
|
hardline safety floors still apply.
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "yolo.current", "conversation_id": "a1b2…", "request_id": "r15" }
|
||||||
|
← { "type": "yolo.snapshot", "enabled": false, "scope": "conversation",
|
||||||
|
"conversation_id": "a1b2…", "request_id": "r15" }
|
||||||
|
|
||||||
|
→ { "type": "yolo.set", "conversation_id": "a1b2…", "enabled": true,
|
||||||
|
"request_id": "r16" }
|
||||||
|
← { "type": "yolo.changed", "enabled": true, "scope": "conversation",
|
||||||
|
"conversation_id": "a1b2…", "request_id": "r16" }
|
||||||
|
// plus broadcast of yolo.changed (without request_id) to all clients
|
||||||
|
```
|
||||||
|
|
||||||
|
`enabled` must be a JSON boolean. Unknown conversations return
|
||||||
|
`conversation_not_found`. The adapter also persists the flag in Hermes session
|
||||||
|
metadata so it can be restored after the gateway restarts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Attachments (agent → client deliverables)
|
## Attachments (agent → client deliverables)
|
||||||
|
|
||||||
When the agent produces a file (image, document, audio, video… via Hermes's
|
When the agent produces a file (image, document, audio, video… via Hermes's
|
||||||
|
|||||||
@@ -686,6 +686,66 @@ async def resolve_clarify(clarify_id: str, response: str) -> bool:
|
|||||||
return bool(ok)
|
return bool(ok)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Conversation-scoped YOLO / approval bypass
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
async def yolo_snapshot(conversation_id: str) -> Dict[str, Any]:
|
||||||
|
"""Return the effective dangerous-command approval bypass for one chat."""
|
||||||
|
store = _session_store()
|
||||||
|
session_key = _session_key_for(conversation_id)
|
||||||
|
session_id = None
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
session_id = await asyncio.to_thread(store.peek_session_id, session_key)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] yolo session lookup failed", exc_info=True)
|
||||||
|
if not session_id:
|
||||||
|
return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND,
|
||||||
|
"message": "Conversation not found"}
|
||||||
|
|
||||||
|
from tools.approval import enable_session_yolo, is_session_yolo_enabled
|
||||||
|
enabled = is_session_yolo_enabled(session_key)
|
||||||
|
if not enabled:
|
||||||
|
db = _session_db()
|
||||||
|
if db is not None:
|
||||||
|
try:
|
||||||
|
meta = await asyncio.to_thread(db.get_session, str(session_id))
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
if SessionDB.session_yolo_enabled(meta):
|
||||||
|
enable_session_yolo(session_key)
|
||||||
|
enabled = True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] persisted yolo read failed", exc_info=True)
|
||||||
|
return {"ok": True, "enabled": bool(enabled), "scope": "conversation",
|
||||||
|
"conversation_id": conversation_id}
|
||||||
|
|
||||||
|
|
||||||
|
async def set_yolo(conversation_id: str, enabled: bool) -> Dict[str, Any]:
|
||||||
|
"""Set YOLO for one chat and persist it with that Hermes session."""
|
||||||
|
store = _session_store()
|
||||||
|
session_key = _session_key_for(conversation_id)
|
||||||
|
session_id = None
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
session_id = await asyncio.to_thread(store.peek_session_id, session_key)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] yolo session lookup failed", exc_info=True)
|
||||||
|
if not session_id:
|
||||||
|
return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND,
|
||||||
|
"message": "Conversation not found"}
|
||||||
|
|
||||||
|
from tools.approval import disable_session_yolo, enable_session_yolo
|
||||||
|
(enable_session_yolo if enabled else disable_session_yolo)(session_key)
|
||||||
|
db = _session_db()
|
||||||
|
if db is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(db.set_session_yolo, str(session_id), bool(enabled))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[pheby] yolo persistence failed", exc_info=True)
|
||||||
|
return {"ok": True, "enabled": bool(enabled), "scope": "conversation",
|
||||||
|
"conversation_id": conversation_id}
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Models & reasoning
|
# Models & reasoning
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ C_MODEL_SET = "model.set"
|
|||||||
C_MODEL_CURRENT = "models.current"
|
C_MODEL_CURRENT = "models.current"
|
||||||
C_REASONING_SET = "reasoning.set"
|
C_REASONING_SET = "reasoning.set"
|
||||||
C_REASONING_CURRENT = "reasoning.current"
|
C_REASONING_CURRENT = "reasoning.current"
|
||||||
|
C_YOLO_SET = "yolo.set"
|
||||||
|
C_YOLO_CURRENT = "yolo.current"
|
||||||
|
|
||||||
# ── Server → client message types ────────────────────────────────────────────
|
# ── Server → client message types ────────────────────────────────────────────
|
||||||
S_READY = "ready"
|
S_READY = "ready"
|
||||||
@@ -98,6 +100,8 @@ S_MODEL_CURRENT_SNAPSHOT = "model.current" # reply to models.current
|
|||||||
S_MODEL_CHANGED = "model.changed" # after model.set accepted
|
S_MODEL_CHANGED = "model.changed" # after model.set accepted
|
||||||
S_REASONING_SNAPSHOT = "reasoning.snapshot" # reply to reasoning.current
|
S_REASONING_SNAPSHOT = "reasoning.snapshot" # reply to reasoning.current
|
||||||
S_REASONING_CHANGED = "reasoning.changed" # after reasoning.set accepted
|
S_REASONING_CHANGED = "reasoning.changed" # after reasoning.set accepted
|
||||||
|
S_YOLO_SNAPSHOT = "yolo.snapshot" # reply to yolo.current
|
||||||
|
S_YOLO_CHANGED = "yolo.changed" # after yolo.set accepted
|
||||||
|
|
||||||
# Reasoning effort levels supported by Hermes (hermes_constants).
|
# Reasoning effort levels supported by Hermes (hermes_constants).
|
||||||
REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||||
@@ -179,7 +183,7 @@ __all__ = [
|
|||||||
"C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE",
|
"C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE",
|
||||||
"C_CHAT_SEND", "C_RUN_CANCEL", "C_APPROVAL_RESPOND", "C_CLARIFY_RESPOND",
|
"C_CHAT_SEND", "C_RUN_CANCEL", "C_APPROVAL_RESPOND", "C_CLARIFY_RESPOND",
|
||||||
"C_MODELS_LIST", "C_MODEL_SET", "C_MODEL_CURRENT", "C_REASONING_SET",
|
"C_MODELS_LIST", "C_MODEL_SET", "C_MODEL_CURRENT", "C_REASONING_SET",
|
||||||
"C_REASONING_CURRENT",
|
"C_REASONING_CURRENT", "C_YOLO_SET", "C_YOLO_CURRENT",
|
||||||
"S_READY", "S_PONG", "S_ERROR", "S_CONVERSATION_SNAPSHOT",
|
"S_READY", "S_PONG", "S_ERROR", "S_CONVERSATION_SNAPSHOT",
|
||||||
"S_CONVERSATION_CREATED", "S_CONVERSATION_RENAMED", "S_CONVERSATION_UPDATED",
|
"S_CONVERSATION_CREATED", "S_CONVERSATION_RENAMED", "S_CONVERSATION_UPDATED",
|
||||||
"S_CONVERSATION_DELETED", "S_CONVERSATION_HISTORY", "S_RUN_ACCEPTED",
|
"S_CONVERSATION_DELETED", "S_CONVERSATION_HISTORY", "S_RUN_ACCEPTED",
|
||||||
@@ -188,7 +192,7 @@ __all__ = [
|
|||||||
"S_APPROVAL_RESOLVED", "S_CLARIFY_REQUEST", "S_CLARIFY_RESOLVED",
|
"S_APPROVAL_RESOLVED", "S_CLARIFY_REQUEST", "S_CLARIFY_RESOLVED",
|
||||||
"S_ATTACHMENT_ADDED", "S_MODELS_SNAPSHOT", "S_MODEL_CURRENT_SNAPSHOT",
|
"S_ATTACHMENT_ADDED", "S_MODELS_SNAPSHOT", "S_MODEL_CURRENT_SNAPSHOT",
|
||||||
"S_MODEL_CHANGED", "S_REASONING_SNAPSHOT", "S_REASONING_CHANGED",
|
"S_MODEL_CHANGED", "S_REASONING_SNAPSHOT", "S_REASONING_CHANGED",
|
||||||
"REASONING_EFFORTS",
|
"S_YOLO_SNAPSHOT", "S_YOLO_CHANGED", "REASONING_EFFORTS",
|
||||||
"now_iso", "new_id", "is_valid_attachment_id", "encode_message",
|
"now_iso", "new_id", "is_valid_attachment_id", "encode_message",
|
||||||
"decode_message", "error_event", "safe_str",
|
"decode_message", "error_event", "safe_str",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -531,6 +531,52 @@ class PhebyServer:
|
|||||||
**({"request_id": request_id} if request_id else {}),
|
**({"request_id": request_id} if request_id else {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async def _handle_yolo_current(self, client, message, request_id):
|
||||||
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
|
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
|
||||||
|
return
|
||||||
|
result = await self.bridge.yolo_snapshot(conversation_id)
|
||||||
|
if not result.get("ok"):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
result.get("code", proto.ERR_INTERNAL),
|
||||||
|
result.get("message", "YOLO state unavailable"), request_id))
|
||||||
|
return
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_YOLO_SNAPSHOT,
|
||||||
|
"enabled": bool(result.get("enabled")),
|
||||||
|
"scope": result.get("scope", "conversation"),
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_yolo_set(self, client, message, request_id):
|
||||||
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
|
enabled = message.get("enabled")
|
||||||
|
if not ConversationRouter.is_valid_conversation_id(conversation_id) \
|
||||||
|
or not isinstance(enabled, bool):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST,
|
||||||
|
"conversation_id and boolean enabled are required", request_id))
|
||||||
|
return
|
||||||
|
result = await self.bridge.set_yolo(conversation_id, enabled)
|
||||||
|
if not result.get("ok"):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
result.get("code", proto.ERR_INTERNAL),
|
||||||
|
result.get("message", "YOLO change failed"), request_id))
|
||||||
|
return
|
||||||
|
event = {
|
||||||
|
"type": proto.S_YOLO_CHANGED,
|
||||||
|
"enabled": bool(result.get("enabled")),
|
||||||
|
"scope": result.get("scope", "conversation"),
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
}
|
||||||
|
await client.send_json(event)
|
||||||
|
await self.broadcast({k: v for k, v in event.items()
|
||||||
|
if k != "request_id"})
|
||||||
|
|
||||||
async def _handle_models_list(self, client, message, request_id):
|
async def _handle_models_list(self, client, message, request_id):
|
||||||
conversation_id = message.get("conversation_id")
|
conversation_id = message.get("conversation_id")
|
||||||
snapshot = await self.bridge.models_snapshot(
|
snapshot = await self.bridge.models_snapshot(
|
||||||
@@ -617,6 +663,8 @@ class PhebyServer:
|
|||||||
proto.C_MODEL_CURRENT: _handle_model_current,
|
proto.C_MODEL_CURRENT: _handle_model_current,
|
||||||
proto.C_REASONING_SET: _handle_reasoning_set,
|
proto.C_REASONING_SET: _handle_reasoning_set,
|
||||||
proto.C_REASONING_CURRENT: _handle_reasoning_current,
|
proto.C_REASONING_CURRENT: _handle_reasoning_current,
|
||||||
|
proto.C_YOLO_SET: _handle_yolo_set,
|
||||||
|
proto.C_YOLO_CURRENT: _handle_yolo_current,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -585,6 +585,86 @@ class TestBridge:
|
|||||||
ev = client.ws.events()[-1]
|
ev = client.ws.events()[-1]
|
||||||
assert ev["error"]["code"] == proto.ERR_APPROVAL_NOT_FOUND
|
assert ev["error"]["code"] == proto.ERR_APPROVAL_NOT_FOUND
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_yolo_state_is_conversation_scoped_and_persisted(self, monkeypatch):
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
import tools.approval
|
||||||
|
|
||||||
|
conversation_id = "a" * 32
|
||||||
|
session_key = f"agent:main:pheby:dm:{conversation_id}"
|
||||||
|
persisted = []
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def peek_session_id(self, key):
|
||||||
|
assert key == session_key
|
||||||
|
return "session-1"
|
||||||
|
|
||||||
|
class DB:
|
||||||
|
def set_session_yolo(self, session_id, enabled):
|
||||||
|
persisted.append((session_id, enabled))
|
||||||
|
|
||||||
|
monkeypatch.setattr(hb, "_session_store", lambda: Store())
|
||||||
|
monkeypatch.setattr(hb, "_session_db", lambda: DB())
|
||||||
|
tools.approval.clear_session(session_key)
|
||||||
|
try:
|
||||||
|
assert (await hb.yolo_snapshot(conversation_id))["enabled"] is False
|
||||||
|
|
||||||
|
enabled = await hb.set_yolo(conversation_id, True)
|
||||||
|
assert enabled == {
|
||||||
|
"ok": True,
|
||||||
|
"enabled": True,
|
||||||
|
"scope": "conversation",
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
}
|
||||||
|
assert tools.approval.is_session_yolo_enabled(session_key) is True
|
||||||
|
assert (await hb.yolo_snapshot(conversation_id))["enabled"] is True
|
||||||
|
|
||||||
|
disabled = await hb.set_yolo(conversation_id, False)
|
||||||
|
assert disabled["enabled"] is False
|
||||||
|
assert tools.approval.is_session_yolo_enabled(session_key) is False
|
||||||
|
assert persisted == [("session-1", True), ("session-1", False)]
|
||||||
|
finally:
|
||||||
|
tools.approval.clear_session(session_key)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_yolo_websocket_handlers_return_snapshot_and_changed(self, tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
conversation_id = "b" * 32
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def snapshot(cid):
|
||||||
|
calls.append(("current", cid))
|
||||||
|
return {"ok": True, "enabled": False, "scope": "conversation",
|
||||||
|
"conversation_id": cid}
|
||||||
|
|
||||||
|
async def change(cid, enabled):
|
||||||
|
calls.append(("set", cid, enabled))
|
||||||
|
return {"ok": True, "enabled": enabled, "scope": "conversation",
|
||||||
|
"conversation_id": cid}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server.bridge, "yolo_snapshot", snapshot)
|
||||||
|
monkeypatch.setattr(server.bridge, "set_yolo", change)
|
||||||
|
|
||||||
|
await server._handle_yolo_current(client, {
|
||||||
|
"type": proto.C_YOLO_CURRENT,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
}, "r-yolo-current")
|
||||||
|
await server._handle_yolo_set(client, {
|
||||||
|
"type": proto.C_YOLO_SET,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"enabled": True,
|
||||||
|
}, "r-yolo-set")
|
||||||
|
|
||||||
|
events = client.ws.events()
|
||||||
|
assert events[0]["type"] == proto.S_YOLO_SNAPSHOT
|
||||||
|
assert events[0]["request_id"] == "r-yolo-current"
|
||||||
|
assert events[1]["type"] == proto.S_YOLO_CHANGED
|
||||||
|
assert events[1]["enabled"] is True
|
||||||
|
assert events[1]["request_id"] == "r-yolo-set"
|
||||||
|
assert calls == [("current", conversation_id), ("set", conversation_id, True)]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_clarify_push_and_resolve_roundtrip(self, tmp_path):
|
async def test_clarify_push_and_resolve_roundtrip(self, tmp_path):
|
||||||
server = make_server(tmp_path)
|
server = make_server(tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user