This repository has no description
6.0 kB
160 lines
1"""hermes-voice's side inside the Hermes gateway (tools/hermes-voice).
2
3matrix_dm_send lets every session, the voice calls on the API server included,
4write to the owner's Matrix DM (the gateway's Matrix home room). Hermes
5registers its send_message tool for no session (tools/send_message_tool.py
6keeps it out of the model's tools on purpose); this one reaches that single
7room and uses the same sender as cron delivery, through the gateway's live
8Matrix adapter. Sent messages are mirrored into the DM's session, so the
9Matrix side sees what was asked when the user answers there.
10
11end_call ends the voice call the session belongs to. The tool itself only
12checks that it runs in a voice session and says so; hermes-voice sees the
13call in the run's event stream and lets the page close the call once the
14current reply has been spoken.
15
16/assist approve|deny [code] answers an approval that hermes-voice asked in the
17DM because no call could hear it. The gateway only dispatches commands of
18allowed users (MATRIX_ALLOWED_USERS), so the answer is the user's own; the
19agent cannot issue it. The code may be left out while exactly one approval
20waits.
21"""
22
23import json
24import os
25import urllib.error
26import urllib.request
27
28SEND_SCHEMA = {
29 "name": "matrix_dm_send",
30 "description": (
31 "Send a message to the user's Matrix chat with you (their DM with the Hermes bot). Use "
32 "it when the user asks you to tell or send them something there, or when you need to ask "
33 "them something while no conversation with them is open: their answer arrives in that "
34 "Matrix chat. To read that chat, look up the Matrix DM session with session_search "
35 "(chat_history_lookup) and read its messages."
36 ),
37 "parameters": {
38 "type": "object",
39 "properties": {
40 "message": {"type": "string", "description": "The message, Markdown allowed."},
41 },
42 "required": ["message"],
43 },
44}
45
46END_CALL_SCHEMA = {
47 "name": "end_call",
48 "description": (
49 "End the current voice call after the current reply has been spoken. Only works in a "
50 "voice call (assist.lassul.us). Call it when the user says goodbye, says that is all, "
51 "asks you to hang up or says they are leaving, once any work they asked for has been "
52 "started or handed off; never while you still need an answer from them, such as an "
53 "approval. Your reply is still spoken before the call ends: keep it to a short "
54 "confirmation or goodbye."
55 ),
56 "parameters": {"type": "object", "properties": {}},
57}
58
59# hermes-voice names the Hermes session of a call voice-<date>-<random>.
60VOICE_SESSION_PREFIX = "voice-"
61
62CHOICES = {"approve": "once", "deny": "deny"}
63
64
65def send(args, **_kw):
66 from tools.send_message_tool import send_message_tool
67
68 return send_message_tool(
69 {"action": "send", "target": "matrix", "message": args.get("message", "")}
70 )
71
72
73def voice_session(session_id):
74 """Whether the calling session is a hermes-voice call: an API server
75 session whose lineage starts at a voice-* session (context compression
76 continues a session under a new id, with the old one as parent)."""
77 from gateway.session_context import get_session_env
78 from hermes_state import SessionDB
79
80 if get_session_env("HERMES_SESSION_PLATFORM", "") != "api_server":
81 return False
82 session_id = session_id or get_session_env("HERMES_SESSION_ID", "")
83 db = SessionDB(read_only=True)
84 try:
85 seen = set()
86 while session_id and session_id not in seen and len(seen) < 100:
87 if session_id.startswith(VOICE_SESSION_PREFIX):
88 return True
89 seen.add(session_id)
90 session = db.get_session(session_id)
91 session_id = session and session.get("parent_session_id")
92 finally:
93 db.close()
94 return False
95
96
97def end_call(args, session_id=None, **_kw):
98 from tools.registry import tool_error
99
100 if not voice_session(session_id):
101 return tool_error("end_call only works in a voice call; there is no call to end here.")
102 return json.dumps({"status": "The call ends once your reply has been spoken."})
103
104
105def assist(raw_args):
106 from agent.secret_scope import get_secret
107
108 words = raw_args.split()
109 if not words or words[0] not in CHOICES or len(words) > 2:
110 return "Usage: /assist approve|deny [code]"
111 answer = {"choice": CHOICES[words[0]]}
112 if len(words) == 2:
113 answer["code"] = words[1]
114 request = urllib.request.Request(
115 os.environ["HERMES_VOICE_URL"].rstrip("/") + "/approve",
116 data=json.dumps(answer).encode(),
117 method="POST",
118 headers={
119 "Authorization": f"Bearer {get_secret('API_SERVER_KEY')}",
120 "Content-Type": "application/json",
121 },
122 )
123 try:
124 with urllib.request.urlopen(request, timeout=30) as response:
125 result = json.load(response)
126 except urllib.error.HTTPError as error:
127 try:
128 reason = json.load(error).get("error")
129 except ValueError:
130 reason = error.reason
131 return f"hermes-voice: {reason}"
132 except (urllib.error.URLError, OSError) as error:
133 return f"hermes-voice unreachable: {error}"
134 what = result.get("description") or "the command"
135 if answer["choice"] == "deny":
136 return f"Denied: {what}."
137 return f"Approved: {what}. Hermes continues."
138
139
140def register(ctx):
141 ctx.register_tool(
142 name="matrix_dm_send",
143 toolset="matrix_dm",
144 schema=SEND_SCHEMA,
145 handler=send,
146 emoji="💬",
147 )
148 ctx.register_tool(
149 name="end_call",
150 toolset="voice_call",
151 schema=END_CALL_SCHEMA,
152 handler=end_call,
153 emoji="📴",
154 )
155 ctx.register_command(
156 "assist",
157 handler=assist,
158 description="Answer an approval from a voice call (assist.lassul.us)",
159 args_hint="approve|deny [code]",
160 )