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
12 changes: 11 additions & 1 deletion plugins/nemo-agents/examples/nemo-agent-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ nemo agents invoke \
--input "Reply with exactly: platform fabric works"
```

To try Claude, authenticate Claude Code first:

```bash
claude
```

Temporarily set `default_harness: claude` in `agent.yaml`, then run the same
`nemo agents invoke` command.

## Relay Local Files

`agent-relay.yaml` enables Relay telemetry without Intake. It writes local ATIF
Expand Down Expand Up @@ -71,7 +80,8 @@ install it with the Fabric adapter in a separate Python 3.12 environment:
uvx uv@0.9.14 venv --python 3.12 .venv-hermes
uvx uv@0.9.14 --no-config pip install \
--python .venv-hermes/bin/python \
"nemo-fabric-adapters-hermes>=0.1.0a20260724,<0.2.0" \
"nemo-fabric[relay]>=0.1.0rc4,<0.2.0" \
"nemo-fabric-adapters-hermes>=0.1.0rc4,<0.2.0" \
"hermes-agent==0.19.0"

export HERMES_ADAPTER_PYTHON="$PWD/.venv-hermes/bin/python"
Expand Down
7 changes: 7 additions & 0 deletions plugins/nemo-agents/examples/nemo-agent-config/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ description: Test agent config
default_harness: codex

harnesses:
claude:
kind: claude
model:
provider: anthropic
model: anthropic/claude-sonnet-4-5
settings:
permission_mode: dontAsk
hermes:
kind: hermes
model:
Expand Down
13 changes: 9 additions & 4 deletions plugins/nemo-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ nat_openclaw_agent_adapter = "nat_openclaw_agent_adapter.register"
fabric = [
# TODO(AIRCORE-932): Move Fabric into the default plugin dependencies once evaluator has migrated
# to the 0.1.0a20260724+ config-first SDK API and Fabric packaging is stable across Platform environments.
# TODO(AIRCORE-897): Move this to a stable Fabric version before release once available.
"nemo-fabric[runtime]>=0.1.0rc2,<0.2.0",
"nemo-fabric-adapters-codex>=0.1.0rc2,<0.2.0",
"nemo-fabric-adapters-hermes>=0.1.0rc2,<0.2.0; python_version < '3.14'",
# TODO(AIRCORE-897): Move this to a stable Fabric version before release once available. and add [relay]
"nemo-fabric>=0.1.0rc4,<0.2.0",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"nemo-fabric-adapters-claude[harness]>=0.1.0rc4,<0.2.0",
"nemo-fabric-adapters-codex[harness]>=0.1.0rc4,<0.2.0",
# TODO(AIRCORE-952): Re-enable once the DeepAgents adapter supports Relay observability v2 configs
# generated by Fabric streaming.
# "nemo-fabric-adapters-deepagents[harness]>=0.1.0rc4,<0.2.0",
# TODO(AIRCORE-952): Switch to [harness] once hermes-agent relaxes vulnerable exact dependency pins.
"nemo-fabric-adapters-hermes>=0.1.0rc4,<0.2.0; python_version < '3.14'",
]
container = [
"jinja2>=3.1",
Expand Down
64 changes: 63 additions & 1 deletion plugins/nemo-agents/src/nemo_agents_plugin/fabric/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from __future__ import annotations

import asyncio
from collections.abc import Mapping, Sequence
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -79,6 +79,54 @@ class FabricRuntimeResult:
request_id: str | None = None


class FabricRuntimeStream:
"""Platform-owned handle for one streaming Fabric runtime invocation."""

def __init__(self, stream: Any, timeout_seconds: float | None = None) -> None:
self._stream = stream
self._timeout_seconds = timeout_seconds

async def records(self) -> AsyncIterator[dict[str, Any]]:
"""Yield raw NeMo Relay ATOF records from Fabric."""
try:
async for record in self._stream:
yield dict(record)
except TimeoutError as error:
raise FabricRuntimeTimeoutError(
_timeout_error_message(self._timeout_seconds),
) from error
except FabricError as error:
raise FabricRuntimeExecutionError(
f"Fabric runtime streaming failed: {error}",
) from error

async def result(self) -> FabricRuntimeResult:
"""Return the authoritative terminal Fabric result for this stream."""
try:
result = await asyncio.wait_for(
self._stream.result(),
timeout=self._timeout_seconds,
)
except TimeoutError as error:
raise FabricRuntimeTimeoutError(
_timeout_error_message(self._timeout_seconds),
) from error
except FabricError as error:
raise FabricRuntimeExecutionError(
f"Fabric runtime streaming failed: {error}",
) from error
return _normalize_fabric_run_result(result)

async def aclose(self) -> None:
"""Finalize unread stream records without cancelling the harness turn."""
try:
await self._stream.aclose()
except FabricError as error:
raise FabricRuntimeExecutionError(
f"Fabric runtime streaming cleanup failed: {error}",
) from error


class FabricRuntimeExecutionError(RuntimeError):
"""Raised when Fabric cannot return a normalized runtime result."""

Expand Down Expand Up @@ -115,6 +163,20 @@ async def invoke_fabric_runtime(
return _normalize_fabric_run_result(result)


def stream_fabric_runtime(
runtime: Runtime,
request: FabricInvocationRequest,
) -> FabricRuntimeStream:
"""Start streaming one turn on an active Fabric runtime."""
try:
stream = runtime.invoke_stream(request=_with_platform_invocation_context(request))
except FabricError as error:
raise FabricRuntimeExecutionError(
f"Fabric runtime streaming failed: {error}",
) from error
return FabricRuntimeStream(stream, request.timeout_seconds)


async def run_fabric_agent_once(
request: FabricOneShotRequest,
*,
Expand Down
132 changes: 128 additions & 4 deletions plugins/nemo-agents/src/nemo_agents_plugin/fabric/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,20 @@
import logging
import sys
import uuid
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any

from fastapi import FastAPI, Header, HTTPException, Response
from fastapi.responses import StreamingResponse
from nemo_agents_plugin.agent_config import AgentConfig, load_agent_config
from nemo_agents_plugin.fabric.runtime import (
FabricInvocationRequest,
FabricRuntimeExecutionError,
FabricRuntimeResult,
FabricRuntimeStream,
FabricRuntimeTimeoutError,
)
from nemo_agents_plugin.fabric.serving_models import (
Expand All @@ -39,6 +41,11 @@
FabricSessionStopError,
)
from nemo_agents_plugin.fabric.session_registry import FabricSessionNotFoundError, FabricSessionRegistry
from nemo_agents_plugin.fabric.streaming import (
iter_fabric_assistant_text_deltas,
iter_openai_chat_completion_sse,
openai_chat_completion_error_sse,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -98,6 +105,11 @@ def _session_headers(session_id: str) -> dict[str, str]:
return {SESSION_ID_HEADER: session_id}


def _request_model_name(request: ChatCompletionRequest) -> str:
model = getattr(request, "model", None)
return model if isinstance(model, str) and model else "unknown-model"


def _failed_result_detail(result: FabricRuntimeResult) -> str:
if isinstance(result.error, Mapping):
message = result.error.get("message")
Expand All @@ -112,6 +124,91 @@ async def _validate_agent_config(config: AgentConfig, *, base_dir: Path) -> Any:
return await validate_platform_agent_config(config, base_dir=base_dir)


def _iter_streaming_chat_completion(
stream_context: AbstractAsyncContextManager[FabricRuntimeStream],
fabric_stream: FabricRuntimeStream,
*,
completion_id: str,
model: str,
) -> AsyncIterator[str]:
return _StreamingChatCompletionIterator(
stream_context,
fabric_stream,
completion_id=completion_id,
model=model,
)


class _StreamingChatCompletionIterator:
"""Async iterator that owns cleanup even when response iteration never starts."""

def __init__(
self,
stream_context: AbstractAsyncContextManager[FabricRuntimeStream],
fabric_stream: FabricRuntimeStream,
*,
completion_id: str,
model: str,
) -> None:
self._stream_context = stream_context
self._fabric_stream = fabric_stream
self._completion_id = completion_id
self._model = model
self._events: AsyncGenerator[str, None] | None = None
self._close_fabric_stream_on_exit = True
self._closed = False

def __aiter__(self) -> AsyncIterator[str]:
return self

async def __anext__(self) -> str:
if self._closed:
raise StopAsyncIteration
if self._events is None:
self._events = self._iter_events()
try:
return await self._events.__anext__()
except StopAsyncIteration:
await self.aclose()
raise
except BaseException:
await self.aclose()
raise

async def aclose(self) -> None:
if self._closed:
return
self._closed = True
if self._events is not None:
await self._events.aclose()
if self._close_fabric_stream_on_exit:
await _close_interrupted_stream(self._fabric_stream)
await self._stream_context.__aexit__(None, None, None)

async def _iter_events(self) -> AsyncGenerator[str, None]:
try:
text_deltas = iter_fabric_assistant_text_deltas(self._fabric_stream)
async for event in iter_openai_chat_completion_sse(
completion_id=self._completion_id,
content_chunks=text_deltas,
model=self._model,
):
yield event
self._close_fabric_stream_on_exit = False
except asyncio.CancelledError:
raise
except Exception as error:
logger.exception("Fabric streaming chat completion failed.")
yield openai_chat_completion_error_sse(error)


async def _close_interrupted_stream(fabric_stream: FabricRuntimeStream) -> None:
try:
await fabric_stream.aclose()
except Exception:
logger.exception("Failed to finalize interrupted Fabric stream.")


async def _run_idle_session_cleanup(
manager: FabricSessionManager,
*,
Expand Down Expand Up @@ -180,12 +277,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async def health() -> dict[str, str]:
return {"status": "ok"}

@app.post("/v1/chat/completions", response_model_exclude_none=True)
@app.post("/v1/chat/completions", response_model=None, response_model_exclude_none=True)
async def chat_completions(
request: ChatCompletionRequest,
response: Response,
session_id: Annotated[str | None, Header(alias=SESSION_ID_HEADER)] = None,
) -> ChatCompletionResponse:
) -> ChatCompletionResponse | StreamingResponse:
try:
session = await app.state.session_manager.resolve_session(session_id)
except FabricSessionNotFoundError as error:
Expand All @@ -194,6 +291,33 @@ async def chat_completions(
raise HTTPException(status_code=503, detail=str(error)) from error

invocation_request = _to_fabric_invocation_request(request, session_id=session.session_id)
if request.stream:
stream_context = app.state.session_manager.stream_session(session, invocation_request)
try:
fabric_stream = await stream_context.__aenter__()
except FabricSessionNotFoundError as error:
raise HTTPException(
status_code=404,
detail=str(error),
headers=_session_headers(session.session_id),
) from error
except FabricRuntimeExecutionError as error:
raise HTTPException(
status_code=502,
detail=str(error),
headers=_session_headers(session.session_id),
) from error
return StreamingResponse(
_iter_streaming_chat_completion(
stream_context,
fabric_stream,
completion_id=f"chatcmpl-{uuid.uuid4().hex}",
model=_request_model_name(request),
),
media_type="text/event-stream",
headers=_session_headers(session.session_id),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

try:
result = await app.state.session_manager.invoke_session(session, invocation_request)
except FabricSessionNotFoundError as error:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import time
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, model_validator
Expand All @@ -29,8 +30,6 @@ class ChatCompletionRequest(BaseModel):

@model_validator(mode="after")
def validate_current_turn(self) -> ChatCompletionRequest:
if self.stream:
raise ValueError("Streaming chat completions are not supported.")
if self.messages[-1].role != "user":
raise ValueError("The final chat message must have role 'user'.")
return self
Expand Down Expand Up @@ -59,3 +58,41 @@ class ChatCompletionResponse(BaseModel):
model: str = "unknown-model"
choices: list[ChatCompletionChoice]
usage: dict[str, Any] | None = None


class ChatCompletionStreamDelta(BaseModel):
"""OpenAI-compatible streaming response delta."""

role: Literal["assistant"] | None = None
content: str | None = None


class ChatCompletionStreamChoice(BaseModel):
"""OpenAI-compatible streaming chat-completion choice."""

index: int = 0
delta: ChatCompletionStreamDelta
finish_reason: Literal["stop"] | None = None


class ChatCompletionStreamResponse(BaseModel):
"""OpenAI-compatible streaming chunk for one Fabric runtime invocation."""

id: str
object: Literal["chat.completion.chunk"] = "chat.completion.chunk"
created: int = Field(default_factory=lambda: int(time.time()))
model: str = "unknown-model"
choices: list[ChatCompletionStreamChoice]


class ChatCompletionStreamError(BaseModel):
"""OpenAI-compatible streaming error payload."""

message: str
type: str


class ChatCompletionStreamErrorResponse(BaseModel):
"""OpenAI-compatible streaming error frame payload."""

error: ChatCompletionStreamError
Loading
Loading