diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json index 6fb6aab..56c1c4b 100644 --- a/clients/typescript/package-lock.json +++ b/clients/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rustic-ai/api-client", - "version": "0.7.5", + "version": "0.7.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rustic-ai/api-client", - "version": "0.7.5", + "version": "0.7.6", "license": "MIT", "dependencies": { "axios": "^1.7.0" diff --git a/clients/typescript/package.json b/clients/typescript/package.json index eb9d9c0..0d364a8 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@rustic-ai/api-client", - "version": "0.7.5", + "version": "0.7.6", "description": "TypeScript client for the Rustic AI HTTP API, generated from its OpenAPI specification.", "license": "MIT", "repository": { diff --git a/forge-go/version/version.go b/forge-go/version/version.go index 02e8e41..173b816 100644 --- a/forge-go/version/version.go +++ b/forge-go/version/version.go @@ -1,7 +1,7 @@ package version var ( - Version = "0.7.5" + Version = "0.7.6" GitCommit = "none" BuildDate = "unknown" ) diff --git a/forge-python/pyproject.toml b/forge-python/pyproject.toml index 8edf09d..7efd681 100644 --- a/forge-python/pyproject.toml +++ b/forge-python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rusticai-forge" -version = "0.7.5" +version = "0.7.6" description = "Python agent wrapper and execution engine for Forge" readme = "README.md" requires-python = ">=3.13,<3.14" diff --git a/forge-python/src/rustic_ai/forge/agents/system/guild_manager_agent.py b/forge-python/src/rustic_ai/forge/agents/system/guild_manager_agent.py index db79c47..1fc253a 100644 --- a/forge-python/src/rustic_ai/forge/agents/system/guild_manager_agent.py +++ b/forge-python/src/rustic_ai/forge/agents/system/guild_manager_agent.py @@ -44,7 +44,6 @@ from rustic_ai.core.guild.dsl import GuildSpec from rustic_ai.core.guild.guild import Guild from rustic_ai.core.guild.metastore.models import AgentStatus, GuildStatus -from rustic_ai.core.guild.metaprog.agent_registry import AgentRegistry from rustic_ai.core.state.manager.state_manager import StateManager from rustic_ai.core.state.models import ( StateFetchError, @@ -56,7 +55,7 @@ StateUpdateResponse, ) from rustic_ai.core.utils.basic_class_utils import get_qualified_class_name -from rustic_ai.core.utils.class_utils import get_agent_class, get_state_manager +from rustic_ai.core.utils.class_utils import get_state_manager from rustic_ai.core.utils.priority import Priority from rustic_ai.forge.agents.system.guild_manager_agent_props import ( @@ -335,10 +334,34 @@ def _materialize_dependency_selections( catalogs = self.guild_spec.properties.get("dependency_selections", {}) resolved_profile_keys: list[str] = [] - get_agent_class(agent_spec.class_name) - registry_entry = AgentRegistry.get_agent(agent_spec.class_name) - if registry_entry is None: - raise ValueError(f"Agent class {agent_spec.class_name!r} is not registered") + catalog_agent = self.metastore.get_catalog_agent(agent_spec.class_name) + if catalog_agent.get("qualified_class_name") != agent_spec.class_name: + raise ValueError( + f"Catalog metadata does not match agent class {agent_spec.class_name!r}" + ) + agent_dependencies = catalog_agent.get("agent_dependencies") + if not isinstance(agent_dependencies, list): + raise ValueError( + f"Catalog metadata for agent class {agent_spec.class_name!r} is invalid" + ) + + declared_dependencies: dict[str, Optional[str]] = {} + for dependency in agent_dependencies: + if not isinstance(dependency, dict): + raise ValueError( + f"Catalog metadata for agent class {agent_spec.class_name!r} is invalid" + ) + dependency_key = dependency.get("dependency_key") + required_type = dependency.get("required_type") + if not isinstance(dependency_key, str) or not dependency_key: + raise ValueError( + f"Catalog metadata for agent class {agent_spec.class_name!r} is invalid" + ) + if required_type is not None and not isinstance(required_type, str): + raise ValueError( + f"Catalog metadata for agent class {agent_spec.class_name!r} is invalid" + ) + declared_dependencies[dependency_key] = required_type for dependency_key in sorted(request.dependency_selections): selection = request.dependency_selections[dependency_key] @@ -353,15 +376,10 @@ def _materialize_dependency_selections( ) required_type = catalog.get("required_type") - declared = next( - ( - dependency - for dependency in registry_entry.agent_dependencies - if dependency.dependency_key == dependency_key - ), - None, - ) - if declared is None or declared.required_type != required_type: + if ( + dependency_key not in declared_dependencies + or declared_dependencies[dependency_key] != required_type + ): raise ValueError( f"Dependency {dependency_key!r} does not match the requested agent type" ) diff --git a/forge-python/src/rustic_ai/forge/metastore/manager_client.py b/forge-python/src/rustic_ai/forge/metastore/manager_client.py index 55ba547..9994dce 100644 --- a/forge-python/src/rustic_ai/forge/metastore/manager_client.py +++ b/forge-python/src/rustic_ai/forge/metastore/manager_client.py @@ -60,6 +60,10 @@ def get_guild_spec(self, guild_id: str) -> dict[str, Any]: gid = quote(guild_id, safe="") return self._request("GET", f"/manager/guilds/{gid}/spec") + def get_catalog_agent(self, class_name: str) -> dict[str, Any]: + encoded_class_name = quote(class_name, safe="") + return self._request("GET", f"/catalog/agents/{encoded_class_name}") + def update_guild_status(self, guild_id: str, status: GuildStatus) -> dict[str, Any]: gid = quote(guild_id, safe="") payload = {"status": _enum_wire_value(status)} diff --git a/forge-python/tests/rustic_ai/forge/test_guild_manager_agent.py b/forge-python/tests/rustic_ai/forge/test_guild_manager_agent.py index 717a359..34ab4c8 100644 --- a/forge-python/tests/rustic_ai/forge/test_guild_manager_agent.py +++ b/forge-python/tests/rustic_ai/forge/test_guild_manager_agent.py @@ -1,18 +1,18 @@ from types import SimpleNamespace from unittest.mock import Mock +import pytest + from rustic_ai.core.agents.system.models import AgentLaunchRequest, ConflictResponse from rustic_ai.core.guild import AgentSpec from rustic_ai.core.guild.agent_ext.mixins.health import HeartbeatStatus from rustic_ai.core.guild.agent_ext.depends.dependency_resolver import DependencySpec from rustic_ai.core.guild.metastore.models import AgentStatus, GuildStatus -from rustic_ai.core.guild.metaprog.agent_registry import AgentDependency, AgentRegistry - -import rustic_ai.forge.agents.system.guild_manager_agent as guild_manager_module from rustic_ai.forge.agents.system.guild_manager_agent import GuildManagerAgent +from rustic_ai.forge.metastore.manager_client import ManagerAPIError -def dynamic_manager(monkeypatch) -> GuildManagerAgent: +def dynamic_manager() -> GuildManagerAgent: manager = object.__new__(GuildManagerAgent) manager.guild_spec = SimpleNamespace( properties={ @@ -46,23 +46,30 @@ def dynamic_manager(monkeypatch) -> GuildManagerAgent: ), }, ) - registry_entry = SimpleNamespace( - agent_dependencies=[ - AgentDependency(dependency_key="llm", required_type="example.LLM"), - AgentDependency( - dependency_key="filesystem", required_type="example.Filesystem" - ), - ] - ) - monkeypatch.setattr(guild_manager_module, "get_agent_class", lambda _: object) - monkeypatch.setattr( - AgentRegistry, - "get_agent", - classmethod(lambda _cls, _class_name: registry_entry), - ) + manager.metastore = Mock() + manager.metastore.get_catalog_agent.return_value = { + "qualified_class_name": "example.Agent", + "agent_dependencies": [ + {"dependency_key": "llm", "required_type": "example.LLM"}, + { + "dependency_key": "filesystem", + "required_type": "example.Filesystem", + }, + ], + } return manager +def requested_agent() -> AgentSpec: + return AgentSpec( + id="reviewer-a", + name="Strict Reviewer", + description="Reviews an answer", + class_name="example.Agent", + properties={}, + ) + + def launch_request( agent_spec: AgentSpec, dependency_selections: dict ) -> AgentLaunchRequest: @@ -94,15 +101,9 @@ def test_dynamic_catalog_selector_reports_ambiguity_to_caller(): assert len(GuildManagerAgent._match_catalog_profiles(profiles, "gpt")) == 2 -def test_dependency_materialization_preserves_requested_agent_identity(monkeypatch): - manager = dynamic_manager(monkeypatch) - requested = AgentSpec( - id="reviewer-a", - name="Strict Reviewer", - description="Reviews an answer", - class_name="example.Agent", - properties={}, - ) +def test_dependency_materialization_preserves_requested_agent_identity(): + manager = dynamic_manager() + requested = requested_agent() materialized, profile_keys = manager._materialize_dependency_selections( launch_request( @@ -116,10 +117,11 @@ def test_dependency_materialization_preserves_requested_agent_identity(monkeypat assert materialized is not requested assert materialized.dependency_map["llm"].class_name == "example.Qwen" assert profile_keys == ["llm_qwen"] + manager.metastore.get_catalog_agent.assert_called_once_with("example.Agent") -def test_dependency_materialization_preserves_generated_agent_id(monkeypatch): - manager = dynamic_manager(monkeypatch) +def test_dependency_materialization_preserves_generated_agent_id(): + manager = dynamic_manager() requested = AgentSpec( name="Generated Reviewer", description="Reviews an answer", @@ -138,10 +140,8 @@ def test_dependency_materialization_preserves_generated_agent_id(monkeypatch): assert materialized.id == requested.id -def test_multiple_profiles_are_order_independent_and_do_not_define_identity( - monkeypatch, -): - manager = dynamic_manager(monkeypatch) +def test_multiple_profiles_are_order_independent_and_do_not_define_identity(): + manager = dynamic_manager() agent = AgentSpec( id="reviewer", name="Reviewer", @@ -170,8 +170,8 @@ def test_multiple_profiles_are_order_independent_and_do_not_define_identity( assert first_profiles == second_profiles == ["filesystem_local", "llm_qwen"] -def test_distinct_agents_can_share_the_same_profile(monkeypatch): - manager = dynamic_manager(monkeypatch) +def test_distinct_agents_can_share_the_same_profile(): + manager = dynamic_manager() selection = {"llm": {"catalog_key": "models", "selector": "llm_qwen"}} first, _ = manager._materialize_dependency_selections( @@ -204,8 +204,8 @@ def test_distinct_agents_can_share_the_same_profile(monkeypatch): assert first.dependency_map == second.dependency_map -def test_dynamic_launch_rejects_a_duplicate_agent_name(monkeypatch): - manager = dynamic_manager(monkeypatch) +def test_dynamic_launch_rejects_a_duplicate_agent_name(): + manager = dynamic_manager() existing = AgentSpec( id="reviewer-a", name="Reviewer", @@ -221,7 +221,6 @@ def test_dynamic_launch_rejects_a_duplicate_agent_name(monkeypatch): properties={}, ) manager.guild = SimpleNamespace(list_agents=lambda: [existing]) - manager.metastore = Mock() ctx = SimpleNamespace( payload=launch_request( requested, @@ -239,6 +238,74 @@ def test_dynamic_launch_rejects_a_duplicate_agent_name(monkeypatch): manager.metastore.ensure_agent.assert_not_called() +def test_dependency_materialization_rejects_mismatched_catalog_class(): + manager = dynamic_manager() + manager.metastore.get_catalog_agent.return_value["qualified_class_name"] = ( + "example.OtherAgent" + ) + + with pytest.raises(ValueError, match="does not match agent class"): + manager._materialize_dependency_selections( + launch_request( + requested_agent(), + {"llm": {"catalog_key": "models", "selector": "Qwen"}}, + ) + ) + + +@pytest.mark.parametrize( + "agent_dependencies", + [None, {}, [None], [{}], [{"dependency_key": "llm", "required_type": 1}]], +) +def test_dependency_materialization_rejects_malformed_catalog_metadata( + agent_dependencies, +): + manager = dynamic_manager() + manager.metastore.get_catalog_agent.return_value["agent_dependencies"] = ( + agent_dependencies + ) + + with pytest.raises(ValueError, match="Catalog metadata .* is invalid"): + manager._materialize_dependency_selections( + launch_request( + requested_agent(), + {"llm": {"catalog_key": "models", "selector": "Qwen"}}, + ) + ) + + +def test_dependency_materialization_rejects_missing_or_mismatched_declaration(): + manager = dynamic_manager() + selection = {"llm": {"catalog_key": "models", "selector": "Qwen"}} + + manager.metastore.get_catalog_agent.return_value["agent_dependencies"] = [] + with pytest.raises(ValueError, match="does not match the requested agent type"): + manager._materialize_dependency_selections( + launch_request(requested_agent(), selection) + ) + + manager.metastore.get_catalog_agent.return_value["agent_dependencies"] = [ + {"dependency_key": "llm", "required_type": "example.OtherLLM"} + ] + with pytest.raises(ValueError, match="does not match the requested agent type"): + manager._materialize_dependency_selections( + launch_request(requested_agent(), selection) + ) + + +def test_dependency_materialization_propagates_catalog_api_failure(): + manager = dynamic_manager() + manager.metastore.get_catalog_agent.side_effect = ManagerAPIError("not found") + + with pytest.raises(ManagerAPIError, match="not found"): + manager._materialize_dependency_selections( + launch_request( + requested_agent(), + {"llm": {"catalog_key": "models", "selector": "Qwen"}}, + ) + ) + + def test_existing_agent_id_is_not_a_name_conflict(): existing = AgentSpec( id="reviewer-a", diff --git a/forge-python/tests/rustic_ai/forge/test_manager_client.py b/forge-python/tests/rustic_ai/forge/test_manager_client.py index 416d2ba..edf7861 100644 --- a/forge-python/tests/rustic_ai/forge/test_manager_client.py +++ b/forge-python/tests/rustic_ai/forge/test_manager_client.py @@ -123,3 +123,31 @@ def handler(_: httpx.Request) -> httpx.Response: with pytest.raises(ManagerAPIError): metastore.get_guild_spec("g-1") + + +def test_manager_client_gets_catalog_agent_with_encoded_class_name(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["method"] = request.method + captured["raw_path"] = request.url.raw_path + captured["headers"] = dict(request.headers) + return httpx.Response( + 200, + json={ + "qualified_class_name": "example.Agent/variant", + "agent_dependencies": [], + }, + ) + + client = httpx.Client( + transport=httpx.MockTransport(handler), base_url="http://forge.test" + ) + metastore = ManagerMetastoreClient("http://forge.test", token="tkn", client=client) + + response = metastore.get_catalog_agent("example.Agent/variant") + + assert response["qualified_class_name"] == "example.Agent/variant" + assert captured["method"] == "GET" + assert captured["raw_path"] == b"/catalog/agents/example.Agent%2Fvariant" + assert captured["headers"]["x-forge-manager-token"] == "tkn" diff --git a/forge-python/uv.lock b/forge-python/uv.lock index e87d102..7b994ef 100644 --- a/forge-python/uv.lock +++ b/forge-python/uv.lock @@ -837,7 +837,7 @@ wheels = [ [[package]] name = "rusticai-forge" -version = "0.7.5" +version = "0.7.6" source = { editable = "." } dependencies = [ { name = "httpx" },