Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions clients/typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion clients/typescript/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion forge-go/version/version.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package version

var (
Version = "0.7.5"
Version = "0.7.6"
GitCommit = "none"
BuildDate = "unknown"
)
2 changes: 1 addition & 1 deletion forge-python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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]
Expand All @@ -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"
)
Expand Down
4 changes: 4 additions & 0 deletions forge-python/src/rustic_ai/forge/metastore/manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
143 changes: 105 additions & 38 deletions forge-python/tests/rustic_ai/forge/test_guild_manager_agent.py
Original file line number Diff line number Diff line change
@@ -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={
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions forge-python/tests/rustic_ai/forge/test_manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion forge-python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading