diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py index 9b3ca199..2f70a56b 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py @@ -6,11 +6,17 @@ from launchdarkly_ai_server import register_ai_sdk_package from ._version import PACKAGE_NAME, __version__ -from .handler import OpenCodeSessionError, create_opencode_handler, opencode_agents +from .handler import ( + OpenCodeSessionError, + create_opencode_handler, + create_opencode_handlers, + opencode_agents, +) __all__ = [ "OpenCodeSessionError", "create_opencode_handler", + "create_opencode_handlers", "opencode_agents", ] diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 076efea3..e0c8d1ef 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -7,8 +7,8 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, AsyncIterable, Mapping -from typing import Any +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping +from typing import Any, Literal import httpx @@ -23,6 +23,7 @@ compose_history, content_to_text, end_span_once, + parse_template, set_input_content_attributes, set_output_content_attributes, text_message, @@ -30,7 +31,10 @@ from launchdarkly_ai_server.agui import TurnEmitter from launchdarkly_ai_server.lifecycle import effective_capture_content from launchdarkly_ai_server.sdk_usage import report_usage -from launchdarkly_ai_server.utils import number_or_zero +from launchdarkly_ai_server.utils import ( + collapse_messages_to_instructions, + number_or_zero, +) from ._version import PACKAGE_NAME, __version__ from .server import ModelResolver, default_model, session_events @@ -470,6 +474,44 @@ def _input_messages( return messages +def create_opencode_handlers( + *, + base_url: str, + capture_content: bool | None = None, + model: ModelResolver | None = None, + agent: str | None = None, + client: httpx.AsyncClient | None = None, +) -> list[ProviderHandler]: + """Return the agent handler and a completion route over the same sessions. + + The completion route collapses messages into the system prompt and renders + ``{{variables}}``. It does not decide where the AI Config is evaluated. + ``create_opencode_handler`` stays agent-only. + """ + if not isinstance(base_url, str) or not base_url: + raise TypeError("The OpenCode handler requires a base_url.") + report_usage("opencode-agents.createOpenCodeHandlers", PACKAGE_NAME, __version__) + resolved = model or default_model + return [ + _create_opencode_handler( + base_url=base_url, + capture_content=capture_content, + model=resolved, + agent=agent, + client=client, + ), + _create_opencode_handler( + base_url=base_url, + capture_content=capture_content, + model=resolved, + agent=agent, + client=client, + prepare=_render_completion, + provides_for=("*", "messages"), + ), + ] + + def create_opencode_handler( *, base_url: str, @@ -525,6 +567,32 @@ def opencode_agents( ).invoke(user_input, context, variables, history) +def _render_completion( + config: AiConfigRep, variables: dict[str, Any] | None +) -> AiConfigRep: + """Collapse a completion variation into an agent system prompt.""" + if not isinstance(config, dict): + return config + values = variables or {} + rendered: list[Any] = [] + messages = config.get("messages") + if isinstance(messages, list): + for message in messages: + if isinstance(message, dict) and isinstance(message.get("content"), str): + rendered.append( + {**message, "content": parse_template(message["content"], values)} + ) + else: + rendered.append(message) + instructions = config.get("instructions") + adapted = {**config, "messages": rendered} + if isinstance(instructions, str) and instructions.strip(): + adapted["instructions"] = parse_template(instructions, values) + return adapted + adapted.pop("instructions", None) + return collapse_messages_to_instructions(adapted) + + def _create_opencode_handler( *, base_url: str, @@ -532,6 +600,8 @@ def _create_opencode_handler( model: ModelResolver, agent: str | None, client: httpx.AsyncClient | None, + prepare: Callable[[AiConfigRep, dict[str, Any] | None], AiConfigRep] | None = None, + provides_for: tuple[str, Literal["agent", "messages"]] = ("*", "agent"), ) -> ProviderHandler: content_override = capture_content bound_url = base_url @@ -547,6 +617,8 @@ async def _stream_impl( history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: del tool_handlers + if prepare is not None: + config = prepare(config, variables) capture = effective_capture_content(content_override) span = start_root_span(config, variables or {}) ended: set[int] = set() @@ -617,7 +689,7 @@ async def _call_impl( return {"output": output, "usage": usage} return _create_handler( - ("*", "agent"), + provides_for, _call_impl, _stream_impl, capture_content=content_override, diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 1d13e322..45c5394e 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -14,6 +14,7 @@ from launchdarkly_ai_opencode_agents import ( OpenCodeSessionError, create_opencode_handler, + create_opencode_handlers, opencode_agents, ) from launchdarkly_ai_opencode_agents.server import default_model @@ -214,6 +215,7 @@ def test_package_exports_the_handler(self) -> None: assert set(package.__all__) == { "OpenCodeSessionError", "create_opencode_handler", + "create_opencode_handlers", "opencode_agents", } for name in package.__all__: @@ -909,6 +911,56 @@ async def test_a_delta_without_part_id_is_not_repeated_by_its_snapshot( assert contents == ["Hi"] assert events[-1]["output"] == "Hi" + async def test_completion_route_renders_messages_into_the_system_prompt( + self, mock_ld_client: MagicMock + ) -> None: + completion = { + "model": {"name": "claude"}, + "provider": {"name": "Anthropic"}, + "messages": [ + {"role": "system", "content": "Hello {{caller_email}}"}, + {"role": "user", "content": "Be {{tone}}."}, + ], + "_ldMeta": { + "enabled": True, + "variationKey": "v1", + "version": 1, + "mode": "completion", + }, + } + mock_ld_client.variation = AsyncMock(return_value=completion) + script = Script( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + assistant_completed("m1"), + ] + ) + client = httpx.AsyncClient( + transport=script.transport(), base_url="http://opencode" + ) + agent_only = create_opencode_handler(base_url="http://opencode", client=client) + with pytest.raises(ValueError, match="mode messages"): + await _drain( + config(key="vega-base-prompt", handler=agent_only).stream("go", CONTEXT) + ) + assert script.sessions == [] + + handlers = create_opencode_handlers(base_url="http://opencode", client=client) + assert [handler.provides_for for handler in handlers] == [ + ("*", "agent"), + ("*", "messages"), + ] + await _drain( + config(key="vega-base-prompt", handler=handlers).stream( + "go", CONTEXT, {"caller_email": "ada@example.com", "tone": "brief"} + ) + ) + assert script.sessions == ["ses"] + posted = script.messages[0] + assert posted["system"] == "Hello ada@example.com\n\nBe brief." + assert posted["parts"] == [{"type": "text", "text": "go"}] + async def test_a_judge_opens_its_own_session( self, mock_ld_client: MagicMock ) -> None: