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
15 changes: 12 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ historical decisions in `docs/changelog.md`.

```
client-samples/ # Platform clients (Android, iOS/visionOS, Web)
agent-sdk/ # Six packages:
agent-sdk/ # Seven packages:
# xr-ai-agent-runtime — agent lifecycles, tools + pub/sub
# xr-ai-hub-client — IPC client library (pyzmq + msgpack only)
# xr-ai-models — LLM/VLM/STT/TTS service protocols + OpenAI-compat clients
# xr-ai-pipecat — optional Pipecat transport bridge (heavier deps)
Expand Down Expand Up @@ -59,14 +60,22 @@ deps/ # Gitignored downloaded binaries (e.g. LOVR AppImage)
`anthropic`, no `litellm`); all in-tree backends speak
OpenAI-compatible HTTP.
- **Workers never import from `xr_media_hub` or `xr_ai_launcher`.** Use the
public `xr_ai_hub`, `xr_ai_models`, `xr_ai_tools`, `xr_ai_nat`, and
`xr_ai_voice` SDK surfaces plus task-specific libraries (numpy, torch, …).
public `xr_ai_runtime`, `xr_ai_hub`, `xr_ai_models`, `xr_ai_tools`,
`xr_ai_nat`, and `xr_ai_voice` SDK surfaces plus task-specific libraries
(numpy, torch, …).
- **Agentic functions are native and in-process.** New and migrated tools live
in `xr-ai-tools`; every tool execution passes through NeMo Relay, and all
model I/O remains in `xr-ai-models`. `xr-ai-nat` retains
NeMo Agent Toolkit compatibility only while existing function groups
migrate. Existing MCP servers remain compatibility surfaces while their
capabilities migrate.
- **Agents expose the existing tools from `xr-ai-tools`.** Each `Agent` owns
state and a set of `Tool` or `AsyncTool` instances. Agents call one another's
tools directly through `execute()` or `stream()`; model-selected calls use
the same normal tool-calling helpers. `xr-ai-agent-runtime` manages typed
`publish()` fan-out and only the delivery tasks it creates. Agents own their
resources, tasks, lifecycle, and concurrency policy. Model loops, planning,
memory, and raw media transport remain outside the runtime.
- **RAG is a native typed capability.** `rag-service` owns document chunking,
embedding caches, and dense retrieval behind private msgpack/ZMQ;
`RAGFunctionsConfig` exposes it as the `xr_rag` NAT function group.
Expand Down
10 changes: 10 additions & 0 deletions DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ CI matrices:
## Internal packages

```
xr-ai-agent-runtime (agent-sdk/xr-ai-agent-runtime/)
└── pydantic >=2.10
└── xr-ai-tools [editable: ../xr-ai-tools]
In-process runtime for agent resource lifetimes, runtime-owned background
tasks, and typed ``publish`` fan-out. Agents expose ordinary ``Tool`` and
``AsyncTool`` instances from ``xr-ai-tools`` and own their synchronization.
Tool execution, model clients, tool loops, planning, memory, and raw media
transport are not runtime responsibilities.

xr-ai-hub-client (agent-sdk/xr-ai-hub-client/)
└── pyzmq >=27.0
└── msgpack >=1.0
Expand Down Expand Up @@ -333,6 +342,7 @@ vec-mcp-server (agent-mcp-servers/vec-mcp/)
remains compatibility-only and is not part of the native function group.

xr-ai-tests (tests/)
└── xr-ai-agent-runtime [editable: ../agent-sdk/xr-ai-agent-runtime]
└── xr-ai-hub-client [editable: ../agent-sdk/xr-ai-hub-client]
└── xr-ai-models [editable: ../agent-sdk/xr-ai-models]
└── xr-ai-nat[agents,services,vision] [editable: ../agent-sdk/xr-ai-nat]
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ frames are dropped if it is closed.
| Hub service | `services/xr-media-hub/` | XR-Media-Hub + LiveKit internal transport |
| Launcher | `utils/xr-ai-launcher/` | stdlib-only process manager used by samples |
| Logging | `utils/xr-ai-logging/` | shared loguru sink + stdlib bridge for every process |
| Agent runtime | `agent-sdk/xr-ai-agent-runtime/` | Agent resource lifetimes, background tasks, existing native tools, and pub/sub |
| Agent tools | `agent-sdk/xr-ai-tools/` | Toolkit-independent Relay-managed native tools |
| Legacy NAT | `agent-sdk/xr-ai-nat/` | NeMo Agent Toolkit compatibility during migration |
| Reusable services | `services/` | Model-serving and typed capability processes |
Expand Down
94 changes: 94 additions & 0 deletions agent-sdk/xr-ai-agent-runtime/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# XR AI agent runtime

`xr-ai-agent-runtime` provides typed pub/sub for composable XR AI agents. An
`Agent` owns private state and exposes ordinary `Tool` or `AsyncTool` instances
from `xr-ai-tools`.

Tools have one invocation path everywhere:

- Direct callers and other agents use `Tool.execute()` or `AsyncTool.stream()`.
- Model loops expose unary tools with `ToolSet` and `handle_tool_call()`.
- The agent runtime never wraps or redispatches a tool call.

Unary tools return one validated Pydantic response or `None` for an
acknowledged side effect. Streaming tools yield validated chunks without
buffering the complete result.

```python
from pydantic import BaseModel
from xr_ai_runtime import Agent, AgentRuntime
from xr_ai_tools import Tool


class TextRequest(BaseModel):
text: str


class TextResponse(BaseModel):
text: str


class TextAgent(Agent):
def __init__(self) -> None:
self.echo = Tool(
"echo",
"Echo the supplied text.",
TextRequest,
TextResponse,
self._echo,
)
self.uppercase = Tool(
"uppercase",
"Uppercase the supplied text.",
TextRequest,
TextResponse,
self._uppercase,
)
super().__init__((self.echo, self.uppercase))

async def _echo(self, request: TextRequest) -> TextResponse:
return TextResponse(text=request.text)

async def _uppercase(self, request: TextRequest) -> TextResponse:
return TextResponse(text=request.text.upper())


runtime = AgentRuntime()
text = runtime.register("text", TextAgent())

async with runtime:
result = await text.uppercase.execute(TextRequest(text="hello"))
```

Another agent receives the concrete tools it needs and calls them normally.
No runtime address or adapter is involved.

When tools from several agents are combined for one model, namespace them at
the workflow boundary with
`ToolSet.namespaced({"vision": vision.tools, "planner": planner.tools})`.
This remaps only model-visible catalog names; the agents and underlying tools
remain unchanged. Participant identity needed by a direct tool belongs in that
tool's typed request. Relay supplies nested execution tracing, while runtime
message metadata applies only to pub/sub.

`publish(topic, event)` is the separate asynchronous fan-out operation for
events. An agent that owns resources or background work is responsible for
controlling them, including creating, cancelling, and awaiting its own tasks.
The runtime neither knows nor controls whether an agent's internal work is
running. `publish()` waits for every fan-out delivery to settle before
propagating any subscriber failures.

Tools and subscription callbacks may run concurrently. An agent whose mutable
state is shared between them owns the appropriate synchronization, such as an
`asyncio.Lock` or a private queue. This avoids imposing serialization and
head-of-line blocking on unrelated or streaming tools.

Domain controls such as `start_monitoring`, `stop_monitoring`, and `status` are
ordinary tools. Agent lifetime itself is not a model tool. Model loops,
planning, memory, and model clients remain agent implementations. Raw audio and
video stay on the XR-Media-Hub path.
22 changes: 22 additions & 0 deletions agent-sdk/xr-ai-agent-runtime/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "xr-ai-agent-runtime"
version = "0.1.0"
description = "Typed pub/sub runtime for XR AI agents."
requires-python = ">=3.11,<3.13"
dependencies = [
"pydantic>=2.10",
"xr-ai-tools",
]

[tool.uv.sources]
xr-ai-tools = { path = "../xr-ai-tools", editable = true }

[tool.hatch.build.targets.wheel]
packages = ["xr_ai_runtime"]
18 changes: 18 additions & 0 deletions agent-sdk/xr-ai-agent-runtime/xr_ai_runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Typed in-process runtime for composable XR AI agents."""

from .agent import Agent
from .events import MessageMetadata, Topic, subscribe
from .runtime import AgentRuntime, RuntimeClosedError, RuntimeContext

__all__ = [
"Agent",
"AgentRuntime",
"MessageMetadata",
"RuntimeClosedError",
"RuntimeContext",
"Topic",
"subscribe",
]
37 changes: 37 additions & 0 deletions agent-sdk/xr-ai-agent-runtime/xr_ai_runtime/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Agents and their exposed native tools."""

from __future__ import annotations

from collections.abc import Iterable
from typing import Any

from xr_ai_tools import AsyncTool, Tool


class Agent:
"""An object that owns state and exposes ordinary native tools."""

def __init__(
self,
tools: Iterable[Tool[Any, Any] | AsyncTool[Any, Any]] = (),
) -> None:
owned = tuple(tools)
names: set[str] = set()
for tool in owned:
if not isinstance(tool, (Tool, AsyncTool)):
raise TypeError("agents may expose only Tool or AsyncTool instances")
if tool.name in names:
raise ValueError(f"duplicate agent tool name: {tool.name}")
names.add(tool.name)
self._tools = owned

@property
def tools(self) -> tuple[Tool[Any, Any] | AsyncTool[Any, Any], ...]:
"""Return the existing native tools exposed by this agent."""

return self._tools

__all__ = ["Agent"]
61 changes: 61 additions & 0 deletions agent-sdk/xr-ai-agent-runtime/xr_ai_runtime/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Typed event contracts for agent communication."""

from __future__ import annotations

from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Generic, TypeVar

from pydantic import BaseModel

MessageT = TypeVar("MessageT", bound=BaseModel)


@dataclass(frozen=True, slots=True)
class Topic(Generic[MessageT]):
"""A stable publish/subscribe name paired with its payload model."""

name: str
message_type: type[MessageT]

def __post_init__(self) -> None:
if not self.name.strip():
raise ValueError("topic name must not be empty")
if not issubclass(self.message_type, BaseModel):
raise TypeError("topic messages must be Pydantic models")

def validate(self, message: MessageT | dict[str, Any]) -> MessageT:
"""Validate a message before delivery."""

return self.message_type.model_validate(message)


@dataclass(frozen=True, slots=True)
class MessageMetadata:
"""Routing and trace context for one published event."""

message_id: str
correlation_id: str
participant_id: str
source: str
parent_message_id: str | None
timestamp_us: int


def subscribe(
topic: Topic[MessageT],
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
"""Register an agent method as a typed topic subscriber."""

def decorate(method: Callable[..., Awaitable[None]]) -> Callable[..., Awaitable[None]]:
topics = (*getattr(method, "__xr_ai_topics__", ()), topic)
setattr(method, "__xr_ai_topics__", topics)
return method

return decorate


__all__ = ["MessageMetadata", "Topic", "subscribe"]
Loading
Loading