diff --git a/src/fi/alk/harness/authoring_entrypoint.py b/src/fi/alk/harness/authoring_entrypoint.py index 1aaec21c3..c0d3a6f5e 100644 --- a/src/fi/alk/harness/authoring_entrypoint.py +++ b/src/fi/alk/harness/authoring_entrypoint.py @@ -63,6 +63,19 @@ def _load_provider_import_profile( "system_prompt": str(job.agent.config["target_system_prompt"]), "tools": [], } + if ( + job.agent.mode is ProviderExecutionMode.CONNECT_ONLY + and job.agent.connector.strip().lower() == "livekit" + ): + if secrets_path is not None: + secrets_path.unlink(missing_ok=True) + return { + "provider": "livekit", + "modality": "voice", + "agent_name": str(job.agent.config["agent_name"]), + "system_prompt": str(job.agent.config["target_system_prompt"]), + "tools": [], + } inspect_connect_only_provider = ( job.agent.mode is ProviderExecutionMode.CONNECT_ONLY and job.agent.connector.strip().lower() in {"vapi", "retell", "retell_chat"} diff --git a/src/fi/alk/harness/call_runner.py b/src/fi/alk/harness/call_runner.py index 09dd800cc..171c73962 100644 --- a/src/fi/alk/harness/call_runner.py +++ b/src/fi/alk/harness/call_runner.py @@ -393,13 +393,16 @@ def _canonical_simulator_secrets(values: Mapping[str, str]) -> dict[str, str]: } -def _dispatch_agent_name(runtime: EnvironmentRuntime) -> str | None: +def _dispatch_agent_name(runtime: EnvironmentRuntime, job: HarnessJob) -> str | None: """The ONLY place this repo reads the dispatch-identity metadata key, so a change to the key name/convention is a one-line adapt. The provisioner mirrors the agent process's rendered LIVEKIT_AGENT_NAME here; a bundle that declares none (or an ambiguous set) leaves the key absent and the caller's typed `CallAborted` below fires.""" - value = runtime.metadata.get("livekit_agent_name") + if job.agent.mode is ProviderExecutionMode.CONNECT_ONLY: + value = job.agent.config.get("agent_name") + else: + value = runtime.metadata.get("livekit_agent_name") return value.strip() if isinstance(value, str) and value.strip() else None @@ -1167,7 +1170,11 @@ async def run( connector = _resolve_connector( self._context.job, self._context.target_provider_secret_values ) - agent_name = _dispatch_agent_name(runtime) if connector == "livekit" else None + agent_name = ( + _dispatch_agent_name(runtime, self._context.job) + if connector == "livekit" + else None + ) if connector == "livekit" and agent_name is None: raise CallAborted( "voice_dispatch_identity_unavailable: runtime.metadata['livekit_agent_name'] is " diff --git a/src/fi/alk/harness/job.py b/src/fi/alk/harness/job.py index 011ae7451..aff506217 100644 --- a/src/fi/alk/harness/job.py +++ b/src/fi/alk/harness/job.py @@ -156,6 +156,15 @@ def _provider_mode_is_explicit_and_safe(self) -> "AgentConnection": if not prompt or len(prompt) > 65536: raise ValueError("phone_connect_only_requires_target_system_prompt") return self + if connector == "livekit": + prompt = str(self.config.get("target_system_prompt") or "").strip() + if not str(self.config.get("agent_name") or "").strip(): + raise ValueError("livekit_connect_only_requires_agent_name") + if not prompt or len(prompt) > 65536: + raise ValueError( + "livekit_connect_only_requires_target_system_prompt" + ) + return self target_key = {"vapi": "assistant_id", "retell": "agent_id"}.get( provider_connector ) diff --git a/src/fi/alk/harness/process_preflight.py b/src/fi/alk/harness/process_preflight.py index 6a8005c66..303dc0383 100644 --- a/src/fi/alk/harness/process_preflight.py +++ b/src/fi/alk/harness/process_preflight.py @@ -508,7 +508,7 @@ def _verify_secret_purposes( # the phone connector dials with platform-owned telephony credentials. connect_only_claims_target_provider = isinstance(connect_only, dict) and str( connect_only.get("connector") or "" - ).lower() in {"vapi", "retell", "retell_chat"} + ).lower() in {"vapi", "retell", "retell_chat", "livekit"} guest_claims_target_provider = ( process_claims_target_provider or lifecycle_claims_target_provider diff --git a/src/fi/alk/harness/sandbox_server.py b/src/fi/alk/harness/sandbox_server.py index e4960bc7d..632d9f490 100644 --- a/src/fi/alk/harness/sandbox_server.py +++ b/src/fi/alk/harness/sandbox_server.py @@ -98,7 +98,7 @@ def _one_source(self) -> "LocalSandboxRequest": ): raise ValueError("exactly_one_source_required") if self.provider_only and ( - self.connector not in {"retell", "retell_chat", "vapi"} + self.connector not in {"retell", "retell_chat", "vapi", "livekit"} or self.connector_mode is not ProviderExecutionMode.CONNECT_ONLY ): raise ValueError("provider_only_requires_connect_only_provider") diff --git a/src/fi/alk/harness/sources.py b/src/fi/alk/harness/sources.py index f959db567..9bc09631f 100644 --- a/src/fi/alk/harness/sources.py +++ b/src/fi/alk/harness/sources.py @@ -199,7 +199,7 @@ def servers(self) -> dict[str, Any]: def briefing(self) -> str: return ( "This is an external agent, not a repository. The sanitized definition below is " - "the only supplied source of truth. A phone-only connection may contain just the " + "the only supplied source of truth. A phone or LiveKit connection may contain just the " "user-supplied prompt; it is not a verified provider inspection. There is no source " "code to search or open. Do not invent behavior or tool inputs absent from this " "definition.\n\n" diff --git a/tests/harness/test_call_runner.py b/tests/harness/test_call_runner.py index ac66fbd60..7eab74416 100644 --- a/tests/harness/test_call_runner.py +++ b/tests/harness/test_call_runner.py @@ -763,6 +763,87 @@ def test_phone_connect_only_authoring_uses_only_supplied_prompt() -> None: } +_LIVEKIT_HOSTED_CONFIG = { + cr.LIVEKIT_URL_CONFIG_KEY: "wss://customer.livekit.cloud", + "agent_name": "returns-agent", + "target_system_prompt": "You handle returns.", +} + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"target_system_prompt": "You handle returns."}, + "livekit_connect_only_requires_agent_name", + ), + ( + {"agent_name": "returns-agent"}, + "livekit_connect_only_requires_target_system_prompt", + ), + ( + {"agent_name": "returns-agent", "target_system_prompt": "x" * 65537}, + "livekit_connect_only_requires_target_system_prompt", + ), + ], +) +def test_livekit_connect_only_requires_agent_name_and_prompt( + config: dict[str, Any], error: str +) -> None: + with pytest.raises(ValueError, match=error): + AgentConnection( + connector="livekit", mode=ProviderExecutionMode.CONNECT_ONLY, config=config + ) + + +def test_livekit_connect_only_authoring_uses_the_pasted_prompt_and_drops_secrets( + tmp_path: Path, +) -> None: + secrets = tmp_path / "authoring-target-secrets.json" + secrets.write_text('{"LIVEKIT_API_KEY": "lk-key"}', encoding="utf-8") + job = _job( + connector="livekit", + mode=ProviderExecutionMode.CONNECT_ONLY, + config=dict(_LIVEKIT_HOSTED_CONFIG), + ) + + assert _load_provider_import_profile(job, secrets) == { + "provider": "livekit", + "modality": "voice", + "agent_name": "returns-agent", + "system_prompt": "You handle returns.", + "tools": [], + } + assert not secrets.exists() + + +def test_livekit_connect_only_dispatches_the_configured_agent_name( + tmp_path: Path, +) -> None: + _job_obj, context = _context( + tmp_path=tmp_path, + mode=ProviderExecutionMode.CONNECT_ONLY, + config=dict(_LIVEKIT_HOSTED_CONFIG), + ) + _write_scenario_doc(context.bundle_dir, scenario_key="livekit-call") + captured: dict[str, Any] = {} + + async def place_call(spec): + captured["spec"] = spec + return _report() + + runner = cr.CallRunnerImpl(FakeAdapter(), context, place_call=place_call) + _run( + runner, + _FakeScenario("livekit-call"), + _runtime(metadata={"livekit_agent_name": "agent-from-source"}), + ) + + config = captured["spec"].environment.config + assert config["agent_definition"]["agent_name"] == "returns-agent" + assert config["livekit_runtime"]["url"] == "wss://customer.livekit.cloud/" + + def test_phone_connect_only_rejects_customer_dialer_override() -> None: with pytest.raises(ValueError, match="phone_dialer_config_is_platform_owned"): AgentConnection( diff --git a/tests/harness/test_process_preflight.py b/tests/harness/test_process_preflight.py index 2def6da85..37e69abea 100644 --- a/tests/harness/test_process_preflight.py +++ b/tests/harness/test_process_preflight.py @@ -524,6 +524,36 @@ def mutate(body: dict[str, Any]) -> dict[str, Any]: ) +@pytest.mark.parametrize("connector", ["vapi", "retell", "retell_chat", "livekit"]) +def test_connect_only_provider_claims_the_customer_target_secret( + tmp_path: Path, connector: str +) -> None: + def mutate(body: dict[str, Any]) -> dict[str, Any]: + body["processes"][1]["secret_purposes"] = [] + body["metadata"] = {"provider_connect_only": {"connector": connector}} + return body + + manifest = _build_bundle(tmp_path, body_overrides=mutate) + preflight_bundle( + tmp_path, manifest, parallelism=1, secret_refs=TARGET_PROVIDER_REFS + ) + + +def test_connect_only_phone_does_not_claim_a_customer_target_secret( + tmp_path: Path, +) -> None: + def mutate(body: dict[str, Any]) -> dict[str, Any]: + body["processes"][1]["secret_purposes"] = [] + body["metadata"] = {"provider_connect_only": {"connector": "phone"}} + return body + + manifest = _build_bundle(tmp_path, body_overrides=mutate) + with pytest.raises(PreflightError, match="secret_unclaimed"): + preflight_bundle( + tmp_path, manifest, parallelism=1, secret_refs=TARGET_PROVIDER_REFS + ) + + def test_customer_process_cannot_claim_simulator_provider_secrets( tmp_path: Path, ) -> None: diff --git a/tests/test_harness_sandbox_server.py b/tests/test_harness_sandbox_server.py index 598eabc12..3628b9ff1 100644 --- a/tests/test_harness_sandbox_server.py +++ b/tests/test_harness_sandbox_server.py @@ -111,6 +111,20 @@ async def submit(): LocalSandboxRequest(provider_only=True, connector="auto") +def test_provider_only_accepts_a_connect_only_livekit_agent(): + request = LocalSandboxRequest( + provider_only=True, + connector="livekit", + connector_mode="connect_only", + connector_config={ + "agent_name": "returns-agent", + "target_system_prompt": "You handle returns.", + }, + ) + + assert request.connector == "livekit" + + def test_second_sandbox_instance_does_not_orphan_live_controller(tmp_path): root = tmp_path / "state" first = LocalSandbox(root)