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.
Files changed (85) hide show
  1. fusion_runtime/__init__.py +63 -0
  2. fusion_runtime/agent.py +346 -0
  3. fusion_runtime/audio/__init__.py +1 -0
  4. fusion_runtime/audio/duplex_audio.py +304 -0
  5. fusion_runtime/audio/echo_canceller.py +482 -0
  6. fusion_runtime/catalog/__init__.py +21 -0
  7. fusion_runtime/catalog/download.py +244 -0
  8. fusion_runtime/catalog/entries.py +173 -0
  9. fusion_runtime/catalog/gguf.py +129 -0
  10. fusion_runtime/catalog/models.toml +91 -0
  11. fusion_runtime/cli/__init__.py +11 -0
  12. fusion_runtime/cli/__main__.py +4 -0
  13. fusion_runtime/cli/_checks.py +388 -0
  14. fusion_runtime/cli/_common.py +21 -0
  15. fusion_runtime/cli/_talk_client.py +249 -0
  16. fusion_runtime/cli/app.py +57 -0
  17. fusion_runtime/cli/doctor.py +30 -0
  18. fusion_runtime/cli/keys.py +98 -0
  19. fusion_runtime/cli/models.py +165 -0
  20. fusion_runtime/cli/talk.py +89 -0
  21. fusion_runtime/cli/up.py +245 -0
  22. fusion_runtime/cli/version.py +13 -0
  23. fusion_runtime/config.py +329 -0
  24. fusion_runtime/contract/__init__.py +37 -0
  25. fusion_runtime/contract/common.py +244 -0
  26. fusion_runtime/contract/llm.py +61 -0
  27. fusion_runtime/contract/stt.py +43 -0
  28. fusion_runtime/contract/tts.py +33 -0
  29. fusion_runtime/contract/turn.py +59 -0
  30. fusion_runtime/engine/__init__.py +4 -0
  31. fusion_runtime/engine/barge_in.py +65 -0
  32. fusion_runtime/engine/conversation.py +97 -0
  33. fusion_runtime/engine/metrics.py +66 -0
  34. fusion_runtime/engine/orchestrator.py +1384 -0
  35. fusion_runtime/engine/scheduler.py +167 -0
  36. fusion_runtime/engine/streaming.py +156 -0
  37. fusion_runtime/engine/text.py +90 -0
  38. fusion_runtime/env.py +47 -0
  39. fusion_runtime/registry.py +122 -0
  40. fusion_runtime/resolver.py +369 -0
  41. fusion_runtime/runtimes/__init__.py +11 -0
  42. fusion_runtime/runtimes/ctranslate2/__init__.py +1 -0
  43. fusion_runtime/runtimes/ctranslate2/stt.py +190 -0
  44. fusion_runtime/runtimes/llama_cpp/__init__.py +1 -0
  45. fusion_runtime/runtimes/llama_cpp/llm.py +202 -0
  46. fusion_runtime/runtimes/onnx/__init__.py +1 -0
  47. fusion_runtime/runtimes/onnx/kokoro.py +93 -0
  48. fusion_runtime/runtimes/onnx/tts.py +100 -0
  49. fusion_runtime/runtimes/openai_http/__init__.py +1 -0
  50. fusion_runtime/runtimes/openai_http/llm.py +259 -0
  51. fusion_runtime/security/__init__.py +32 -0
  52. fusion_runtime/security/auth.py +177 -0
  53. fusion_runtime/security/keys.py +164 -0
  54. fusion_runtime/security/limits.py +179 -0
  55. fusion_runtime/security/origins.py +61 -0
  56. fusion_runtime/security/tokens.py +81 -0
  57. fusion_runtime/server.py +745 -0
  58. fusion_runtime/telemetry/__init__.py +20 -0
  59. fusion_runtime/telemetry/errors.py +74 -0
  60. fusion_runtime/telemetry/events.py +57 -0
  61. fusion_runtime/telemetry/hub.py +184 -0
  62. fusion_runtime/telemetry/loop_monitor.py +66 -0
  63. fusion_runtime/telemetry/metrics.py +179 -0
  64. fusion_runtime/telemetry/redact.py +21 -0
  65. fusion_runtime/telemetry/sinks.py +142 -0
  66. fusion_runtime/telemetry/trace.py +247 -0
  67. fusion_runtime/testing/__init__.py +1 -0
  68. fusion_runtime/testing/conformance.py +379 -0
  69. fusion_runtime/testing/fakes.py +218 -0
  70. fusion_runtime/turns/__init__.py +22 -0
  71. fusion_runtime/turns/silence.py +34 -0
  72. fusion_runtime/vad/__init__.py +11 -0
  73. fusion_runtime/vad/base.py +19 -0
  74. fusion_runtime/vad/silero.py +24 -0
  75. fusion_runtime/vad/turn.py +27 -0
  76. fusion_runtime/web/__init__.py +41 -0
  77. fusion_runtime/web/fusion-runtime.js +418 -0
  78. fusion_runtime/web/index.html +210 -0
  79. fusion_runtime-0.1.0.dist-info/METADATA +300 -0
  80. fusion_runtime-0.1.0.dist-info/RECORD +85 -0
  81. fusion_runtime-0.1.0.dist-info/WHEEL +5 -0
  82. fusion_runtime-0.1.0.dist-info/entry_points.txt +3 -0
  83. fusion_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
  84. fusion_runtime-0.1.0.dist-info/licenses/NOTICE +13 -0
  85. 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
@@ -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."""