feat(server): LangGraph Platform RemoteGraph integration #10792
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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")
|
||||
|
HAL9001
commented
Suggestion: Consider extracting the try-catch error capture pattern used across many step functions into a reusable helper function (e.g., 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:
|
||||
|
HAL9001
commented
Suggestion: 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.
|
||||
|
HAL9001
commented
Minor suggestion: 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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 = (
|
||||
|
HAL9001
commented
Suggestion: 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",
|
||||
]
|
||||
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.