agentship-voice 0.0.3__tar.gz

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.
@@ -0,0 +1,27 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+ .coverage
14
+ htmlcov/
15
+
16
+ # Env / secrets — never commit
17
+ .env
18
+ .env.*
19
+ !.env.example
20
+
21
+ # Editor / OS
22
+ .DS_Store
23
+ .idea/
24
+ .vscode/
25
+
26
+ # Docs build
27
+ site/
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentship-voice
3
+ Version: 0.0.3
4
+ Summary: AgentShip voice — run the same agent over a live audio stream. A cascaded pipeline (transport/VAD → STT → agent → TTS) with barge-in, hosted in either Pipecat or LiveKit Agents. The agent is the one the REST service runs; only the hosting differs.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: agentship-core==0.0.3
8
+ Provides-Extra: all
9
+ Requires-Dist: livekit-agents<2,>=1.8; extra == 'all'
10
+ Requires-Dist: pipecat-ai<2,>=1.8; extra == 'all'
11
+ Provides-Extra: livekit
12
+ Requires-Dist: livekit-agents<2,>=1.8; extra == 'livekit'
13
+ Provides-Extra: pipecat
14
+ Requires-Dist: pipecat-ai<2,>=1.8; extra == 'pipecat'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # agentship-voice
18
+
19
+ Run the same AgentShip agent over a live audio stream.
20
+
21
+ ```bash
22
+ pip install "agentship-voice[pipecat]" # or [livekit]
23
+ ```
24
+
25
+ The agent is the one the REST service runs — same spec, memory, tools and tracing. A voice
26
+ framework supplies the ears (transport, VAD, STT) and the mouth (TTS); this package supplies
27
+ the agent in the shape that framework expects.
28
+
29
+ See [`docs/capabilities/voice.md`](../../docs/capabilities/voice.md).
@@ -0,0 +1,13 @@
1
+ # agentship-voice
2
+
3
+ Run the same AgentShip agent over a live audio stream.
4
+
5
+ ```bash
6
+ pip install "agentship-voice[pipecat]" # or [livekit]
7
+ ```
8
+
9
+ The agent is the one the REST service runs — same spec, memory, tools and tracing. A voice
10
+ framework supplies the ears (transport, VAD, STT) and the mouth (TTS); this package supplies
11
+ the agent in the shape that framework expects.
12
+
13
+ See [`docs/capabilities/voice.md`](../../docs/capabilities/voice.md).
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentship-voice"
7
+ version = "0.0.3"
8
+ description = "AgentShip voice — run the same agent over a live audio stream. A cascaded pipeline (transport/VAD → STT → agent → TTS) with barge-in, hosted in either Pipecat or LiveKit Agents. The agent is the one the REST service runs; only the hosting differs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "Apache-2.0"
12
+ # The base install carries NO voice framework. `VoiceTurn` is pure asyncio over
13
+ # agentship-core, so the seam, the config surface and their tests run keyless in CI;
14
+ # a framework arrives only with its extra. Same shape as the observability package,
15
+ # where the vendor SDK is never a hard dependency of the contract.
16
+ dependencies = ["agentship-core==0.0.3"]
17
+
18
+ [project.optional-dependencies]
19
+ # Pipecat hosts the agent as a FrameProcessor in a frame pipeline.
20
+ pipecat = ["pipecat-ai>=1.8,<2"]
21
+ # LiveKit hosts the agent as an llm.LLM inside an AgentSession — a different shape
22
+ # for the same agent, which is exactly why the seam below is text-in/text-out and
23
+ # not a pipeline node. See adapters/base.py.
24
+ livekit = ["livekit-agents>=1.8,<2"]
25
+ all = ["pipecat-ai>=1.8,<2", "livekit-agents>=1.8,<2"]
26
+
27
+ [project.entry-points."agentship.voice_frameworks"]
28
+ pipecat = "agentship_voice.adapters.pipecat_adapter:PipecatAdapter"
29
+ livekit = "agentship_voice.adapters.livekit_adapter:LiveKitAdapter"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/agentship_voice"]
@@ -0,0 +1,25 @@
1
+ """Run the same AgentShip agent over a live audio stream.
2
+
3
+ The agent is the one the REST service runs — same spec, same memory, same tools, same tracing.
4
+ Only the hosting differs: a voice framework supplies the ears (transport, VAD, STT) and the
5
+ mouth (TTS), and this package supplies the agent in the shape that framework expects.
6
+
7
+ The public surface is small on purpose:
8
+
9
+ * :class:`~agentship_voice.turn.VoiceTurn` — text in, spoken text out. The whole vendor-free
10
+ seam, and the only thing both frameworks could agree on.
11
+ * :class:`~agentship_voice.trace.LatencyTrace` — where a turn's time went, emitted every turn.
12
+ * :class:`~agentship.spec.VoiceSpec` — the ``voice:`` block. It lives in the **kernel**, beside
13
+ ``ObservabilitySpec``, because the kernel owns every authoring surface and imports no vendor;
14
+ the provider *names* in it are validated here, in :mod:`agentship_voice.factories`, which is
15
+ the half that knows what it can actually build.
16
+
17
+ Adapters live in :mod:`agentship_voice.adapters` and each needs its own extra installed.
18
+ """
19
+
20
+ from agentship.spec import VoiceSpec
21
+
22
+ from .trace import LatencyTrace
23
+ from .turn import VoiceTurn
24
+
25
+ __all__ = ["LatencyTrace", "VoiceSpec", "VoiceTurn"]
@@ -0,0 +1,40 @@
1
+ """Framework adapters. Each imports its framework lazily, so importing this package is free.
2
+
3
+ An adapter is only meaningful with its extra installed (``agentship-voice[pipecat]`` or
4
+ ``[livekit]``); the contract they implement lives in :mod:`agentship_voice.adapters.base`.
5
+
6
+ Adapters are discovered through the ``agentship.voice_frameworks`` entry-point group, the same
7
+ mechanism engines use — so a third party ships a class and one packaging line, and
8
+ ``voice.framework: theirs`` works with no edit here. Our own two are registered the same way
9
+ rather than special-cased, because a plugin system its own author bypasses is one that quietly
10
+ stops working for everyone else.
11
+ """
12
+
13
+ from agentship.errors import CapabilityError
14
+ from agentship.registry import Registry
15
+
16
+ from .base import VoiceAdapter
17
+
18
+ __all__ = ["VOICE_FRAMEWORKS", "VoiceAdapter", "get_adapter"]
19
+
20
+ #: Voice framework adapters by ``voice.framework`` name.
21
+ VOICE_FRAMEWORKS: Registry[type[VoiceAdapter]] = Registry(
22
+ "agentship.voice_frameworks", label="voice framework"
23
+ )
24
+
25
+
26
+ def get_adapter(name: str) -> VoiceAdapter:
27
+ """Return the adapter for ``name``, or raise naming the frameworks that exist.
28
+
29
+ An unknown framework fails here rather than deep inside a session, and the message lists the
30
+ real choices instead of leaving the author to guess the spelling.
31
+
32
+ Loading is lazy by construction: an entry point is only imported when its name is asked for,
33
+ so a stack with neither framework installed can still read this package's config and docs —
34
+ and a broken third-party adapter cannot stop ours from loading.
35
+ """
36
+ adapter_class = VOICE_FRAMEWORKS.get(name)
37
+ if adapter_class is None:
38
+ known = ", ".join(VOICE_FRAMEWORKS.names()) or "none installed"
39
+ raise CapabilityError(f"unknown voice framework {name!r} — available: {known}")
40
+ return adapter_class()
@@ -0,0 +1,62 @@
1
+ """The contract a voice framework must satisfy to host an AgentShip agent.
2
+
3
+ Deliberately narrow. An adapter is asked for two things:
4
+
5
+ 1. **host the agent** — wrap a :class:`~agentship_voice.turn.VoiceTurn` in whatever shape its
6
+ framework requires (a Pipecat ``FrameProcessor``, a LiveKit ``llm.LLM``);
7
+ 2. **run a session** — assemble transport, VAD, STT and TTS around it and serve until stopped.
8
+
9
+ Everything else a voice stack does — endpointing, turn detection, barge-in, jitter buffers,
10
+ audio codecs — belongs to the framework and is not restated here. Restating it would mean
11
+ owning it, and the point of two adapters is that neither framework's model leaks into ours.
12
+
13
+ **Why this ABC has two implementations from the start.** The engine ABC deliberately waits
14
+ for its second engine before freezing (ADR: the shape of an abstraction is a guess until a
15
+ second thing fits into it). Voice does not get that luxury cheaply: Pipecat and LiveKit
16
+ disagree about something fundamental — where the agent sits — so an ABC drawn against either
17
+ one alone would encode that framework's assumptions. Writing both at once is what proved the
18
+ seam had to be text-in/text-out rather than a pipeline node.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from abc import ABC, abstractmethod
24
+
25
+ from agentship.spec import VoiceSpec
26
+
27
+ from ..turn import VoiceTurn
28
+
29
+
30
+ class VoiceAdapter(ABC):
31
+ """Host an AgentShip agent inside one voice framework."""
32
+
33
+ #: Name used in ``voice.framework`` to select this adapter.
34
+ name: str
35
+
36
+ @abstractmethod
37
+ def host(self, turn: VoiceTurn) -> object:
38
+ """Return ``turn`` wrapped in this framework's own agent shape.
39
+
40
+ The return type is deliberately ``object``: a Pipecat ``FrameProcessor`` and a
41
+ LiveKit ``llm.LLM`` share no base class, and inventing a common one would mean
42
+ adding a layer neither framework asked for. Callers hand the result straight back
43
+ to the framework that produced its type.
44
+ """
45
+
46
+ @abstractmethod
47
+ async def run(self, turn: VoiceTurn, config: VoiceSpec) -> None:
48
+ """Assemble the pipeline around ``turn`` and serve until cancelled.
49
+
50
+ Returns only when the session ends. Cancellation is the normal way to stop, so an
51
+ implementation must leave the transport and provider connections closed on the way
52
+ out rather than relying on process exit.
53
+ """
54
+
55
+ @abstractmethod
56
+ def missing_dependency(self) -> str | None:
57
+ """Return an actionable message when this framework is not installed, else ``None``.
58
+
59
+ Checked before a session is assembled so an absent extra is one clear line naming the
60
+ install, not an ``ImportError`` from three frames inside the framework. This mirrors
61
+ ``agentship doctor``: a capability that cannot run should say so up front.
62
+ """
@@ -0,0 +1,149 @@
1
+ """Host an AgentShip agent as the LLM inside a LiveKit ``AgentSession``.
2
+
3
+ LiveKit does not have a pipeline node to drop an agent into. It has an ``AgentSession`` with
4
+ slots — ``stt=``, ``vad=``, ``llm=``, ``tts=`` — and the agent goes in the ``llm`` slot. So
5
+ what we implement is ``llm.LLM``: given a chat context, return a stream of ``ChatChunk``.
6
+
7
+ That is a genuinely different shape from Pipecat's frame processor, which is why the seam
8
+ these two share had to be smaller than a pipeline node (see :mod:`agentship_voice.turn`).
9
+
10
+ **Who witnesses what was spoken.** On Pipecat we add a processor after TTS, because nothing
11
+ else reports it. LiveKit already does this work: it emits ``conversation_item_added`` with a
12
+ ``ChatMessage`` that carries ``interrupted`` and content **already truncated to what was
13
+ actually spoken**. So here the adapter subscribes rather than measures — using the
14
+ framework's own answer instead of a second, worse one of our own.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import logging
21
+
22
+ from agentship.spec import VoiceSpec
23
+
24
+ from ..turn import VoiceTurn
25
+ from .base import VoiceAdapter
26
+
27
+ logger = logging.getLogger("agentship.voice")
28
+
29
+
30
+ def _build_llm(turn: VoiceTurn):
31
+ """Return an ``llm.LLM`` subclass bound to ``turn``.
32
+
33
+ Built inside a function for the same reason as the Pipecat processors: importing this
34
+ package must not require the framework, so the base classes are resolved on use.
35
+ """
36
+ from livekit.agents import llm
37
+
38
+ class _AgentShipStream(llm.LLMStream):
39
+ """Streams one AgentShip turn into LiveKit's chunk channel."""
40
+
41
+ async def _run(self) -> None:
42
+ """Run the agent for the newest user message and emit its reply as chunks.
43
+
44
+ LiveKit hands the whole chat context, but AgentShip keeps its own conversation
45
+ through the session id — the same short-term memory the REST path uses. So only
46
+ the latest user message is taken; replaying LiveKit's history as well would give
47
+ the agent the conversation twice.
48
+ """
49
+ spoken_to = _latest_user_text(self._chat_ctx)
50
+ if not spoken_to:
51
+ return # nothing was said; emit no chunks rather than an empty assistant turn
52
+
53
+ async for chunk in turn.say(spoken_to):
54
+ self._event_ch.send_nowait(
55
+ llm.ChatChunk(
56
+ id=self._llm._next_chunk_id(),
57
+ delta=llm.ChoiceDelta(role="assistant", content=chunk),
58
+ )
59
+ )
60
+
61
+ class AgentShipLLM(llm.LLM):
62
+ """An AgentShip agent in the shape LiveKit's ``AgentSession`` expects."""
63
+
64
+ def __init__(self) -> None:
65
+ """Hold a counter so each emitted chunk carries a distinct id."""
66
+ super().__init__()
67
+ self._chunks = 0
68
+
69
+ def _next_chunk_id(self) -> str:
70
+ """Return a per-stream unique chunk id."""
71
+ self._chunks += 1
72
+ return f"agentship-{self._chunks}"
73
+
74
+ def chat(self, *, chat_ctx, tools=None, conn_options=None, **kwargs):
75
+ """Return the stream that runs one AgentShip turn.
76
+
77
+ Tool arguments are accepted and ignored: tools belong to the agent's own spec and
78
+ run inside the engine, so letting LiveKit also inject them would give the model
79
+ two competing tool sets for one turn.
80
+ """
81
+ from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS
82
+
83
+ return _AgentShipStream(
84
+ self,
85
+ chat_ctx=chat_ctx,
86
+ tools=tools or [],
87
+ conn_options=conn_options or DEFAULT_API_CONNECT_OPTIONS,
88
+ )
89
+
90
+ return AgentShipLLM
91
+
92
+
93
+ def _latest_user_text(chat_ctx) -> str:
94
+ """Return the text of the newest user message in ``chat_ctx``, or ``""``."""
95
+ for item in reversed(getattr(chat_ctx, "items", [])):
96
+ if getattr(item, "role", None) == "user":
97
+ return (getattr(item, "text_content", None) or "").strip()
98
+ return ""
99
+
100
+
101
+ class LiveKitAdapter(VoiceAdapter):
102
+ """Run an AgentShip agent inside a LiveKit ``AgentSession``."""
103
+
104
+ name = "livekit"
105
+
106
+ def missing_dependency(self) -> str | None:
107
+ """Return an install line when LiveKit Agents is absent, else ``None``."""
108
+ try:
109
+ import livekit.agents # noqa: F401
110
+ except ImportError:
111
+ return (
112
+ "voice.framework is 'livekit' but livekit-agents is not installed — "
113
+ 'pip install "agentship-voice[livekit]"'
114
+ )
115
+ return None
116
+
117
+ def host(self, turn: VoiceTurn) -> object:
118
+ """Return the ``llm.LLM`` to pass as ``AgentSession(llm=...)``."""
119
+ return _build_llm(turn)()
120
+
121
+ def witness(self, session, turn: VoiceTurn) -> None:
122
+ """Subscribe to the session so what LiveKit actually spoke reaches ``turn``.
123
+
124
+ LiveKit truncates an interrupted assistant message to the words that played, so this
125
+ confirms against the framework's own record rather than a second measurement of ours.
126
+ Call once, after the session is created and before it starts.
127
+ """
128
+
129
+ def _on_item(event) -> None:
130
+ """Record an assistant message as spoken, and correct history if it was cut off."""
131
+ item = getattr(event, "item", None)
132
+ if item is None or getattr(item, "role", None) != "assistant":
133
+ return
134
+ text = getattr(item, "text_content", None) or ""
135
+ if text:
136
+ turn.confirm_spoken(text)
137
+ if getattr(item, "interrupted", False):
138
+ # LiveKit says so itself, so there is nothing to infer. Scheduled rather than
139
+ # awaited because this is a synchronous event callback: blocking it would stall
140
+ # the session's own event loop while we write to a checkpoint store.
141
+ asyncio.create_task(turn.interrupted()) # noqa: RUF006 — fire-and-forget by design
142
+
143
+ session.on("conversation_item_added", _on_item)
144
+
145
+ async def run(self, turn: VoiceTurn, config: VoiceSpec) -> None:
146
+ """Assemble a room session around ``turn`` and serve until cancelled."""
147
+ raise NotImplementedError(
148
+ "LiveKitAdapter.run lands with the transport task — host() and witness() work now."
149
+ )