fusion-runtime 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.
- fusion_runtime/__init__.py +63 -0
- fusion_runtime/agent.py +346 -0
- fusion_runtime/audio/__init__.py +1 -0
- fusion_runtime/audio/duplex_audio.py +304 -0
- fusion_runtime/audio/echo_canceller.py +482 -0
- fusion_runtime/catalog/__init__.py +21 -0
- fusion_runtime/catalog/download.py +244 -0
- fusion_runtime/catalog/entries.py +173 -0
- fusion_runtime/catalog/gguf.py +129 -0
- fusion_runtime/catalog/models.toml +91 -0
- fusion_runtime/cli/__init__.py +11 -0
- fusion_runtime/cli/__main__.py +4 -0
- fusion_runtime/cli/_checks.py +388 -0
- fusion_runtime/cli/_common.py +21 -0
- fusion_runtime/cli/_talk_client.py +249 -0
- fusion_runtime/cli/app.py +57 -0
- fusion_runtime/cli/doctor.py +30 -0
- fusion_runtime/cli/keys.py +98 -0
- fusion_runtime/cli/models.py +165 -0
- fusion_runtime/cli/talk.py +89 -0
- fusion_runtime/cli/up.py +245 -0
- fusion_runtime/cli/version.py +13 -0
- fusion_runtime/config.py +329 -0
- fusion_runtime/contract/__init__.py +37 -0
- fusion_runtime/contract/common.py +244 -0
- fusion_runtime/contract/llm.py +61 -0
- fusion_runtime/contract/stt.py +43 -0
- fusion_runtime/contract/tts.py +33 -0
- fusion_runtime/contract/turn.py +59 -0
- fusion_runtime/engine/__init__.py +4 -0
- fusion_runtime/engine/barge_in.py +65 -0
- fusion_runtime/engine/conversation.py +97 -0
- fusion_runtime/engine/metrics.py +66 -0
- fusion_runtime/engine/orchestrator.py +1384 -0
- fusion_runtime/engine/scheduler.py +167 -0
- fusion_runtime/engine/streaming.py +156 -0
- fusion_runtime/engine/text.py +90 -0
- fusion_runtime/env.py +47 -0
- fusion_runtime/registry.py +122 -0
- fusion_runtime/resolver.py +369 -0
- fusion_runtime/runtimes/__init__.py +11 -0
- fusion_runtime/runtimes/ctranslate2/__init__.py +1 -0
- fusion_runtime/runtimes/ctranslate2/stt.py +190 -0
- fusion_runtime/runtimes/llama_cpp/__init__.py +1 -0
- fusion_runtime/runtimes/llama_cpp/llm.py +202 -0
- fusion_runtime/runtimes/onnx/__init__.py +1 -0
- fusion_runtime/runtimes/onnx/kokoro.py +93 -0
- fusion_runtime/runtimes/onnx/tts.py +100 -0
- fusion_runtime/runtimes/openai_http/__init__.py +1 -0
- fusion_runtime/runtimes/openai_http/llm.py +259 -0
- fusion_runtime/security/__init__.py +32 -0
- fusion_runtime/security/auth.py +177 -0
- fusion_runtime/security/keys.py +164 -0
- fusion_runtime/security/limits.py +179 -0
- fusion_runtime/security/origins.py +61 -0
- fusion_runtime/security/tokens.py +81 -0
- fusion_runtime/server.py +745 -0
- fusion_runtime/telemetry/__init__.py +20 -0
- fusion_runtime/telemetry/errors.py +74 -0
- fusion_runtime/telemetry/events.py +57 -0
- fusion_runtime/telemetry/hub.py +184 -0
- fusion_runtime/telemetry/loop_monitor.py +66 -0
- fusion_runtime/telemetry/metrics.py +179 -0
- fusion_runtime/telemetry/redact.py +21 -0
- fusion_runtime/telemetry/sinks.py +142 -0
- fusion_runtime/telemetry/trace.py +247 -0
- fusion_runtime/testing/__init__.py +1 -0
- fusion_runtime/testing/conformance.py +379 -0
- fusion_runtime/testing/fakes.py +218 -0
- fusion_runtime/turns/__init__.py +22 -0
- fusion_runtime/turns/silence.py +34 -0
- fusion_runtime/vad/__init__.py +11 -0
- fusion_runtime/vad/base.py +19 -0
- fusion_runtime/vad/silero.py +24 -0
- fusion_runtime/vad/turn.py +27 -0
- fusion_runtime/web/__init__.py +41 -0
- fusion_runtime/web/fusion-runtime.js +418 -0
- fusion_runtime/web/index.html +210 -0
- fusion_runtime-0.1.0.dist-info/METADATA +300 -0
- fusion_runtime-0.1.0.dist-info/RECORD +85 -0
- fusion_runtime-0.1.0.dist-info/WHEEL +5 -0
- fusion_runtime-0.1.0.dist-info/entry_points.txt +3 -0
- fusion_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
- fusion_runtime-0.1.0.dist-info/licenses/NOTICE +13 -0
- fusion_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fusion-runtime: Low-latency voice AI inference runtime.
|
|
3
|
+
"""
|
|
4
|
+
import importlib
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _package_version
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = _package_version("fusion-runtime") # the one real version lives in pyproject.toml
|
|
11
|
+
except PackageNotFoundError: # source checkout that was never pip-installed
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
13
|
+
|
|
14
|
+
# Public names load on first use, so light entry points like `frun --help`
|
|
15
|
+
# don't pull in pydantic, numpy and the whole pipeline just by importing
|
|
16
|
+
# the package. `from fusion_runtime import PipelineOrchestrator` still works.
|
|
17
|
+
_EXPORTS = {
|
|
18
|
+
"PipelineConfig": "fusion_runtime.config",
|
|
19
|
+
"STTConfig": "fusion_runtime.config",
|
|
20
|
+
"LLMConfig": "fusion_runtime.config",
|
|
21
|
+
"TTSConfig": "fusion_runtime.config",
|
|
22
|
+
"VADConfig": "fusion_runtime.config",
|
|
23
|
+
"TurnDetectionConfig": "fusion_runtime.config",
|
|
24
|
+
"Provider": "fusion_runtime.config",
|
|
25
|
+
"DEVELOPMENT_CONFIG": "fusion_runtime.config",
|
|
26
|
+
"PRODUCTION_CONFIG": "fusion_runtime.config",
|
|
27
|
+
"HYBRID_CONFIG": "fusion_runtime.config",
|
|
28
|
+
"Agent": "fusion_runtime.agent",
|
|
29
|
+
"STT": "fusion_runtime.agent",
|
|
30
|
+
"LLM": "fusion_runtime.agent",
|
|
31
|
+
"TTS": "fusion_runtime.agent",
|
|
32
|
+
"Turns": "fusion_runtime.agent",
|
|
33
|
+
"VAD": "fusion_runtime.agent",
|
|
34
|
+
"load_agent": "fusion_runtime.agent",
|
|
35
|
+
"PipelineOrchestrator": "fusion_runtime.engine",
|
|
36
|
+
"run_single_turn": "fusion_runtime.engine",
|
|
37
|
+
}
|
|
38
|
+
__all__ = list(_EXPORTS)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING: # let editors and type checkers see the real names
|
|
41
|
+
from fusion_runtime.agent import LLM, STT, TTS, VAD, Agent, Turns, load_agent
|
|
42
|
+
from fusion_runtime.config import (
|
|
43
|
+
DEVELOPMENT_CONFIG,
|
|
44
|
+
HYBRID_CONFIG,
|
|
45
|
+
PRODUCTION_CONFIG,
|
|
46
|
+
LLMConfig,
|
|
47
|
+
PipelineConfig,
|
|
48
|
+
Provider,
|
|
49
|
+
STTConfig,
|
|
50
|
+
TTSConfig,
|
|
51
|
+
TurnDetectionConfig,
|
|
52
|
+
VADConfig,
|
|
53
|
+
)
|
|
54
|
+
from fusion_runtime.engine import PipelineOrchestrator, run_single_turn
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def __getattr__(name: str):
|
|
58
|
+
module = _EXPORTS.get(name)
|
|
59
|
+
if module is None:
|
|
60
|
+
raise AttributeError(f"module 'fusion_runtime' has no attribute {name!r}")
|
|
61
|
+
value = getattr(importlib.import_module(module), name)
|
|
62
|
+
globals()[name] = value # cache so the lookup happens once
|
|
63
|
+
return value
|
fusion_runtime/agent.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""An agent in one Python file: what it says, which models it uses, how it takes turns.
|
|
2
|
+
|
|
3
|
+
# agent.py
|
|
4
|
+
from fusion_runtime import Agent, LLM, TTS, Turns
|
|
5
|
+
|
|
6
|
+
agent = Agent(
|
|
7
|
+
prompt="You take orders for ShopKart. Keep answers to one short sentence.",
|
|
8
|
+
llm=LLM("qwen2.5-0.5b-q4", max_tokens=200),
|
|
9
|
+
tts=TTS("kokoro-v1.0", voice="af_heart"),
|
|
10
|
+
turns=Turns(wait_ms=500),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
frun up agent.py
|
|
14
|
+
|
|
15
|
+
The agent is the product, so it lives in code: versioned, reviewed, and able to
|
|
16
|
+
hold the tools it will gain later. Secrets never belong here — those come from
|
|
17
|
+
environment variables (`api_key_env`, `HF_TOKEN`).
|
|
18
|
+
|
|
19
|
+
A stage takes a model name, with settings when it needs them:
|
|
20
|
+
|
|
21
|
+
stt="whisper-tiny.en" just the model
|
|
22
|
+
llm=LLM("qwen2.5-0.5b-q4", max_tokens=200) model plus settings
|
|
23
|
+
|
|
24
|
+
A model is named the way the resolver understands, optionally with the runtime
|
|
25
|
+
in front:
|
|
26
|
+
|
|
27
|
+
"qwen2.5-0.5b-q4" a catalog model (`frun models list`)
|
|
28
|
+
"./models/my-finetune.gguf" a file
|
|
29
|
+
"hf:Systran/faster-whisper-small" a Hugging Face model
|
|
30
|
+
"vllm:hf:mistralai/Mistral-7B-Instruct" that model, served by vLLM
|
|
31
|
+
"http://localhost:8000/v1" an OpenAI-compatible endpoint
|
|
32
|
+
|
|
33
|
+
Settings the runtime's config already knows (`max_tokens`, `voice`, `n_ctx`,
|
|
34
|
+
...) are applied to it; anything else is handed to that runtime untouched, so a
|
|
35
|
+
new engine flag needs no change here.
|
|
36
|
+
"""
|
|
37
|
+
import importlib.util
|
|
38
|
+
import sys
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Dict, Mapping, Optional, Sequence, Union
|
|
42
|
+
|
|
43
|
+
DEFAULT_PROMPT = "You are a helpful voice assistant. Answer briefly."
|
|
44
|
+
|
|
45
|
+
# Runtimes that can appear in front of a model reference ("vllm:hf:org/repo").
|
|
46
|
+
# "module:Class" works too, for plugins.
|
|
47
|
+
KNOWN_RUNTIMES = ("llama_cpp", "ctranslate2", "onnx", "openai_http", "vllm", "sglang", "llama_server")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AgentError(ValueError):
|
|
51
|
+
"""The agent file can't be used as written."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Stage:
|
|
56
|
+
"""One stage's model and its settings. Use STT, LLM or TTS, not this."""
|
|
57
|
+
|
|
58
|
+
ref: str
|
|
59
|
+
runtime: Optional[str] = None
|
|
60
|
+
options: Dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
stage: str = ""
|
|
62
|
+
|
|
63
|
+
def __init__(self, ref: str, runtime: Optional[str] = None, **options: Any) -> None:
|
|
64
|
+
name = type(self).__name__
|
|
65
|
+
if not isinstance(ref, str) or not ref.strip():
|
|
66
|
+
raise AgentError(f"{name} needs a model, for example {name}('kokoro-v1.0')")
|
|
67
|
+
object.__setattr__(self, "ref", ref.strip())
|
|
68
|
+
object.__setattr__(self, "runtime", runtime.strip() if isinstance(runtime, str) else None)
|
|
69
|
+
object.__setattr__(self, "options", dict(options))
|
|
70
|
+
object.__setattr__(self, "stage", getattr(type(self), "STAGE", ""))
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def of(cls, value: Union[str, "Stage", None], stage: str) -> Optional["Stage"]:
|
|
74
|
+
"""Accept a plain model name, or the stage class for this stage."""
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
return _STAGE_CLASSES[stage](value)
|
|
79
|
+
if isinstance(value, Stage):
|
|
80
|
+
if value.stage and value.stage != stage:
|
|
81
|
+
raise AgentError(f"{type(value).__name__}(...) was given as the {stage} model; "
|
|
82
|
+
f"use {_STAGE_CLASSES[stage].__name__}(...) instead")
|
|
83
|
+
return value
|
|
84
|
+
raise AgentError(f"{stage} takes a model name or {_STAGE_CLASSES[stage].__name__}(...), "
|
|
85
|
+
f"got {type(value).__name__}")
|
|
86
|
+
|
|
87
|
+
def split(self) -> tuple:
|
|
88
|
+
"""(runtime, model reference).
|
|
89
|
+
|
|
90
|
+
"vllm:hf:org/repo" → ("vllm", "hf:org/repo") a runtime in front
|
|
91
|
+
"my_pkg.turns:Detector" → ("my_pkg.turns:Detector", "") a plugin runtime
|
|
92
|
+
"hf:org/repo", a URL, an id, a path → (None, as written) the format decides
|
|
93
|
+
"""
|
|
94
|
+
if self.runtime:
|
|
95
|
+
return self.runtime, self.ref
|
|
96
|
+
prefix, _, rest = self.ref.partition(":")
|
|
97
|
+
if rest and prefix in KNOWN_RUNTIMES:
|
|
98
|
+
return prefix, rest
|
|
99
|
+
if rest and "." in prefix and "/" not in rest and ":" not in rest:
|
|
100
|
+
return self.ref, "" # "module:Class": the whole thing names the runtime
|
|
101
|
+
return None, self.ref
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class STT(Stage):
|
|
105
|
+
"""The speech recognition model: STT("whisper-tiny.en", beam_size=5)."""
|
|
106
|
+
|
|
107
|
+
STAGE = "stt"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class LLM(Stage):
|
|
111
|
+
"""The language model: LLM("qwen2.5-0.5b-q4", max_tokens=200)."""
|
|
112
|
+
|
|
113
|
+
STAGE = "llm"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TTS(Stage):
|
|
117
|
+
"""The voice: TTS("kokoro-v1.0", voice="af_heart", speed=1.1)."""
|
|
118
|
+
|
|
119
|
+
STAGE = "tts"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_STAGE_CLASSES = {"stt": STT, "llm": LLM, "tts": TTS}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class Turns:
|
|
127
|
+
"""Turn taking: when the agent answers, and when it stops for the caller.
|
|
128
|
+
|
|
129
|
+
Turns(wait_ms=500, interrupt_after_ms=300)
|
|
130
|
+
Turns("my_package.turns:MyDetector", wait_ms=400) # with a turn detector model
|
|
131
|
+
|
|
132
|
+
A detector predicts whether the caller is done, which shortens or lengthens
|
|
133
|
+
the wait (see fusion_runtime.contract.turn). Without one, the agent answers
|
|
134
|
+
after `wait_ms` of silence.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
detector: Optional[Stage] = None
|
|
138
|
+
wait_ms: Optional[int] = None # silence before the agent answers
|
|
139
|
+
interrupt_after_ms: Optional[int] = None # speech over the agent before it stops
|
|
140
|
+
resume_window_ms: Optional[int] = None # speaking again this soon continues the same turn
|
|
141
|
+
options: Dict[str, Any] = field(default_factory=dict)
|
|
142
|
+
|
|
143
|
+
def __init__(self, detector: Union[str, Stage, None] = None, *, wait_ms: Optional[int] = None,
|
|
144
|
+
interrupt_after_ms: Optional[int] = None, resume_window_ms: Optional[int] = None,
|
|
145
|
+
**options: Any) -> None:
|
|
146
|
+
if isinstance(detector, str):
|
|
147
|
+
detector = Stage(detector)
|
|
148
|
+
elif detector is not None and not isinstance(detector, Stage):
|
|
149
|
+
raise AgentError(f"Turns takes a detector name or Stage(...), got {type(detector).__name__}")
|
|
150
|
+
object.__setattr__(self, "detector", detector)
|
|
151
|
+
for name, value in (("wait_ms", wait_ms), ("interrupt_after_ms", interrupt_after_ms),
|
|
152
|
+
("resume_window_ms", resume_window_ms)):
|
|
153
|
+
if value is not None and (not isinstance(value, int) or isinstance(value, bool) or value < 0):
|
|
154
|
+
raise AgentError(f"Turns({name}=...) must be a whole number of milliseconds, got {value!r}")
|
|
155
|
+
object.__setattr__(self, name, value)
|
|
156
|
+
object.__setattr__(self, "options", dict(options))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class VAD:
|
|
161
|
+
"""Which audio counts as speech: VAD(threshold=0.6).
|
|
162
|
+
|
|
163
|
+
Feeds both turn taking and interruption. Silero is the only detector today,
|
|
164
|
+
so a model reference isn't accepted yet.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
threshold: Optional[float] = None # higher ignores more background noise
|
|
168
|
+
min_speech_ms: Optional[int] = None
|
|
169
|
+
min_silence_ms: Optional[int] = None
|
|
170
|
+
|
|
171
|
+
def __init__(self, model: Optional[str] = None, *, threshold: Optional[float] = None,
|
|
172
|
+
min_speech_ms: Optional[int] = None, min_silence_ms: Optional[int] = None) -> None:
|
|
173
|
+
if model is not None:
|
|
174
|
+
raise AgentError(
|
|
175
|
+
"VAD models can't be swapped yet (Silero is built in); VAD(threshold=..., "
|
|
176
|
+
"min_speech_ms=..., min_silence_ms=...) sets its behaviour"
|
|
177
|
+
)
|
|
178
|
+
if threshold is not None and not 0.0 <= float(threshold) <= 1.0:
|
|
179
|
+
raise AgentError(f"VAD(threshold=...) is between 0 and 1, got {threshold!r}")
|
|
180
|
+
object.__setattr__(self, "threshold", threshold)
|
|
181
|
+
for name, value in (("min_speech_ms", min_speech_ms), ("min_silence_ms", min_silence_ms)):
|
|
182
|
+
if value is not None and (not isinstance(value, int) or isinstance(value, bool) or value < 0):
|
|
183
|
+
raise AgentError(f"VAD({name}=...) must be a whole number of milliseconds, got {value!r}")
|
|
184
|
+
object.__setattr__(self, name, value)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class Agent:
|
|
189
|
+
"""Everything that makes one voice agent."""
|
|
190
|
+
|
|
191
|
+
prompt: str = DEFAULT_PROMPT
|
|
192
|
+
name: Optional[str] = None
|
|
193
|
+
stt: Union[str, Stage, None] = None
|
|
194
|
+
llm: Union[str, Stage, None] = None
|
|
195
|
+
tts: Union[str, Stage, None] = None
|
|
196
|
+
language: Optional[str] = None # what callers speak; None keeps the profile's setting
|
|
197
|
+
turns: Optional[Turns] = None
|
|
198
|
+
vad: Optional[VAD] = None
|
|
199
|
+
tools: Sequence[Any] = ()
|
|
200
|
+
profile: str = "development" # the defaults everything above is applied to
|
|
201
|
+
source: Optional[Path] = None # the file it was loaded from, when it came from one
|
|
202
|
+
|
|
203
|
+
def __post_init__(self) -> None:
|
|
204
|
+
if not isinstance(self.prompt, str) or not self.prompt.strip():
|
|
205
|
+
raise AgentError("prompt must not be empty: it's what the agent is told to do")
|
|
206
|
+
for stage in ("stt", "llm", "tts"):
|
|
207
|
+
setattr(self, stage, Stage.of(getattr(self, stage), stage))
|
|
208
|
+
if self.turns is not None and not isinstance(self.turns, Turns):
|
|
209
|
+
raise AgentError(f"turns takes Turns(...), got {type(self.turns).__name__}")
|
|
210
|
+
if self.vad is not None and not isinstance(self.vad, VAD):
|
|
211
|
+
raise AgentError(f"vad takes VAD(...), got {type(self.vad).__name__}")
|
|
212
|
+
if self.tools:
|
|
213
|
+
raise AgentError(
|
|
214
|
+
"tool calling isn't supported yet, so tools= can't be used. "
|
|
215
|
+
"Everything else in the agent works; tools are the next piece being built"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def config(self, environ: Optional[Mapping[str, str]] = None):
|
|
219
|
+
"""This agent as a PipelineConfig: profile defaults, the agent on top, then the environment."""
|
|
220
|
+
from fusion_runtime.config import PROFILES, with_env_overrides
|
|
221
|
+
|
|
222
|
+
if self.profile not in PROFILES:
|
|
223
|
+
raise AgentError(f"unknown profile {self.profile!r}; choose one of: {', '.join(PROFILES)}")
|
|
224
|
+
config = PROFILES[self.profile]
|
|
225
|
+
parts: Dict[str, Any] = {}
|
|
226
|
+
|
|
227
|
+
for stage in ("stt", "llm", "tts"):
|
|
228
|
+
model = getattr(self, stage)
|
|
229
|
+
stage_config = getattr(config, stage)
|
|
230
|
+
updates: Dict[str, Any] = {}
|
|
231
|
+
if model is not None:
|
|
232
|
+
runtime, ref = model.split()
|
|
233
|
+
fields, options = _split_settings(model.options, stage_config)
|
|
234
|
+
updates.update(model=ref, runtime=runtime, options=options, **fields)
|
|
235
|
+
if self.language is not None and stage in ("stt", "tts"):
|
|
236
|
+
updates["language"] = self.language
|
|
237
|
+
if updates:
|
|
238
|
+
parts[stage] = stage_config.model_copy(update=updates)
|
|
239
|
+
|
|
240
|
+
if self.turns is not None:
|
|
241
|
+
turn_config = config.turn_detection
|
|
242
|
+
updates = {}
|
|
243
|
+
for setting, field_name in (("wait_ms", "min_silence_ms"),
|
|
244
|
+
("interrupt_after_ms", "barge_in_min_speech_ms"),
|
|
245
|
+
("resume_window_ms", "resume_window_ms")):
|
|
246
|
+
value = getattr(self.turns, setting)
|
|
247
|
+
if value is not None:
|
|
248
|
+
updates[field_name] = value
|
|
249
|
+
if self.turns.detector is not None:
|
|
250
|
+
runtime, ref = self.turns.detector.split()
|
|
251
|
+
updates.update(runtime=runtime or ref, model=ref if runtime else None)
|
|
252
|
+
fields, options = _split_settings(self.turns.options, turn_config)
|
|
253
|
+
updates.update(fields)
|
|
254
|
+
if options:
|
|
255
|
+
updates["options"] = options
|
|
256
|
+
parts["turn_detection"] = turn_config.model_copy(update=updates)
|
|
257
|
+
|
|
258
|
+
if self.vad is not None:
|
|
259
|
+
updates = {k: v for k, v in {
|
|
260
|
+
"threshold": self.vad.threshold,
|
|
261
|
+
"min_speech_ms": self.vad.min_speech_ms,
|
|
262
|
+
"min_silence_ms": self.vad.min_silence_ms,
|
|
263
|
+
}.items() if v is not None}
|
|
264
|
+
if updates:
|
|
265
|
+
parts["vad"] = config.vad.model_copy(update=updates)
|
|
266
|
+
|
|
267
|
+
# Environment last: a deployment can point the LLM elsewhere or change the
|
|
268
|
+
# waits without editing the agent (see fusion_runtime.config.with_env_overrides).
|
|
269
|
+
return with_env_overrides(config.model_copy(update=parts) if parts else config, environ)
|
|
270
|
+
|
|
271
|
+
def describe(self) -> Dict[str, Any]:
|
|
272
|
+
"""A short summary for logs and `frun up` output. Never includes the prompt text."""
|
|
273
|
+
def model_of(stage: str) -> Optional[str]:
|
|
274
|
+
model = getattr(self, stage)
|
|
275
|
+
return model.ref if model is not None else None
|
|
276
|
+
|
|
277
|
+
detector = self.turns.detector if self.turns is not None else None
|
|
278
|
+
return {k: v for k, v in {
|
|
279
|
+
"agent": self.name or (self.source.stem if self.source else None),
|
|
280
|
+
"source": str(self.source) if self.source else None,
|
|
281
|
+
"prompt_chars": len(self.prompt),
|
|
282
|
+
"stt": model_of("stt"), "llm": model_of("llm"), "tts": model_of("tts"),
|
|
283
|
+
"turn_detector": detector.ref if detector is not None else None,
|
|
284
|
+
"language": self.language,
|
|
285
|
+
"profile": self.profile,
|
|
286
|
+
}.items() if v is not None}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _split_settings(options: Mapping[str, Any], stage_config) -> tuple:
|
|
290
|
+
"""Settings the config declares become config fields; the rest go to the runtime."""
|
|
291
|
+
known = set(type(stage_config).model_fields) - {"model", "runtime", "family", "options", "provider"}
|
|
292
|
+
fields = {k: v for k, v in options.items() if k in known}
|
|
293
|
+
return fields, {k: v for k, v in options.items() if k not in known}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def load_agent(path: Union[str, Path]) -> Agent:
|
|
297
|
+
"""Run an agent file and return the Agent it defines.
|
|
298
|
+
|
|
299
|
+
The file is ordinary Python: it may import anything and define helpers. It
|
|
300
|
+
needs one Agent, either assigned to `agent` or as the only one defined.
|
|
301
|
+
"""
|
|
302
|
+
file_path = Path(path).expanduser().resolve()
|
|
303
|
+
if file_path.is_dir():
|
|
304
|
+
raise AgentError(f"{file_path} is a folder; point at one agent file (running a folder of agents comes later)")
|
|
305
|
+
if not file_path.is_file():
|
|
306
|
+
raise AgentError(f"no agent file at {file_path}")
|
|
307
|
+
if file_path.suffix != ".py":
|
|
308
|
+
raise AgentError(f"an agent file must be a .py file, got {file_path.name}")
|
|
309
|
+
|
|
310
|
+
module_name = f"fusion_agent_{abs(hash(str(file_path)))}"
|
|
311
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
312
|
+
if spec is None or spec.loader is None:
|
|
313
|
+
raise AgentError(f"can't load {file_path} as Python")
|
|
314
|
+
module = importlib.util.module_from_spec(spec)
|
|
315
|
+
sys.modules[module_name] = module
|
|
316
|
+
# The file's own folder first, so `import my_tools` next to the agent works
|
|
317
|
+
added_path = str(file_path.parent)
|
|
318
|
+
inserted = added_path not in sys.path
|
|
319
|
+
if inserted:
|
|
320
|
+
sys.path.insert(0, added_path)
|
|
321
|
+
try:
|
|
322
|
+
spec.loader.exec_module(module)
|
|
323
|
+
except AgentError:
|
|
324
|
+
raise
|
|
325
|
+
except Exception as e:
|
|
326
|
+
raise AgentError(f"{file_path.name} failed while loading: {type(e).__name__}: {e}") from e
|
|
327
|
+
finally:
|
|
328
|
+
if inserted:
|
|
329
|
+
sys.path.remove(added_path)
|
|
330
|
+
sys.modules.pop(module_name, None)
|
|
331
|
+
|
|
332
|
+
agent = getattr(module, "agent", None)
|
|
333
|
+
if not isinstance(agent, Agent):
|
|
334
|
+
found = [value for value in vars(module).values() if isinstance(value, Agent)]
|
|
335
|
+
if len(found) == 1:
|
|
336
|
+
agent = found[0]
|
|
337
|
+
elif not found:
|
|
338
|
+
raise AgentError(
|
|
339
|
+
f"{file_path.name} defines no Agent. Add one:\n"
|
|
340
|
+
" from fusion_runtime import Agent\n"
|
|
341
|
+
' agent = Agent(prompt="...")'
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
raise AgentError(f"{file_path.name} defines {len(found)} agents; name the one to run `agent`")
|
|
345
|
+
agent.source = file_path
|
|
346
|
+
return agent
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Audio processing: echo cancellation, resampling, full-duplex device I/O."""
|