Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,14 +23,18 @@
compose_history,
content_to_text,
end_span_once,
parse_template,
set_input_content_attributes,
set_output_content_attributes,
text_message,
)
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -525,13 +567,41 @@ 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,
capture_content: bool | None,
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
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode-agents/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__:
Expand Down Expand Up @@ -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:
Expand Down
Loading