diff --git a/features/langgraph_platform_remote_graph.feature b/features/langgraph_platform_remote_graph.feature new file mode 100644 index 000000000..494736038 --- /dev/null +++ b/features/langgraph_platform_remote_graph.feature @@ -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 + 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 diff --git a/features/steps/postgresql_config_steps.py b/features/steps/postgresql_config_steps.py new file mode 100644 index 000000000..d99cebbd3 --- /dev/null +++ b/features/steps/postgresql_config_steps.py @@ -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.*)" 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}" + ) diff --git a/features/steps/remote_graph_config_steps.py b/features/steps/remote_graph_config_steps.py new file mode 100644 index 000000000..a101ba088 --- /dev/null +++ b/features/steps/remote_graph_config_steps.py @@ -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") +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 diff --git a/features/steps/remote_graph_manager_steps.py b/features/steps/remote_graph_manager_steps.py new file mode 100644 index 000000000..7cbc212dd --- /dev/null +++ b/features/steps/remote_graph_manager_steps.py @@ -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() diff --git a/robot/helper_langgraph_platform_integration.py b/robot/helper_langgraph_platform_integration.py new file mode 100644 index 000000000..9d731bc61 --- /dev/null +++ b/robot/helper_langgraph_platform_integration.py @@ -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() diff --git a/robot/langgraph_platform_integration.robot b/robot/langgraph_platform_integration.robot new file mode 100644 index 000000000..8dcccb370 --- /dev/null +++ b/robot/langgraph_platform_integration.robot @@ -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 diff --git a/src/cleveragents/infrastructure/database/__init__.py b/src/cleveragents/infrastructure/database/__init__.py index af36bc4ba..20f92c97b 100644 --- a/src/cleveragents/infrastructure/database/__init__.py +++ b/src/cleveragents/infrastructure/database/__init__.py @@ -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", ] diff --git a/src/cleveragents/infrastructure/database/postgresql.py b/src/cleveragents/infrastructure/database/postgresql.py new file mode 100644 index 000000000..34241f980 --- /dev/null +++ b/src/cleveragents/infrastructure/database/postgresql.py @@ -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: + """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. + 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+://`` 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", +] diff --git a/src/cleveragents/langgraph/__init__.py b/src/cleveragents/langgraph/__init__.py index 89a431f53..db9b86fd9 100644 --- a/src/cleveragents/langgraph/__init__.py +++ b/src/cleveragents/langgraph/__init__.py @@ -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", ] diff --git a/src/cleveragents/langgraph/remote_graph.py b/src/cleveragents/langgraph/remote_graph.py new file mode 100644 index 000000000..5ce7b0879 --- /dev/null +++ b/src/cleveragents/langgraph/remote_graph.py @@ -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 = ( + "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", +]