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
78 changes: 78 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ repository = "https://github.com/NVIDIA/nemo-fabric"
fabric-core = { path = "crates/fabric-core", version = "0.1.0" }

clap = { version = "4", features = ["derive"] }
ctrlc = "3"
schemars = { version = "1", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
14 changes: 12 additions & 2 deletions POC-TO-MVP-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ The repo already contains the core shape of the MVP:
generation, and running.
- Python package with native Rust bindings plus CLI fallback.
- SDK support for both agent-package paths and typed/in-memory config.
- Session-mode SDK lifecycle support with a stable `session_id` resume key for
both agent-package paths and typed/in-memory config.
- Agent package examples with `agent.yaml`, `profiles/`, `skills/`, and
workspace fixtures.
- Ordered multi-profile resolution.
Expand Down Expand Up @@ -174,6 +176,10 @@ Status:
- Fabric model, workspace, skills, MCP, tools, telemetry, and artifact config
remains visible in generated Hermes-native config or launch settings.
- Unsupported Hermes MCP mappings with no target fail before invocation.
- Session-mode adapters receive Fabric's stable session key from
`runtime_context.session_id` when supplied, or `runtime_context.runtime_id`
as the default. Hermes CLI maps that Fabric key onto Hermes session id/title
for resume.

Next steps:

Expand Down Expand Up @@ -224,6 +230,11 @@ Status:
- Base Python SDK and CLI surfaces are in place.
- SDK supports agent-package paths and typed/in-memory config.
- CLI supports validate, inspect, plan, doctor, schema generation, and run.
- SDK session APIs cover `start`, `start_config`, `invoke`, `stream`, `cancel`,
and `stop` for `runtime.mode: session`, including caller-provided
`session_id` propagation.
- CLI includes `fabric chat` for local interactive session-mode debugging with
explicit `--session-id`, `/info`, `/verbose`, and oneshot-profile rejection.
- SDK and CLI can plan and run Hermes without callers importing
Hermes-specific code.
- CLI and SDK smoke tests cover core planning and run paths.
Expand All @@ -235,8 +246,6 @@ Next steps:
- Keep Python SDK as the primary API for consumers.
- Keep CLI behavior aligned with SDK behavior for the same config/profile stack.
- Keep plan/doctor/run examples in the README accurate.
- Finish the async SDK boundary for start, invoke, stream, cancel, stop, and
run.
- Keep typed config as a first-class SDK path so Platform can construct the
Fabric agent slice from its own job/deployment config without materializing
an agent directory.
Expand Down Expand Up @@ -310,6 +319,7 @@ Before calling the MVP complete:
- `cargo fmt --check` passes.
- Python SDK smoke passes.
- CLI smoke passes.
- CLI chat smoke passes for session-mode profiles.
- real Hermes SDK smoke passes in a documented clean environment.
- real Hermes CLI smoke passes in a documented clean environment.
- Hermes config-variation matrix passes for supported profile combinations.
Expand Down
53 changes: 45 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,17 @@ plan = client.plan_config(
)
```

For multi-turn sessions, open a `Session` and invoke it repeatedly. The session
keeps one Fabric runtime handle active across turns; harness/adapter state is
authoritative rather than reconstructed from a Python-side transcript:
### Multi-Turn SDK Sessions

Open a `Session` and invoke it repeatedly. The session keeps one Fabric runtime
handle active across turns; harness/adapter state is authoritative rather than
reconstructed from a Python-side transcript.

Fabric separates runtime identity from conversation identity. Each
`start(...)`/`start_config(...)` call creates a new `runtime_id` for that
runtime lifecycle. `session_id` is the stable conversation key used for resume:
if omitted, Fabric uses the generated `runtime_id`; if supplied, Fabric uses the
caller-provided `session_id`.

```python
import asyncio
Expand All @@ -185,21 +193,50 @@ from nemo_fabric import FabricClient

async def chat():
async with await FabricClient().start(
"examples/code-review-agent", profile="hermes_session"
"examples/code-review-agent",
profile="hermes_session",
session_id="review-session-123",
) as session:
await session.invoke("My name is Robin.")
reply = await session.invoke("What's my name?") # recalls "Robin"
print(session.runtime_id, session.status.value, len(session.messages))
print(session.runtime_id, session.session_id, session.status.value)
print(reply["output"]["response"])

asyncio.run(chat())
```

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`.
`cancel()` cooperatively aborts an in-flight turn. Session APIs require
`runtime.mode: session`.

### Interactive CLI Chat

For local manual multi-turn testing, use `fabric chat` with a session-mode
profile. It drives the same started runtime in an interactive loop:

```bash
fabric chat examples/code-review-agent \
--profile hermes_cli_session \
--session-id review-session-123 \
--verbose
```

`--session-id` is optional. Each `fabric chat` start creates a new `runtime_id`;
the session id is the stable resume key. If `--session-id` is omitted, Fabric
uses the generated `runtime_id` as the session id. If you want a later chat run
to resume the same conversation, pass that prior session id explicitly.
`fabric chat` prints a `NEMO FABRIC` session banner with the agent, profile,
harness, runtime id, and session id at startup and from `/info`, then uses a
`you[profile:session]>` prompt and `agent>` responses for the transcript.
`/help` shows commands, `/verbose on|off` toggles a fenced per-turn metadata
block after each agent response with request/invocation ids, status, artifact
count, and telemetry details, and `/clear` clears the terminal. `fabric chat`
requires `runtime.mode: session`; use `fabric run` for oneshot profiles and
machine-readable stdout. Because `chat` is an interactive terminal UI, the
transcript and metadata are written together on stderr.

The real-Hermes integration check is `tests/smoke_hermes_session.py`.

When installed from the repository root, `FabricClient()` uses the native Rust
binding. SDK `run(...)`, `start(...)`, and their typed-config equivalents all
Expand Down
41 changes: 31 additions & 10 deletions adapters/common/src/nemo_fabric_adapters/common/hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ def runtime_context(payload: dict[str, Any]) -> dict[str, Any]:


def runtime_session_id(payload: dict[str, Any]) -> str | None:
runtime_id = runtime_context(payload).get("runtime_id")
"""Return Fabric's session key for adapter-owned harness session mapping."""

context = runtime_context(payload)
session_id = context.get("session_id")
if session_id:
return str(session_id)
runtime_id = context.get("runtime_id")
if runtime_id:
return str(runtime_id)
return None
Expand Down Expand Up @@ -339,27 +345,42 @@ def collect_relay_artifacts(plugin_config: dict[str, Any]) -> list[dict[str, str
artifacts.append({"kind": section_name, "path": str(path)})
return artifacts

def ensure_hermes_session(fabric_runtime_id: str, model_name: str, model_config: dict[str, Any], hermes_home: Path) -> dict[str, Any]:
def ensure_hermes_session(
fabric_session_id: str,
model_name: str,
model_config: dict[str, Any],
hermes_home: Path,
) -> dict[str, Any]:
"""
Ensure that a session exists in the Hermes session database for the given fabric_runtime_id.
Ensure that Hermes has a session mapped from Fabric's session key.

Fabric chooses this key from runtime_context.session_id when the caller
supplies one, otherwise from runtime_context.runtime_id. The adapter maps
that Fabric-owned key onto Hermes' session id/title.

If the session does not exist, it will be created.

When creating a new session, Hermes allows us to provide our own session_id (as long as it's unique), which for
convenience will be set to the fabric_runtime_id.
convenience will be set to the Fabric session key.

However when Hermes compresses a session, it will return a new session_id, so we can't depend on the
fabric_runtime_id being the same as the session_id after a session has been compressed.
Fabric session key being the same as the session_id after a session has been compressed.

However looking up a session by title will always return the most recent session, so after creating the session
we will set the title to the fabric_runtime_id, and then we can always look up the session by title.
we will set the title to the Fabric session key, and then we can always look up the session by title.
"""
from hermes_state import SessionDB

session_db = SessionDB(db_path=hermes_home / "state.db")
session = session_db.get_session_by_title(fabric_runtime_id)
session = session_db.get_session_by_title(fabric_session_id)
if session is None:
session_db.ensure_session(fabric_runtime_id, source="fabric", model=model_name, model_config=model_config)
session_db.set_session_title(session_id=fabric_runtime_id, title=fabric_runtime_id)
session = session_db.get_session_by_title(fabric_runtime_id)
session_db.ensure_session(
fabric_session_id,
source="fabric",
model=model_name,
model_config=model_config,
)
session_db.set_session_title(session_id=fabric_session_id, title=fabric_session_id)
session = session_db.get_session_by_title(fabric_session_id)

return session
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]:
model_name = settings.get("model_name") or model_config.get("model")
runtime_mode = get_runtime_mode(payload)
use_session = runtime_mode == "session"
fabric_runtime_id = hermes_common.runtime_session_id(payload)
fabric_session_id = hermes_common.runtime_session_id(payload)

relay_plugin_config = hermes_common.configure_hermes_relay(payload)

Expand All @@ -87,12 +87,17 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]:
)

if use_session:
if fabric_runtime_id is None:
if fabric_session_id is None:
raise RuntimeError(
"runtime.mode=session is set, but no runtime_id was provided in the payload. "
"Please provide a runtime_id to resume an existing session."
"runtime.mode=session is set, but no session_id or runtime_id was provided "
"in the payload. Please provide an id to resume an existing session."
)
hermes_common.ensure_hermes_session(fabric_runtime_id, model_name, model_config, hermes_home)
hermes_common.ensure_hermes_session(
fabric_session_id,
model_name,
model_config,
hermes_home,
)

prompt = request_to_prompt(request)
toolsets = hermes_common.normalize_list(settings.get("enabled_toolsets"))
Expand All @@ -105,7 +110,7 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]:
prompt,
toolsets=toolsets,
use_session=use_session,
fabric_runtime_id=fabric_runtime_id,
fabric_session_id=fabric_session_id,
)
cwd = resolve_path(
config_root,
Expand Down Expand Up @@ -145,7 +150,7 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]:
"model": model_name,
"returncode": return_code,
"response": response,
"session_id": fabric_runtime_id,
"session_id": fabric_session_id,
"stdout": completed.stdout,
"stderr": completed.stderr,
"failed": return_code != 0,
Expand Down Expand Up @@ -174,7 +179,7 @@ def build_command(
prompt: str,
toolsets: list[str] | None = None,
use_session: bool = False,
fabric_runtime_id: str | None = None,
fabric_session_id: str | None = None,
) -> list[str]:
command = resolve_command(
config_root,
Expand All @@ -185,9 +190,11 @@ def build_command(

args = [command, *command_args]
if use_session:
# On the first invocation, we create the session up-front, and use the `--continue` flag to resume it even
# though technically it's an empty session.
args.extend(["chat", "--quiet", "--continue", fabric_runtime_id, "--query", prompt])
if not fabric_session_id:
raise RuntimeError("session mode requires a session_id or runtime_id")
# Fabric's session key is explicitly mapped onto Hermes' session id/title.
# On the first invocation, this resumes an empty session created up front.
args.extend(["chat", "--quiet", "--continue", fabric_session_id, "--query", prompt])
else:
args.extend(["-z", prompt])

Expand Down
1 change: 1 addition & 0 deletions crates/fabric-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ workspace = true

[dependencies]
clap.workspace = true
ctrlc.workspace = true
fabric-core.workspace = true
serde_json.workspace = true
Loading