feat(server): LangGraph Platform RemoteGraph integration #10792

Merged
HAL9000 merged 4 commits from feature/m9-langgraph-platform into master 2026-06-06 18:13:29 +00:00
10 changed files with 1807 additions and 0 deletions
@@ -0,0 +1,195 @@
@server @langgraph-platform
Feature: LangGraph Platform RemoteGraph Integration
As a server-mode deployment
I want to manage actor graphs via LangGraph Platform RemoteGraph
So that actor execution can be delegated to the platform
# -----------------------------------------------------------------------
# RemoteGraphConfig validation
# -----------------------------------------------------------------------
Scenario: RemoteGraphConfig accepts valid HTTPS platform URL
When I create a basic RemoteGraphConfig with graph_id "strategy-actor" and platform_url "https://langgraph.example.com"
Then the config graph_id should be "strategy-actor"
And the config platform_url should be "https://langgraph.example.com"
And the config api_key_env should be "LANGGRAPH_API_KEY"
And the config timeout should be 60.0
Scenario: RemoteGraphConfig accepts valid HTTP platform URL
When I create a basic RemoteGraphConfig with graph_id "execution-actor" and platform_url "http://localhost:8123"
Then the config graph_id should be "execution-actor"
And the config platform_url should be "http://localhost:8123"
Scenario: RemoteGraphConfig accepts custom api_key_env
When I create a RemoteGraphConfig with custom api_key_env "MY_API_KEY"
Then the config api_key_env should be "MY_API_KEY"
Scenario: RemoteGraphConfig accepts custom timeout
When I create a RemoteGraphConfig with custom timeout 120.0
Then the config timeout should be 120.0
Scenario: RemoteGraphConfig rejects empty graph_id
When I try to create a RemoteGraphConfig with empty graph_id
Then a remote graph config validation error should be raised
Scenario: RemoteGraphConfig rejects empty platform_url
When I try to create a RemoteGraphConfig with empty platform_url
Then a remote graph config validation error should be raised
Scenario: RemoteGraphConfig rejects non-HTTP platform_url
When I try to create a RemoteGraphConfig with platform_url "ftp://langgraph.example.com"
Then a remote graph config validation error should be raised
Scenario: RemoteGraphConfig rejects non-positive timeout
When I try to create a RemoteGraphConfig with timeout 0
Outdated
Review

Suggestion: The scenario "RemoteGraphConfig rejects non-positive timeout" tests timeout 0, but there is no scenario for negative timeouts (e.g., -1). Consider adding a test case for negative values to verify the validator catches them.

Suggestion: The scenario "RemoteGraphConfig rejects non-positive timeout" tests `timeout 0`, but there is no scenario for negative timeouts (e.g., -1). Consider adding a test case for negative values to verify the validator catches them.
Then a remote graph config validation error should be raised
Scenario: RemoteGraphConfig is frozen
When I create a basic RemoteGraphConfig with graph_id "actor" and platform_url "https://langgraph.example.com"
Then the remote graph config should be immutable
# -----------------------------------------------------------------------
# RemoteGraphManager — not configured (no platform URL)
# -----------------------------------------------------------------------
Scenario: RemoteGraphManager is not available when no platform URL given
Given a RemoteGraphManager with no platform URL
Then the manager should not be available
And the manager platform_url should be None
Scenario: RemoteGraphManager register_graph raises when not configured
Given a RemoteGraphManager with no platform URL
When I try to register a graph on the unconfigured manager
Then a RemoteGraphNotAvailableError should be raised
Scenario: RemoteGraphManager list_graphs raises when not configured
Given a RemoteGraphManager with no platform URL
When I try to list graphs on the unconfigured manager
Then a RemoteGraphNotAvailableError should be raised
Scenario: RemoteGraphManager invoke raises when not configured
Given a RemoteGraphManager with no platform URL
When I try to invoke a graph on the unconfigured manager
Then a RemoteGraphNotAvailableError should be raised
Scenario: RemoteGraphManager health_check returns False when not configured
Given a RemoteGraphManager with no platform URL
When I call health_check on the unconfigured manager
Then the health check result should be False
# -----------------------------------------------------------------------
# RemoteGraphManager — configured (with platform URL)
# -----------------------------------------------------------------------
Scenario: RemoteGraphManager is available when platform URL is given
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
Then the manager should be available
And the manager platform_url should be "https://langgraph.example.com"
Scenario: RemoteGraphManager can register a graph
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I register a graph with id "strategy-actor"
Then the graph "strategy-actor" should be registered
Scenario: RemoteGraphManager list_graphs returns registered graph IDs
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I register graphs with ids "alpha" and "beta"
Then list_graphs should return "alpha" and "beta" in sorted order
Scenario: RemoteGraphManager get_graph_config returns registered config
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I register a graph with id "execution-actor"
Then get_graph_config for "execution-actor" should return the config
Scenario: RemoteGraphManager get_graph_config raises for unknown graph
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I try to get config for unregistered graph "unknown-actor"
Then a KeyError should be raised for the unknown graph
Scenario: RemoteGraphManager unregister_graph removes a graph
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I register then unregister graph "temp-actor"
Then the graph "temp-actor" should not be registered
Scenario: RemoteGraphManager unregister_graph raises for unknown graph
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I try to unregister an unknown graph "ghost-actor"
Then a KeyError should be raised for the unregistered graph
Scenario: RemoteGraphManager invoke raises RemoteGraphNotAvailableError for registered graph
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I register a graph with id "strategy-actor"
And I try to invoke the registered graph "strategy-actor"
Then a RemoteGraphNotAvailableError should be raised for the stub invocation
Scenario: RemoteGraphManager invoke raises KeyError for unregistered graph
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I try to invoke unregistered graph "missing-actor"
Then a KeyError should be raised for the missing graph invocation
Scenario: RemoteGraphManager health_check returns False for stub
Given a RemoteGraphManager with platform URL "https://langgraph.example.com"
When I call health_check on the configured manager
Then the health check result should be False
# -----------------------------------------------------------------------
# PostgreSQL connection utilities
# -----------------------------------------------------------------------
Scenario: PostgreSQLConnectionConfig accepts valid parameters
When I create a PostgreSQLConnectionConfig with host "db.example.com" database "cleveragents" username "app" password "secret"
Then the pg config host should be "db.example.com"
And the pg config database should be "cleveragents"
And the pg config username should be "app"
And the pg config port should be 5432
And the pg config ssl_mode should be "prefer"
Scenario: PostgreSQLConnectionConfig accepts custom port
When I create a PostgreSQLConnectionConfig with host "db.example.com" database "cleveragents" username "app" password "secret" port 5433
Then the pg config port should be 5433
Scenario: PostgreSQLConnectionConfig to_url returns async URL by default
When I create a PostgreSQLConnectionConfig with host "db.example.com" database "cleveragents" username "app" password "secret"
Then the pg config async URL should start with "postgresql+asyncpg://"
Scenario: PostgreSQLConnectionConfig to_url returns sync URL when requested
When I create a PostgreSQLConnectionConfig with host "db.example.com" database "cleveragents" username "app" password "secret"
Then the pg config sync URL should start with "postgresql://"
Scenario: PostgreSQLConnectionConfig rejects empty host
When I try to create a PostgreSQLConnectionConfig with empty host
Then a pg config validation error should be raised
Scenario: PostgreSQLConnectionConfig rejects invalid port
When I try to create a PostgreSQLConnectionConfig with port 0
Then a pg config validation error should be raised
Scenario: PostgreSQLConnectionConfig rejects negative pool_size
When I try to create a PostgreSQLConnectionConfig with pool_size 0
Then a pg config validation error should be raised
Scenario: build_postgresql_url returns async URL
When I call build_postgresql_url with host "db.example.com" database "mydb" username "user" password "pass"
Then the built URL should start with "postgresql+asyncpg://"
And the built URL should contain "db.example.com"
And the built URL should contain "mydb"
Scenario: build_postgresql_url returns sync URL when async_driver is False
When I call build_postgresql_url with async_driver False
Then the built URL should start with "postgresql://"
Scenario: is_postgresql_url returns True for postgresql URL
When I check if "postgresql://user:pass@host/db" is a PostgreSQL URL
Then the pg url check result should be True
Scenario: is_postgresql_url returns True for postgresql+asyncpg URL
When I check if "postgresql+asyncpg://user:pass@host/db" is a PostgreSQL URL
Then the pg url check result should be True
Scenario: is_postgresql_url returns False for sqlite URL
When I check if "sqlite:///path/to/db.sqlite" is a PostgreSQL URL
Then the pg url check result should be False
Scenario: is_postgresql_url returns False for empty string
When I check if "" is a PostgreSQL URL
Then the pg url check result should be False
+212
View File
@@ -0,0 +1,212 @@
"""Step definitions for PostgreSQL connection utility scenarios in langgraph_platform_remote_graph.feature."""
from __future__ import annotations
from behave import then, use_step_matcher, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.infrastructure.database.postgresql import (
PostgreSQLConnectionConfig,
build_postgresql_url,
is_postgresql_url,
)
# ---------------------------------------------------------------------------
# PostgreSQL connection utilities steps
# ---------------------------------------------------------------------------
@when(
'I create a PostgreSQLConnectionConfig with host "{host}" database "{database}" username "{username}" password "{password}"'
)
def step_create_pg_config(
context: Context, host: str, database: str, username: str, password: str
) -> None:
context.pg_config = PostgreSQLConnectionConfig(
host=host,
database=database,
username=username,
password=password,
)
@when(
'I create a PostgreSQLConnectionConfig with host "{host}" database "{database}" username "{username}" password "{password}" port {port:d}'
)
def step_create_pg_config_with_port(
context: Context,
host: str,
database: str,
username: str,
password: str,
port: int,
) -> None:
context.pg_config = PostgreSQLConnectionConfig(
host=host,
database=database,
username=username,
password=password,
port=port,
)
@then('the pg config host should be "{expected}"')
def step_pg_config_host(context: Context, expected: str) -> None:
assert context.pg_config.host == expected, (
f"Expected host={expected!r}, got {context.pg_config.host!r}"
)
@then('the pg config database should be "{expected}"')
def step_pg_config_database(context: Context, expected: str) -> None:
assert context.pg_config.database == expected, (
f"Expected database={expected!r}, got {context.pg_config.database!r}"
)
@then('the pg config username should be "{expected}"')
def step_pg_config_username(context: Context, expected: str) -> None:
assert context.pg_config.username == expected, (
f"Expected username={expected!r}, got {context.pg_config.username!r}"
)
@then("the pg config port should be {expected:d}")
def step_pg_config_port(context: Context, expected: int) -> None:
assert context.pg_config.port == expected, (
f"Expected port={expected}, got {context.pg_config.port}"
)
@then('the pg config ssl_mode should be "{expected}"')
def step_pg_config_ssl_mode(context: Context, expected: str) -> None:
assert context.pg_config.ssl_mode == expected, (
f"Expected ssl_mode={expected!r}, got {context.pg_config.ssl_mode!r}"
)
@then('the pg config async URL should start with "{prefix}"')
def step_pg_config_async_url(context: Context, prefix: str) -> None:
url = context.pg_config.to_url(async_driver=True)
assert url.startswith(prefix), f"Expected URL to start with {prefix!r}, got {url!r}"
@then('the pg config sync URL should start with "{prefix}"')
def step_pg_config_sync_url(context: Context, prefix: str) -> None:
url = context.pg_config.to_url(async_driver=False)
assert url.startswith(prefix), f"Expected URL to start with {prefix!r}, got {url!r}"
@when("I try to create a PostgreSQLConnectionConfig with empty host")
def step_try_create_pg_config_empty_host(context: Context) -> None:
try:
PostgreSQLConnectionConfig(
host="",
database="mydb",
username="user",
password="pass",
)
context.pg_config_error = None
except Exception as exc:
context.pg_config_error = exc
@when("I try to create a PostgreSQLConnectionConfig with port {port:d}")
def step_try_create_pg_config_bad_port(context: Context, port: int) -> None:
try:
PostgreSQLConnectionConfig(
host="db.example.com",
database="mydb",
username="user",
password="pass",
port=port,
)
context.pg_config_error = None
except Exception as exc:
context.pg_config_error = exc
@when("I try to create a PostgreSQLConnectionConfig with pool_size {pool_size:d}")
def step_try_create_pg_config_bad_pool_size(context: Context, pool_size: int) -> None:
try:
PostgreSQLConnectionConfig(
host="db.example.com",
database="mydb",
username="user",
password="pass",
pool_size=pool_size,
)
context.pg_config_error = None
except Exception as exc:
context.pg_config_error = exc
@then("a pg config validation error should be raised")
def step_pg_config_validation_error(context: Context) -> None:
assert context.pg_config_error is not None, (
"Expected a validation error but none was raised"
)
@when(
'I call build_postgresql_url with host "{host}" database "{database}" username "{username}" password "{password}"'
)
def step_build_postgresql_url(
context: Context, host: str, database: str, username: str, password: str
) -> None:
context.built_url = build_postgresql_url(
host=host,
database=database,
username=username,
password=password,
)
@when("I call build_postgresql_url with async_driver False")
def step_build_postgresql_url_sync(context: Context) -> None:
context.built_url = build_postgresql_url(
host="db.example.com",
database="mydb",
username="user",
password="pass",
async_driver=False,
)
@then('the built URL should start with "{prefix}"')
def step_built_url_starts_with(context: Context, prefix: str) -> None:
assert context.built_url.startswith(prefix), (
f"Expected URL to start with {prefix!r}, got {context.built_url!r}"
)
@then('the built URL should contain "{substring}"')
def step_built_url_contains(context: Context, substring: str) -> None:
assert substring in context.built_url, (
f"Expected URL to contain {substring!r}, got {context.built_url!r}"
)
use_step_matcher("re")
@when(r'I check if "(?P<url>.*)" is a PostgreSQL URL')
def step_check_is_postgresql_url(context: Context, url: str) -> None:
context.is_pg_url_result = is_postgresql_url(url)
use_step_matcher("parse")
@then("the pg url check result should be True")
def step_pg_url_check_true(context: Context) -> None:
assert context.is_pg_url_result is True, (
f"Expected True, got {context.is_pg_url_result!r}"
)
@then("the pg url check result should be False")
def step_pg_url_check_false(context: Context) -> None:
assert context.is_pg_url_result is False, (
f"Expected False, got {context.is_pg_url_result!r}"
)
+136
View File
@@ -0,0 +1,136 @@
"""Step definitions for RemoteGraphConfig scenarios in langgraph_platform_remote_graph.feature."""
from __future__ import annotations
from behave import then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.langgraph.remote_graph import (
RemoteGraphConfig,
)
# ---------------------------------------------------------------------------
# RemoteGraphConfig steps
# ---------------------------------------------------------------------------
@when(
'I create a basic RemoteGraphConfig with graph_id "{graph_id}" and platform_url "{platform_url}"'
)
def step_create_basic_remote_graph_config(
context: Context, graph_id: str, platform_url: str
) -> None:
context.remote_graph_config = RemoteGraphConfig(
graph_id=graph_id,
platform_url=platform_url,
)
@when('I create a RemoteGraphConfig with custom api_key_env "{api_key_env}"')
def step_create_remote_graph_config_with_api_key(
context: Context, api_key_env: str
) -> None:
context.remote_graph_config = RemoteGraphConfig(
graph_id="actor",
platform_url="https://langgraph.example.com",
api_key_env=api_key_env,
)
@when("I create a RemoteGraphConfig with custom timeout {timeout:f}")
def step_create_remote_graph_config_with_timeout(
context: Context, timeout: float
) -> None:
context.remote_graph_config = RemoteGraphConfig(
graph_id="actor",
platform_url="https://langgraph.example.com",
timeout=timeout,
)
@then('the config graph_id should be "{expected}"')
def step_config_graph_id(context: Context, expected: str) -> None:
assert context.remote_graph_config.graph_id == expected, (
f"Expected graph_id={expected!r}, got {context.remote_graph_config.graph_id!r}"
)
@then('the config platform_url should be "{expected}"')
def step_config_platform_url(context: Context, expected: str) -> None:
assert context.remote_graph_config.platform_url == expected, (
f"Expected platform_url={expected!r}, "
f"got {context.remote_graph_config.platform_url!r}"
)
@then('the config api_key_env should be "{expected}"')
def step_config_api_key_env(context: Context, expected: str) -> None:
assert context.remote_graph_config.api_key_env == expected, (
f"Expected api_key_env={expected!r}, "
f"got {context.remote_graph_config.api_key_env!r}"
)
@then("the config timeout should be {expected:f}")
def step_config_timeout(context: Context, expected: float) -> None:
assert context.remote_graph_config.timeout == expected, (
f"Expected timeout={expected}, got {context.remote_graph_config.timeout}"
)
@when("I try to create a RemoteGraphConfig with empty graph_id")
Outdated
Review

Suggestion: Consider extracting the try-catch error capture pattern used across many step functions into a reusable helper function (e.g., capture_error(func, *args, **kwargs) -> Exception | None). Currently this pattern is duplicated in ~20+ step functions.

Suggestion: Consider extracting the try-catch error capture pattern used across many step functions into a reusable helper function (e.g., `capture_error(func, *args, **kwargs) -> Exception | None`). Currently this pattern is duplicated in ~20+ step functions.
def step_try_create_config_empty_graph_id(context: Context) -> None:
try:
RemoteGraphConfig(graph_id="", platform_url="https://langgraph.example.com")
context.remote_graph_config_error = None
except Exception as exc:
context.remote_graph_config_error = exc
@when("I try to create a RemoteGraphConfig with empty platform_url")
def step_try_create_config_empty_platform_url(context: Context) -> None:
try:
RemoteGraphConfig(graph_id="actor", platform_url="")
context.remote_graph_config_error = None
except Exception as exc:
context.remote_graph_config_error = exc
@when('I try to create a RemoteGraphConfig with platform_url "{bad_url}"')
def step_try_create_config_bad_platform_url(context: Context, bad_url: str) -> None:
try:
RemoteGraphConfig(graph_id="actor", platform_url=bad_url)
context.remote_graph_config_error = None
except Exception as exc:
context.remote_graph_config_error = exc
@when("I try to create a RemoteGraphConfig with timeout {timeout:d}")
def step_try_create_config_bad_timeout(context: Context, timeout: int) -> None:
try:
RemoteGraphConfig(
graph_id="actor",
platform_url="https://langgraph.example.com",
timeout=float(timeout),
)
context.remote_graph_config_error = None
except Exception as exc:
context.remote_graph_config_error = exc
@then("a remote graph config validation error should be raised")
def step_remote_graph_config_validation_error(context: Context) -> None:
assert context.remote_graph_config_error is not None, (
"Expected a validation error but none was raised"
)
@then("the remote graph config should be immutable")
def step_remote_graph_config_immutable(context: Context) -> None:
try:
context.remote_graph_config.graph_id = "mutated" # type: ignore[misc]
raise AssertionError("Expected immutability error but assignment succeeded")
except AssertionError:
raise
except Exception:
pass # Expected — frozen model
@@ -0,0 +1,246 @@
"""Step definitions for RemoteGraphManager scenarios in langgraph_platform_remote_graph.feature."""
from __future__ import annotations
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.langgraph.remote_graph import (
RemoteGraphConfig,
RemoteGraphManager,
RemoteGraphNotAvailableError,
)
# ---------------------------------------------------------------------------
# RemoteGraphManager — not configured
# ---------------------------------------------------------------------------
@given("a RemoteGraphManager with no platform URL")
def step_manager_no_platform_url(context: Context) -> None:
context.manager = RemoteGraphManager()
@then("the manager should not be available")
def step_manager_not_available(context: Context) -> None:
assert not context.manager.is_available, (
"Expected manager.is_available=False but got True"
)
@then("the manager platform_url should be None")
def step_manager_platform_url_none(context: Context) -> None:
assert context.manager.platform_url is None, (
f"Expected platform_url=None, got {context.manager.platform_url!r}"
)
@when("I try to register a graph on the unconfigured manager")
def step_try_register_unconfigured(context: Context) -> None:
try:
config = RemoteGraphConfig(
graph_id="test-actor",
platform_url="https://langgraph.example.com",
)
context.manager.register_graph(config)
context.manager_error = None
except RemoteGraphNotAvailableError as exc:
context.manager_error = exc
@when("I try to list graphs on the unconfigured manager")
def step_try_list_unconfigured(context: Context) -> None:
try:
context.manager.list_graphs()
context.manager_error = None
except RemoteGraphNotAvailableError as exc:
context.manager_error = exc
@when("I try to invoke a graph on the unconfigured manager")
def step_try_invoke_unconfigured(context: Context) -> None:
try:
context.manager.invoke("test-actor", {})
context.manager_error = None
except RemoteGraphNotAvailableError as exc:
context.manager_error = exc
@then("a RemoteGraphNotAvailableError should be raised")
def step_remote_graph_not_available_error(context: Context) -> None:
assert isinstance(context.manager_error, RemoteGraphNotAvailableError), (
f"Expected RemoteGraphNotAvailableError, got {type(context.manager_error)!r}"
)
@when("I call health_check on the unconfigured manager")
def step_health_check_unconfigured(context: Context) -> None:
context.health_check_result = context.manager.health_check()
@then("the health check result should be False")
def step_health_check_false(context: Context) -> None:
assert context.health_check_result is False, (
f"Expected health_check=False, got {context.health_check_result!r}"
)
# ---------------------------------------------------------------------------
# RemoteGraphManager — configured
# ---------------------------------------------------------------------------
@given('a RemoteGraphManager with platform URL "{platform_url}"')
def step_manager_with_platform_url(context: Context, platform_url: str) -> None:
context.manager = RemoteGraphManager(platform_url=platform_url)
context.platform_url = platform_url
@then("the manager should be available")
def step_manager_available(context: Context) -> None:
assert context.manager.is_available, (
"Expected manager.is_available=True but got False"
)
@then('the manager platform_url should be "{expected}"')
def step_manager_platform_url(context: Context, expected: str) -> None:
assert context.manager.platform_url == expected, (
f"Expected platform_url={expected!r}, got {context.manager.platform_url!r}"
)
@when('I register a graph with id "{graph_id}"')
def step_register_graph(context: Context, graph_id: str) -> None:
config = RemoteGraphConfig(
graph_id=graph_id,
platform_url=context.platform_url,
)
context.manager.register_graph(config)
context.last_registered_graph_id = graph_id
context.last_registered_config = config
@then('the graph "{graph_id}" should be registered')
def step_graph_registered(context: Context, graph_id: str) -> None:
graphs = context.manager.list_graphs()
assert graph_id in graphs, (
f"Expected {graph_id!r} in registered graphs, got {graphs!r}"
)
@when('I register graphs with ids "{graph_id_a}" and "{graph_id_b}"')
def step_register_two_graphs(
context: Context, graph_id_a: str, graph_id_b: str
) -> None:
for gid in (graph_id_a, graph_id_b):
config = RemoteGraphConfig(
graph_id=gid,
platform_url=context.platform_url,
)
context.manager.register_graph(config)
context.registered_graph_ids = [graph_id_a, graph_id_b]
@then('list_graphs should return "{graph_id_a}" and "{graph_id_b}" in sorted order')
def step_list_graphs_sorted(context: Context, graph_id_a: str, graph_id_b: str) -> None:
graphs = context.manager.list_graphs()
expected = sorted([graph_id_a, graph_id_b])
assert graphs == expected, f"Expected {expected!r}, got {graphs!r}"
@then('get_graph_config for "{graph_id}" should return the config')
def step_get_graph_config(context: Context, graph_id: str) -> None:
config = context.manager.get_graph_config(graph_id)
assert config.graph_id == graph_id, (
f"Expected config.graph_id={graph_id!r}, got {config.graph_id!r}"
)
@when('I try to get config for unregistered graph "{graph_id}"')
def step_try_get_config_unregistered(context: Context, graph_id: str) -> None:
try:
context.manager.get_graph_config(graph_id)
context.manager_error = None
except KeyError as exc:
context.manager_error = exc
@then("a KeyError should be raised for the unknown graph")
def step_key_error_unknown_graph(context: Context) -> None:
assert isinstance(context.manager_error, KeyError), (
f"Expected KeyError, got {type(context.manager_error)!r}"
)
@when('I register then unregister graph "{graph_id}"')
def step_register_then_unregister(context: Context, graph_id: str) -> None:
config = RemoteGraphConfig(
graph_id=graph_id,
platform_url=context.platform_url,
)
context.manager.register_graph(config)
context.manager.unregister_graph(graph_id)
@then('the graph "{graph_id}" should not be registered')
def step_graph_not_registered(context: Context, graph_id: str) -> None:
graphs = context.manager.list_graphs()
assert graph_id not in graphs, (
f"Expected {graph_id!r} NOT in registered graphs, but found it in {graphs!r}"
)
@when('I try to unregister an unknown graph "{graph_id}"')
def step_try_unregister_unknown(context: Context, graph_id: str) -> None:
try:
context.manager.unregister_graph(graph_id)
context.manager_error = None
except KeyError as exc:
context.manager_error = exc
@then("a KeyError should be raised for the unregistered graph")
def step_key_error_unregistered_graph(context: Context) -> None:
assert isinstance(context.manager_error, KeyError), (
f"Expected KeyError, got {type(context.manager_error)!r}"
)
@when('I try to invoke the registered graph "{graph_id}"')
def step_try_invoke_registered(context: Context, graph_id: str) -> None:
try:
context.manager.invoke(graph_id, {"messages": []})
context.manager_error = None
except RemoteGraphNotAvailableError as exc:
context.manager_error = exc
@then("a RemoteGraphNotAvailableError should be raised for the stub invocation")
def step_remote_graph_not_available_stub(context: Context) -> None:
assert isinstance(context.manager_error, RemoteGraphNotAvailableError), (
f"Expected RemoteGraphNotAvailableError, got {type(context.manager_error)!r}"
)
@when('I try to invoke unregistered graph "{graph_id}"')
def step_try_invoke_unregistered(context: Context, graph_id: str) -> None:
try:
context.manager.invoke(graph_id, {})
context.manager_error = None
except KeyError as exc:
context.manager_error = exc
except RemoteGraphNotAvailableError as exc:
context.manager_error = exc
@then("a KeyError should be raised for the missing graph invocation")
def step_key_error_missing_graph(context: Context) -> None:
assert isinstance(context.manager_error, KeyError), (
f"Expected KeyError, got {type(context.manager_error)!r}"
)
@when("I call health_check on the configured manager")
def step_health_check_configured(context: Context) -> None:
context.health_check_result = context.manager.health_check()
@@ -0,0 +1,403 @@
"""Helper script for langgraph_platform_integration.robot tests.
Each subcommand is a self-contained check that prints a sentinel on success.
Validates the LangGraph Platform RemoteGraph integration per ADR-048.
"""
from __future__ import annotations
import logging
import sys
from collections.abc import Callable
from pathlib import Path
# Keep helper stderr focused on assertion failures and tracebacks.
logging.disable(logging.CRITICAL)
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.infrastructure.database.postgresql import ( # noqa: E402
PostgreSQLConnectionConfig,
build_postgresql_url,
is_postgresql_url,
)
from cleveragents.langgraph.remote_graph import ( # noqa: E402
RemoteGraphConfig,
RemoteGraphManager,
RemoteGraphNotAvailableError,
)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def manager_not_configured() -> None:
"""Verify RemoteGraphManager reports unavailable when no platform URL given."""
manager = RemoteGraphManager()
assert not manager.is_available, (
"Expected manager.is_available=False when no platform URL given"
)
assert manager.platform_url is None, (
f"Expected platform_url=None, got {manager.platform_url!r}"
)
# All operations should raise RemoteGraphNotAvailableError
config = RemoteGraphConfig(
graph_id="test-actor",
platform_url="https://langgraph.example.com",
)
try:
manager.register_graph(config)
raise AssertionError(
"Expected RemoteGraphNotAvailableError from register_graph"
)
except RemoteGraphNotAvailableError:
pass
try:
manager.list_graphs()
raise AssertionError("Expected RemoteGraphNotAvailableError from list_graphs")
except RemoteGraphNotAvailableError:
pass
try:
manager.invoke("test-actor", {})
raise AssertionError("Expected RemoteGraphNotAvailableError from invoke")
except RemoteGraphNotAvailableError:
pass
# health_check returns False (no exception)
result = manager.health_check()
assert result is False, f"Expected health_check=False, got {result!r}"
print("remote-graph-manager-not-configured-ok")
def manager_configured() -> None:
"""Verify RemoteGraphManager is available when platform URL is given."""
platform_url = "https://langgraph.example.com"
manager = RemoteGraphManager(platform_url=platform_url)
assert manager.is_available, (
"Expected manager.is_available=True when platform URL is given"
)
assert manager.platform_url == platform_url, (
f"Expected platform_url={platform_url!r}, got {manager.platform_url!r}"
)
# Initially no graphs registered
graphs = manager.list_graphs()
assert graphs == [], f"Expected empty graph list, got {graphs!r}"
print("remote-graph-manager-configured-ok")
def graph_registration() -> None:
"""Verify graph registration and listing lifecycle."""
platform_url = "https://langgraph.example.com"
manager = RemoteGraphManager(platform_url=platform_url)
# Register multiple graphs
for graph_id in ("strategy-actor", "execution-actor", "estimation-actor"):
config = RemoteGraphConfig(
graph_id=graph_id,
platform_url=platform_url,
)
manager.register_graph(config)
# Verify all are listed in sorted order
graphs = manager.list_graphs()
expected = sorted(["strategy-actor", "execution-actor", "estimation-actor"])
assert graphs == expected, f"Expected {expected!r}, got {graphs!r}"
# Verify get_graph_config returns correct config
config = manager.get_graph_config("strategy-actor")
assert config.graph_id == "strategy-actor", (
f"Expected graph_id='strategy-actor', got {config.graph_id!r}"
)
assert config.platform_url == platform_url, (
f"Expected platform_url={platform_url!r}, got {config.platform_url!r}"
)
# Verify unregister removes the graph
manager.unregister_graph("estimation-actor")
graphs_after = manager.list_graphs()
assert "estimation-actor" not in graphs_after, (
f"Expected 'estimation-actor' to be removed, but found in {graphs_after!r}"
)
assert len(graphs_after) == 2, (
f"Expected 2 graphs after unregister, got {len(graphs_after)}"
)
# Verify get_graph_config raises for unknown graph
try:
manager.get_graph_config("unknown-actor")
raise AssertionError("Expected KeyError for unknown graph")
except KeyError:
pass
# Verify unregister raises for unknown graph
try:
manager.unregister_graph("ghost-actor")
raise AssertionError("Expected KeyError for unregistered graph")
except KeyError:
pass
print("remote-graph-registration-ok")
def invoke_stub() -> None:
"""Verify invoke raises RemoteGraphNotAvailableError for stub implementation."""
platform_url = "https://langgraph.example.com"
manager = RemoteGraphManager(platform_url=platform_url)
# Register a graph
config = RemoteGraphConfig(
graph_id="strategy-actor",
platform_url=platform_url,
)
manager.register_graph(config)
# Invoke should raise RemoteGraphNotAvailableError (stub)
try:
manager.invoke("strategy-actor", {"messages": []})
raise AssertionError("Expected RemoteGraphNotAvailableError from invoke")
except RemoteGraphNotAvailableError as exc:
assert "strategy-actor" in str(exc.details), (
f"Expected graph_id in error details, got {exc.details!r}"
)
# Invoke unregistered graph should raise KeyError
try:
manager.invoke("missing-actor", {})
raise AssertionError("Expected KeyError for unregistered graph")
except KeyError:
pass
# Invoke with thread_id should also raise RemoteGraphNotAvailableError
try:
manager.invoke("strategy-actor", {}, thread_id="thread-001")
raise AssertionError(
"Expected RemoteGraphNotAvailableError from invoke with thread_id"
)
except RemoteGraphNotAvailableError as exc:
assert exc.details.get("thread_id") == "thread-001", (
f"Expected thread_id in error details, got {exc.details!r}"
)
print("remote-graph-invoke-stub-ok")
def health_check() -> None:
"""Verify health_check returns False for stub implementation."""
# Not configured
manager_unconfigured = RemoteGraphManager()
result = manager_unconfigured.health_check()
assert result is False, (
f"Expected health_check=False for unconfigured manager, got {result!r}"
)
# Configured (stub still returns False)
manager_configured = RemoteGraphManager(
platform_url="https://langgraph.example.com"
)
result = manager_configured.health_check()
assert result is False, (
f"Expected health_check=False for stub configured manager, got {result!r}"
)
print("remote-graph-health-check-ok")
def postgresql_config() -> None:
"""Verify PostgreSQLConnectionConfig validation and URL building."""
# Valid config
config = PostgreSQLConnectionConfig(
host="db.example.com",
database="cleveragents",
username="app",
password="secret",
)
assert config.host == "db.example.com", (
f"Expected host='db.example.com', got {config.host!r}"
)
assert config.database == "cleveragents", (
f"Expected database='cleveragents', got {config.database!r}"
)
assert config.username == "app", f"Expected username='app', got {config.username!r}"
assert config.port == 5432, f"Expected port=5432, got {config.port}"
assert config.ssl_mode == "prefer", (
f"Expected ssl_mode='prefer', got {config.ssl_mode!r}"
)
assert config.pool_size == 5, f"Expected pool_size=5, got {config.pool_size}"
assert config.max_overflow == 10, (
f"Expected max_overflow=10, got {config.max_overflow}"
)
# Async URL
async_url = config.to_url(async_driver=True)
assert async_url.startswith("postgresql+asyncpg://"), (
f"Expected async URL to start with 'postgresql+asyncpg://', got {async_url!r}"
)
assert "db.example.com" in async_url, f"Expected host in URL, got {async_url!r}"
assert "cleveragents" in async_url, f"Expected database in URL, got {async_url!r}"
# Sync URL
sync_url = config.to_url(async_driver=False)
assert sync_url.startswith("postgresql://"), (
f"Expected sync URL to start with 'postgresql://', got {sync_url!r}"
)
# Custom port
config_custom_port = PostgreSQLConnectionConfig(
host="db.example.com",
database="cleveragents",
username="app",
password="secret",
port=5433,
)
assert config_custom_port.port == 5433, (
f"Expected port=5433, got {config_custom_port.port}"
)
# build_postgresql_url helper
url = build_postgresql_url(
host="db.example.com",
database="mydb",
username="user",
password="pass",
)
assert url.startswith("postgresql+asyncpg://"), (
f"Expected async URL from build_postgresql_url, got {url!r}"
)
assert "db.example.com" in url, f"Expected host in URL, got {url!r}"
assert "mydb" in url, f"Expected database in URL, got {url!r}"
# Validation errors
try:
PostgreSQLConnectionConfig(
host="",
database="mydb",
username="user",
password="pass",
)
raise AssertionError("Expected validation error for empty host")
except AssertionError:
raise
except Exception:
pass
try:
PostgreSQLConnectionConfig(
host="db.example.com",
database="mydb",
username="user",
password="pass",
port=0,
)
raise AssertionError("Expected validation error for port=0")
except AssertionError:
raise
except Exception:
pass
try:
PostgreSQLConnectionConfig(
host="db.example.com",
database="mydb",
username="user",
password="pass",
pool_size=0,
)
raise AssertionError("Expected validation error for pool_size=0")
except AssertionError:
raise
except Exception:
pass
print("postgresql-config-ok")
def postgresql_url_detection() -> None:
"""Verify is_postgresql_url correctly identifies PostgreSQL URLs."""
# PostgreSQL URLs
assert is_postgresql_url("postgresql://user:pass@host/db") is True, (
"Expected True for postgresql:// URL"
)
assert is_postgresql_url("postgresql+asyncpg://user:pass@host/db") is True, (
"Expected True for postgresql+asyncpg:// URL"
)
assert is_postgresql_url("POSTGRESQL://user:pass@host/db") is True, (
"Expected True for uppercase POSTGRESQL:// URL"
)
# Non-PostgreSQL URLs
assert is_postgresql_url("sqlite:///path/to/db.sqlite") is False, (
"Expected False for sqlite:// URL"
)
assert is_postgresql_url("mysql://user:pass@host/db") is False, (
"Expected False for mysql:// URL"
)
assert is_postgresql_url("") is False, "Expected False for empty string"
print("postgresql-url-detection-ok")
def module_exports() -> None:
"""Verify RemoteGraph classes are exported from langgraph package."""
from cleveragents.infrastructure.database import build_postgresql_url as bpu
from cleveragents.infrastructure.database import is_postgresql_url as ipgu
from cleveragents.langgraph import RemoteGraphConfig as RGC
from cleveragents.langgraph import RemoteGraphManager as RGM
from cleveragents.langgraph import RemoteGraphNotAvailableError as RGNAE
# Verify classes are importable and functional
manager = RGM()
assert not manager.is_available, "Expected manager.is_available=False"
config = RGC(
graph_id="test",
platform_url="https://langgraph.example.com",
)
assert config.graph_id == "test", (
f"Expected graph_id='test', got {config.graph_id!r}"
)
# Verify error class
err = RGNAE("test error")
assert str(err) == "test error", f"Expected 'test error', got {str(err)!r}"
# Verify database exports
assert callable(bpu), "Expected build_postgresql_url to be callable"
assert callable(ipgu), "Expected is_postgresql_url to be callable"
print("module-exports-ok")
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"manager-not-configured": manager_not_configured,
"manager-configured": manager_configured,
"graph-registration": graph_registration,
"invoke-stub": invoke_stub,
"health-check": health_check,
"postgresql-config": postgresql_config,
"postgresql-url-detection": postgresql_url_detection,
"module-exports": module_exports,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
fn = _COMMANDS[sys.argv[1]]
fn()
@@ -0,0 +1,93 @@
*** Settings ***
Documentation Integration tests for LangGraph Platform RemoteGraph integration.
... Validates RemoteGraphManager lifecycle, graph registration,
... invocation stubs, and PostgreSQL connection utilities.
... Per ADR-048: server uses LangGraph Platform with RemoteGraph
... for server-side actor execution.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_langgraph_platform_integration.py
*** Test Cases ***
RemoteGraphManager Not Configured
[Documentation] Verify RemoteGraphManager reports unavailable when no platform URL given
[Tags] integration langgraph-platform server remote-graph
${result}= Run Process ${PYTHON} ${HELPER} manager-not-configured cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Not Contain ${result.stdout}${result.stderr} Traceback
Should Contain ${result.stdout} remote-graph-manager-not-configured-ok
RemoteGraphManager Configured
[Documentation] Verify RemoteGraphManager is available when platform URL is given
[Tags] integration langgraph-platform server remote-graph
${result}= Run Process ${PYTHON} ${HELPER} manager-configured cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Not Contain ${result.stdout}${result.stderr} Traceback
Should Contain ${result.stdout} remote-graph-manager-configured-ok
RemoteGraphManager Graph Registration
[Documentation] Verify graph registration and listing lifecycle
[Tags] integration langgraph-platform server remote-graph
${result}= Run Process ${PYTHON} ${HELPER} graph-registration cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Not Contain ${result.stdout}${result.stderr} Traceback
Should Contain ${result.stdout} remote-graph-registration-ok
RemoteGraphManager Invoke Stub
[Documentation] Verify invoke raises RemoteGraphNotAvailableError for stub
[Tags] integration langgraph-platform server remote-graph
${result}= Run Process ${PYTHON} ${HELPER} invoke-stub cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Not Contain ${result.stdout}${result.stderr} Traceback
Should Contain ${result.stdout} remote-graph-invoke-stub-ok
RemoteGraphManager Health Check
[Documentation] Verify health_check returns False for stub implementation
[Tags] integration langgraph-platform server remote-graph
${result}= Run Process ${PYTHON} ${HELPER} health-check cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Not Contain ${result.stdout}${result.stderr} Traceback
Should Contain ${result.stdout} remote-graph-health-check-ok
PostgreSQL Connection Config
[Documentation] Verify PostgreSQLConnectionConfig validation and URL building
[Tags] integration langgraph-platform server postgresql
${result}= Run Process ${PYTHON} ${HELPER} postgresql-config cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Not Contain ${result.stdout}${result.stderr} Traceback
Should Contain ${result.stdout} postgresql-config-ok
PostgreSQL URL Detection
[Documentation] Verify is_postgresql_url correctly identifies PostgreSQL URLs
[Tags] integration langgraph-platform server postgresql
${result}= Run Process ${PYTHON} ${HELPER} postgresql-url-detection cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Not Contain ${result.stdout}${result.stderr} Traceback
Should Contain ${result.stdout} postgresql-url-detection-ok
LangGraph Platform Module Exports
[Documentation] Verify RemoteGraph classes are exported from langgraph package
[Tags] integration langgraph-platform server remote-graph
${result}= Run Process ${PYTHON} ${HELPER} module-exports cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Not Contain ${result.stdout}${result.stderr} Traceback
Should Contain ${result.stdout} module-exports-ok
@@ -1,6 +1,8 @@
"""Database infrastructure for CleverAgents.
Provides database models, repositories, unit of work, and session management.
Also provides PostgreSQL connection utilities for server-mode deployments
(ADR-048).
"""
from .models import (
@@ -33,6 +35,11 @@ from .models import (
get_session,
init_database,
)
from .postgresql import (
PostgreSQLConnectionConfig,
build_postgresql_url,
is_postgresql_url,
)
from .repositories import (
ActionInUseError,
ActionRepository,
@@ -110,6 +117,7 @@ __all__ = [
"PlanNotFoundError",
"PlanProjectModel",
"PlanRepository",
"PostgreSQLConnectionConfig",
"ProjectModel",
"ProjectNotFoundError",
"ProjectRepository",
@@ -146,6 +154,8 @@ __all__ = [
"UnitOfWorkContext",
"ValidationAttachmentModel",
"ValidationAttachmentRepository",
"build_postgresql_url",
"get_session",
"init_database",
"is_postgresql_url",
]
@@ -0,0 +1,178 @@
"""PostgreSQL async connection support for server-mode deployments.
Per ADR-048 (Server Application Architecture), the CleverAgents server uses
PostgreSQL for multi-user persistence. This module provides:
- :class:`PostgreSQLConnectionConfig` — validated configuration for a
PostgreSQL connection
- :func:`build_postgresql_url` — construct a SQLAlchemy-compatible async URL
- :func:`is_postgresql_url` — check whether a database URL targets PostgreSQL
The implementation is a **stub** that validates configuration and constructs
connection URLs. Actual async engine creation requires ``asyncpg`` and
``sqlalchemy[asyncio]`` to be installed in the server deployment.
In local mode (SQLite), these utilities are not used. They are imported
only by server-mode infrastructure code.
"""
from __future__ import annotations
import logging
import re
from pydantic import BaseModel, ConfigDict, field_validator
_logger: logging.Logger = logging.getLogger(__name__)
# Pattern for a minimal PostgreSQL URL (scheme://[user[:pass]@]host[:port]/db)
_PG_URL_PATTERN: re.Pattern[str] = re.compile(
r"^postgresql(?:\+\w+)?://",
re.IGNORECASE,
)
# Async driver suffix used by SQLAlchemy for asyncpg
_ASYNC_DRIVER = "asyncpg"
class PostgreSQLConnectionConfig(BaseModel):
"""Validated configuration for a PostgreSQL server-mode connection.
Attributes:
host: Database server hostname or IP address.
port: Database server port (default: 5432).
database: Database name.
username: Database user.
password: Database password (treated as a secret).
ssl_mode: SSL mode for the connection (default: ``"prefer"``).
pool_size: SQLAlchemy connection pool size (default: 5).
max_overflow: Maximum pool overflow connections (default: 10).
"""
model_config = ConfigDict(frozen=True, strict=False)
host: str
port: int = 5432
database: str
username: str
password: str
ssl_mode: str = "prefer"
pool_size: int = 5
max_overflow: int = 10
@field_validator("host")
@classmethod
def _validate_host(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("host must not be empty")
return value.strip()
@field_validator("database")
@classmethod
def _validate_database(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("database must not be empty")
return value.strip()
@field_validator("username")
@classmethod
def _validate_username(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("username must not be empty")
return value.strip()
@field_validator("port")
@classmethod
def _validate_port(cls, value: int) -> int:
if not (1 <= value <= 65535):
raise ValueError("port must be between 1 and 65535")
return value
@field_validator("pool_size")
@classmethod
def _validate_pool_size(cls, value: int) -> int:
if value < 1:
raise ValueError("pool_size must be at least 1")
return value
@field_validator("max_overflow")
@classmethod
def _validate_max_overflow(cls, value: int) -> int:
if value < 0:
raise ValueError("max_overflow must be non-negative")
return value
def to_url(self, *, async_driver: bool = True) -> str:
Outdated
Review

Suggestion: to_url() embeds the password in the connection string. If this URL is ever written to logs (debug output, SQLAlchemy logging, or error messages), the password will be exposed. Consider adding a redacted variant or ensuring all logging paths mask secrets before output.

Suggestion: `to_url()` embeds the password in the connection string. If this URL is ever written to logs (debug output, SQLAlchemy logging, or error messages), the password will be exposed. Consider adding a redacted variant or ensuring all logging paths mask secrets before output.
"""Build a SQLAlchemy-compatible connection URL.
Args:
async_driver: When ``True`` (default), use the ``asyncpg``
driver (``postgresql+asyncpg://``). When ``False``, use
the synchronous ``psycopg2`` driver.
Returns:
A SQLAlchemy connection URL string.
"""
driver = f"+{_ASYNC_DRIVER}" if async_driver else ""
return (
f"postgresql{driver}://{self.username}:{self.password}"
f"@{self.host}:{self.port}/{self.database}"
)
def build_postgresql_url(
host: str,
database: str,
username: str,
password: str,
*,
port: int = 5432,
async_driver: bool = True,
) -> str:
"""Build a SQLAlchemy-compatible PostgreSQL connection URL.
Args:
host: Database server hostname or IP address.
database: Database name.
username: Database user.
password: Database password.
Outdated
Review

Minor suggestion: to_url() embeds passwords in the connection string. If this URL is ever written to logs (debug output, error messages, or SQLAlchemy logging), the password will be exposed in plaintext. Consider adding password redaction if there is any path for URL strings to reach log output.

Minor suggestion: `to_url()` embeds passwords in the connection string. If this URL is ever written to logs (debug output, error messages, or SQLAlchemy logging), the password will be exposed in plaintext. Consider adding password redaction if there is any path for URL strings to reach log output.
port: Database server port (default: 5432).
async_driver: When ``True`` (default), use the ``asyncpg`` driver.
Returns:
A SQLAlchemy connection URL string.
Raises:
ValueError: When any required parameter is empty or invalid.
"""
config = PostgreSQLConnectionConfig(
host=host,
port=port,
database=database,
username=username,
password=password,
)
return config.to_url(async_driver=async_driver)
def is_postgresql_url(url: str) -> bool:
"""Return ``True`` when *url* targets a PostgreSQL database.
Recognises both ``postgresql://`` and ``postgresql+<driver>://`` schemes.
Args:
url: Database URL to check.
Returns:
``True`` when the URL scheme indicates PostgreSQL.
"""
if not url:
return False
return bool(_PG_URL_PATTERN.match(url))
__all__ = [
"PostgreSQLConnectionConfig",
"build_postgresql_url",
"is_postgresql_url",
]
+11
View File
@@ -4,9 +4,17 @@
``langchain_core``, ``langsmith``, and ``rx.operators`` which add
multiple seconds of import overhead. Lightweight symbols from
``nodes`` and ``state`` are available eagerly.
``remote_graph`` sub-module provides LangGraph Platform RemoteGraph
integration for server-side actor execution (ADR-048).
"""
from .nodes import Edge, NodeConfig, NodeType
from .remote_graph import (
RemoteGraphConfig,
RemoteGraphManager,
RemoteGraphNotAvailableError,
)
from .state import GraphState
@@ -29,4 +37,7 @@ __all__ = [
"LangGraph",
"NodeConfig",
"NodeType",
"RemoteGraphConfig",
"RemoteGraphManager",
"RemoteGraphNotAvailableError",
]
+323
View File
@@ -0,0 +1,323 @@
"""LangGraph Platform RemoteGraph integration for server-side actor execution.
Per ADR-048 (Server Application Architecture), the CleverAgents server uses
LangGraph Platform with ``RemoteGraph`` for server-side actor execution.
Actor graphs (StateGraphs defined in YAML) are deployed to LangGraph Platform
as separate deployments and invoked via ``RemoteGraph``.
This module provides:
- :class:`RemoteGraphConfig` configuration for a remote graph deployment
- :class:`RemoteGraphManager` manages registration and invocation of remote
actor graphs via LangGraph Platform
- :class:`RemoteGraphNotAvailableError` raised when LangGraph Platform is
not configured or unreachable
The implementation is a **stub** that defines the interface and raises
:class:`RemoteGraphNotAvailableError` when no LangGraph Platform URL is
configured. When a real LangGraph Platform deployment is available, the
manager delegates execution to it via the ``RemoteGraph`` client.
"""
from __future__ import annotations
import logging
from typing import Any
from pydantic import BaseModel, ConfigDict, field_validator
_logger: logging.Logger = logging.getLogger(__name__)
_PLATFORM_NOT_CONFIGURED_MSG = (
Outdated
Review

Suggestion: _PLATFORM_NOT_CONFIGURED_MSG has an odd line break — it reads "LangGraph Platform is not configured\n — set LANGGRAPH_PLATFORM_URL..." (with a dash-separated continuation). The newline character appears before the em-dash separator. Consider making this a single-line string or using proper multi-line formatting. Example: fix the line break to avoid unexpected spacing in error messages.

Suggestion: `_PLATFORM_NOT_CONFIGURED_MSG` has an odd line break — it reads "LangGraph Platform is not configured\n — set LANGGRAPH_PLATFORM_URL..." (with a dash-separated continuation). The newline character appears before the em-dash separator. Consider making this a single-line string or using proper multi-line formatting. Example: fix the line break to avoid unexpected spacing in error messages.
"LangGraph Platform is not configured"
" — set LANGGRAPH_PLATFORM_URL to enable remote graph execution"
)
class RemoteGraphNotAvailableError(Exception):
"""Raised when LangGraph Platform is not configured or unreachable.
This error is raised when:
- No LangGraph Platform URL is configured
- The platform is unreachable
- The requested graph is not deployed
Attributes:
message: Human-readable error description.
details: Optional mapping of additional context.
"""
def __init__(
self,
message: str = _PLATFORM_NOT_CONFIGURED_MSG,
*,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.details: dict[str, Any] = details or {}
def __repr__(self) -> str:
return f"{type(self).__name__}({self.message!r}, details={self.details!r})"
class RemoteGraphConfig(BaseModel):
"""Configuration for a remote graph deployment on LangGraph Platform.
Attributes:
graph_id: Unique identifier for the graph on LangGraph Platform.
platform_url: Base URL of the LangGraph Platform deployment.
api_key_env: Environment variable name containing the API key.
timeout: Request timeout in seconds (default: 60).
metadata: Optional metadata for the graph deployment.
"""
model_config = ConfigDict(frozen=True, strict=False)
graph_id: str
platform_url: str
api_key_env: str = "LANGGRAPH_API_KEY"
timeout: float = 60.0
metadata: dict[str, Any] = {}
@field_validator("graph_id")
@classmethod
def _validate_graph_id(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("graph_id must not be empty")
return value.strip()
@field_validator("platform_url")
@classmethod
def _validate_platform_url(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("platform_url must not be empty")
stripped = value.strip()
if not stripped.startswith(("http://", "https://")):
raise ValueError("platform_url must start with http:// or https://")
return stripped
@field_validator("timeout")
@classmethod
def _validate_timeout(cls, value: float) -> float:
if value <= 0:
raise ValueError("timeout must be positive")
return value
class RemoteGraphManager:
"""Manages registration and invocation of remote actor graphs.
Per ADR-048, the server delegates actor graph execution to LangGraph
Platform via RemoteGraph. This manager:
- Registers actor graphs by their graph ID
- Invokes registered graphs with input state
- Raises :class:`RemoteGraphNotAvailableError` when the platform is
not configured or a graph is not registered
When ``platform_url`` is ``None`` (no LangGraph Platform configured),
all invocation methods raise :class:`RemoteGraphNotAvailableError`.
This allows the server to start and report its unavailability gracefully
rather than failing at startup.
Example::
manager = RemoteGraphManager(platform_url="https://langgraph.example.com")
manager.register_graph(RemoteGraphConfig(
graph_id="strategy-actor",
platform_url="https://langgraph.example.com",
))
result = manager.invoke("strategy-actor", {"messages": []})
"""
def __init__(self, platform_url: str | None = None) -> None:
"""Initialise the manager.
Args:
platform_url: Base URL of the LangGraph Platform deployment.
When ``None``, all invocation methods raise
:class:`RemoteGraphNotAvailableError`.
"""
self._platform_url = platform_url
self._graphs: dict[str, RemoteGraphConfig] = {}
_logger.debug(
"remote_graph_manager.init",
extra={
"platform_url": platform_url,
"available": platform_url is not None,
},
)
@property
def platform_url(self) -> str | None:
"""Base URL of the LangGraph Platform deployment, or ``None``."""
return self._platform_url
@property
def is_available(self) -> bool:
"""``True`` when a LangGraph Platform URL is configured."""
return self._platform_url is not None
def register_graph(self, config: RemoteGraphConfig) -> None:
"""Register an actor graph for remote execution.
Args:
config: Configuration for the remote graph deployment.
Raises:
RemoteGraphNotAvailableError: When no platform URL is configured.
"""
if not self.is_available:
raise RemoteGraphNotAvailableError(
_PLATFORM_NOT_CONFIGURED_MSG,
details={"graph_id": config.graph_id},
)
self._graphs[config.graph_id] = config
_logger.info(
"remote_graph_manager.graph_registered",
extra={
"graph_id": config.graph_id,
"platform_url": config.platform_url,
},
)
def unregister_graph(self, graph_id: str) -> None:
"""Unregister a previously registered actor graph.
Args:
graph_id: Identifier of the graph to unregister.
Raises:
KeyError: When the graph is not registered.
RemoteGraphNotAvailableError: When no platform URL is configured.
"""
if not self.is_available:
raise RemoteGraphNotAvailableError(
_PLATFORM_NOT_CONFIGURED_MSG,
details={"graph_id": graph_id},
)
if graph_id not in self._graphs:
raise KeyError(f"Graph {graph_id!r} is not registered")
del self._graphs[graph_id]
_logger.info(
"remote_graph_manager.graph_unregistered",
extra={"graph_id": graph_id},
)
def get_graph_config(self, graph_id: str) -> RemoteGraphConfig:
"""Return the configuration for a registered graph.
Args:
graph_id: Identifier of the graph.
Returns:
The :class:`RemoteGraphConfig` for the graph.
Raises:
KeyError: When the graph is not registered.
RemoteGraphNotAvailableError: When no platform URL is configured.
"""
if not self.is_available:
raise RemoteGraphNotAvailableError(
_PLATFORM_NOT_CONFIGURED_MSG,
details={"graph_id": graph_id},
)
if graph_id not in self._graphs:
raise KeyError(f"Graph {graph_id!r} is not registered")
return self._graphs[graph_id]
def list_graphs(self) -> list[str]:
"""Return the IDs of all registered graphs.
Returns:
Sorted list of registered graph IDs.
Raises:
RemoteGraphNotAvailableError: When no platform URL is configured.
"""
if not self.is_available:
raise RemoteGraphNotAvailableError(_PLATFORM_NOT_CONFIGURED_MSG)
return sorted(self._graphs.keys())
def invoke(
self,
graph_id: str,
input_state: dict[str, Any],
*,
thread_id: str | None = None,
config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Invoke a registered actor graph on LangGraph Platform.
This method is a **stub** that raises
:class:`RemoteGraphNotAvailableError` in all cases. When a real
LangGraph Platform deployment is available, this method will delegate
to the ``RemoteGraph`` client.
Args:
graph_id: Identifier of the graph to invoke.
input_state: Input state dictionary for the graph.
thread_id: Optional thread ID for stateful execution.
config: Optional LangGraph run configuration overrides.
Returns:
Output state dictionary from the graph execution.
Raises:
RemoteGraphNotAvailableError: Always LangGraph Platform
execution is not yet implemented in this stub.
KeyError: When the graph is not registered (checked before
raising the not-available error).
"""
if not self.is_available:
raise RemoteGraphNotAvailableError(
_PLATFORM_NOT_CONFIGURED_MSG,
details={"graph_id": graph_id},
)
if graph_id not in self._graphs:
raise KeyError(f"Graph {graph_id!r} is not registered")
graph_config = self._graphs[graph_id]
_logger.warning(
"remote_graph_manager.invoke_stub",
extra={
"graph_id": graph_id,
"platform_url": graph_config.platform_url,
"thread_id": thread_id,
},
)
# Stub: real implementation would call RemoteGraph.invoke() here.
raise RemoteGraphNotAvailableError(
"LangGraph Platform RemoteGraph invocation is not yet implemented"
" — deploy actor graphs to LangGraph Platform to enable remote execution",
details={
"graph_id": graph_id,
"platform_url": graph_config.platform_url,
"thread_id": thread_id,
},
)
def health_check(self) -> bool:
"""Check whether LangGraph Platform is reachable.
Returns:
``False`` when no platform URL is configured (stub behaviour).
A real implementation would perform an HTTP health check.
"""
if not self.is_available:
_logger.debug("remote_graph_manager.health_check.not_configured")
return False
# Stub: real implementation would perform an HTTP health check.
_logger.warning(
"remote_graph_manager.health_check_stub",
extra={"platform_url": self._platform_url},
)
return False
__all__ = [
"RemoteGraphConfig",
"RemoteGraphManager",
"RemoteGraphNotAvailableError",
]