Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0cbd112
feat(p07): the vendor-free voice seam, sized by what the frameworks a…
harshuljain13 Sep 9, 2026
52563a8
feat(p07): what the human HEARD is what history records
harshuljain13 Sep 9, 2026
f94185e
feat(p07): the Pipecat adapter — and the clip point comes from TTS, n…
harshuljain13 Sep 9, 2026
10349f4
feat(p07): the LiveKit adapter — and LiveKit is its own witness
harshuljain13 Sep 9, 2026
916bf6b
feat(p07): the whole loop runs keyless — audio in, spoken reply out
harshuljain13 Sep 11, 2026
0d09543
feat(p07): run() — real providers, and every missing piece named befo…
harshuljain13 Sep 12, 2026
ba5e1b9
feat(p07): `agentship voice serve`, and doctor checks a voice agent too
harshuljain13 Sep 12, 2026
09c86c0
refactor(p07): the end-of-phase readability pass
harshuljain13 Sep 12, 2026
7abc432
fix(p07): a VAD is not a pipeline stage — found by running the demo
harshuljain13 Sep 12, 2026
7d188a4
feat(p07): voice belongs to the service, not a second server
harshuljain13 Sep 12, 2026
2957352
feat(p07): Studio shows how long a turn took, and what it is talking to
harshuljain13 Sep 13, 2026
e6e75c6
fix(p07): the voice socket speaks — four bugs, each hidden by the one…
harshuljain13 Sep 13, 2026
1f34456
feat(p07): Studio becomes a playground — steps that show their work, …
harshuljain13 Sep 13, 2026
2b3de03
feat(p07): a voice room — one orb, a transcript, and where the time went
harshuljain13 Sep 13, 2026
c24ea8f
feat(p07): voice is a turn in the conversation, not a second UI
harshuljain13 Sep 13, 2026
94d3658
feat(p07): the mic sits above the conversation, where the eye lands f…
harshuljain13 Sep 13, 2026
14a20a2
fix(p07): it answered in Urdu, and it spoke slowly — two bugs from re…
harshuljain13 Sep 13, 2026
ca75c07
fix(p07): it answered itself in a loop, and the transcript never appe…
harshuljain13 Sep 13, 2026
a70ca87
fix(p07): it talked over anyone who paused to think
harshuljain13 Sep 13, 2026
4375668
fix(p03): an agent had no memory at all — the state channel overwrote…
harshuljain13 Sep 13, 2026
d2969e8
feat(p07): name the models — the spec said "openai" and meant three d…
harshuljain13 Sep 13, 2026
ca3f640
fix(p03): a session id is not a conversation key — two tenants could …
harshuljain13 Sep 13, 2026
19f016c
docs(p03): write down the concurrency hazard I could not fix safely
harshuljain13 Sep 13, 2026
8196248
fix(p07): the vocabulary hint I added was inventing words nobody said
harshuljain13 Sep 13, 2026
cee0271
feat(p07): eight providers each side, and the witness no longer quote…
harshuljain13 Sep 13, 2026
9846bcd
fix(p07): the mic could latch shut, and a browser that ignores the ra…
harshuljain13 Sep 13, 2026
2bda32c
ci: build and test agentship-voice — CI proves the tree, release ship…
harshuljain13 Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ jobs:
-e 'packages/agentship-service[serve,a2a]' \
-e 'packages/agentship-observability[phoenix]' \
-e packages/agentship-cli \
-e packages/agentship-sdk
-e packages/agentship-sdk \
-e 'packages/agentship-voice[all]'
pip install -r requirements-dev.txt

- name: Lint
Expand Down Expand Up @@ -86,10 +87,16 @@ jobs:
- name: Check version lockstep
run: python scripts/versions.py --check

# agentship-voice is built and TESTED here but is not in release.yml's publish list: CI
# proves what is in the tree, releasing ships what is ready, and those are different
# sets while a package is still being built. Leaving it out of this loop meant its tests
# were collected against a package that had never been installed, so the job failed on
# import with nothing to say about the code.
- name: Build every wheel and sdist
run: |
for pkg in agentship-core agentship-langgraph agentship-service \
agentship-observability agentship-cli agentship-sdk; do
agentship-observability agentship-cli agentship-sdk \
agentship-voice; do
python -m build --outdir dist "packages/$pkg"
done
ls -l dist
Expand Down Expand Up @@ -118,7 +125,8 @@ jobs:
"$(resolve agentship_service)[serve,a2a]" \
"$(resolve agentship_observability)[phoenix]" \
"$(resolve agentship_cli)" \
"$(resolve agentship_sdk)"
"$(resolve agentship_sdk)" \
"$(resolve agentship_voice)[all]"
/tmp/clean/bin/pip install -r requirements-dev.txt

# Without this the job could pass by importing the source tree and prove nothing.
Expand Down
244 changes: 244 additions & 0 deletions packages/agentship-cli/src/agentship_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from __future__ import annotations

import asyncio
import json
import logging
import os
import re
Expand Down Expand Up @@ -218,6 +219,43 @@ def _check_agent(path: Path) -> str | None:
mcp_reason = _check_mcp_version(spec)
if mcp_reason is not None:
return mcp_reason
voice_reason = _check_voice(spec)
if voice_reason is not None:
return voice_reason
return None


def _check_voice(spec) -> str | None:
"""Guard an agent that declares ``voice:`` against a missing framework, SDK or key.

Returns one actionable reason, or ``None`` when the spec has no ``voice:`` block or the
stack is ready. A voice agent fails in the worst possible place — the human speaks and
hears silence — so the same problems are surfaced here, before anyone dials in.

Never raises: if the voice package is not importable the guard is skipped rather than
failing an otherwise valid text agent, matching how the MCP and autonomous guards behave.
"""
voice = getattr(spec, "voice", None)
if voice is None:
return None
try:
from agentship_voice.adapters import get_adapter
from agentship_voice.factories import preflight
except ImportError:
return (
"declares `voice:` but the voice package is not installed — "
'pip install "agentship-voice[pipecat]"'
)
try:
adapter = get_adapter(voice.framework)
except ValueError as exc:
return str(exc)
missing = adapter.missing_dependency()
if missing:
return missing
problems = preflight(voice)
if problems:
return "; ".join(problems)
return None


Expand Down Expand Up @@ -648,6 +686,7 @@ def _serve(
# uninstalled or misconfigured provider (e.g. forwarded-header without an allow-list).
os.environ[ENV_AGENTS_DIR] = str(agents_dir)
os.environ[ENV_AUTH_PROVIDER] = auth_provider
dev_key = _dev_key_if_unconfigured(auth_provider, host)
build_auth_provider(auth_provider, _auth_config_from_env(auth_provider))

# Turn the agentship.* logger tree on. Without this every component logger
Expand All @@ -660,6 +699,13 @@ def _serve(
versions = installed_versions()
click.echo(f"Serving {len(specs)} agent(s) from {agents_dir} on http://{host}:{port}")
click.echo(f"Auth provider: {auth_provider} Log level: {log_level}")
if dev_key:
click.secho(
f"No API keys configured — minted a local dev key: {dev_key}\n"
f" Studio: http://{host}:{port}/studio (paste that key when it asks)\n"
" Set AGENTSHIP_API_KEYS to turn this off. Loopback only; never happens on 0.0.0.0.",
fg="yellow",
)
click.echo(
f"Build: {build_id()} "
+ " ".join(f"{n.removeprefix('agentship-')}={v}" for n, v in versions.items())
Expand Down Expand Up @@ -1021,3 +1067,201 @@ def _apply_migrations(migrations: list[Migration], database_url: str | None) ->
migration.apply(resolved)

click.echo(f"applied {len(migrations)} migrations (0 pending)")


@main.group()
def voice() -> None:
"""Run an agent over live audio.

Needs the voice package and a framework extra:
``pip install "agentship-voice[pipecat]"``.
"""


@voice.command("providers")
@click.option(
"--env-file",
"env_file",
type=click.Path(dir_okay=False),
default=None,
help="Read keys from this .env instead of ./.env.",
)
def voice_providers(env_file: str | None) -> None:
"""List the speech providers this install can reach, and what each one still needs.

Answers the question a spec cannot: `stt: deepgram` is easy to write and gives no hint
whether this machine can actually run it. Shows SDK and key status per provider so the
gap between "supported" and "usable here" is visible before a session fails.
"""
# Load .env first, or this reports "needs key" for keys that are sitting right there and
# would be found by `voice serve` — a status command that disagrees with the thing it is
# reporting on is worse than no status command.
load_env_for_run(env_file)
try:
from agentship_voice.factories import STT_PROVIDERS, TTS_PROVIDERS
except ImportError:
raise click.ClickException(
'voice needs the voice package — pip install "agentship-voice[pipecat]"'
) from None

from importlib import import_module

for label, table in (("speech-to-text", STT_PROVIDERS), ("text-to-speech", TTS_PROVIDERS)):
click.echo(f"\n{label}")
for name, provider in sorted(table.items()):
try:
import_module(provider.module)
installed = True
except Exception: # noqa: BLE001 — any import failure means "not usable here",
# and a provider SDK that raises something exotic on import is still absent.
installed = False
keyed = bool(os.environ.get(provider.env_var))
if installed and keyed:
mark, note = click.style("ready", fg="green"), ""
elif installed:
mark, note = click.style("needs key", fg="yellow"), f"set {provider.env_var}"
else:
mark, note = (
click.style("not installed", fg="red"),
f'pip install "pipecat-ai[{provider.extra}]"',
)
click.echo(f" {name:<14} {mark:<22} {note}")


@voice.command("serve")
@click.argument("file", type=click.Path(exists=True, dir_okay=False))
@click.option(
"--env-file",
"env_file",
type=click.Path(dir_okay=False),
default=None,
help="Load provider keys from this .env instead of ./.env.",
)
@click.option(
"--dry-run",
is_flag=True,
help="Check dependencies, keys and config, then exit without binding a transport.",
)
@click.option("--debug", is_flag=True, help="Re-raise on failure so the full traceback is shown.")
def voice_serve(file: str, env_file: str | None, dry_run: bool, debug: bool) -> None:
"""Serve the agent declared in FILE over a live voice session.

Reads the agent's ``voice:`` block for which framework, ears and mouth to use, checks that
each one's SDK is installed and its key is set, then runs until interrupted.

``--dry-run`` performs every check and stops before binding a transport, so a deployment can
prove its configuration without opening a port or spending a provider call — the same reason
``doctor`` exists for text agents.
"""
try:
load_env_for_run(env_file)
if debug:
configure_logging(logging.DEBUG)

spec = _spec_from(file)
if spec.voice is None:
raise click.ClickException(
f"{file} has no `voice:` block — add one to run this agent over audio"
)

adapter = _voice_adapter(spec.voice.framework)
missing = adapter.missing_dependency()
if missing:
raise click.ClickException(missing)

from agentship_voice.factories import preflight

problems = preflight(spec.voice)
if problems:
raise click.ClickException("voice cannot start:\n - " + "\n - ".join(problems))

if dry_run:
click.echo(
f"ok: {spec.name} would serve on {spec.voice.framework} "
f"({spec.voice.stt} → agent → {spec.voice.tts}, "
f"transport={spec.voice.transport})"
)
return

from agentship.runtime import build_agent
from agentship_voice import VoiceTurn

turn = VoiceTurn(build_agent(file), session_id=f"voice-{spec.name}")
click.echo(f"serving {spec.name} over voice — ctrl-c to stop")
asyncio.run(adapter.run(turn, spec.voice))
except KeyboardInterrupt:
# Ctrl-C is how a voice session is meant to end, not a failure.
click.echo("stopped")
except click.ClickException:
raise
except AgentShipError as exc:
if debug:
raise
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
except Exception as exc:
if debug:
raise
click.echo(f"Error: {exc} (run with --debug for the full traceback)", err=True)
sys.exit(1)


def _spec_from(path: str):
"""Load an :class:`~agentship.spec.AgentSpec` from a YAML file."""
import yaml
from agentship.spec import AgentSpec

return AgentSpec.model_validate(yaml.safe_load(Path(path).read_text(encoding="utf-8")))


def _voice_adapter(framework: str):
"""Return the voice adapter for ``framework``, or fail naming the install.

The import is inside the function so every other CLI command keeps working on a stack with
no voice package at all — the same reason engines are resolved lazily.
"""
try:
from agentship_voice.adapters import get_adapter
except ImportError as exc:
raise click.ClickException(
'voice needs the voice package — pip install "agentship-voice[pipecat]"'
) from exc
try:
return get_adapter(framework)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc


#: The environment variable the api_key provider reads its table from.
DEFAULT_KEYS_ENV = "AGENTSHIP_API_KEYS"


#: Hosts that only this machine can reach. A convenience that weakens auth must never be
#: reachable from anywhere else, so it is gated on the bind address rather than on a flag
#: somebody could set in production by accident.
_LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"})


def _dev_key_if_unconfigured(auth_provider: str, host: str) -> str | None:
"""Mint a throwaway API key when nobody configured one and we are bound to loopback.

Running `agentship serve` with no keys used to return 401 for everything, including
Studio's own calls — a UI you can open and cannot use, with nothing saying why. The fix is
not to drop auth: it is to admit that an unconfigured local run still needs A key, and to
print one.

Returns the key so the caller can show it, or ``None`` when keys are already configured or
the service is reachable from off-box. Deliberately regenerated per start: it is a
convenience for a developer at a terminal, not a credential anything should depend on.
"""
import secrets

if auth_provider != "api_key" or os.environ.get(DEFAULT_KEYS_ENV):
return None
if host not in _LOOPBACK:
return None
key = "dev-" + secrets.token_hex(8)
os.environ[DEFAULT_KEYS_ENV] = json.dumps(
[{"key": key, "user": "local-dev", "tenant": "local", "scopes": ["*"]}]
)
return key
81 changes: 81 additions & 0 deletions packages/agentship-cli/tests/test_cli_voice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""``agentship voice serve`` — the launch path for running an agent over live audio.

Every check must happen BEFORE a transport is bound, because a voice session that dies on a
missing key mid-sentence fails where nobody can see it. So these assert what the command says
and what it exits with, using ``--dry-run`` — no port, no provider call, no key.
"""

from __future__ import annotations

from agentship_cli.main import main
from click.testing import CliRunner

VOICE_SPEC = "name: talker\nengine: echo\nprompt: hi\nvoice:\n stt: openai\n tts: openai\n"


def _spec(tmp_path, text: str = VOICE_SPEC, name: str = "voice.yaml"):
"""Write a spec file and return its path."""
path = tmp_path / name
path.write_text(text)
return str(path)


def test_dry_run_reports_the_session_it_would_serve(tmp_path, monkeypatch) -> None:
"""A ready configuration is confirmed without binding anything or spending a call."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
result = CliRunner().invoke(main, ["voice", "serve", _spec(tmp_path), "--dry-run"])

assert result.exit_code == 0, result.output
assert "talker" in result.output
assert "pipecat" in result.output, "say which framework would host the agent"
assert "openai → agent → openai" in result.output, "show the cascade, not just 'ok'"


def test_a_missing_key_is_reported_before_anything_binds(monkeypatch) -> None:
"""Missing credentials fail at launch with every problem listed, exit 1.

Runs in an isolated filesystem on purpose. ``voice serve`` loads ``./.env`` like every
other command, so a repository that has one on disk makes an unkeyed run look keyed — the
test would pass for the wrong reason, and this is exactly the trap the demo suite fell into
when a local ``.env`` quietly re-supplied a key that had been unset.
"""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
runner = CliRunner()
with runner.isolated_filesystem():
with open("voice.yaml", "w") as handle:
handle.write(VOICE_SPEC)
result = runner.invoke(main, ["voice", "serve", "voice.yaml", "--dry-run"])

assert result.exit_code == 1
assert "voice cannot start" in result.output
assert "OPENAI_API_KEY" in result.output


def test_an_agent_without_a_voice_block_says_so(tmp_path, monkeypatch) -> None:
"""A text-only agent is not a failure to diagnose — it is a missing block to add."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
path = _spec(tmp_path, "name: plain\nengine: echo\nprompt: hi\n", name="plain.yaml")
result = CliRunner().invoke(main, ["voice", "serve", path, "--dry-run"])

assert result.exit_code == 1
assert "no `voice:` block" in result.output


def test_an_unknown_framework_names_the_real_choices(tmp_path, monkeypatch) -> None:
"""A typo in ``framework:`` is caught by the spec itself, before any adapter loads."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
path = _spec(
tmp_path,
"name: t\nengine: echo\nprompt: hi\nvoice:\n framework: pipcat\n",
name="bad.yaml",
)
result = CliRunner().invoke(main, ["voice", "serve", path, "--dry-run"])

assert result.exit_code == 1
assert "pipcat" in result.output


def test_voice_is_discoverable_from_the_top_level_help() -> None:
"""Someone who does not know the command exists must be able to find it."""
result = CliRunner().invoke(main, ["--help"])
assert "voice" in result.output
Loading
Loading