From e90ca953f8d5bd5452e7a5262dc67da9f8e9baeb Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 25 Jun 2026 17:46:56 -0700 Subject: [PATCH 1/5] Add Fabric chat session entrypoint Signed-off-by: Ajay Thorve --- Cargo.lock | 78 +++++ Cargo.toml | 1 + README.md | 34 +- .../src/nemo_fabric_adapters/common/hermes.py | 34 +- .../hermes_cli/adapter.py | 25 +- crates/fabric-cli/Cargo.toml | 1 + crates/fabric-cli/src/main.rs | 329 +++++++++++++++++- crates/fabric-core/src/runtime.rs | 78 +++++ python/src/nemo_fabric/client.py | 44 ++- python/tests/smoke_readme_examples.py | 5 + python/tests/smoke_sdk_sessions.py | 11 + schemas/adapter-invocation.schema.json | 7 + schemas/runtime-context.schema.json | 7 + .../hermes_shim/adapter.py | 2 + tests/smoke_cli.py | 67 ++++ tests/smoke_hermes_session.py | 6 +- tests/test_adapaters_hermes_common.py | 16 + tests/test_session.py | 86 ++++- 18 files changed, 795 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a40aba25..5c020e469 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,33 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -98,6 +125,29 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -115,6 +165,7 @@ name = "fabric-cli" version = "0.1.0" dependencies = [ "clap", + "ctrlc", "fabric-core", "serde_json", ] @@ -186,6 +237,33 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell" version = "1.21.4" diff --git a/Cargo.toml b/Cargo.toml index cefcf9de5..5f0dade01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 0f1090372..8f463f987 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,9 @@ 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: +authoritative rather than reconstructed from a Python-side transcript. +Pass `session_id` when the caller already owns the harness conversation id; +otherwise Fabric uses the generated runtime id: ```python import asyncio @@ -185,11 +187,13 @@ 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()) @@ -197,9 +201,27 @@ 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`. For local manual testing, the CLI can drive 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 +``` + +`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. Because `chat` +is an interactive terminal UI, the transcript and metadata are written together +on stderr; use `fabric run` for machine-readable stdout. + +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 diff --git a/adapters/common/src/nemo_fabric_adapters/common/hermes.py b/adapters/common/src/nemo_fabric_adapters/common/hermes.py index eda9a1623..18831af96 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/hermes.py +++ b/adapters/common/src/nemo_fabric_adapters/common/hermes.py @@ -32,7 +32,11 @@ 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") + 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 @@ -339,27 +343,37 @@ 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( + harness_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 a session exists in the Hermes session database for the given harness session id. 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 harness session id. 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. + harness session id 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 harness session id, 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(harness_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( + harness_session_id, + source="fabric", + model=model_name, + model_config=model_config, + ) + session_db.set_session_title(session_id=harness_session_id, title=harness_session_id) + session = session_db.get_session_by_title(harness_session_id) return session diff --git a/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py b/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py index 28051f236..2fc313ff9 100644 --- a/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py +++ b/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py @@ -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) + harness_session_id = hermes_common.runtime_session_id(payload) relay_plugin_config = hermes_common.configure_hermes_relay(payload) @@ -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 harness_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( + harness_session_id, + model_name, + model_config, + hermes_home, + ) prompt = request_to_prompt(request) toolsets = hermes_common.normalize_list(settings.get("enabled_toolsets")) @@ -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, + harness_session_id=harness_session_id, ) cwd = resolve_path( config_root, @@ -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": harness_session_id, "stdout": completed.stdout, "stderr": completed.stderr, "failed": return_code != 0, @@ -174,7 +179,7 @@ def build_command( prompt: str, toolsets: list[str] | None = None, use_session: bool = False, - fabric_runtime_id: str | None = None, + harness_session_id: str | None = None, ) -> list[str]: command = resolve_command( config_root, @@ -185,9 +190,11 @@ def build_command( args = [command, *command_args] if use_session: + if not harness_session_id: + raise RuntimeError("session mode requires a session_id or runtime_id") # 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]) + args.extend(["chat", "--quiet", "--continue", harness_session_id, "--query", prompt]) else: args.extend(["-z", prompt]) diff --git a/crates/fabric-cli/Cargo.toml b/crates/fabric-cli/Cargo.toml index dce848802..7b9cc7772 100644 --- a/crates/fabric-cli/Cargo.toml +++ b/crates/fabric-cli/Cargo.toml @@ -18,5 +18,6 @@ workspace = true [dependencies] clap.workspace = true +ctrlc.workspace = true fabric-core.workspace = true serde_json.workspace = true diff --git a/crates/fabric-cli/src/main.rs b/crates/fabric-cli/src/main.rs index 5c92a43ec..7bf56151f 100644 --- a/crates/fabric-cli/src/main.rs +++ b/crates/fabric-cli/src/main.rs @@ -3,15 +3,25 @@ //! NeMo Fabric command-line interface. +use std::io::{self, BufRead, IsTerminal, Write}; use std::path::PathBuf; use std::process::ExitCode; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver}, +}; +use std::thread; +use std::time::Duration; use clap::{Parser, Subcommand}; use fabric_core::{ - RunRequest, SchemaName, doctor_plan, generate_all_schemas, generate_schema_json, + AdapterKind, RunPlan, RunRequest, RunResult, RunStatus, RuntimeHandle, RuntimeMode, SchemaName, + doctor_plan, generate_all_schemas, generate_schema_json, invoke_runtime, resolve_effective_config_with_profiles, resolve_run_plan_with_profiles, run_plan, - validate_agent_directory, write_schema_snapshots, + start_runtime, stop_runtime, validate_agent_directory, write_schema_snapshots, }; +use serde_json::Value; #[derive(Debug, Parser)] #[command(name = "fabric")] @@ -53,6 +63,20 @@ enum Command { #[arg(long = "profile")] profile: Vec, }, + /// Start an interactive multi-turn session. + Chat { + /// Path to an agent directory or YAML config. + path: PathBuf, + /// Profile name from configured profile directories, or a YAML profile path. + #[arg(long = "profile")] + profile: Vec, + /// Caller-provided harness conversation id. + #[arg(long = "session-id")] + session_id: Option, + /// Show per-turn runtime, invocation, artifact, and telemetry details. + #[arg(long)] + verbose: bool, + }, /// Run an agent/profile through its Fabric adapter. Run { /// Path to an agent directory or YAML config. @@ -119,6 +143,14 @@ fn run() -> Result<(), Box> { let report = doctor_plan(&plan); println!("{}", serde_json::to_string_pretty(&report)?); } + Some(Command::Chat { + path, + profile, + session_id, + verbose, + }) => { + run_chat(path, &profile, session_id, verbose)?; + } Some(Command::Run { path, profile, @@ -198,3 +230,296 @@ fn run() -> Result<(), Box> { } Ok(()) } + +fn run_chat( + path: PathBuf, + profile: &[String], + session_id: Option, + verbose: bool, +) -> Result<(), Box> { + let plan = resolve_run_plan_with_profiles(path, profile)?; + if plan.config.runtime.mode != RuntimeMode::Session { + return Err( + "fabric chat requires runtime.mode=session; use `fabric run` for oneshot profiles" + .into(), + ); + } + let runtime = start_runtime(&plan)?; + let chat_result = chat_loop(&plan, &runtime, session_id.as_deref(), verbose); + let stop_result = stop_runtime(&plan, &runtime); + if let Err(error) = chat_result { + return Err(error); + } + stop_result?; + Ok(()) +} + +fn chat_loop( + plan: &RunPlan, + runtime: &RuntimeHandle, + session_id: Option<&str>, + mut verbose: bool, +) -> Result<(), Box> { + let harness_session_id = session_id + .unwrap_or(runtime.runtime_id.as_str()) + .to_string(); + let session_provided = session_id.is_some(); + let prompt = chat_prompt(plan, &harness_session_id); + let interrupted = Arc::new(AtomicBool::new(false)); + { + let interrupted = Arc::clone(&interrupted); + ctrlc::set_handler(move || { + interrupted.store(true, Ordering::SeqCst); + })?; + } + let input_is_terminal = io::stdin().is_terminal(); + let lines = stdin_lines(); + print_chat_info(plan, runtime, &harness_session_id, session_provided); + eprintln!(); + let mut turn_count = 0_u64; + + loop { + eprint!("{prompt}> "); + io::stderr().flush()?; + + let Some(line) = next_chat_line(&lines, &interrupted)? else { + break; + }; + let input = line; + let command = input.trim(); + match command { + "/exit" | "/quit" => break, + "/help" => { + print_chat_help(); + continue; + } + "/info" => { + print_chat_info(plan, runtime, &harness_session_id, session_provided); + continue; + } + "/clear" => { + eprint!("\x1b[2J\x1b[H"); + continue; + } + "/verbose" => { + verbose = !verbose; + eprintln!("verbose: {}", if verbose { "on" } else { "off" }); + continue; + } + "" => continue, + _ => {} + } + if let Some(value) = command.strip_prefix("/verbose ") { + match value.trim() { + "on" => { + verbose = true; + eprintln!("verbose: on"); + continue; + } + "off" => { + verbose = false; + eprintln!("verbose: off"); + continue; + } + _ => { + eprintln!("usage: /verbose on|off"); + continue; + } + } + } + if command.starts_with('/') { + eprintln!("unknown command: {command}"); + eprintln!("type /help for available commands"); + continue; + } + + let mut request = RunRequest::text(input); + request.context.insert( + "session_id".to_string(), + Value::String(harness_session_id.clone()), + ); + let result = invoke_runtime(plan, runtime, request)?; + turn_count += 1; + if !input_is_terminal { + eprintln!(); + } + print_chat_response(&result.output)?; + if verbose { + eprintln!(); + print_turn_verbose(turn_count, &result); + } + + let exit_code = result + .metadata + .get("exit_code") + .and_then(Value::as_i64) + .unwrap_or(0); + if exit_code != 0 { + let message = result + .error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| format!("harness exited with an exit code of {exit_code}")); + return Err(message.into()); + } + } + Ok(()) +} + +fn print_chat_info( + plan: &RunPlan, + runtime: &RuntimeHandle, + session_id: &str, + session_provided: bool, +) { + eprintln!("+================================================================+"); + eprintln!("| NEMO FABRIC |"); + eprintln!("| interactive runtime session |"); + eprintln!("+----------------------------------------------------------------+"); + eprintln!("| agent: {}", plan.agent_name); + eprintln!("| profile: {}", profile_label(plan)); + eprintln!("| harness: {}", runtime.harness_type); + eprintln!("| adapter: {}", adapter_kind_label(runtime.adapter_kind)); + eprintln!("| runtime_id: {}", runtime.runtime_id); + eprintln!( + "| session_id: {} ({})", + session_id, + if session_provided { + "provided" + } else { + "runtime_id default" + } + ); + eprintln!("| commands: /help, /info, /verbose on|off, /clear, /exit, /quit"); + eprintln!("+----------------------------------------------------------------"); +} + +fn print_chat_help() { + eprintln!("Commands:"); + eprintln!(" /help show this help"); + eprintln!(" /info show session/runtime info"); + eprintln!(" /verbose on|off toggle per-turn metadata"); + eprintln!(" /clear clear the terminal"); + eprintln!(" /exit, /quit stop the runtime and exit"); + eprintln!("Type a non-empty message to invoke the same runtime session."); +} + +fn print_turn_verbose(turn: u64, result: &RunResult) { + eprintln!("+-- turn {turn} metadata ---------------------------------------------"); + eprintln!("| status: {}", status_label(result.status)); + eprintln!("| request_id: {}", result.request_id); + eprintln!("| invocation_id: {}", result.invocation_id); + eprintln!("| runtime_id: {}", result.runtime_id); + eprintln!("| artifact_count: {}", result.artifacts.artifacts.len()); + if let Some(telemetry) = result.telemetry.as_ref() { + eprintln!("| telemetry: relay_enabled={}", telemetry.relay_enabled); + if let Some(path) = telemetry + .metadata + .get("relay_config_path") + .and_then(Value::as_str) + { + eprintln!("| telemetry_config: {path}"); + } + } + if let Some(error) = result.error.as_ref() { + eprintln!("| error: {} {}", error.code, error.message); + } + eprintln!("+----------------------------------------------------------------"); +} + +fn chat_prompt(plan: &RunPlan, session_id: &str) -> String { + format!( + "you[{}:{}]", + profile_label(plan), + short_prompt_label(session_id) + ) +} + +fn short_prompt_label(value: &str) -> String { + let mut chars = value.chars(); + let short: String = chars.by_ref().take(24).collect(); + if chars.next().is_none() { + short + } else { + format!("{short}...") + } +} + +fn profile_label(plan: &RunPlan) -> String { + if !plan.profiles.is_empty() { + return plan.profiles.join(", "); + } + plan.profile + .clone() + .unwrap_or_else(|| "default".to_string()) +} + +fn adapter_kind_label(adapter_kind: AdapterKind) -> &'static str { + match adapter_kind { + AdapterKind::Process => "process", + AdapterKind::Http => "http", + AdapterKind::Python => "python", + AdapterKind::NativePlugin => "native_plugin", + } +} + +fn status_label(status: RunStatus) -> &'static str { + match status { + RunStatus::Succeeded => "succeeded", + RunStatus::Failed => "failed", + RunStatus::Cancelled => "cancelled", + } +} + +fn stdin_lines() -> Receiver> { + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + if sender.send(line).is_err() { + break; + } + } + }); + receiver +} + +fn next_chat_line( + lines: &Receiver>, + interrupted: &AtomicBool, +) -> Result, Box> { + loop { + if interrupted.load(Ordering::SeqCst) { + eprintln!(); + return Ok(None); + } + match lines.recv_timeout(Duration::from_millis(100)) { + Ok(line) => return Ok(Some(line?)), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(None), + } + } +} + +fn print_chat_response(output: &Value) -> Result<(), Box> { + let response = output.get("response").unwrap_or(output); + if let Some(response) = response.as_str() { + print_chat_text("agent", response); + } else { + let response = serde_json::to_string_pretty(response)?; + print_chat_text("agent", &response); + } + Ok(()) +} + +fn print_chat_text(role: &str, text: &str) { + let mut lines = text.lines(); + if let Some(first) = lines.next() { + eprintln!("{role}> {first}"); + for line in lines { + eprintln!("{:width$}{line}", "", width = role.len() + 2); + } + } else { + eprintln!("{role}>"); + } +} diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 347c0292c..16a038c71 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -256,6 +256,9 @@ pub struct InvocationHandle { pub struct RuntimeContext { /// Runtime handle id. pub runtime_id: String, + /// Optional caller-provided harness conversation id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, /// Invocation handle id. pub invocation_id: String, /// Request id. @@ -1103,6 +1106,7 @@ fn adapter_invocation( effective_config, runtime_context: RuntimeContext { runtime_id: runtime.runtime_id.clone(), + session_id: request_session_id(request), invocation_id: invocation.invocation_id.clone(), request_id: request.request_id.clone(), environment: runtime.environment.clone(), @@ -1115,6 +1119,15 @@ fn adapter_invocation( }) } +fn request_session_id(request: &RunRequest) -> Option { + request + .context + .get("session_id") + .and_then(Value::as_str) + .filter(|session_id| !session_id.is_empty()) + .map(ToOwned::to_owned) +} + fn runtime_telemetry_context( plan: &RunPlan, relay_config: Option<&RelayRuntimeConfig>, @@ -1906,6 +1919,71 @@ print(json.dumps({ let _ = fs::remove_dir_all(root); } + #[test] + fn adapter_runtime_context_includes_caller_session_id() { + let root = std::env::temp_dir().join(format!( + "fabric-session-context-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("adapters/process")).expect("create adapters dir"); + fs::write( + root.join("agent.yaml"), + r#"schema_version: fabric.agent/v1alpha1 +metadata: + name: session-context-agent +harness: + adapter_id: acme.fabric.process + settings: + command: python3 + args: + - -c + - | + import json + import sys + payload = json.load(sys.stdin) + print(json.dumps(payload["runtime_context"], sort_keys=True)) + stdin_payload: fabric_request +models: + default: + provider: test + model: test-model +runtime: + mode: session + transport: cli + input_schema: text + output_schema: text + artifacts: ./artifacts +"#, + ) + .expect("write config"); + fs::write( + root.join("adapters/process/fabric-adapter.json"), + process_adapter_descriptor(), + ) + .expect("write adapter descriptor"); + + let plan = resolve_run_plan(&root, None).expect("run plan"); + let mut request = RunRequest::text("hello fabric"); + request.context.insert( + "session_id".to_string(), + Value::String("caller-session-123".to_string()), + ); + let result = run_plan(&plan, request).expect("run result"); + + assert_eq!(result.status, RunStatus::Succeeded); + assert_eq!( + result.output["session_id"], + Value::String("caller-session-123".to_string()) + ); + assert_eq!( + result.output["runtime_id"], + Value::String(result.runtime_id.clone()) + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn process_adapter_failure_returns_structured_error() { let root = std::env::temp_dir().join(format!( diff --git a/python/src/nemo_fabric/client.py b/python/src/nemo_fabric/client.py index 37b2728f6..ce12f1627 100644 --- a/python/src/nemo_fabric/client.py +++ b/python/src/nemo_fabric/client.py @@ -216,6 +216,7 @@ async def start( *, profile: str | Sequence[str] | None = None, overrides: dict[str, Any] | None = None, + session_id: str | None = None, ) -> "Session": """Open a multi-turn session over an agent/profile runtime. @@ -225,6 +226,8 @@ async def start( base config. overrides: Config overrides applied to every turn in the session; a turn's own ``overrides`` merge over these. + session_id: Optional caller-provided harness conversation id. + Defaults to the Fabric runtime id. Returns: An active :class:`Session` bound to the resolved plan. @@ -236,10 +239,17 @@ async def start( native = self._require_native_module("start") plan = self.plan(path, profile=profile) + _require_session_runtime(plan, "start") runtime = await _call_blocking( lambda: json.loads(native.start_runtime(json.dumps(plan))) ) - return Session(client=self, plan=plan, runtime=runtime, overrides=overrides) + return Session( + client=self, + plan=plan, + runtime=runtime, + overrides=overrides, + session_id=session_id, + ) async def start_config( self, @@ -248,6 +258,7 @@ async def start_config( profile_configs: Sequence[Mapping[str, Any] | Any] | None = None, base_dir: str | Path | None = None, overrides: dict[str, Any] | None = None, + session_id: str | None = None, ) -> "Session": """Open a multi-turn session over an in-memory typed config. @@ -259,6 +270,8 @@ async def start_config( adapters. ``None`` resolves against the process working directory. overrides: Config overrides applied to every turn; a turn's own ``overrides`` merge over these. + session_id: Optional caller-provided harness conversation id. + Defaults to the Fabric runtime id. Returns: An active :class:`Session` bound to the resolved plan. @@ -271,10 +284,17 @@ async def start_config( plan = self.plan_config( config, profile_configs=profile_configs, base_dir=base_dir ) + _require_session_runtime(plan, "start_config") runtime = await _call_blocking( lambda: json.loads(native.start_runtime(json.dumps(plan))) ) - return Session(client=self, plan=plan, runtime=runtime, overrides=overrides) + return Session( + client=self, + plan=plan, + runtime=runtime, + overrides=overrides, + session_id=session_id, + ) def _command(self) -> tuple[str, ...]: if self.command is not None: @@ -365,11 +385,14 @@ def __init__( plan: dict[str, Any], runtime: dict[str, Any], overrides: dict[str, Any] | None = None, + session_id: str | None = None, ) -> None: + _require_session_runtime(plan, "Session") self._client = client self._plan = plan self._runtime = runtime self._overrides = overrides + self._session_id = session_id self._messages: list[Any] = [] self._invocations: list[dict[str, Any]] = [] self._status = SessionStatus.ACTIVE @@ -404,6 +427,12 @@ def runtime_id(self) -> str: return str(self._runtime["runtime_id"]) + @property + def session_id(self) -> str: + """Harness conversation id used for this session.""" + + return str(self._session_id or self.runtime_id) + @property def info(self) -> dict[str, Any]: """Summary handle for the active Fabric runtime and selected adapter.""" @@ -458,6 +487,7 @@ async def invoke( request=request, request_file=None, ) + request_payload["context"]["session_id"] = self.session_id # Merge overrides as session < request < per-turn; request-level # overrides must not bypass the documented session/turn merge. merged_overrides = _merge_overrides(self._overrides, request_payload.get("overrides")) @@ -719,3 +749,13 @@ def _adapter_kind(plan: dict[str, Any]) -> str: def _harness_type(plan: dict[str, Any]) -> str: descriptor = ((plan.get("adapter_descriptor") or {}).get("descriptor") or {}) return descriptor.get("adapter_id", "unknown") + + +def _require_session_runtime(plan: dict[str, Any], method: str) -> None: + runtime = ((plan.get("config") or {}).get("runtime") or {}) + mode = runtime.get("mode") + if mode != "session": + resolved = mode if isinstance(mode, str) else "unknown" + raise RuntimeError( + f"{method} requires runtime.mode=session; resolved runtime.mode={resolved}" + ) diff --git a/python/tests/smoke_readme_examples.py b/python/tests/smoke_readme_examples.py index b7d06733d..88d6c79d2 100644 --- a/python/tests/smoke_readme_examples.py +++ b/python/tests/smoke_readme_examples.py @@ -33,6 +33,11 @@ "plan = client.plan_config(", '"harness": {"adapter_id": "nvidia.fabric.hermes.sdk"},', 'base_dir="examples/code-review-agent",', + 'session_id="review-session-123",', + "fabric chat examples/code-review-agent \\", + "--profile hermes_cli_session", + "--session-id review-session-123", + "--verbose", 'client = FabricClient(command=("cargo", "run", "-q", "-p", "fabric-cli", "--"))', ] diff --git a/python/tests/smoke_sdk_sessions.py b/python/tests/smoke_sdk_sessions.py index 896f727d6..13fc709fb 100644 --- a/python/tests/smoke_sdk_sessions.py +++ b/python/tests/smoke_sdk_sessions.py @@ -20,6 +20,14 @@ def _plan() -> dict[str, Any]: return { "agent_name": "demo", "profile": "hermes_sdk", + "config": { + "runtime": { + "mode": "session", + "transport": "library", + "input_schema": "chat", + "output_schema": "message", + }, + }, "adapter_descriptor": { "descriptor": {"adapter_kind": "python", "adapter_id": "test.fabric.shim"} }, @@ -91,6 +99,7 @@ async def stable_runtime_across_turns() -> None: session = _session(native) assert session.status is SessionStatus.ACTIVE assert session.runtime_id == "runtime-1" + assert session.session_id == "runtime-1" assert "session_id" not in session.info assert not hasattr(session, "id") @@ -98,6 +107,8 @@ async def stable_runtime_across_turns() -> None: await session.invoke("What's my name?") assert [inv["runtime_id"] for inv in session.invocations] == ["runtime-1", "runtime-1"] + assert native.requests[0]["context"]["session_id"] == "runtime-1" + assert native.requests[1]["context"]["session_id"] == "runtime-1" assert "history" not in native.requests[0]["context"] assert "history" not in native.requests[1]["context"] assert session.runtime_id == "runtime-1" diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index 7d25f23cb..1014ae34d 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -748,6 +748,13 @@ "description": "Runtime handle id.", "type": "string" }, + "session_id": { + "description": "Optional caller-provided harness conversation id.", + "type": [ + "string", + "null" + ] + }, "telemetry": { "anyOf": [ { diff --git a/schemas/runtime-context.schema.json b/schemas/runtime-context.schema.json index c6be10700..f97175860 100644 --- a/schemas/runtime-context.schema.json +++ b/schemas/runtime-context.schema.json @@ -188,6 +188,13 @@ "description": "Runtime handle id.", "type": "string" }, + "session_id": { + "description": "Optional caller-provided harness conversation id.", + "type": [ + "string", + "null" + ] + }, "telemetry": { "anyOf": [ { diff --git a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py index 511ff6c09..5d80a2acf 100644 --- a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py +++ b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py @@ -65,6 +65,7 @@ def run_selected_mode(payload: dict[str, Any]) -> dict[str, Any]: def run_shim(payload: dict[str, Any]) -> dict[str, Any]: settings = settings_payload(payload) request = request_payload(payload) + context = runtime_context(payload) environment = environment_payload(payload) capabilities = capability_plan(payload) @@ -73,6 +74,7 @@ def run_shim(payload: dict[str, Any]) -> dict[str, Any]: "adapter": "test-shim", "mode": "shim", "received": request.get("input"), + "session_id": context.get("session_id") or context.get("runtime_id"), "workspace": environment.get("workspace") or settings.get("workspace"), "native_skill_paths": (capabilities.get("native") or {}).get("skill_paths", []), "native_mcp_servers": sorted((capabilities.get("native") or {}).get("mcp_servers", {}).keys()), diff --git a/tests/smoke_cli.py b/tests/smoke_cli.py index a31058382..f1cf51455 100644 --- a/tests/smoke_cli.py +++ b/tests/smoke_cli.py @@ -94,6 +94,53 @@ def main() -> None: assert structured["request_id"] == "cli-structured-request" assert structured["output"]["received"] == "hello structured hermes" + chat = run_with_stdin( + "/help\n/verbose on\nhello chat\n/verbose off\n/clear\n/info\n/exit\n", + "chat", + temp_fixture, + "--profile", + "env_local", + "--session-id", + "cli-session-123", + "--verbose", + ) + assert chat.stdout == "" + assert '"received": "hello chat"' in chat.stderr + assert '"session_id": "cli-session-123"' in chat.stderr + assert "NEMO FABRIC" in chat.stderr + assert "interactive runtime session" in chat.stderr + assert "agent: hermes-shim-agent" in chat.stderr + assert "profile: env_local" in chat.stderr + assert "harness: test.fabric.hermes_shim" in chat.stderr + assert "adapter: python" in chat.stderr + assert chat.stderr.count("session_id: cli-session-123 (provided)") >= 2 + assert "you[env_local:cli-session-123]> " in chat.stderr + assert "you[env_local:cli-session-123]> \nagent> {" in chat.stderr + assert "agent> {" in chat.stderr + assert "runtime_id: runtime-" in chat.stderr + assert "/verbose on|off" in chat.stderr + assert "/clear" in chat.stderr + assert "verbose: on" in chat.stderr + assert "verbose: off" in chat.stderr + assert "\x1b[2J\x1b[H" in chat.stderr + assert "\n\n+-- turn 1 metadata" in chat.stderr + assert "+-- turn 1 metadata" in chat.stderr + assert "| status: succeeded" in chat.stderr + assert "| request_id: request-" in chat.stderr + assert "| invocation_id: invocation-" in chat.stderr + assert "| artifact_count:" in chat.stderr + + rejected_chat = run_raw( + "", + "chat", + temp_example, + "--profile", + "hermes_sdk", + ) + assert rejected_chat.returncode != 0 + assert rejected_chat.stdout == "" + assert "fabric chat requires runtime.mode=session" in rejected_chat.stderr + def call_text(*args: object) -> str: completed = run(*args) @@ -120,5 +167,25 @@ def run(*args: object) -> subprocess.CompletedProcess[str]: return completed +def run_with_stdin(stdin: str, *args: object) -> subprocess.CompletedProcess[str]: + completed = run_raw(stdin, *args) + if completed.returncode != 0: + raise AssertionError( + f"command failed: {completed.args}\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + return completed + + +def run_raw(stdin: str, *args: object) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [*COMMAND, *(str(arg) for arg in args)], + cwd=ROOT, + input=stdin, + text=True, + capture_output=True, + check=False, + ) + + if __name__ == "__main__": main() diff --git a/tests/smoke_hermes_session.py b/tests/smoke_hermes_session.py index f0302ea9d..f09ba1180 100644 --- a/tests/smoke_hermes_session.py +++ b/tests/smoke_hermes_session.py @@ -7,8 +7,8 @@ and CLI adapters and asserts the session carries conversation memory across turns through the same Fabric runtime handle. -Unlike ``smoke_hermes_sdk.py`` (which shells out to the CLI), the session path is -SDK-only and runs through the native Fabric runtime lifecycle, so this must be +Unlike ``smoke_hermes_sdk.py`` (which shells out to the CLI), this exercises the +SDK session APIs through the native Fabric runtime lifecycle, so this must be executed by an interpreter that has BOTH the nemo_fabric native extension and Hermes importable: @@ -105,7 +105,7 @@ async def _run_cli_session() -> None: r1 = await session.invoke("My name is Robin. Please remember it for later.") assert r1["status"] == "succeeded", r1 assert r1["output"]["mode"] == "hermes_cli_session", r1 - assert r1["output"]["session_id"] == session.runtime_id, r1 + assert r1["output"]["session_id"] == session.session_id, r1 r2 = await session.invoke("What is my name? Reply with just the name.") assert r2["status"] == "succeeded", r2 diff --git a/tests/test_adapaters_hermes_common.py b/tests/test_adapaters_hermes_common.py index d90e39ca0..f425966c1 100644 --- a/tests/test_adapaters_hermes_common.py +++ b/tests/test_adapaters_hermes_common.py @@ -45,6 +45,22 @@ def test_payload_accessors_prefer_effective_config(hermes_common: types.ModuleTy assert hermes_common.capability_plan(payload) == {"native": {"skill_paths": ["skills"]}} +@pytest.mark.parametrize( + ("runtime_context", "expected"), + [ + ({"session_id": "caller-session", "runtime_id": "runtime-1"}, "caller-session"), + ({"runtime_id": "runtime-1"}, "runtime-1"), + ({}, None), + ], +) +def test_runtime_session_id_prefers_caller_session_id( + hermes_common: types.ModuleType, + runtime_context: dict[str, object], + expected: str | None, +) -> None: + assert hermes_common.runtime_session_id({"runtime_context": runtime_context}) == expected + + @pytest.mark.parametrize( ("provider", "expected"), [ diff --git a/tests/test_session.py b/tests/test_session.py index 56a1e9fbb..1bbf5c7f2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -20,10 +20,18 @@ from nemo_fabric import client as client_mod -def _plan(adapter_kind: str = "python") -> dict[str, Any]: +def _plan(adapter_kind: str = "python", runtime_mode: str = "session") -> dict[str, Any]: return { "agent_name": "demo", "profile": "hermes_sdk", + "config": { + "runtime": { + "mode": runtime_mode, + "transport": "library", + "input_schema": "chat", + "output_schema": "message", + }, + }, "adapter_descriptor": { "descriptor": { "adapter_kind": adapter_kind, @@ -52,7 +60,8 @@ def _runtime() -> dict[str, Any]: class FakeNative: - def __init__(self) -> None: + def __init__(self, runtime_mode: str = "session") -> None: + self.runtime_mode = runtime_mode self.plans: list[dict[str, Any]] = [] self.requests: list[dict[str, Any]] = [] self.stopped = 0 @@ -65,7 +74,7 @@ def plan(self, path: str, profile: Any = None) -> str: assert path == "agent" if profile is not None: assert profile == "hermes_sdk" - return json.dumps(_plan()) + return json.dumps(_plan(runtime_mode=self.runtime_mode)) def plan_config( self, @@ -74,7 +83,7 @@ def plan_config( base_dir: str | None = None, ) -> str: assert json.loads(config_json)["metadata"]["name"] == "demo" - return json.dumps(_plan()) + return json.dumps(_plan(runtime_mode=self.runtime_mode)) def start_runtime(self, plan_json: str) -> str: assert json.loads(plan_json)["agent_name"] == "demo" @@ -154,6 +163,15 @@ def _session(native: FakeNative | None = None, overrides: dict | None = None) -> ) +def test_session_constructor_rejects_non_session_runtime_mode(): + with pytest.raises(RuntimeError, match="requires runtime.mode=session"): + Session( + client=NativeClient(FakeNative()), + plan=_plan(runtime_mode="oneshot"), + runtime=_runtime(), + ) + + async def test_start_creates_session_from_core_runtime_handle() -> None: native = FakeNative() session = await NativeClient(native).start("agent", profile="hermes_sdk") @@ -167,6 +185,41 @@ async def test_start_creates_session_from_core_runtime_handle() -> None: assert not hasattr(session, "id") +async def test_start_accepts_caller_session_id_and_propagates_to_turn_context(): + native = FakeNative() + session = await NativeClient(native).start( + "agent", + profile="hermes_sdk", + session_id="caller-session-123", + ) + + result = await session.invoke("hello session") + + assert session.session_id == "caller-session-123" + assert result["runtime_id"] == "runtime-1" + assert native.requests[0]["context"]["session_id"] == "caller-session-123" + + +async def test_start_rejects_non_session_runtime_mode(): + native = FakeNative(runtime_mode="oneshot") + + with pytest.raises(RuntimeError, match="requires runtime.mode=session"): + await NativeClient(native).start("agent", profile="hermes_sdk") + + assert native.stopped == 0 + assert native.requests == [] + + +async def test_session_id_defaults_to_runtime_id_for_adapter_context(): + native = FakeNative() + session = _session(native) + + await session.invoke("hello default session") + + assert session.session_id == "runtime-1" + assert native.requests[0]["context"]["session_id"] == "runtime-1" + + async def test_invoke_uses_stable_runtime_and_does_not_replay_history() -> None: native = FakeNative() session = _session(native) @@ -537,3 +590,28 @@ async def test_start_config_creates_session_from_core_runtime_handle() -> None: assert session.runtime_id == "runtime-1" assert result["runtime_id"] == "runtime-1" assert native.requests[0]["input"] == "hello typed session" + + +async def test_start_config_accepts_caller_session_id(): + native = FakeNative() + config = {"schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "demo"}} + + session = await NativeClient(native).start_config( + config, + session_id="typed-session-123", + ) + await session.invoke("hello typed session") + + assert session.session_id == "typed-session-123" + assert native.requests[0]["context"]["session_id"] == "typed-session-123" + + +async def test_start_config_rejects_non_session_runtime_mode(): + native = FakeNative(runtime_mode="oneshot") + config = {"schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "demo"}} + + with pytest.raises(RuntimeError, match="requires runtime.mode=session"): + await NativeClient(native).start_config(config) + + assert native.stopped == 0 + assert native.requests == [] From 07fbdff7316541cd3cc5107ee8d301a0cf323d60 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 25 Jun 2026 17:53:23 -0700 Subject: [PATCH 2/5] Document Fabric chat sessions Signed-off-by: Ajay Thorve --- README.md | 21 +++++++++++++++------ python/tests/smoke_readme_examples.py | 3 +++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8f463f987..632c163b4 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,9 @@ plan = client.plan_config( ) ``` -For multi-turn sessions, open a `Session` and invoke it repeatedly. The session +### 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. Pass `session_id` when the caller already owns the harness conversation id; @@ -202,8 +204,12 @@ 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. Session APIs require -`runtime.mode: session`. For local manual testing, the CLI can drive the same -started runtime in an interactive loop: +`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 \ @@ -212,14 +218,17 @@ fabric chat examples/code-review-agent \ --verbose ``` +`--session-id` is optional. Pass it when you want to resume or share a known +harness conversation id; otherwise Fabric uses the generated runtime id. `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. Because `chat` -is an interactive terminal UI, the transcript and metadata are written together -on stderr; use `fabric run` for machine-readable stdout. +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`. diff --git a/python/tests/smoke_readme_examples.py b/python/tests/smoke_readme_examples.py index 88d6c79d2..8b1b522ff 100644 --- a/python/tests/smoke_readme_examples.py +++ b/python/tests/smoke_readme_examples.py @@ -33,11 +33,14 @@ "plan = client.plan_config(", '"harness": {"adapter_id": "nvidia.fabric.hermes.sdk"},', 'base_dir="examples/code-review-agent",', + "### Multi-Turn SDK Sessions", + "### Interactive CLI Chat", 'session_id="review-session-123",', "fabric chat examples/code-review-agent \\", "--profile hermes_cli_session", "--session-id review-session-123", "--verbose", + "requires `runtime.mode: session`; use `fabric run`", 'client = FabricClient(command=("cargo", "run", "-q", "-p", "fabric-cli", "--"))', ] From 7f1e6f2d244b15955a3c2edc3e986e7ed89100d0 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 26 Jun 2026 09:27:58 -0700 Subject: [PATCH 3/5] Clarify Fabric session mapping Signed-off-by: Ajay Thorve --- .../src/nemo_fabric_adapters/common/hermes.py | 25 ++++++++++++------- .../hermes_cli/adapter.py | 20 +++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/adapters/common/src/nemo_fabric_adapters/common/hermes.py b/adapters/common/src/nemo_fabric_adapters/common/hermes.py index 18831af96..e0d17c234 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/hermes.py +++ b/adapters/common/src/nemo_fabric_adapters/common/hermes.py @@ -32,6 +32,8 @@ def runtime_context(payload: dict[str, Any]) -> dict[str, Any]: def runtime_session_id(payload: dict[str, Any]) -> str | None: + """Return Fabric's session key for adapter-owned harness session mapping.""" + context = runtime_context(payload) session_id = context.get("session_id") if session_id: @@ -344,36 +346,41 @@ def collect_relay_artifacts(plugin_config: dict[str, Any]) -> list[dict[str, str return artifacts def ensure_hermes_session( - harness_session_id: str, + 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 harness session 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 harness session 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 - harness session 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 harness session 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(harness_session_id) + session = session_db.get_session_by_title(fabric_session_id) if session is None: session_db.ensure_session( - harness_session_id, + fabric_session_id, source="fabric", model=model_name, model_config=model_config, ) - session_db.set_session_title(session_id=harness_session_id, title=harness_session_id) - session = session_db.get_session_by_title(harness_session_id) + 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 diff --git a/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py b/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py index 2fc313ff9..9a002faaa 100644 --- a/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py +++ b/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py @@ -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" - harness_session_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) @@ -87,13 +87,13 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]: ) if use_session: - if harness_session_id is None: + if fabric_session_id is None: raise RuntimeError( "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( - harness_session_id, + fabric_session_id, model_name, model_config, hermes_home, @@ -110,7 +110,7 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]: prompt, toolsets=toolsets, use_session=use_session, - harness_session_id=harness_session_id, + fabric_session_id=fabric_session_id, ) cwd = resolve_path( config_root, @@ -150,7 +150,7 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]: "model": model_name, "returncode": return_code, "response": response, - "session_id": harness_session_id, + "session_id": fabric_session_id, "stdout": completed.stdout, "stderr": completed.stderr, "failed": return_code != 0, @@ -179,7 +179,7 @@ def build_command( prompt: str, toolsets: list[str] | None = None, use_session: bool = False, - harness_session_id: str | None = None, + fabric_session_id: str | None = None, ) -> list[str]: command = resolve_command( config_root, @@ -190,11 +190,11 @@ def build_command( args = [command, *command_args] if use_session: - if not harness_session_id: + if not fabric_session_id: raise RuntimeError("session mode requires a session_id or runtime_id") - # 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", harness_session_id, "--query", prompt]) + # 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]) From bb25d71662daafb96423c7f20441e7f2c3720895 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 26 Jun 2026 09:52:07 -0700 Subject: [PATCH 4/5] Clarify Fabric session ids Signed-off-by: Ajay Thorve --- README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 632c163b4..b556b5866 100644 --- a/README.md +++ b/README.md @@ -176,11 +176,15 @@ plan = client.plan_config( ### 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. -Pass `session_id` when the caller already owns the harness conversation id; -otherwise Fabric uses the generated runtime id: +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 @@ -218,8 +222,10 @@ fabric chat examples/code-review-agent \ --verbose ``` -`--session-id` is optional. Pass it when you want to resume or share a known -harness conversation id; otherwise Fabric uses the generated runtime id. +`--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. From 8fbd82982fcd6acc102a54e4dcb2c8de30062d25 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 26 Jun 2026 09:58:48 -0700 Subject: [PATCH 5/5] Update MVP plan for Fabric sessions Signed-off-by: Ajay Thorve --- POC-TO-MVP-PLAN.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/POC-TO-MVP-PLAN.md b/POC-TO-MVP-PLAN.md index ca77e5b7e..0540f640e 100644 --- a/POC-TO-MVP-PLAN.md +++ b/POC-TO-MVP-PLAN.md @@ -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. @@ -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: @@ -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. @@ -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. @@ -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.