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
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ plan = client.plan_config(
```

For multi-turn sessions, open a `Session` and invoke it repeatedly. The session
replays the accumulated transcript as conversation history so the harness has access to
prior turns:
keeps one Fabric runtime handle active across turns; harness/adapter state is
authoritative rather than reconstructed from a Python-side transcript:

```python
import asyncio
Expand All @@ -189,23 +189,23 @@ async def chat():
) as session:
await session.invoke("My name is Robin.")
reply = await session.invoke("What's my name?") # recalls "Robin"
print(session.id, session.status.value, len(session.messages))
print(session.runtime_id, session.status.value, len(session.messages))
print(reply["output"]["response"])

asyncio.run(chat())
```

Sessions require the native binding and a session-capable (inline Python)
adapter; `start_config(...)` is the typed-config equivalent. `stream(...)` yields
events then the final result (buffered today); `cancel()` cooperatively aborts an
in-flight turn. Sessions are SDK-only — there is no `fabric` CLI equivalent (the
CLI runs one invocation per process). See `examples/session_quickstart.py`.
Sessions require the native binding; `start_config(...)` is the typed-config
equivalent. `stream(...)` yields events then the final result (buffered today);
`cancel()` cooperatively aborts an in-flight turn. Sessions are SDK-only — there
is no `fabric` CLI equivalent (the CLI runs one invocation per process). The
real-Hermes integration check is `tests/smoke_hermes_session.py`.

When installed from the repository root, `FabricClient()` uses the native Rust
binding. If the selected Python adapter descriptor provides a `runner.module`
and `runner.callable`, the SDK imports and invokes that adapter inline. The
`fabric` CLI continues to use the adapter script/process path, which is useful
for local debugging and environment-backed consumers.
binding. SDK `run(...)`, `start(...)`, and their typed-config equivalents all
drive the core Fabric runtime lifecycle (`start_runtime` / `invoke_runtime` /
`stop_runtime`) so one-shot and session paths use the same adapter execution
contract.

For source-tree debugging, pass an explicit CLI command:

Expand Down
17 changes: 7 additions & 10 deletions adapters/hermes-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ This adapter runs Hermes through its Python SDK. It is the preferred Hermes path
for Python consumers such as NeMo Platform, Gym-style agent servers, and direct
Fabric SDK use.

The adapter exposes two entrypoints:

- `runner.module` + `runner.callable` in `fabric-adapter.json` point to the
inline SDK entrypoint used by `FabricClient`.
- `runner.script` points to a thin executable wrapper used by `fabric run` and
process-style fallback paths.
The adapter descriptor records both callable and script metadata, but Fabric's
current SDK and CLI paths invoke the adapter through the core runtime lifecycle
and its `runner.script` wrapper. Keep the callable and script pointing at the
same `run(payload: dict) -> dict` implementation so future in-process adapter
execution remains equivalent.

## What It Maps

Expand All @@ -33,10 +32,8 @@ Keep `fabric-adapter.json` aligned with the Python implementation:

- `adapter_id` is the stable id selected by `harness.adapter_id`.
- `adapter_kind` is `python` because Fabric can invoke it through Python.
- `runner.module` and `runner.callable` define the inline SDK entrypoint with
the shape `run(payload: dict) -> dict`.
- `runner.script` is the process fallback and must remain a thin wrapper around
the same callable.
- `runner.module`, `runner.callable`, and `runner.script` must remain thin
routes to the same `run(payload: dict) -> dict` implementation.
- `requirements` powers `fabric doctor`; keep required env vars, binaries, or
packages current.
- `config.accepts` must match the Fabric sections this adapter maps into Hermes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def main() -> None:


def run(payload: dict[str, Any]) -> dict[str, Any]:
"""Inline Fabric adapter entrypoint used by the Python SDK."""
"""Fabric adapter entrypoint used by script and native SDK runtime calls."""

return run_hermes_sdk(payload)

Expand All @@ -51,20 +51,28 @@ def resolve_hermes_toolsets(settings: dict[str, Any], config: dict[str, Any]) ->
platform = settings.get("toolset_platform", "cli")
return sorted(_get_platform_tools(config, platform))

def resolve_history(payload: dict[str, Any]) -> Any:
"""Conversation history for this turn.

A per-invocation request context wins over static harness settings, so the
SDK can drive multi-turn sessions by passing accumulated messages in
``request.context.history`` without mutating the agent config.
"""
def runtime_session_id(payload: dict[str, Any]) -> str | None:
runtime_id = hermes_common.runtime_context(payload).get("runtime_id")
if runtime_id:
return str(runtime_id)
return None

context = hermes_common.request_payload(payload).get("context") or {}
# Check key presence so an explicit empty history ([]) clears the conversation
# rather than falling back to static harness settings.
if isinstance(context, dict) and "history" in context:
return context["history"]
return hermes_common.settings_payload(payload).get("history")

def load_runtime_history(session_db: Any, session_id: str | None) -> list[dict[str, Any]] | None:
if not session_id:
return None

resolved_id = session_id
resolve_session = getattr(session_db, "resolve_resume_session_id", None)
if resolve_session is not None:
resolved_id = resolve_session(session_id) or session_id
if session_db.get_session(resolved_id) is None:
return None

messages = session_db.get_messages_as_conversation(resolved_id)
messages = [message for message in messages if message.get("role") != "session_meta"]
return messages or None


def run_hermes_sdk(payload: dict[str, Any]) -> dict[str, Any]:
Expand All @@ -79,6 +87,7 @@ def run_hermes_sdk(payload: dict[str, Any]) -> dict[str, Any]:
os.environ["HERMES_HOME"] = str(hermes_home)
os.environ.setdefault("HERMES_YOLO_MODE", "1")
os.environ.setdefault("HERMES_ACCEPT_HOOKS", "1")
os.environ["HERMES_SESSION_SOURCE"] = "fabric"
os.environ.setdefault("TERMINAL_ENV", settings.get("terminal_backend", "local"))
os.environ.setdefault("TERMINAL_TIMEOUT", str(settings.get("terminal_timeout", 60)))
relay_plugin_config = hermes_common.configure_hermes_relay(payload)
Expand All @@ -105,11 +114,15 @@ def run_hermes_sdk(payload: dict[str, Any]) -> dict[str, Any]:
with redirect_stdout(hermes_stdout):
from hermes_cli.config import load_config
from hermes_cli.plugins import discover_plugins, invoke_hook
from hermes_state import SessionDB
from run_agent import AIAgent

discover_plugins(force=True)
loaded_hermes_config = load_config()
enabled_toolsets = resolve_hermes_toolsets(settings, loaded_hermes_config)
session_id = runtime_session_id(payload)
session_db = SessionDB()
conversation_history = load_runtime_history(session_db, session_id)
agent = None
agent = AIAgent(**filter_supported_kwargs(
AIAgent,
Expand All @@ -128,12 +141,15 @@ def run_hermes_sdk(payload: dict[str, Any]) -> dict[str, Any]:
temperature=settings.get("temperature", model_config.get("temperature", 0.0)),
reasoning_config=settings.get("reasoning_config", {"effort": "none"}),
insert_reasoning=bool(settings.get("insert_reasoning", False)),
platform="fabric",
session_id=session_id,
session_db=session_db,
))
try:
conversation_kwargs = filter_supported_call_kwargs(
agent.run_conversation,
system_message=settings.get("system_prompt"),
conversation_history=resolve_history(payload),
conversation_history=conversation_history,
sync_honcho=False,
dont_review=True,
)
Expand Down
53 changes: 51 additions & 2 deletions crates/fabric-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
use std::path::PathBuf;

use fabric_core::{
FabricConfig, ProfileConfig, ResolveContext, RunRequest, doctor_plan, load_fabric_document,
resolve_effective_config_with_profiles, resolve_run_plan_from_config,
FabricConfig, ProfileConfig, ResolveContext, RunPlan, RunRequest, RuntimeHandle, doctor_plan,
load_fabric_document, resolve_effective_config_with_profiles, resolve_run_plan_from_config,
resolve_run_plan_with_profiles, run_plan,
};
use pyo3::exceptions::PyRuntimeError;
Expand Down Expand Up @@ -158,6 +158,44 @@ fn run_config(
to_json(&result)
}

/// Start a runtime for a resolved run plan and return its RuntimeHandle JSON.
#[pyfunction]
fn start_runtime(py: Python<'_>, plan_json: String) -> PyResult<String> {
let plan = parse_run_plan(plan_json)?;
let runtime = py
.detach(|| fabric_core::start_runtime(&plan))
.map_err(to_py_error)?;
to_json(&runtime)
}

/// Invoke a previously started runtime and return RunResult JSON.
#[pyfunction]
fn invoke_runtime(
py: Python<'_>,
plan_json: String,
runtime_json: String,
request_json: String,
) -> PyResult<String> {
let plan = parse_run_plan(plan_json)?;
let runtime = parse_runtime_handle(runtime_json)?;
let request = parse_run_request(request_json)?;
let result = py
.detach(|| fabric_core::invoke_runtime(&plan, &runtime, request))
.map_err(to_py_error)?;
to_json(&result)
}

/// Stop a previously started runtime and return FabricEvent list JSON.
#[pyfunction]
fn stop_runtime(py: Python<'_>, plan_json: String, runtime_json: String) -> PyResult<String> {
let plan = parse_run_plan(plan_json)?;
let runtime = parse_runtime_handle(runtime_json)?;
let events = py
.detach(|| fabric_core::stop_runtime(&plan, &runtime))
.map_err(to_py_error)?;
to_json(&events)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[pymodule]
fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(version, m)?)?;
Expand All @@ -169,6 +207,9 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(doctor_config, m)?)?;
m.add_function(wrap_pyfunction!(run, m)?)?;
m.add_function(wrap_pyfunction!(run_config, m)?)?;
m.add_function(wrap_pyfunction!(start_runtime, m)?)?;
m.add_function(wrap_pyfunction!(invoke_runtime, m)?)?;
m.add_function(wrap_pyfunction!(stop_runtime, m)?)?;
Ok(())
}

Expand Down Expand Up @@ -220,3 +261,11 @@ fn parse_profiles(contents: Option<String>) -> PyResult<Vec<ProfileConfig>> {
fn parse_run_request(contents: String) -> PyResult<RunRequest> {
serde_json::from_str(&contents).map_err(|error| PyRuntimeError::new_err(error.to_string()))
}

fn parse_run_plan(contents: String) -> PyResult<RunPlan> {
serde_json::from_str(&contents).map_err(|error| PyRuntimeError::new_err(error.to_string()))
}

fn parse_runtime_handle(contents: String) -> PyResult<RuntimeHandle> {
serde_json::from_str(&contents).map_err(|error| PyRuntimeError::new_err(error.to_string()))
}
50 changes: 0 additions & 50 deletions examples/session_quickstart.py

This file was deleted.

2 changes: 0 additions & 2 deletions python/src/nemo_fabric/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
FabricCliError,
FabricClient,
FabricNativeUnavailableError,
FabricSessionUnsupportedError,
Session,
SessionStatus,
)
Expand All @@ -16,7 +15,6 @@
"FabricCliError",
"FabricClient",
"FabricNativeUnavailableError",
"FabricSessionUnsupportedError",
"Session",
"SessionStatus",
]
7 changes: 7 additions & 0 deletions python/src/nemo_fabric/_native.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,10 @@ def run_config(
request_json: str | None = None,
request_file: str | None = None,
) -> str: ...
def start_runtime(plan_json: str) -> str: ...
def invoke_runtime(
plan_json: str,
runtime_json: str,
request_json: str,
) -> str: ...
def stop_runtime(plan_json: str, runtime_json: str) -> str: ...
Loading