minmo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minmo/__init__.py +15 -0
- minmo/agent.py +179 -0
- minmo/cli.py +99 -0
- minmo/errors.py +14 -0
- minmo/logging.py +18 -0
- minmo/providers/__init__.py +0 -0
- minmo/providers/assemblyai.py +95 -0
- minmo/providers/base.py +56 -0
- minmo/providers/hume.py +231 -0
- minmo/providers/openai_realtime.py +139 -0
- minmo/schema.py +81 -0
- minmo/server.py +28 -0
- minmo/testing.py +68 -0
- minmo-0.1.0.dist-info/METADATA +176 -0
- minmo-0.1.0.dist-info/RECORD +18 -0
- minmo-0.1.0.dist-info/WHEEL +4 -0
- minmo-0.1.0.dist-info/entry_points.txt +2 -0
- minmo-0.1.0.dist-info/licenses/LICENSE +21 -0
minmo/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from minmo.agent import VoiceAgent
|
|
2
|
+
from minmo.errors import (
|
|
3
|
+
MinmoError,
|
|
4
|
+
MinmoSchemaError,
|
|
5
|
+
MinmoDeployError,
|
|
6
|
+
MinmoAuthError,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"VoiceAgent",
|
|
11
|
+
"MinmoError",
|
|
12
|
+
"MinmoSchemaError",
|
|
13
|
+
"MinmoDeployError",
|
|
14
|
+
"MinmoAuthError",
|
|
15
|
+
]
|
minmo/agent.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import socket
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
from minmo.errors import MinmoDeployError, MinmoSchemaError
|
|
12
|
+
from minmo.logging import write_session_log
|
|
13
|
+
from minmo.schema import function_to_tool_schema
|
|
14
|
+
|
|
15
|
+
_VALID_TRANSPORTS = ("http", "client")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Tool:
|
|
20
|
+
name: str
|
|
21
|
+
description: str
|
|
22
|
+
parameters: dict
|
|
23
|
+
func: Callable
|
|
24
|
+
transport: str = "http"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class VoiceAgent:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
prompt: str,
|
|
31
|
+
api_key: str,
|
|
32
|
+
name: str = "minmo-agent",
|
|
33
|
+
llm: Optional[dict] = None,
|
|
34
|
+
provider=None,
|
|
35
|
+
provider_options: Optional[dict] = None,
|
|
36
|
+
log_path: Optional[str] = None,
|
|
37
|
+
on_session_end: Optional[Callable] = None,
|
|
38
|
+
):
|
|
39
|
+
if not api_key:
|
|
40
|
+
raise ValueError("api_key is required and cannot be empty.")
|
|
41
|
+
|
|
42
|
+
if llm is not None:
|
|
43
|
+
missing = [
|
|
44
|
+
key for key in ("base_url", "model", "api_key") if not llm.get(key)
|
|
45
|
+
]
|
|
46
|
+
if missing:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"llm is missing required key(s): {', '.join(missing)}. "
|
|
49
|
+
"minmo requires base_url, model, and api_key when llm is given."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self.prompt = prompt
|
|
53
|
+
self.api_key = api_key
|
|
54
|
+
self.name = name
|
|
55
|
+
self.llm = llm
|
|
56
|
+
self.provider_options = provider_options or {}
|
|
57
|
+
self.log_path = log_path
|
|
58
|
+
self.on_session_end = on_session_end
|
|
59
|
+
self.tools: dict[str, Tool] = {}
|
|
60
|
+
self.agent_id: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
if provider is None:
|
|
63
|
+
from minmo.providers.assemblyai import AssemblyAIProvider
|
|
64
|
+
|
|
65
|
+
provider = AssemblyAIProvider()
|
|
66
|
+
self.provider = provider
|
|
67
|
+
|
|
68
|
+
def tool(
|
|
69
|
+
self, func: Optional[Callable] = None, *, transport: str = "http"
|
|
70
|
+
) -> Callable:
|
|
71
|
+
if transport not in _VALID_TRANSPORTS:
|
|
72
|
+
raise MinmoSchemaError(
|
|
73
|
+
f"transport must be one of {_VALID_TRANSPORTS!r}, got {transport!r}."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def register(f: Callable) -> Callable:
|
|
77
|
+
schema = function_to_tool_schema(f)
|
|
78
|
+
self.tools[schema["name"]] = Tool(
|
|
79
|
+
name=schema["name"],
|
|
80
|
+
description=schema["description"],
|
|
81
|
+
parameters=schema["parameters"],
|
|
82
|
+
func=f,
|
|
83
|
+
transport=transport,
|
|
84
|
+
)
|
|
85
|
+
return f
|
|
86
|
+
|
|
87
|
+
if func is not None:
|
|
88
|
+
return register(func)
|
|
89
|
+
return register
|
|
90
|
+
|
|
91
|
+
def deploy(self, local: bool = False, host_url: Optional[str] = None) -> dict:
|
|
92
|
+
self.provider.validate_tools(list(self.tools.values()))
|
|
93
|
+
|
|
94
|
+
needs_base = any(t.transport == "http" for t in self.tools.values())
|
|
95
|
+
|
|
96
|
+
base = None
|
|
97
|
+
if needs_base:
|
|
98
|
+
if local:
|
|
99
|
+
base = self._start_local_server_and_tunnel()
|
|
100
|
+
else:
|
|
101
|
+
base = host_url or os.environ.get("MINMO_HOST_URL")
|
|
102
|
+
if not base:
|
|
103
|
+
raise MinmoDeployError(
|
|
104
|
+
"local=False requires host_url or the MINMO_HOST_URL env var, "
|
|
105
|
+
"pointing at your already-deployed tool server, since at least one "
|
|
106
|
+
"registered tool uses transport='http'."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self._snapshot_history()
|
|
110
|
+
|
|
111
|
+
record = self.provider.deploy(
|
|
112
|
+
name=self.name,
|
|
113
|
+
prompt=self.prompt,
|
|
114
|
+
tools=list(self.tools.values()),
|
|
115
|
+
base=base,
|
|
116
|
+
llm=self.llm,
|
|
117
|
+
provider_options=self.provider_options,
|
|
118
|
+
api_key=self.api_key,
|
|
119
|
+
)
|
|
120
|
+
self.agent_id = record.get("id")
|
|
121
|
+
return record
|
|
122
|
+
|
|
123
|
+
def mint_token(self) -> str:
|
|
124
|
+
if not getattr(self, "agent_id", None):
|
|
125
|
+
raise MinmoDeployError(
|
|
126
|
+
"mint_token() requires a prior successful deploy() call."
|
|
127
|
+
)
|
|
128
|
+
return self.provider.mint_token(self.agent_id, self.api_key)
|
|
129
|
+
|
|
130
|
+
def log_session(
|
|
131
|
+
self, session_id: str, transcript: list, tool_calls: list
|
|
132
|
+
) -> Optional[dict]:
|
|
133
|
+
if not self.log_path:
|
|
134
|
+
return None
|
|
135
|
+
record = write_session_log(self.log_path, session_id, transcript, tool_calls)
|
|
136
|
+
if self.on_session_end:
|
|
137
|
+
self.on_session_end(record)
|
|
138
|
+
return record
|
|
139
|
+
|
|
140
|
+
def _snapshot_history(self) -> None:
|
|
141
|
+
history_dir = Path(".minmo") / "history"
|
|
142
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + "Z"
|
|
144
|
+
snapshot = {
|
|
145
|
+
"prompt": self.prompt,
|
|
146
|
+
"tool_schemas": [
|
|
147
|
+
{
|
|
148
|
+
"name": t.name,
|
|
149
|
+
"description": t.description,
|
|
150
|
+
"parameters": t.parameters,
|
|
151
|
+
}
|
|
152
|
+
for t in self.tools.values()
|
|
153
|
+
],
|
|
154
|
+
"timestamp": timestamp,
|
|
155
|
+
}
|
|
156
|
+
(history_dir / f"{timestamp}.json").write_text(json.dumps(snapshot, indent=2))
|
|
157
|
+
|
|
158
|
+
def _start_local_server_and_tunnel(self) -> str:
|
|
159
|
+
import uvicorn
|
|
160
|
+
from pyngrok import ngrok
|
|
161
|
+
|
|
162
|
+
from minmo.server import create_tool_server
|
|
163
|
+
|
|
164
|
+
app = create_tool_server(self.tools)
|
|
165
|
+
|
|
166
|
+
sock = socket.socket()
|
|
167
|
+
sock.bind(("", 0))
|
|
168
|
+
port = sock.getsockname()[1]
|
|
169
|
+
sock.close()
|
|
170
|
+
|
|
171
|
+
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
|
|
172
|
+
server = uvicorn.Server(config)
|
|
173
|
+
thread = threading.Thread(target=server.run, daemon=True)
|
|
174
|
+
thread.start()
|
|
175
|
+
while not server.started:
|
|
176
|
+
time.sleep(0.05)
|
|
177
|
+
|
|
178
|
+
tunnel = ngrok.connect(port, "http")
|
|
179
|
+
return tunnel.public_url
|
minmo/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from minmo.agent import VoiceAgent
|
|
9
|
+
|
|
10
|
+
MAIN_PY_TEMPLATE = '''\
|
|
11
|
+
from minmo import VoiceAgent
|
|
12
|
+
|
|
13
|
+
agent = VoiceAgent(
|
|
14
|
+
prompt="You are a friendly voice assistant. Keep answers short.",
|
|
15
|
+
api_key="YOUR_ASSEMBLYAI_API_KEY",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@agent.tool
|
|
20
|
+
def get_weather(city: str) -> str:
|
|
21
|
+
"""Get the current weather for a city."""
|
|
22
|
+
return f"It's sunny in {city}."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
result = agent.deploy(local=True)
|
|
27
|
+
print(result)
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
ENV_EXAMPLE_TEMPLATE = "ASSEMBLYAI_API_KEY=\n"
|
|
31
|
+
|
|
32
|
+
REQUIREMENTS_TEMPLATE = "minmo\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.group()
|
|
36
|
+
def main():
|
|
37
|
+
"""minmo — build voice agents without the boilerplate."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@main.command()
|
|
41
|
+
def init():
|
|
42
|
+
"""Scaffold a new minmo project in the current directory."""
|
|
43
|
+
Path("main.py").write_text(MAIN_PY_TEMPLATE)
|
|
44
|
+
Path(".env.example").write_text(ENV_EXAMPLE_TEMPLATE)
|
|
45
|
+
Path("requirements.txt").write_text(REQUIREMENTS_TEMPLATE)
|
|
46
|
+
click.echo("Scaffolded main.py, .env.example, requirements.txt")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_agent_from_main() -> VoiceAgent:
|
|
50
|
+
main_path = Path.cwd() / "main.py"
|
|
51
|
+
if not main_path.exists():
|
|
52
|
+
raise click.ClickException(
|
|
53
|
+
"No main.py found in the current directory. Run `minmo init` first."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
spec = importlib.util.spec_from_file_location("minmo_user_main", main_path)
|
|
57
|
+
module = importlib.util.module_from_spec(spec)
|
|
58
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
59
|
+
|
|
60
|
+
for value in vars(module).values():
|
|
61
|
+
if isinstance(value, VoiceAgent):
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
raise click.ClickException("No VoiceAgent instance found in main.py.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@main.command()
|
|
68
|
+
@click.option(
|
|
69
|
+
"--local/--remote",
|
|
70
|
+
default=True,
|
|
71
|
+
help="Run a local tool server via ngrok tunnel (default), or deploy against an "
|
|
72
|
+
"already-hosted tool server (set MINMO_HOST_URL or use main.py's host_url).",
|
|
73
|
+
)
|
|
74
|
+
def deploy(local):
|
|
75
|
+
"""Deploy the VoiceAgent defined in main.py."""
|
|
76
|
+
agent = _load_agent_from_main()
|
|
77
|
+
record = agent.deploy(local=local)
|
|
78
|
+
click.echo(json.dumps(record, indent=2))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@main.command()
|
|
82
|
+
def logs():
|
|
83
|
+
"""Tail the most recent session log for the VoiceAgent in main.py."""
|
|
84
|
+
agent = _load_agent_from_main()
|
|
85
|
+
if not agent.log_path:
|
|
86
|
+
raise click.ClickException(
|
|
87
|
+
"The VoiceAgent in main.py has no log_path configured."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
log_dir = Path(agent.log_path)
|
|
91
|
+
files = sorted(log_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
|
92
|
+
if not files:
|
|
93
|
+
raise click.ClickException(f"No session logs found in {log_dir}.")
|
|
94
|
+
|
|
95
|
+
click.echo(files[-1].read_text())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
minmo/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class MinmoError(Exception):
|
|
2
|
+
"""Base class for all minmo errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MinmoSchemaError(MinmoError):
|
|
6
|
+
"""Raised when a @agent.tool function can't be turned into a JSON-Schema tool."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MinmoDeployError(MinmoError):
|
|
10
|
+
"""Raised when deploying an agent fails (missing config, AssemblyAI rejected the request)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MinmoAuthError(MinmoError):
|
|
14
|
+
"""Raised when the AssemblyAI API key is missing, empty, or rejected."""
|
minmo/logging.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def write_session_log(
|
|
7
|
+
log_path: str, session_id: str, transcript: list, tool_calls: list
|
|
8
|
+
) -> dict:
|
|
9
|
+
record = {
|
|
10
|
+
"session_id": session_id,
|
|
11
|
+
"transcript": transcript,
|
|
12
|
+
"tool_calls": tool_calls,
|
|
13
|
+
"ended_at": datetime.now(timezone.utc).isoformat(),
|
|
14
|
+
}
|
|
15
|
+
log_dir = Path(log_path)
|
|
16
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
(log_dir / f"{session_id}.json").write_text(json.dumps(record, indent=2))
|
|
18
|
+
return record
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Optional
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from minmo.errors import MinmoAuthError, MinmoDeployError
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from minmo.agent import Tool
|
|
9
|
+
|
|
10
|
+
BASE_URL = "https://agents.assemblyai.com/v1"
|
|
11
|
+
DEFAULT_VOICE_ID = "alba"
|
|
12
|
+
DEFAULT_TOKEN_EXPIRY_SECONDS = 60 # AssemblyAI requires 1-600; docs recommend 60-300
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AssemblyAIProvider:
|
|
16
|
+
"""A VoiceProvider implementation backed by AssemblyAI's Voice Agent API. See providers/base.py."""
|
|
17
|
+
|
|
18
|
+
def tool_url(self, base: str, tool_name: str) -> str:
|
|
19
|
+
return f"{base.rstrip('/')}/tools/{tool_name}"
|
|
20
|
+
|
|
21
|
+
def validate_tools(self, tools: "list[Tool]") -> None:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def deploy(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
name: str,
|
|
28
|
+
prompt: str,
|
|
29
|
+
tools: "list[Tool]",
|
|
30
|
+
base: Optional[str],
|
|
31
|
+
llm: Optional[dict],
|
|
32
|
+
provider_options: dict,
|
|
33
|
+
api_key: str,
|
|
34
|
+
) -> dict:
|
|
35
|
+
voice_id = provider_options.get("voice_id", DEFAULT_VOICE_ID)
|
|
36
|
+
config: dict = {
|
|
37
|
+
"name": name,
|
|
38
|
+
"system_prompt": prompt,
|
|
39
|
+
"voice": {"voice_id": voice_id},
|
|
40
|
+
}
|
|
41
|
+
if tools:
|
|
42
|
+
config["tools"] = [self._tool_config(t, base) for t in tools]
|
|
43
|
+
if llm:
|
|
44
|
+
config["llm"] = llm
|
|
45
|
+
|
|
46
|
+
resp = requests.post(
|
|
47
|
+
f"{BASE_URL}/agents",
|
|
48
|
+
headers={"Authorization": api_key, "Content-Type": "application/json"},
|
|
49
|
+
json=config,
|
|
50
|
+
timeout=30,
|
|
51
|
+
)
|
|
52
|
+
if resp.status_code == 401:
|
|
53
|
+
raise MinmoAuthError(f"AssemblyAI rejected the API key: {resp.text}")
|
|
54
|
+
if resp.status_code >= 400:
|
|
55
|
+
raise MinmoDeployError(
|
|
56
|
+
f"AssemblyAI deploy failed ({resp.status_code}): {resp.text}"
|
|
57
|
+
)
|
|
58
|
+
record = resp.json()
|
|
59
|
+
record["info"] = (
|
|
60
|
+
f"Hosted on AssemblyAI's servers as agent '{record.get('id')}' — it stays "
|
|
61
|
+
"there until you delete or redeploy it. To use it, call mint_token() to get "
|
|
62
|
+
"a short-lived client token, then open a voice session with that token "
|
|
63
|
+
"(browser SDK, phone bridge, etc). AssemblyAI reaches your http-transport "
|
|
64
|
+
f"tools by calling {base or '(no http tools registered)'} whenever the model "
|
|
65
|
+
"wants to use one."
|
|
66
|
+
)
|
|
67
|
+
return record
|
|
68
|
+
|
|
69
|
+
def _tool_config(self, tool: "Tool", base: Optional[str]) -> dict:
|
|
70
|
+
common = {
|
|
71
|
+
"name": tool.name,
|
|
72
|
+
"description": tool.description,
|
|
73
|
+
"parameters": tool.parameters,
|
|
74
|
+
}
|
|
75
|
+
if tool.transport == "client":
|
|
76
|
+
return {**common, "type": "function"}
|
|
77
|
+
return {
|
|
78
|
+
**common,
|
|
79
|
+
"http": {"url": self.tool_url(base, tool.name), "http_method": "POST"},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def mint_token(self, agent_id: str, api_key: str) -> str:
|
|
83
|
+
resp = requests.get(
|
|
84
|
+
f"{BASE_URL}/token",
|
|
85
|
+
headers={"Authorization": api_key},
|
|
86
|
+
params={"expires_in_seconds": DEFAULT_TOKEN_EXPIRY_SECONDS},
|
|
87
|
+
timeout=30,
|
|
88
|
+
)
|
|
89
|
+
if resp.status_code == 401:
|
|
90
|
+
raise MinmoAuthError(f"AssemblyAI rejected the API key: {resp.text}")
|
|
91
|
+
if resp.status_code >= 400:
|
|
92
|
+
raise MinmoDeployError(
|
|
93
|
+
f"Token mint failed ({resp.status_code}): {resp.text}"
|
|
94
|
+
)
|
|
95
|
+
return resp.json()["token"]
|
minmo/providers/base.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Optional, Protocol
|
|
2
|
+
|
|
3
|
+
if TYPE_CHECKING:
|
|
4
|
+
from minmo.agent import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VoiceProvider(Protocol):
|
|
8
|
+
"""Everything minmo needs from a voice-agent backend.
|
|
9
|
+
|
|
10
|
+
AssemblyAI and Hume are the implementations today (see assemblyai.py,
|
|
11
|
+
hume.py). A future provider (OpenAI Realtime, ElevenLabs) implements
|
|
12
|
+
this same protocol as its own module — nothing outside providers/
|
|
13
|
+
changes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def tool_url(self, base: str, tool_name: str) -> str:
|
|
17
|
+
"""Build the public URL the provider should call for a given tool."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def validate_tools(self, tools: "list[Tool]") -> None:
|
|
21
|
+
"""Raise MinmoDeployError early if `tools` is incompatible with what
|
|
22
|
+
this provider supports (e.g. a provider with no HTTP-tool concept
|
|
23
|
+
rejecting transport="http" tools). Called by VoiceAgent.deploy() before
|
|
24
|
+
any expensive/side-effecting base-URL resolution (local server, ngrok
|
|
25
|
+
tunnel, host_url check) so an unsupported tool set fails fast. A
|
|
26
|
+
provider with no such restrictions is a no-op.
|
|
27
|
+
"""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def deploy(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
name: str,
|
|
34
|
+
prompt: str,
|
|
35
|
+
tools: "list[Tool]",
|
|
36
|
+
base: Optional[str],
|
|
37
|
+
llm: Optional[dict],
|
|
38
|
+
provider_options: dict,
|
|
39
|
+
api_key: str,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Build AND publish the agent config, in however many network calls
|
|
42
|
+
this provider's API needs. Returns the created agent record (must
|
|
43
|
+
include an "id" the caller can store as agent_id).
|
|
44
|
+
|
|
45
|
+
`tools` is the raw list of registered Tool objects (name/description/
|
|
46
|
+
parameters/func/transport); `base` is the public URL the tool server
|
|
47
|
+
is reachable at, or None if no registered tool needs one (i.e. every
|
|
48
|
+
tool is transport="client"). The provider is responsible for turning
|
|
49
|
+
each Tool into whatever wire shape its transport needs — nothing
|
|
50
|
+
outside providers/ should know that shape.
|
|
51
|
+
"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def mint_token(self, agent_id: str, api_key: str) -> str:
|
|
55
|
+
"""Mint a short-lived client token for browser/embedded use."""
|
|
56
|
+
...
|
minmo/providers/hume.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import TYPE_CHECKING, Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from minmo.errors import MinmoAuthError, MinmoDeployError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from minmo.agent import Tool
|
|
10
|
+
|
|
11
|
+
BASE_URL = "https://api.hume.ai/v0/evi"
|
|
12
|
+
TOKEN_URL = "https://api.hume.ai/oauth2-cc/token"
|
|
13
|
+
DEFAULT_VOICE = {"name": "Ava Song", "provider": "HUME_AI"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HumeProvider:
|
|
17
|
+
"""A VoiceProvider implementation backed by Hume AI's EVI API. See providers/base.py.
|
|
18
|
+
|
|
19
|
+
Hume has no HTTP-tool concept — every tool must be transport="client".
|
|
20
|
+
Requires provider_options={"secret_key": ...} in addition to the usual
|
|
21
|
+
api_key, since Hume issues both separately. EVI3 configs also require a
|
|
22
|
+
voice; provider_options["voice"] (a {"name", "provider"} dict) overrides
|
|
23
|
+
DEFAULT_VOICE if given.
|
|
24
|
+
|
|
25
|
+
Hume's prompts, tools, and configs are all name-unique, versioned
|
|
26
|
+
resources: POST to the collection endpoint with a name that already
|
|
27
|
+
exists returns 409. So every redeploy (same agent name, same tool
|
|
28
|
+
names) must add a new version to the existing resource instead of
|
|
29
|
+
creating a fresh one — see _get_or_create_version().
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._secret_key: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
def tool_url(self, base: str, tool_name: str) -> str:
|
|
36
|
+
raise MinmoDeployError(
|
|
37
|
+
"HumeProvider has no HTTP-tool support — this method should never be called."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def validate_tools(self, tools: "list[Tool]") -> None:
|
|
41
|
+
http_tools = [t for t in tools if t.transport == "http"]
|
|
42
|
+
if http_tools:
|
|
43
|
+
names = ", ".join(t.name for t in http_tools)
|
|
44
|
+
raise MinmoDeployError(
|
|
45
|
+
f"HumeProvider only supports transport='client' tools; "
|
|
46
|
+
f"{names} use transport='http', which Hume cannot call. "
|
|
47
|
+
"Register these with @agent.tool(transport='client') instead."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def deploy(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
name: str,
|
|
54
|
+
prompt: str,
|
|
55
|
+
tools: "list[Tool]",
|
|
56
|
+
base: Optional[str],
|
|
57
|
+
llm: Optional[dict],
|
|
58
|
+
provider_options: dict,
|
|
59
|
+
api_key: str,
|
|
60
|
+
) -> dict:
|
|
61
|
+
self.validate_tools(tools)
|
|
62
|
+
|
|
63
|
+
secret_key = provider_options.get("secret_key")
|
|
64
|
+
if not secret_key:
|
|
65
|
+
raise MinmoDeployError(
|
|
66
|
+
"HumeProvider requires provider_options={'secret_key': ...} "
|
|
67
|
+
"(Hume issues an api_key and a secret_key separately)."
|
|
68
|
+
)
|
|
69
|
+
self._secret_key = secret_key
|
|
70
|
+
|
|
71
|
+
headers = {"X-Hume-Api-Key": api_key, "Content-Type": "application/json"}
|
|
72
|
+
|
|
73
|
+
prompt_name = f"{name} prompt"
|
|
74
|
+
prompt_record = self._get_or_create_version(
|
|
75
|
+
resource="prompts",
|
|
76
|
+
headers=headers,
|
|
77
|
+
name=prompt_name,
|
|
78
|
+
create_body={"name": prompt_name, "text": prompt},
|
|
79
|
+
version_body={"text": prompt},
|
|
80
|
+
)
|
|
81
|
+
prompt_ref = {"id": prompt_record["id"], "version": prompt_record["version"]}
|
|
82
|
+
|
|
83
|
+
tool_refs = []
|
|
84
|
+
for tool in tools:
|
|
85
|
+
tool_record = self._get_or_create_version(
|
|
86
|
+
resource="tools",
|
|
87
|
+
headers=headers,
|
|
88
|
+
name=tool.name,
|
|
89
|
+
create_body={
|
|
90
|
+
"name": tool.name,
|
|
91
|
+
"description": tool.description,
|
|
92
|
+
"parameters": json.dumps(tool.parameters),
|
|
93
|
+
},
|
|
94
|
+
version_body={
|
|
95
|
+
"description": tool.description,
|
|
96
|
+
"parameters": json.dumps(tool.parameters),
|
|
97
|
+
},
|
|
98
|
+
already_created={"prompt": prompt_ref},
|
|
99
|
+
)
|
|
100
|
+
tool_refs.append(
|
|
101
|
+
{"id": tool_record["id"], "version": tool_record["version"]}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
config_body: dict = {
|
|
105
|
+
"evi_version": "3",
|
|
106
|
+
"prompt": prompt_ref,
|
|
107
|
+
"voice": provider_options.get("voice", DEFAULT_VOICE),
|
|
108
|
+
}
|
|
109
|
+
if tool_refs:
|
|
110
|
+
config_body["tools"] = tool_refs
|
|
111
|
+
|
|
112
|
+
warnings = []
|
|
113
|
+
if llm:
|
|
114
|
+
config_body["language_model"] = {"model_resource": llm.get("model")}
|
|
115
|
+
warnings.append(
|
|
116
|
+
"llm.base_url and llm.api_key are not applicable to Hume's language_model "
|
|
117
|
+
"config (Hume selects from its own supported provider list by name) and "
|
|
118
|
+
"were ignored; only llm.model was passed through."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
config_record = self._get_or_create_version(
|
|
122
|
+
resource="configs",
|
|
123
|
+
headers=headers,
|
|
124
|
+
name=name,
|
|
125
|
+
create_body={**config_body, "name": name},
|
|
126
|
+
version_body=config_body,
|
|
127
|
+
already_created={"prompt": prompt_ref, "tools": tool_refs},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
result = dict(config_record)
|
|
131
|
+
result["info"] = (
|
|
132
|
+
f"Hosted on Hume as a versioned config resource named '{name}' (id "
|
|
133
|
+
f"{config_record.get('id')}, version {config_record.get('version')}) — "
|
|
134
|
+
"redeploying with the same name adds a new version instead of replacing it. "
|
|
135
|
+
"To use it, call mint_token() (needs the secret_key you passed in "
|
|
136
|
+
"provider_options) to get an OAuth access token, then start an EVI session "
|
|
137
|
+
"with it. All tools are transport='client', so your own app code (not "
|
|
138
|
+
"minmo) must handle each tool.call over that session."
|
|
139
|
+
)
|
|
140
|
+
if warnings:
|
|
141
|
+
result["warnings"] = warnings
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
def _get_or_create_version(
|
|
145
|
+
self,
|
|
146
|
+
resource: str,
|
|
147
|
+
headers: dict,
|
|
148
|
+
name: str,
|
|
149
|
+
create_body: dict,
|
|
150
|
+
version_body: dict,
|
|
151
|
+
already_created: Optional[dict] = None,
|
|
152
|
+
) -> dict:
|
|
153
|
+
"""Hume's prompts/tools/configs are name-unique, versioned resources.
|
|
154
|
+
|
|
155
|
+
If a resource with this name already exists, POST a new version onto
|
|
156
|
+
it (.../{resource}/{id}); otherwise POST the collection endpoint to
|
|
157
|
+
create it fresh. Makes deploy() safe to call repeatedly with the same
|
|
158
|
+
agent/tool names, which is the normal redeploy path.
|
|
159
|
+
"""
|
|
160
|
+
list_resp = requests.get(
|
|
161
|
+
f"{BASE_URL}/{resource}",
|
|
162
|
+
headers=headers,
|
|
163
|
+
params={"name": name, "page_size": 1},
|
|
164
|
+
timeout=30,
|
|
165
|
+
)
|
|
166
|
+
if list_resp.status_code == 401:
|
|
167
|
+
raise MinmoAuthError(f"Hume rejected the API key: {list_resp.text}")
|
|
168
|
+
if list_resp.status_code == 404:
|
|
169
|
+
# Hume returns 404 (not 200 + empty page) when nothing matches the name filter.
|
|
170
|
+
existing = []
|
|
171
|
+
elif list_resp.status_code >= 400:
|
|
172
|
+
raise MinmoDeployError(
|
|
173
|
+
f"Hume list-{resource} request failed ({list_resp.status_code}): {list_resp.text}"
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
existing = list_resp.json().get(f"{resource}_page", [])
|
|
177
|
+
|
|
178
|
+
if existing:
|
|
179
|
+
existing_id = existing[0]["id"]
|
|
180
|
+
return self._post(
|
|
181
|
+
f"{BASE_URL}/{resource}/{existing_id}",
|
|
182
|
+
headers,
|
|
183
|
+
version_body,
|
|
184
|
+
already_created=already_created,
|
|
185
|
+
)
|
|
186
|
+
return self._post(
|
|
187
|
+
f"{BASE_URL}/{resource}",
|
|
188
|
+
headers,
|
|
189
|
+
create_body,
|
|
190
|
+
already_created=already_created,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _post(
|
|
194
|
+
self,
|
|
195
|
+
url: str,
|
|
196
|
+
headers: dict,
|
|
197
|
+
body: dict,
|
|
198
|
+
already_created: Optional[dict] = None,
|
|
199
|
+
) -> dict:
|
|
200
|
+
resp = requests.post(url, headers=headers, json=body, timeout=30)
|
|
201
|
+
if resp.status_code == 401:
|
|
202
|
+
raise MinmoAuthError(f"Hume rejected the API key: {resp.text}")
|
|
203
|
+
if resp.status_code >= 400:
|
|
204
|
+
context = (
|
|
205
|
+
f" Already created on Hume: {already_created}."
|
|
206
|
+
if already_created
|
|
207
|
+
else ""
|
|
208
|
+
)
|
|
209
|
+
raise MinmoDeployError(
|
|
210
|
+
f"Hume request to {url} failed ({resp.status_code}): {resp.text}.{context}"
|
|
211
|
+
)
|
|
212
|
+
return resp.json()
|
|
213
|
+
|
|
214
|
+
def mint_token(self, agent_id: str, api_key: str) -> str:
|
|
215
|
+
if not self._secret_key:
|
|
216
|
+
raise MinmoDeployError(
|
|
217
|
+
"mint_token() requires a prior deploy() call (secret_key not set)."
|
|
218
|
+
)
|
|
219
|
+
resp = requests.post(
|
|
220
|
+
TOKEN_URL,
|
|
221
|
+
auth=(api_key, self._secret_key),
|
|
222
|
+
data={"grant_type": "client_credentials"},
|
|
223
|
+
timeout=30,
|
|
224
|
+
)
|
|
225
|
+
if resp.status_code == 401:
|
|
226
|
+
raise MinmoAuthError(f"Hume rejected the credentials: {resp.text}")
|
|
227
|
+
if resp.status_code >= 400:
|
|
228
|
+
raise MinmoDeployError(
|
|
229
|
+
f"Hume token mint failed ({resp.status_code}): {resp.text}"
|
|
230
|
+
)
|
|
231
|
+
return resp.json()["access_token"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import TYPE_CHECKING, Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from minmo.errors import MinmoAuthError, MinmoDeployError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from minmo.agent import Tool
|
|
10
|
+
|
|
11
|
+
BASE_URL = "https://api.openai.com/v1/realtime"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenAIRealtimeProvider:
|
|
15
|
+
"""A VoiceProvider implementation backed by OpenAI's Realtime API. See providers/base.py.
|
|
16
|
+
|
|
17
|
+
OpenAI Realtime has no persistent agent resource and no HTTP-tool concept.
|
|
18
|
+
deploy() makes no network call: it validates tools, requires
|
|
19
|
+
provider_options={"model": ..., "voice": ...} (no defaults — the caller
|
|
20
|
+
must choose), and stores the built session config in-memory keyed by a
|
|
21
|
+
locally generated id — this id is valid only for the lifetime of this
|
|
22
|
+
provider instance, not a server-side resource, and cannot be persisted
|
|
23
|
+
across processes or looked up by a different OpenAIRealtimeProvider instance.
|
|
24
|
+
mint_token() does the one real network call (POST /v1/realtime/client_secrets),
|
|
25
|
+
which both mints the ephemeral client token and (implicitly, on OpenAI's side)
|
|
26
|
+
starts the session bound to that config.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._sessions: dict[str, dict] = {}
|
|
31
|
+
|
|
32
|
+
def tool_url(self, base: str, tool_name: str) -> str:
|
|
33
|
+
raise MinmoDeployError(
|
|
34
|
+
"OpenAIRealtimeProvider has no HTTP-tool support — this method should never be called."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def validate_tools(self, tools: "list[Tool]") -> None:
|
|
38
|
+
http_tools = [t for t in tools if t.transport == "http"]
|
|
39
|
+
if http_tools:
|
|
40
|
+
names = ", ".join(t.name for t in http_tools)
|
|
41
|
+
raise MinmoDeployError(
|
|
42
|
+
f"OpenAIRealtimeProvider only supports transport='client' tools; "
|
|
43
|
+
f"{names} use transport='http', which OpenAI Realtime cannot call. "
|
|
44
|
+
"Register these with @agent.tool(transport='client') instead."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def deploy(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
name: str,
|
|
51
|
+
prompt: str,
|
|
52
|
+
tools: "list[Tool]",
|
|
53
|
+
base: Optional[str],
|
|
54
|
+
llm: Optional[dict],
|
|
55
|
+
provider_options: dict,
|
|
56
|
+
api_key: str,
|
|
57
|
+
) -> dict:
|
|
58
|
+
self.validate_tools(tools)
|
|
59
|
+
|
|
60
|
+
model = provider_options.get("model")
|
|
61
|
+
if not model:
|
|
62
|
+
raise MinmoDeployError(
|
|
63
|
+
"OpenAIRealtimeProvider requires provider_options={'model': ..., 'voice': ...} "
|
|
64
|
+
"(no default model — pick the realtime model you want)."
|
|
65
|
+
)
|
|
66
|
+
voice = provider_options.get("voice")
|
|
67
|
+
if not voice:
|
|
68
|
+
raise MinmoDeployError(
|
|
69
|
+
"OpenAIRealtimeProvider requires provider_options={'model': ..., 'voice': ...} "
|
|
70
|
+
"(no default voice — pick the voice you want)."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
warnings = []
|
|
74
|
+
if llm:
|
|
75
|
+
warnings.append(
|
|
76
|
+
"llm is ignored for OpenAIRealtimeProvider: the realtime model configured via "
|
|
77
|
+
"provider_options['model'] IS the LLM, there is no separate one to plug in."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
session_config: dict = {
|
|
81
|
+
"type": "realtime",
|
|
82
|
+
"model": model,
|
|
83
|
+
"instructions": prompt,
|
|
84
|
+
"audio": {"output": {"voice": voice}},
|
|
85
|
+
}
|
|
86
|
+
if tools:
|
|
87
|
+
session_config["tools"] = [self._tool_config(t) for t in tools]
|
|
88
|
+
|
|
89
|
+
session_id = uuid.uuid4().hex
|
|
90
|
+
self._sessions[session_id] = session_config
|
|
91
|
+
|
|
92
|
+
result: dict = {
|
|
93
|
+
"id": session_id,
|
|
94
|
+
"hosted": False,
|
|
95
|
+
"info": (
|
|
96
|
+
f"Not hosted anywhere — '{session_id}' only exists in this process's "
|
|
97
|
+
"memory, on this OpenAIRealtimeProvider instance. Nothing is saved on "
|
|
98
|
+
"OpenAI's side yet. To use it, call mint_token() now, in this same "
|
|
99
|
+
"process, to actually send the config to OpenAI and get back a "
|
|
100
|
+
"short-lived client token; open a Realtime session with that token. "
|
|
101
|
+
"All tools are transport='client', so your own app code handles each "
|
|
102
|
+
"tool call over that session."
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
if warnings:
|
|
106
|
+
result["warnings"] = warnings
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def _tool_config(self, tool: "Tool") -> dict:
|
|
110
|
+
return {
|
|
111
|
+
"type": "function",
|
|
112
|
+
"name": tool.name,
|
|
113
|
+
"description": tool.description,
|
|
114
|
+
"parameters": tool.parameters,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def mint_token(self, agent_id: str, api_key: str) -> str:
|
|
118
|
+
config = self._sessions.get(agent_id)
|
|
119
|
+
if config is None:
|
|
120
|
+
raise MinmoDeployError(
|
|
121
|
+
f"mint_token() called with unknown id {agent_id!r} — requires a prior deploy() call on this provider instance."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
resp = requests.post(
|
|
125
|
+
f"{BASE_URL}/client_secrets",
|
|
126
|
+
headers={
|
|
127
|
+
"Authorization": f"Bearer {api_key}",
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
},
|
|
130
|
+
json={"session": config},
|
|
131
|
+
timeout=30,
|
|
132
|
+
)
|
|
133
|
+
if resp.status_code == 401:
|
|
134
|
+
raise MinmoAuthError(f"OpenAI rejected the API key: {resp.text}")
|
|
135
|
+
if resp.status_code >= 400:
|
|
136
|
+
raise MinmoDeployError(
|
|
137
|
+
f"OpenAI Realtime client_secrets request failed ({resp.status_code}): {resp.text}"
|
|
138
|
+
)
|
|
139
|
+
return resp.json()["value"]
|
minmo/schema.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import typing
|
|
3
|
+
from typing import Callable, Optional, Union, get_args, get_origin
|
|
4
|
+
|
|
5
|
+
from minmo.errors import MinmoSchemaError
|
|
6
|
+
|
|
7
|
+
_PRIMITIVE_MAP = {
|
|
8
|
+
str: {"type": "string"},
|
|
9
|
+
int: {"type": "integer"},
|
|
10
|
+
float: {"type": "number"},
|
|
11
|
+
bool: {"type": "boolean"},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _hint_to_json_schema(hint, param_name: str, func_name: str) -> tuple[dict, bool]:
|
|
16
|
+
"""Returns (schema, required)."""
|
|
17
|
+
origin = get_origin(hint)
|
|
18
|
+
|
|
19
|
+
if origin is Union:
|
|
20
|
+
args = [a for a in get_args(hint) if a is not type(None)]
|
|
21
|
+
if len(args) != 1:
|
|
22
|
+
raise MinmoSchemaError(
|
|
23
|
+
f"{func_name}: parameter '{param_name}' has an unsupported Union hint; "
|
|
24
|
+
"only Optional[T] is supported."
|
|
25
|
+
)
|
|
26
|
+
schema, _ = _hint_to_json_schema(args[0], param_name, func_name)
|
|
27
|
+
return schema, False
|
|
28
|
+
|
|
29
|
+
if origin in (list, typing.List):
|
|
30
|
+
(item_hint,) = get_args(hint) or (str,)
|
|
31
|
+
if item_hint is not str:
|
|
32
|
+
raise MinmoSchemaError(
|
|
33
|
+
f"{func_name}: parameter '{param_name}' uses list[{item_hint}] — "
|
|
34
|
+
"only list[str] is supported."
|
|
35
|
+
)
|
|
36
|
+
return {"type": "array", "items": {"type": "string"}}, True
|
|
37
|
+
|
|
38
|
+
if hint in _PRIMITIVE_MAP:
|
|
39
|
+
return dict(_PRIMITIVE_MAP[hint]), True
|
|
40
|
+
|
|
41
|
+
raise MinmoSchemaError(
|
|
42
|
+
f"{func_name}: parameter '{param_name}' has unsupported type hint {hint!r}. "
|
|
43
|
+
"Supported: str, int, float, bool, list[str], Optional[...] of those."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def function_to_tool_schema(func: Callable) -> dict:
|
|
48
|
+
func_name = func.__name__
|
|
49
|
+
doc = inspect.getdoc(func)
|
|
50
|
+
if not doc:
|
|
51
|
+
raise MinmoSchemaError(
|
|
52
|
+
f"{func_name}: missing docstring — the first line becomes the tool description."
|
|
53
|
+
)
|
|
54
|
+
description = doc.strip().splitlines()[0]
|
|
55
|
+
|
|
56
|
+
hints = typing.get_type_hints(func)
|
|
57
|
+
signature = inspect.signature(func)
|
|
58
|
+
|
|
59
|
+
properties = {}
|
|
60
|
+
required = []
|
|
61
|
+
for param_name in signature.parameters:
|
|
62
|
+
if param_name not in hints:
|
|
63
|
+
raise MinmoSchemaError(
|
|
64
|
+
f"{func_name}: parameter '{param_name}' is missing a type hint."
|
|
65
|
+
)
|
|
66
|
+
schema, is_required = _hint_to_json_schema(
|
|
67
|
+
hints[param_name], param_name, func_name
|
|
68
|
+
)
|
|
69
|
+
properties[param_name] = schema
|
|
70
|
+
if is_required:
|
|
71
|
+
required.append(param_name)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"name": func_name,
|
|
75
|
+
"description": description,
|
|
76
|
+
"parameters": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": properties,
|
|
79
|
+
"required": required,
|
|
80
|
+
},
|
|
81
|
+
}
|
minmo/server.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import jsonschema
|
|
2
|
+
from fastapi import FastAPI, Request
|
|
3
|
+
|
|
4
|
+
from minmo.agent import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _make_handler(tool: Tool):
|
|
8
|
+
async def handler(request: Request):
|
|
9
|
+
payload = await request.json() if await request.body() else {}
|
|
10
|
+
try:
|
|
11
|
+
jsonschema.validate(payload, tool.parameters)
|
|
12
|
+
result = tool.func(**payload)
|
|
13
|
+
return {"result": result}
|
|
14
|
+
except (
|
|
15
|
+
Exception
|
|
16
|
+
) as exc: # noqa: BLE001 - intentional: surface any tool error to the model
|
|
17
|
+
return {"error": str(exc)}
|
|
18
|
+
|
|
19
|
+
return handler
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_tool_server(tools: dict[str, Tool]) -> FastAPI:
|
|
23
|
+
app = FastAPI()
|
|
24
|
+
for tool in tools.values():
|
|
25
|
+
if tool.transport != "http":
|
|
26
|
+
continue
|
|
27
|
+
app.add_api_route(f"/tools/{tool.name}", _make_handler(tool), methods=["POST"])
|
|
28
|
+
return app
|
minmo/testing.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from openai import OpenAI
|
|
4
|
+
|
|
5
|
+
from minmo.agent import VoiceAgent
|
|
6
|
+
from minmo.errors import MinmoError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def simulate(agent: VoiceAgent, transcript: list[str]) -> dict:
|
|
10
|
+
if not agent.llm:
|
|
11
|
+
raise MinmoError(
|
|
12
|
+
"simulate() requires VoiceAgent(llm={'base_url', 'model', 'api_key'}) — "
|
|
13
|
+
"there is no way to call AssemblyAI's managed model directly outside a live voice session."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
client = OpenAI(base_url=agent.llm["base_url"], api_key=agent.llm["api_key"])
|
|
17
|
+
model = agent.llm["model"]
|
|
18
|
+
|
|
19
|
+
tool_defs = [
|
|
20
|
+
{
|
|
21
|
+
"type": "function",
|
|
22
|
+
"function": {
|
|
23
|
+
"name": t.name,
|
|
24
|
+
"description": t.description,
|
|
25
|
+
"parameters": t.parameters,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
for t in agent.tools.values()
|
|
29
|
+
] or None
|
|
30
|
+
|
|
31
|
+
messages: list[dict] = [{"role": "system", "content": agent.prompt}]
|
|
32
|
+
tool_call_log: list[dict] = []
|
|
33
|
+
|
|
34
|
+
for line in transcript:
|
|
35
|
+
messages.append({"role": "user", "content": line})
|
|
36
|
+
|
|
37
|
+
while True:
|
|
38
|
+
response = client.chat.completions.create(
|
|
39
|
+
model=model, messages=messages, tools=tool_defs
|
|
40
|
+
)
|
|
41
|
+
message = response.choices[0].message
|
|
42
|
+
messages.append(message.model_dump())
|
|
43
|
+
|
|
44
|
+
if not message.tool_calls:
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
for call in message.tool_calls:
|
|
48
|
+
tool = agent.tools[call.function.name]
|
|
49
|
+
args = json.loads(call.function.arguments)
|
|
50
|
+
try:
|
|
51
|
+
output = {"result": tool.func(**args)}
|
|
52
|
+
except (
|
|
53
|
+
Exception
|
|
54
|
+
) as exc: # noqa: BLE001 - mirrors server.py's structured-error behavior
|
|
55
|
+
output = {"error": str(exc)}
|
|
56
|
+
|
|
57
|
+
tool_call_log.append(
|
|
58
|
+
{"name": call.function.name, "input": args, "output": output}
|
|
59
|
+
)
|
|
60
|
+
messages.append(
|
|
61
|
+
{
|
|
62
|
+
"role": "tool",
|
|
63
|
+
"tool_call_id": call.id,
|
|
64
|
+
"content": json.dumps(output),
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return {"conversation": messages, "tool_calls": tool_call_log}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: minmo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Boilerplate-free SDK for building voice agents on AssemblyAI's Voice Agent API
|
|
5
|
+
Project-URL: Homepage, https://github.com/a-elhaag/minmo
|
|
6
|
+
Author: Anas Elhaag
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: click>=8.1
|
|
11
|
+
Requires-Dist: fastapi>=0.110
|
|
12
|
+
Requires-Dist: jsonschema>=4.0
|
|
13
|
+
Requires-Dist: openai>=1.30
|
|
14
|
+
Requires-Dist: pyngrok>=7.0
|
|
15
|
+
Requires-Dist: requests>=2.31
|
|
16
|
+
Requires-Dist: uvicorn>=0.29
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: responses>=0.25; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# minmo
|
|
25
|
+
|
|
26
|
+
[](https://github.com/a-elhaag/minmo/actions/workflows/ci.yml)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
[](pyproject.toml)
|
|
29
|
+
|
|
30
|
+
**Boilerplate-free SDK for building voice agents** on [AssemblyAI's Voice Agent API](https://assemblyai.com/docs/voice-agents/voice-agent-api), with support for Hume and OpenAI Realtime too.
|
|
31
|
+
|
|
32
|
+
Define a prompt, register Python functions as tools, deploy. minmo handles
|
|
33
|
+
JSON-Schema generation, local tool hosting + tunneling, and talking to each
|
|
34
|
+
provider's REST API — so you write the assistant, not the plumbing.
|
|
35
|
+
|
|
36
|
+
📖 **[Full documentation](https://a-elhaag.github.io/minmo/)**
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -e .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
(Set `NGROK_AUTHTOKEN` in your environment if you plan to use `local=True` —
|
|
45
|
+
see [pyngrok's docs](https://pyngrok.readthedocs.io/) for how to get one.)
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from minmo import VoiceAgent
|
|
51
|
+
|
|
52
|
+
agent = VoiceAgent(
|
|
53
|
+
prompt="You are a friendly voice assistant. Keep answers short.",
|
|
54
|
+
api_key="YOUR_ASSEMBLYAI_API_KEY",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@agent.tool
|
|
59
|
+
def get_weather(city: str) -> str:
|
|
60
|
+
"""Get the current weather for a city."""
|
|
61
|
+
return f"It's sunny in {city}."
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
result = agent.deploy(local=True)
|
|
65
|
+
print(result) # the created AssemblyAI agent record, including its id
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
That's it — `deploy(local=True)` starts a local tool server, tunnels it
|
|
69
|
+
publicly, and registers `get_weather` as a real tool your live voice agent
|
|
70
|
+
can call.
|
|
71
|
+
|
|
72
|
+
`result` always includes an `"info"` field — a plain-language paragraph
|
|
73
|
+
telling you whether the agent is hosted on the provider's servers or only
|
|
74
|
+
exists locally, and how to actually connect to it (usually via
|
|
75
|
+
`mint_token()`). Worth printing after every deploy, especially since this
|
|
76
|
+
differs per provider — see below.
|
|
77
|
+
|
|
78
|
+
## Testing tool logic without burning session minutes
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from minmo import VoiceAgent
|
|
82
|
+
from minmo.testing import simulate
|
|
83
|
+
|
|
84
|
+
agent = VoiceAgent(
|
|
85
|
+
prompt="You are a friendly voice assistant.",
|
|
86
|
+
api_key="YOUR_ASSEMBLYAI_API_KEY",
|
|
87
|
+
llm={"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini", "api_key": "YOUR_OPENAI_KEY"},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@agent.tool
|
|
92
|
+
def get_weather(city: str) -> str:
|
|
93
|
+
"""Get the current weather for a city."""
|
|
94
|
+
return f"It's sunny in {city}."
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
result = simulate(agent, transcript=["What's the weather in Paris?"])
|
|
98
|
+
print(result["tool_calls"])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`simulate()` calls the `llm` you configured directly (any OpenAI-compatible
|
|
102
|
+
endpoint) and runs tool calls against your local Python functions — no
|
|
103
|
+
AssemblyAI session, no phone call.
|
|
104
|
+
|
|
105
|
+
## Client-side tools (no server, no tunnel)
|
|
106
|
+
|
|
107
|
+
`@agent.tool` defaults to `transport="http"` — minmo hosts the function
|
|
108
|
+
behind a URL AssemblyAI calls. Pass `transport="client"` instead to declare
|
|
109
|
+
a [client-side/function tool](https://assemblyai.com/docs/voice-agents/voice-agent-api/tools/client-side-tools):
|
|
110
|
+
no server, no tunnel, no `host_url` needed for that tool.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
@agent.tool(transport="client")
|
|
114
|
+
def get_account_balance(account_id: str) -> str:
|
|
115
|
+
"""Look up an account's balance."""
|
|
116
|
+
return "$42.00"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
minmo only builds the correct agent config for this — declaring the tool
|
|
120
|
+
as client-side. Running the actual session (the WebSocket connection that
|
|
121
|
+
receives `tool.call` and sends back `tool.result`) is your own code's job:
|
|
122
|
+
a browser app, a Twilio bridge, whatever already holds that live connection.
|
|
123
|
+
If every registered tool is `transport="client"`, `deploy()` skips the local
|
|
124
|
+
server and tunnel (or `host_url` requirement) entirely — nothing to host.
|
|
125
|
+
|
|
126
|
+
## Providers
|
|
127
|
+
|
|
128
|
+
minmo supports AssemblyAI (default), Hume, and OpenAI Realtime. They don't
|
|
129
|
+
all host your agent the same way:
|
|
130
|
+
|
|
131
|
+
| Provider | Hosted on their servers? | How you use it |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| AssemblyAI | Yes — `deploy()` creates a persistent agent record | `mint_token()` for a client token, connect a voice session with it |
|
|
134
|
+
| Hume | Yes — `deploy()` creates/versions a config resource | `mint_token()` for an access token, start an EVI session with it |
|
|
135
|
+
| OpenAI Realtime | No — `deploy()` only builds a config in your process's memory | Call `mint_token()` right after, in the same process, to actually send it to OpenAI and get a session token |
|
|
136
|
+
|
|
137
|
+
Pass a different provider via `VoiceAgent(provider=..., provider_options=...)`;
|
|
138
|
+
see each provider's docstring in `minmo/providers/` for required options.
|
|
139
|
+
|
|
140
|
+
## CLI
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
minmo init # scaffold main.py, .env.example, requirements.txt
|
|
144
|
+
minmo deploy # import main.py, find the VoiceAgent, deploy it
|
|
145
|
+
minmo logs # print the most recent session log
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Deploying to a real server (not `local=True`)
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
agent.deploy(local=False, host_url="https://your-deployed-tool-server.example.com")
|
|
152
|
+
# or set MINMO_HOST_URL in the environment instead of passing host_url
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Your host must already be running the tool server — `local=True` is for
|
|
156
|
+
development; production tool hosting is up to you (minmo's local server
|
|
157
|
+
factory, `minmo.server.create_tool_server`, is reusable if you want to
|
|
158
|
+
deploy it yourself behind a real domain).
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pip install -e ".[dev]"
|
|
164
|
+
pytest -q
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Pushes are auto-formatted with [Black](https://black.readthedocs.io/) via
|
|
168
|
+
GitHub Actions, and every push/PR runs the test suite across Python 3.10–3.12.
|
|
169
|
+
|
|
170
|
+
## Contributing
|
|
171
|
+
|
|
172
|
+
Issues and PRs welcome. Keep changes small and covered by a test.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](LICENSE) © [Anas Elhaag](https://github.com/a-elhaag)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
minmo/__init__.py,sha256=G7vOsyFjTOCcNvMnb4g0zRGxFv4RojRm3MGO0bf2vdo,265
|
|
2
|
+
minmo/agent.py,sha256=Fy-UnO7Kk88u_XBd19gByk3eGvgnhUAVNTZSaevR9XY,5758
|
|
3
|
+
minmo/cli.py,sha256=TP2qhk_-fGPG81r4qm2vXA2JCCUuKJMi37E1ICUBpns,2658
|
|
4
|
+
minmo/errors.py,sha256=Ebqd7vATKCCQfJea2pJ2yxId7Om9BKz33Ll9K5n9Vps,445
|
|
5
|
+
minmo/logging.py,sha256=cF_yPBMzKPG4DgWWYvYmk7Qu2goTUOBjQj-5vM8kJoQ,539
|
|
6
|
+
minmo/schema.py,sha256=DJ37CZar8DZt6awDGiRBjVF4qDBuQU-aq5ymUUpBKok,2581
|
|
7
|
+
minmo/server.py,sha256=iUsTmfKZQ-IudWimiAXcWiHX5-Foh6GG01lhfv7Uels,838
|
|
8
|
+
minmo/testing.py,sha256=vQORRSeON0X0zne_QLfs3Z9y7H0N2964s5lO4lLPcUQ,2219
|
|
9
|
+
minmo/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
minmo/providers/assemblyai.py,sha256=-XVn7JMCKS-mLDEDfL7QJH3U3kzme-5QjY4VEo-SYzo,3327
|
|
11
|
+
minmo/providers/base.py,sha256=V3_3BbxS1l0I-na332MX1DMmylL1smdIHoGjq-Zc6uc,2162
|
|
12
|
+
minmo/providers/hume.py,sha256=sVjT_AxyPnBUccIv8TCZZcPf-fKc-4nP7aQamijfPjY,8655
|
|
13
|
+
minmo/providers/openai_realtime.py,sha256=UolKCk480dLStf52irtCx5KizYLYMz6S2VkXLrimsAE,5333
|
|
14
|
+
minmo-0.1.0.dist-info/METADATA,sha256=2u-kJISPxnHLSTwPDSXKQ9UmxL9dlAy0jr3UELVcAOc,6238
|
|
15
|
+
minmo-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
16
|
+
minmo-0.1.0.dist-info/entry_points.txt,sha256=yVq0PWqm9rSExZVypzd1ZYSp5RoQ4KJEzla4CR7XuyA,41
|
|
17
|
+
minmo-0.1.0.dist-info/licenses/LICENSE,sha256=qv_G1ukgjdOZd4NueyR_BduXSsaATBk7q1GVngnlSzs,1068
|
|
18
|
+
minmo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anas Elhaag
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|