diff --git a/.github/workflows/integration-smoke.yml b/.github/workflows/integration-smoke.yml index c54ce1a..345a2a4 100644 --- a/.github/workflows/integration-smoke.yml +++ b/.github/workflows/integration-smoke.yml @@ -3,7 +3,7 @@ name: integration-smoke -# End-to-end check that x-dynamo-trajectory-id emitted by this package +# End-to-end check that x-dynamo-session-id emitted by this package # round-trips through Dynamo's actual frontend + mocker into the request trace # sink. Builds Dynamo from ai-dynamo/dynamo@main on every run — published # wheels lag behind features (e.g. the agent_trace sink), so we need source @@ -66,6 +66,9 @@ jobs: with: python-version: "3.11" + - name: Test Hermes plugin + run: python3 -m unittest discover -s hermes-plugin/tests + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@master with: diff --git a/CLAUDE.md b/CLAUDE.md index c8e5403..1faf22c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,12 @@ SPDX-License-Identifier: Apache-2.0 Repo layout: - `pi-plugin/` — Pi extension registering a `dynamo` provider for Dynamo's OpenAI-compatible chat-completions endpoint. -- `hermes-plugin/` — Hermes middleware plugin that injects Dynamo trajectory headers from Hermes `session_id`. +- `hermes-plugin/` — Hermes middleware plugin that injects Dynamo session headers from Hermes `session_id`. The Pi plugin has three source files under `pi-plugin/src/`: - `index.ts` — thin re-export of the light implementation. -- `src/light/provider.ts` — config + streamSimple wrapper. Reads `DYN_REQUEST_TRACE`, `DYN_AGENT_*`, and `PI_SUBAGENT_*` env vars. When tracing is enabled, stamps `x-dynamo-trajectory-id` / parent headers and leaves Pi `sessionId` untouched. +- `src/light/provider.ts` — config + streamSimple wrapper. Reads `DYN_REQUEST_TRACE`, `DYN_AGENT_*`, and `PI_SUBAGENT_*` env vars. When tracing is enabled, stamps `x-dynamo-session-id` / parent headers and leaves Pi `sessionId` untouched. - `src/light/tool-relay.ts` — ZMQ PUSH publisher for Pi tool events. Connects to a Dynamo-bound PULL endpoint. Wire format: `[topic, seq_be_u64, msgpack(RequestTraceRecord)]`. ## Build, test, check @@ -28,7 +28,7 @@ npm run build # tsc -p tsconfig.build.json → dist/ Pi tests live in `pi-plugin/test/` as siblings of `pi-plugin/src/`. Use vitest's `describe`/`it`/`expect`. Mirror the existing structure: one test file per source file, fixture data inline rather than separate fixture files. -`pi-plugin/test/integration/smoke.mjs` is the out-of-band end-to-end check — driven by `pi-plugin/scripts/integration-smoke.sh`, not vitest. It boots Dynamo's frontend + mocker, sends one real chat completion, and asserts `x-dynamo-trajectory-id` becomes `trajectory_id` in the request trace JSONL. Two cases: top-level trajectory id and the pi-subagents bridge. Mocker output is garbage; assertions only target the trace envelope. CI clones `ai-dynamo/dynamo@main` and builds from source. Cargo cache keeps warm runs ~60-90s, cold ~10 min. `workflow_dispatch` accepts a `dynamo_ref` input for ad-hoc validation against a specific branch, tag, or SHA. +`pi-plugin/test/integration/smoke.mjs` is the out-of-band end-to-end check — driven by `pi-plugin/scripts/integration-smoke.sh`, not vitest. It boots Dynamo's frontend + mocker, sends one real chat completion, and asserts `x-dynamo-session-id` becomes `session_id` in the request trace JSONL. Two cases: top-level session id and the pi-subagents bridge. Mocker output is garbage; assertions only target the trace envelope. CI clones `ai-dynamo/dynamo@main` and builds from source. Cargo cache keeps warm runs ~60-90s, cold ~10 min. `workflow_dispatch` accepts a `dynamo_ref` input for ad-hoc validation against a specific branch, tag, or SHA. For real Pi CLI lifecycle validation against a Dynamo endpoint, read `pi-plugin/skills/pi-headless-dynamo/SKILL.md` first and drive the actual interactive Pi TUI instead of faking provider requests or pi-subagents env. @@ -61,7 +61,7 @@ python3 -m unittest discover -s hermes-plugin/tests | Prefix | Direction | Examples | |---|---|---| | `DYNAMO_*` | client config (we read) | `DYNAMO_BASE_URL`, `DYNAMO_API_KEY` | -| `DYN_AGENT_*` | optional trajectory override / subagent parent link | `DYN_AGENT_TRAJECTORY_ID`, `DYN_AGENT_PARENT_TRAJECTORY_ID` | +| `DYN_AGENT_*` | optional session override / subagent parent link | `DYN_AGENT_SESSION_ID`, `DYN_AGENT_PARENT_SESSION_ID` | | `DYN_REQUEST_TRACE*` | request trace switch and tool bridge | `DYN_REQUEST_TRACE`, `DYN_REQUEST_TRACE_TOOL_EVENTS_ZMQ_ENDPOINT` | | `PI_SUBAGENT_*` | pi-subagents bookkeeping (we read only) | `PI_SUBAGENT_CHILD`, `PI_SUBAGENT_RUN_ID`, `PI_SUBAGENT_CHILD_AGENT`, `PI_SUBAGENT_CHILD_INDEX` | | `OPENAI_BASE_URL` | OpenAI-compatibility fallback (we read) | only consulted when `DYNAMO_BASE_URL` is unset | @@ -83,6 +83,6 @@ External contributions are not currently accepted. This is an NVIDIA-internal co ## What to leave alone -- Dynamo owns the request trace schema. The Pi provider stamps trajectory headers for LLM requests and keeps explicit tool calls on the ZMQ trace path. The Hermes plugin only stamps request headers. +- Dynamo owns the request trace schema. The Pi provider stamps session headers for LLM requests and keeps explicit tool calls on the ZMQ trace path. The Hermes plugin only stamps request headers. - The `request.trace.v1` schema is owned upstream by Dynamo (`dynamo/lib/llm/src/request_trace/`). Don't change record shapes here without an upstream PR landing first. - `pi-plugin/package-lock.json` churn from npm version differences should be reverted before committing (`git checkout -- pi-plugin/package-lock.json` if a no-op edit appears). diff --git a/README.md b/README.md index 0dd3877..5a28730 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,6 @@ Small agent integrations for Dynamo request tracing. ## Layout - `pi-plugin/` - Pi provider plugin for Dynamo's OpenAI-compatible endpoint. -- `hermes-plugin/` - Hermes middleware plugin that maps Hermes `session_id` to `x-dynamo-trajectory-id`. +- `hermes-plugin/` - Hermes middleware plugin that maps Hermes `session_id` to `x-dynamo-session-id`. Each plugin owns its own tests and install instructions. diff --git a/hermes-plugin/README.md b/hermes-plugin/README.md index b0ed00c..c3f61a1 100644 --- a/hermes-plugin/README.md +++ b/hermes-plugin/README.md @@ -1,14 +1,14 @@ -# Hermes Dynamo Trajectory Plugin +# Hermes Dynamo Session Plugin -Hermes plugin that copies the current Hermes `session_id` into Dynamo's `x-dynamo-trajectory-id` request header. +Hermes plugin that copies the current Hermes `session_id` into Dynamo's `x-dynamo-session-id` request header. ## Install ```bash git clone https://github.com/ai-dynamo/agent-plugins.git ~/agent-plugins mkdir -p ~/.hermes/plugins -ln -sfnT ~/agent-plugins/hermes-plugin ~/.hermes/plugins/dynamo_trajectory -hermes plugins enable dynamo_trajectory +ln -sfnT ~/agent-plugins/hermes-plugin ~/.hermes/plugins/dynamo_session +hermes plugins enable dynamo_session ``` ## Validate diff --git a/hermes-plugin/__init__.py b/hermes-plugin/__init__.py index 0d79d8c..b71a773 100644 --- a/hermes-plugin/__init__.py +++ b/hermes-plugin/__init__.py @@ -1,10 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Inject Hermes session IDs as Dynamo trajectory headers.""" +"""Inject Hermes session IDs as Dynamo session headers.""" -HEADER = "x-dynamo-trajectory-id" -_PATCHED_ATTR = "_dynamo_trajectory_headers_patched" +HEADER = "x-dynamo-session-id" +_PATCHED_ATTR = "_dynamo_session_headers_patched" def register(ctx) -> None: diff --git a/hermes-plugin/plugin.yaml b/hermes-plugin/plugin.yaml index 306362f..e6c6f81 100644 --- a/hermes-plugin/plugin.yaml +++ b/hermes-plugin/plugin.yaml @@ -1,4 +1,4 @@ -name: dynamo_trajectory +name: dynamo_session version: "0.1.0" -description: "Optional Dynamo trajectory header injection for Hermes." +description: "Optional Dynamo session header injection for Hermes." author: NVIDIA diff --git a/hermes-plugin/tests/test_dynamo_trajectory_plugin.py b/hermes-plugin/tests/test_dynamo_session_plugin.py similarity index 88% rename from hermes-plugin/tests/test_dynamo_trajectory_plugin.py rename to hermes-plugin/tests/test_dynamo_session_plugin.py index dae969c..14d2254 100644 --- a/hermes-plugin/tests/test_dynamo_trajectory_plugin.py +++ b/hermes-plugin/tests/test_dynamo_session_plugin.py @@ -12,14 +12,14 @@ def load_plugin(): - spec = importlib.util.spec_from_file_location("dynamo_trajectory_plugin", PLUGIN_PATH) + spec = importlib.util.spec_from_file_location("dynamo_session_plugin", PLUGIN_PATH) module = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(module) return module -class DynamoTrajectoryPluginTest(unittest.TestCase): +class DynamoSessionPluginTest(unittest.TestCase): def test_pre_api_request_patches_openai_client_creation(self): plugin = load_plugin() calls = [] @@ -52,7 +52,7 @@ def _create_openai_client(self, client_kwargs, *, reason, shared): self.assertEqual(calls[0][0], "pre_api_request") self.assertEqual(result["default_headers"]["x-test"], "1") self.assertEqual( - result["default_headers"]["x-dynamo-trajectory-id"], + result["default_headers"]["x-dynamo-session-id"], "hermes-session", ) diff --git a/pi-plugin/README.md b/pi-plugin/README.md index c43d5ac..b720491 100644 --- a/pi-plugin/README.md +++ b/pi-plugin/README.md @@ -6,16 +6,17 @@ A Pi extension that registers a `dynamo` provider backed by [Dynamo](https://git pi --model dynamo/ ``` -With one switch (`DYN_REQUEST_TRACE=1`) it also stamps Dynamo trajectory headers, gives each pi-subagent its own trajectory id, and can relay Pi tool events into the trace — all without patching `pi-mono`. +With one switch (`DYN_REQUEST_TRACE=1`) it also stamps Dynamo session headers, gives each pi-subagent its own session id, and can relay Pi tool events into the trace — all without patching `pi-mono`. ## What it does - **Model provider** — registers `dynamo`, discovers models from `/v1/models` (falls back to `dynamo/default`), and streams via Pi's OpenAI-compatible path. -- **Trajectory headers** — adds `x-dynamo-trajectory-id` and optional parent headers so Dynamo can attribute each LLM request as a trajectory in its trace. -- **Subagent trajectory ids** — gives each [pi-subagents](https://github.com/nicobailon/pi-subagents) child its own trajectory id. See [Subagent trajectory ids](#subagent-trajectory-ids). +- **Session headers** — adds `x-dynamo-session-id` and optional parent headers so Dynamo can attribute each LLM request as a session in its trace. +- **Subagent session ids** — gives each [pi-subagents](https://github.com/nicobailon/pi-subagents) child its own session id. See [Subagent session ids](#subagent-session-ids). - **Tool-event relay** — optionally pushes Pi `tool_start` / `tool_end` / `tool_error` events to Dynamo over ZMQ so one trace shows LLM spans and tool spans together. Everything but the bare model provider is gated by the `DYN_REQUEST_TRACE` master switch and is off by default. +Session headers carry identity only; they do not activate sticky or session-aware routing. ## Install @@ -35,31 +36,31 @@ Point Pi at a running Dynamo endpoint: ```bash export DYNAMO_BASE_URL=http://127.0.0.1:8000/v1 export DYNAMO_API_KEY=dummy # local Dynamo usually ignores this; defaults to dynamo-local -export DYN_REQUEST_TRACE=1 # opt into trajectory tracing + optional tool relay +export DYN_REQUEST_TRACE=1 # opt into session tracing + optional tool relay pi --model dynamo/ -p "Reply exactly ok." ``` That's the whole required setup. Everything else is only set when you want to override it — see [Configuration](#configuration). -## Subagent trajectory ids +## Subagent session ids -When `DYN_REQUEST_TRACE=1`, the provider preserves Pi's normal `sessionId` and adds explicit Dynamo trajectory headers. +When `DYN_REQUEST_TRACE=1`, the provider preserves Pi's normal `sessionId` and adds explicit Dynamo session headers. ```mermaid sequenceDiagram participant Root as Root pi process participant Child as Subagent pi process participant Dynamo - Root->>Dynamo: x-dynamo-trajectory-id = S_root - Child->>Dynamo: x-dynamo-trajectory-id = T_child, parent = S_root + Root->>Dynamo: x-dynamo-session-id = S_root + Child->>Dynamo: x-dynamo-session-id = S_child, parent = S_root ``` -- The root `trajectory_id` is Pi's own `sessionId`. -- The child `trajectory_id` is the subagent's own identity (`PI_SUBAGENT_RUN_ID:PI_SUBAGENT_CHILD_AGENT:PI_SUBAGENT_CHILD_INDEX`), so it needs no extra operator setup. -- The provider sends those values as `x-dynamo-trajectory-id` and `x-dynamo-parent-trajectory-id`. +- The root `session_id` is Pi's own `sessionId`. +- The child `session_id` is the subagent's own identity (`PI_SUBAGENT_RUN_ID:PI_SUBAGENT_CHILD_AGENT:PI_SUBAGENT_CHILD_INDEX`), so it needs no extra operator setup. +- The provider sends those values as `x-dynamo-session-id` and `x-dynamo-parent-session-id`. -> ZMQ tool records can include parent/child **trajectory ids** when `DYN_AGENT_TRAJECTORY_ID` is set on the root. See [Trajectory linking](#trajectory-linking). +> ZMQ tool records can include parent/child **session ids** when `DYN_AGENT_SESSION_ID` is set on the root. See [Session linking](#session-linking). ## Configuration @@ -69,14 +70,14 @@ The only thing you must set is the connection (`DYNAMO_BASE_URL`) and, to enable | --- | --- | --- | | `DYNAMO_BASE_URL` | `http://127.0.0.1:8000/v1` | Dynamo endpoint root (falls back to `OPENAI_BASE_URL`). | | `DYNAMO_API_KEY` | `dynamo-local` | Bearer token. | -| `DYN_REQUEST_TRACE` | off | **Master switch.** When truthy (`1`/`true`/`yes`/`on`), enables Dynamo trajectory headers and the tool relay. | -| `DYN_AGENT_TRAJECTORY_ID` | unset | Optional parent trajectory seed for [trajectory linking](#trajectory-linking) in subagents. | -| `DYN_AGENT_PARENT_TRAJECTORY_ID` | unset | Parent trajectory; set manually to override the bridge. | +| `DYN_REQUEST_TRACE` | off | **Master switch.** When truthy (`1`/`true`/`yes`/`on`), enables Dynamo session headers and the tool relay. | +| `DYN_AGENT_SESSION_ID` | unset | Optional parent session seed for [session linking](#session-linking) in subagents. | +| `DYN_AGENT_PARENT_SESSION_ID` | unset | Parent session; set manually to override the bridge. | | `DYN_REQUEST_TRACE_TOOL_EVENTS_ZMQ_ENDPOINT` | unset | Dynamo-bound ZMQ PULL endpoint for the tool relay. | -`PI_SUBAGENT_CHILD` / `PI_SUBAGENT_RUN_ID` / `PI_SUBAGENT_CHILD_AGENT` / `PI_SUBAGENT_CHILD_INDEX` are **read, never set** — pi-subagents populates them and the provider uses them to derive the child `trajectory_id` and parent link. +`PI_SUBAGENT_CHILD` / `PI_SUBAGENT_RUN_ID` / `PI_SUBAGENT_CHILD_AGENT` / `PI_SUBAGENT_CHILD_INDEX` are **read, never set** — pi-subagents populates them and the provider uses them to derive the child `session_id` and parent link. -With `DYN_REQUEST_TRACE` on, the provider does not mutate request payloads. It adds Dynamo trajectory headers and `x-request-id` when absent. +With `DYN_REQUEST_TRACE` on, the provider does not mutate request payloads. It adds Dynamo session headers and `x-request-id` when absent.
Tool-event wire format @@ -90,9 +91,9 @@ When a tool-event endpoint is set, Pi connects a ZMQ PUSH socket and sends one m The record uses Dynamo's `dynamo.request.trace.v1` schema (`event_type`, `event_source`, `agent_context`, and a `tool` object with timing/status). Dynamo owns the PULL bind side, so multiple Pi processes and subagents can all connect as producers. Terminal `tool_end` / `tool_error` records are self-contained.
-## Trajectory linking +## Session linking -The provider keeps parent and child trajectory ids distinct for ZMQ tool records. When a pi-subagents child inherits the parent's `DYN_AGENT_TRAJECTORY_ID`, the provider reinterprets it as the child's `parent_trajectory_id` and synthesizes a fresh child `trajectory_id` (`runId:childAgent:childIndex`), mutating `process.env` so nested chains stay attributable. Setting `DYN_AGENT_PARENT_TRAJECTORY_ID` manually overrides the parent link. If you don't set `DYN_AGENT_TRAJECTORY_ID` at all, every subagent still gets its own child trajectory id — only the explicit parent-to-child link is absent. +The provider keeps parent and child session ids distinct for ZMQ tool records. When a pi-subagents child inherits the parent's `DYN_AGENT_SESSION_ID`, the provider reinterprets it as the child's `parent_session_id` and synthesizes a fresh child `session_id` (`runId:childAgent:childIndex`), mutating `process.env` so nested chains stay attributable. Setting `DYN_AGENT_PARENT_SESSION_ID` manually overrides the parent link. If you don't set `DYN_AGENT_SESSION_ID` at all, every subagent still gets its own child session id — only the explicit parent-to-child link is absent. ## Local Dynamo @@ -122,13 +123,13 @@ npm run test # vitest npm run build # -> dist/ ``` -`scripts/integration-smoke.sh` boots Dynamo's frontend + mocker and asserts `x-dynamo-trajectory-id` becomes `trajectory_id` in the trace; it is the out-of-band end-to-end check. +`scripts/integration-smoke.sh` boots Dynamo's frontend + mocker and asserts `x-dynamo-session-id` becomes `session_id` in the trace; it is the out-of-band end-to-end check. ## Troubleshooting - **`/v1/models` empty** — wait for the backend to load; confirm frontend and worker share the same discovery/request/event planes and `DYN_FILE_KV`. - **Model unknown** — `curl "$DYNAMO_BASE_URL/models"` and use the returned id as `dynamo/`; restart Pi if discovery failed before Dynamo was ready. -- **No agent_context in trace rows** — make sure `DYN_REQUEST_TRACE` is set and Dynamo is new enough to map `x-dynamo-trajectory-id`. +- **No agent_context in trace rows** — make sure `DYN_REQUEST_TRACE` is set and Dynamo is new enough to map `x-dynamo-session-id`. - **Tool spans missing** — set a tool-event endpoint on both sides and confirm the run actually used tools. ## Scope diff --git a/pi-plugin/package.json b/pi-plugin/package.json index d48d095..f15a4ca 100644 --- a/pi-plugin/package.json +++ b/pi-plugin/package.json @@ -1,7 +1,7 @@ { "name": "pi-dynamo-provider", "version": "0.1.0", - "description": "Pi extension package that registers a Dynamo OpenAI-compatible provider with trajectory headers.", + "description": "Pi extension package that registers a Dynamo OpenAI-compatible provider with session headers.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/pi-plugin/scripts/integration-smoke.sh b/pi-plugin/scripts/integration-smoke.sh index ed50f29..1582c59 100755 --- a/pi-plugin/scripts/integration-smoke.sh +++ b/pi-plugin/scripts/integration-smoke.sh @@ -3,8 +3,8 @@ # SPDX-License-Identifier: Apache-2.0 # # Start a Dynamo frontend + mocker worker against a known-good Dynamo version, -# then run test/integration/smoke.mjs which asserts that x-dynamo-trajectory-id -# becomes trajectory identity in the trace sink. Tears down processes on exit. +# then run test/integration/smoke.mjs which asserts that x-dynamo-session-id +# becomes session identity in the trace sink. Tears down processes on exit. # # Required env: # DYNAMO_TEST_MODEL_ID HuggingFace model id for the mocker tokenizer diff --git a/pi-plugin/skills/pi-headless-dynamo/SKILL.md b/pi-plugin/skills/pi-headless-dynamo/SKILL.md index 990d691..4c7f86b 100644 --- a/pi-plugin/skills/pi-headless-dynamo/SKILL.md +++ b/pi-plugin/skills/pi-headless-dynamo/SKILL.md @@ -1,6 +1,6 @@ --- name: pi-headless-dynamo -description: Drive the real Pi CLI headlessly against a Dynamo or OpenAI-compatible endpoint for pi-dynamo-provider validation. Use when testing Pi provider installs, trajectory header tracing, Pi subagent runs, saved traces, or parent/child trajectory behavior without manually faking Pi or pi-subagents internals. +description: Drive the real Pi CLI headlessly against a Dynamo or OpenAI-compatible endpoint for pi-dynamo-provider validation. Use when testing Pi provider installs, session header tracing, Pi subagent runs, saved traces, or parent/child session behavior without manually faking Pi or pi-subagents internals. --- # Pi Headless Dynamo @@ -29,7 +29,7 @@ Before launching Pi, verify the endpoint and model: curl -sf http://127.0.0.1:18083/v1/models ``` -For trajectory-native release evidence, the endpoint must use Dynamo +For session-native release evidence, the endpoint must use Dynamo `--router-mode kv` and an SGLang worker with `--enable-session-radix-cache`. The local launcher prints the exact Pi environment and trace path; prefer that block over hand-rolled env. @@ -65,7 +65,7 @@ Control that process through its PTY like a user: Do not kill Pi to end a lifecycle run unless it is hung and the failure is the thing being tested. -When `DYN_REQUEST_TRACE=1`, the provider stamps `x-dynamo-trajectory-id` on LLM requests. Normal root turns use Pi's own session id as the trajectory id; pi-subagents children derive their id from `PI_SUBAGENT_*`. +When `DYN_REQUEST_TRACE=1`, the provider stamps `x-dynamo-session-id` on LLM requests. Normal root turns use Pi's own session id as the session id; pi-subagents children derive their id from `PI_SUBAGENT_*`. ## Drive A Lifecycle Run @@ -164,11 +164,11 @@ nvidia-smi --query-gpu=index,name,memory.used,memory.total --format=csv,noheader The lifecycle ordering to prove: -1. Child LLM requests carry child trajectory ids. -2. Parent-only turns still carry the parent trajectory id. +1. Child LLM requests carry child session ids. +2. Parent-only turns still carry the parent session id. 3. The server is stopped and GPUs return to baseline. -With Dynamo request-trace unification (#10701 and later), trajectory identity +With Dynamo request-trace unification (#10701 and later), session identity lives on the same `dynamo.request.trace.v1` rows as request metrics. If trace rows are present but `agent_context_rows` is zero, check that Pi had `DYN_REQUEST_TRACE=1` and that the provider package was installed from this repo. diff --git a/pi-plugin/src/light/index.ts b/pi-plugin/src/light/index.ts index 2a9b5ea..14f282f 100644 --- a/pi-plugin/src/light/index.ts +++ b/pi-plugin/src/light/index.ts @@ -11,10 +11,10 @@ import { readDynamoConfig, } from "./provider.js"; import { registerDynamoToolEventRelay } from "./tool-relay.js"; -import { applySubagentTrajectoryBridge } from "./trajectory.js"; +import { applySubagentSessionBridge } from "./session.js"; export default async function dynamoProviderExtension(pi: ExtensionAPI): Promise { - applySubagentTrajectoryBridge(); + applySubagentSessionBridge(); const config = readDynamoConfig(); const discoveredModels = await discoverDynamoModels(config); const models = @@ -25,4 +25,4 @@ export default async function dynamoProviderExtension(pi: ExtensionAPI): Promise export * from "./provider.js"; export * from "./tool-relay.js"; -export * from "./trajectory.js"; +export * from "./session.js"; diff --git a/pi-plugin/src/light/provider.ts b/pi-plugin/src/light/provider.ts index 7349a27..2e60812 100644 --- a/pi-plugin/src/light/provider.ts +++ b/pi-plugin/src/light/provider.ts @@ -15,9 +15,9 @@ import type { ProviderConfig, ProviderModelConfig } from "@mariozechner/pi-codin import { envValue, isTruthyEnv, - resolveTrajectoryContext, - type DynamoTrajectoryEnvironment, -} from "./trajectory.js"; + resolveSessionContext, + type DynamoSessionEnvironment, +} from "./session.js"; export const DYNAMO_PROVIDER_ID = "dynamo"; export const DYNAMO_API = "dynamo-openai-completions" satisfies Api; @@ -25,7 +25,7 @@ export const DEFAULT_DYNAMO_BASE_URL = "http://127.0.0.1:8000/v1"; export const DEFAULT_DYNAMO_API_KEY = "dynamo-local"; export const DEFAULT_DYNAMO_MODEL_ID = "default"; -export interface DynamoEnvironment extends DynamoTrajectoryEnvironment { +export interface DynamoEnvironment extends DynamoSessionEnvironment { DYNAMO_BASE_URL?: string; OPENAI_BASE_URL?: string; DYNAMO_API_KEY?: string; @@ -35,8 +35,8 @@ export interface DynamoConfig { baseUrl: string; apiKey: string; traceEnabled: boolean; - trajectoryId?: string; - parentTrajectoryId?: string; + sessionId?: string; + parentSessionId?: string; } interface OpenAIModelsResponse { @@ -64,13 +64,13 @@ export function normalizeDynamoBaseUrl(rawBaseUrl: string | undefined): string { } export function readDynamoConfig(env: DynamoEnvironment = process.env): DynamoConfig { - const trajectory = resolveTrajectoryContext(env); + const session = resolveSessionContext(env); return { baseUrl: normalizeDynamoBaseUrl(envValue(env, "DYNAMO_BASE_URL") ?? envValue(env, "OPENAI_BASE_URL")), apiKey: envValue(env, "DYNAMO_API_KEY") ?? DEFAULT_DYNAMO_API_KEY, traceEnabled: isTruthyEnv(envValue(env, "DYN_REQUEST_TRACE")), - ...(trajectory.trajectoryId ? { trajectoryId: trajectory.trajectoryId } : {}), - ...(trajectory.parentTrajectoryId ? { parentTrajectoryId: trajectory.parentTrajectoryId } : {}), + ...(session.sessionId ? { sessionId: session.sessionId } : {}), + ...(session.parentSessionId ? { parentSessionId: session.parentSessionId } : {}), }; } @@ -81,7 +81,7 @@ function hasHeader(headers: Record, target: string): boolean { export function buildDynamoHeaders( headers: Record | undefined, - config: Pick, + config: Pick, runtimeSessionId: string | undefined, createRequestId: () => string = randomUUID, ): Record { @@ -89,12 +89,12 @@ export function buildDynamoHeaders( if (!hasHeader(nextHeaders, "x-request-id")) nextHeaders["x-request-id"] = createRequestId(); if (!config.traceEnabled) return nextHeaders; - const trajectoryId = config.trajectoryId ?? runtimeSessionId; - if (trajectoryId && !hasHeader(nextHeaders, "x-dynamo-trajectory-id")) { - nextHeaders["x-dynamo-trajectory-id"] = trajectoryId; + const sessionId = config.sessionId ?? runtimeSessionId; + if (sessionId && !hasHeader(nextHeaders, "x-dynamo-session-id")) { + nextHeaders["x-dynamo-session-id"] = sessionId; } - if (config.parentTrajectoryId && !hasHeader(nextHeaders, "x-dynamo-parent-trajectory-id")) { - nextHeaders["x-dynamo-parent-trajectory-id"] = config.parentTrajectoryId; + if (config.parentSessionId && !hasHeader(nextHeaders, "x-dynamo-parent-session-id")) { + nextHeaders["x-dynamo-parent-session-id"] = config.parentSessionId; } return nextHeaders; } @@ -107,7 +107,6 @@ const dynamoOpenAICompat = { maxTokensField: "max_tokens", supportsStrictMode: false, supportsLongCacheRetention: false, - sendSessionAffinityHeaders: true, } satisfies OpenAICompletionsCompat; export function createDynamoModels(modelIds: string[], baseUrl: string): ProviderModelConfig[] { diff --git a/pi-plugin/src/light/session.ts b/pi-plugin/src/light/session.ts new file mode 100644 index 0000000..f491ca9 --- /dev/null +++ b/pi-plugin/src/light/session.ts @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface DynamoSessionEnvironment { + DYN_REQUEST_TRACE?: string; + DYN_AGENT_SESSION_ID?: string; + DYN_AGENT_PARENT_SESSION_ID?: string; + PI_SUBAGENT_CHILD?: string; + PI_SUBAGENT_RUN_ID?: string; + PI_SUBAGENT_CHILD_AGENT?: string; + PI_SUBAGENT_CHILD_INDEX?: string; +} + +export interface DynamoSessionContext { + sessionId?: string; + parentSessionId?: string; +} + +export function envValue(env: T, key: K): string | undefined { + const value = env[key]; + const trimmed = typeof value === "string" ? value.trim() : undefined; + return trimmed ? trimmed : undefined; +} + +export function isTruthyEnv(value: string | undefined): boolean { + return value ? ["1", "true", "yes", "on"].includes(value.toLowerCase()) : false; +} + +export function subagentSessionId(env: DynamoSessionEnvironment): string | undefined { + if (envValue(env, "PI_SUBAGENT_CHILD") !== "1") return undefined; + const runId = envValue(env, "PI_SUBAGENT_RUN_ID"); + const childAgent = envValue(env, "PI_SUBAGENT_CHILD_AGENT"); + if (!runId || !childAgent) return undefined; + return `${runId}:${childAgent}:${envValue(env, "PI_SUBAGENT_CHILD_INDEX") ?? "0"}`; +} + +export function resolveSessionContext(env: DynamoSessionEnvironment): DynamoSessionContext { + const childSessionId = subagentSessionId(env); + if (childSessionId) { + const explicitParentSessionId = envValue(env, "DYN_AGENT_PARENT_SESSION_ID"); + const inheritedSessionId = envValue(env, "DYN_AGENT_SESSION_ID"); + const parentCandidate = explicitParentSessionId ?? inheritedSessionId; + const parentSessionId = parentCandidate !== childSessionId ? parentCandidate : undefined; + return { + sessionId: childSessionId, + ...(parentSessionId ? { parentSessionId } : {}), + }; + } + + const sessionId = envValue(env, "DYN_AGENT_SESSION_ID"); + const parentSessionId = envValue(env, "DYN_AGENT_PARENT_SESSION_ID"); + return { + ...(sessionId ? { sessionId } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + }; +} + +export function applySubagentSessionBridge(env: NodeJS.ProcessEnv = process.env): boolean { + const context = resolveSessionContext(env); + if (!subagentSessionId(env) || !context.sessionId) return false; + if ( + envValue(env, "DYN_AGENT_SESSION_ID") === context.sessionId && + envValue(env, "DYN_AGENT_PARENT_SESSION_ID") === context.parentSessionId + ) { + return false; + } + if (context.parentSessionId) { + env.DYN_AGENT_PARENT_SESSION_ID = context.parentSessionId; + } else { + delete env.DYN_AGENT_PARENT_SESSION_ID; + } + env.DYN_AGENT_SESSION_ID = context.sessionId; + return true; +} diff --git a/pi-plugin/src/light/tool-relay.ts b/pi-plugin/src/light/tool-relay.ts index 5a0d2be..4e50cc1 100644 --- a/pi-plugin/src/light/tool-relay.ts +++ b/pi-plugin/src/light/tool-relay.ts @@ -6,7 +6,7 @@ import { encode } from "@msgpack/msgpack"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Push } from "zeromq"; import type { DynamoConfig, DynamoEnvironment } from "./provider.js"; -import { envValue } from "./trajectory.js"; +import { envValue } from "./session.js"; export const DEFAULT_TOOL_EVENTS_TOPIC = "agent-tool-events"; export const DEFAULT_TOOL_EVENT_QUEUE_CAPACITY = 100000; @@ -24,8 +24,8 @@ export interface DynamoToolRelayConfig { } export interface DynamoRequestTraceAgentContext { - trajectory_id: string; - parent_trajectory_id?: string; + session_id: string; + parent_session_id?: string; } type ToolTraceEventType = "tool_start" | "tool_end" | "tool_error"; @@ -81,12 +81,12 @@ export function readDynamoToolRelayConfig(env: DynamoToolRelayEnvironment = proc }; } -export function buildToolAgentContext(config: DynamoConfig, sessionId: string | undefined) { - const trajectoryId = config.trajectoryId ?? sessionId; - if (!trajectoryId) return undefined; +export function buildToolAgentContext(config: DynamoConfig, runtimeSessionId: string | undefined) { + const sessionId = config.sessionId ?? runtimeSessionId; + if (!sessionId) return undefined; return { - trajectory_id: trajectoryId, - ...(config.parentTrajectoryId ? { parent_trajectory_id: config.parentTrajectoryId } : {}), + session_id: sessionId, + ...(config.parentSessionId ? { parent_session_id: config.parentSessionId } : {}), }; } diff --git a/pi-plugin/src/light/trajectory.ts b/pi-plugin/src/light/trajectory.ts deleted file mode 100644 index c8bc819..0000000 --- a/pi-plugin/src/light/trajectory.ts +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export interface DynamoTrajectoryEnvironment { - DYN_REQUEST_TRACE?: string; - DYN_AGENT_TRAJECTORY_ID?: string; - DYN_AGENT_PARENT_TRAJECTORY_ID?: string; - PI_SUBAGENT_CHILD?: string; - PI_SUBAGENT_RUN_ID?: string; - PI_SUBAGENT_CHILD_AGENT?: string; - PI_SUBAGENT_CHILD_INDEX?: string; -} - -export interface DynamoTrajectoryContext { - trajectoryId?: string; - parentTrajectoryId?: string; -} - -export function envValue(env: T, key: K): string | undefined { - const value = env[key]; - const trimmed = typeof value === "string" ? value.trim() : undefined; - return trimmed ? trimmed : undefined; -} - -export function isTruthyEnv(value: string | undefined): boolean { - return value ? ["1", "true", "yes", "on"].includes(value.toLowerCase()) : false; -} - -export function subagentTrajectoryId(env: DynamoTrajectoryEnvironment): string | undefined { - if (envValue(env, "PI_SUBAGENT_CHILD") !== "1") return undefined; - const runId = envValue(env, "PI_SUBAGENT_RUN_ID"); - const childAgent = envValue(env, "PI_SUBAGENT_CHILD_AGENT"); - if (!runId || !childAgent) return undefined; - return `${runId}:${childAgent}:${envValue(env, "PI_SUBAGENT_CHILD_INDEX") ?? "0"}`; -} - -export function resolveTrajectoryContext(env: DynamoTrajectoryEnvironment): DynamoTrajectoryContext { - const childTrajectoryId = subagentTrajectoryId(env); - if (childTrajectoryId) { - const parentTrajectoryId = - envValue(env, "DYN_AGENT_PARENT_TRAJECTORY_ID") ?? envValue(env, "DYN_AGENT_TRAJECTORY_ID"); - return { - trajectoryId: childTrajectoryId, - ...(parentTrajectoryId ? { parentTrajectoryId } : {}), - }; - } - - const trajectoryId = envValue(env, "DYN_AGENT_TRAJECTORY_ID"); - const parentTrajectoryId = envValue(env, "DYN_AGENT_PARENT_TRAJECTORY_ID"); - return { - ...(trajectoryId ? { trajectoryId } : {}), - ...(parentTrajectoryId ? { parentTrajectoryId } : {}), - }; -} - -export function applySubagentTrajectoryBridge(env: NodeJS.ProcessEnv = process.env): boolean { - const context = resolveTrajectoryContext(env); - if (!subagentTrajectoryId(env) || !context.trajectoryId) return false; - if ( - envValue(env, "DYN_AGENT_TRAJECTORY_ID") === context.trajectoryId && - envValue(env, "DYN_AGENT_PARENT_TRAJECTORY_ID") === context.parentTrajectoryId - ) { - return false; - } - if (context.parentTrajectoryId) env.DYN_AGENT_PARENT_TRAJECTORY_ID = context.parentTrajectoryId; - env.DYN_AGENT_TRAJECTORY_ID = context.trajectoryId; - return true; -} diff --git a/pi-plugin/test/integration/smoke.mjs b/pi-plugin/test/integration/smoke.mjs index 9353e8d..c0de8a4 100644 --- a/pi-plugin/test/integration/smoke.mjs +++ b/pi-plugin/test/integration/smoke.mjs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 // Integration smoke test: spins up a Dynamo frontend + mocker, sends one chat -// completion through Dynamo and asserts that x-dynamo-trajectory-id becomes -// trajectory identity in the JSONL request trace. +// completion through Dynamo and asserts that x-dynamo-session-id becomes +// session identity in the JSONL request trace. // // Not a unit test — runs out-of-band of vitest. Driven by // scripts/integration-smoke.sh which boots Dynamo, exports the trace sink env @@ -11,8 +11,8 @@ // transport failure. // // Assertions, in order: -// 1. x-dynamo-trajectory-id becomes Dynamo agent_context trajectory_id -// 2. subagent bridge derives a child trajectory id when +// 1. x-dynamo-session-id becomes Dynamo agent_context session_id +// 2. subagent bridge derives a child session id when // PI_SUBAGENT_CHILD=1 + bookkeeping vars are exported // // Mocker output text is intentionally garbage; we never assert on response @@ -64,7 +64,7 @@ async function waitForTraceMatching(predicate, label, timeoutMs = 15000) { throw new Error(`smoke: timed out waiting for trace event: ${label}`); } -async function postChat({ trajectoryId, parentTrajectoryId, xRequestId }) { +async function postChat({ sessionId, parentSessionId, xRequestId }) { const body = { model: MODEL_ID, messages: [{ role: "user", content: "smoke" }], @@ -75,8 +75,8 @@ async function postChat({ trajectoryId, parentTrajectoryId, xRequestId }) { method: "POST", headers: { "content-type": "application/json", - "x-dynamo-trajectory-id": trajectoryId, - ...(parentTrajectoryId ? { "x-dynamo-parent-trajectory-id": parentTrajectoryId } : {}), + "x-dynamo-session-id": sessionId, + ...(parentSessionId ? { "x-dynamo-parent-session-id": parentSessionId } : {}), "x-request-id": xRequestId, authorization: `Bearer ${process.env.DYNAMO_API_KEY ?? "dynamo-local"}`, }, @@ -97,8 +97,8 @@ function assert(condition, message) { async function caseTopLevelSessionHeader() { const xRequestId = "smoke-toplevel-" + Date.now(); - const trajectoryId = "smoke-session-toplevel"; - await postChat({ trajectoryId, xRequestId }); + const sessionId = "smoke-session-toplevel"; + await postChat({ sessionId, xRequestId }); const event = await waitForTraceMatching( (e) => e.event_type === "request_end" && e.request?.x_request_id === xRequestId, @@ -107,25 +107,25 @@ async function caseTopLevelSessionHeader() { assert(event.agent_context, "trace event missing agent_context"); assert( - event.agent_context.trajectory_id === trajectoryId, - `trajectory_id mismatch: got ${event.agent_context.trajectory_id}`, + event.agent_context.session_id === sessionId, + `session_id mismatch: got ${event.agent_context.session_id}`, ); assert( - event.agent_context.parent_trajectory_id === undefined || - event.agent_context.parent_trajectory_id === null, - `parent_trajectory_id should be unset for top-level case`, + event.agent_context.parent_session_id === undefined || + event.agent_context.parent_session_id === null, + `parent_session_id should be unset for top-level case`, ); - console.log(" PASS top-level trajectory_id from x-dynamo-trajectory-id"); + console.log(" PASS top-level session_id from x-dynamo-session-id"); } async function caseSubagentBridge() { // Simulate the env shape pi-subagents would set on a spawned child: - // inherited DYN_AGENT_TRAJECTORY_ID (parent's id) plus PI_SUBAGENT_* bookkeeping. + // inherited DYN_AGENT_SESSION_ID (parent's id) plus PI_SUBAGENT_* bookkeeping. // readDynamoConfig should rewrite both ids before the request is sent. const env = { DYNAMO_BASE_URL: BASE_URL, DYN_REQUEST_TRACE: "1", - DYN_AGENT_TRAJECTORY_ID: "smoke-orchestrator", + DYN_AGENT_SESSION_ID: "smoke-orchestrator", PI_SUBAGENT_CHILD: "1", PI_SUBAGENT_RUN_ID: "smoke-run", PI_SUBAGENT_CHILD_AGENT: "researcher", @@ -133,18 +133,18 @@ async function caseSubagentBridge() { }; const config = readDynamoConfig(env); assert( - config.trajectoryId === "smoke-run:researcher:0", - `bridge did not rewrite trajectory_id: got ${config.trajectoryId}`, + config.sessionId === "smoke-run:researcher:0", + `bridge did not rewrite session_id: got ${config.sessionId}`, ); assert( - config.parentTrajectoryId === "smoke-orchestrator", - `bridge did not set parent_trajectory_id: got ${config.parentTrajectoryId}`, + config.parentSessionId === "smoke-orchestrator", + `bridge did not set parent_session_id: got ${config.parentSessionId}`, ); const xRequestId = "smoke-subagent-" + Date.now(); await postChat({ - trajectoryId: config.trajectoryId, - parentTrajectoryId: config.parentTrajectoryId, + sessionId: config.sessionId, + parentSessionId: config.parentSessionId, xRequestId, }); @@ -155,10 +155,10 @@ async function caseSubagentBridge() { assert(event.agent_context, "trace event missing agent_context"); assert( - event.agent_context.trajectory_id === "smoke-run:researcher:0", - `subagent trajectory_id mismatch: got ${event.agent_context.trajectory_id}`, + event.agent_context.session_id === "smoke-run:researcher:0", + `subagent session_id mismatch: got ${event.agent_context.session_id}`, ); - console.log(" PASS pi-subagents trajectory_id header"); + console.log(" PASS pi-subagents session_id header"); } async function main() { diff --git a/pi-plugin/test/light.test.ts b/pi-plugin/test/light.test.ts index 668cfc4..9255b0a 100644 --- a/pi-plugin/test/light.test.ts +++ b/pi-plugin/test/light.test.ts @@ -6,7 +6,7 @@ import { createAssistantMessageEventStream, type Context, type Model, type Simpl import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { describe, expect, it } from "vitest"; import { - applySubagentTrajectoryBridge, + applySubagentSessionBridge, buildToolAgentContext, createDynamoStreamSimple, DEFAULT_DYNAMO_BASE_URL, @@ -54,11 +54,13 @@ function createContext(sessionId: string): ExtensionContext { } describe("light provider", () => { - it("keeps Pi sessionId and adds Dynamo trajectory headers", () => { + it("keeps Pi sessionId and adds Dynamo session headers", () => { + let capturedModel: Model<"openai-completions"> | undefined; let capturedOptions: SimpleStreamOptions | undefined; const streamSimple = createDynamoStreamSimple( config, - (_model, _context, options) => { + (model, _context, options) => { + capturedModel = model; capturedOptions = options; return createAssistantMessageEventStream(); }, @@ -68,21 +70,22 @@ describe("light provider", () => { streamSimple(model, context, { sessionId: "pi-session" }); expect(capturedOptions?.sessionId).toBe("pi-session"); + expect(capturedModel?.compat?.sendSessionAffinityHeaders).toBeUndefined(); expect(capturedOptions?.headers).toEqual({ "x-request-id": "request-1", - "x-dynamo-trajectory-id": "pi-session", + "x-dynamo-session-id": "pi-session", }); }); - it("bridges pi-subagents through Dynamo trajectory headers", () => { + it("bridges pi-subagents through Dynamo session headers", () => { const env: NodeJS.ProcessEnv = { DYN_REQUEST_TRACE: "1", - DYN_AGENT_TRAJECTORY_ID: "parent", + DYN_AGENT_SESSION_ID: "parent", PI_SUBAGENT_CHILD: "1", PI_SUBAGENT_RUN_ID: "run", PI_SUBAGENT_CHILD_AGENT: "researcher", }; - expect(applySubagentTrajectoryBridge(env)).toBe(true); + expect(applySubagentSessionBridge(env)).toBe(true); const cfg = readDynamoConfig(env); let capturedOptions: SimpleStreamOptions | undefined; @@ -97,12 +100,30 @@ describe("light provider", () => { expect(capturedOptions?.sessionId).toBe("pi-session"); expect(capturedOptions?.headers).toMatchObject({ - "x-dynamo-trajectory-id": "run:researcher:0", - "x-dynamo-parent-trajectory-id": "parent", + "x-dynamo-session-id": "run:researcher:0", + "x-dynamo-parent-session-id": "parent", }); }); - it("emits trajectory-only ZMQ tool context", async () => { + it("does not self-parent child sessions", () => { + const env: NodeJS.ProcessEnv = { + DYN_REQUEST_TRACE: "1", + DYN_AGENT_SESSION_ID: "run:researcher:0", + DYN_AGENT_PARENT_SESSION_ID: "run:researcher:0", + PI_SUBAGENT_CHILD: "1", + PI_SUBAGENT_RUN_ID: "run", + PI_SUBAGENT_CHILD_AGENT: "researcher", + }; + + expect(applySubagentSessionBridge(env)).toBe(true); + const cfg = readDynamoConfig(env); + + expect(cfg.sessionId).toBe("run:researcher:0"); + expect(cfg.parentSessionId).toBeUndefined(); + expect(env.DYN_AGENT_PARENT_SESSION_ID).toBeUndefined(); + }); + + it("emits session-only ZMQ tool context", async () => { const socket = new FakeToolEventSocket(); const publisher = new DynamoToolEventPublisher( { endpoint: "tcp://127.0.0.1:20390", topic: "tools", queueCapacity: 10 }, @@ -115,7 +136,7 @@ describe("light provider", () => { await publisher.flush(); const record = decode(socket.sent[0]?.[2] ?? Buffer.alloc(0)) as DynamoRequestTraceRecord; - expect(buildToolAgentContext(config, "pi-session")).toEqual({ trajectory_id: "pi-session" }); - expect(record.agent_context).toEqual({ trajectory_id: "pi-session" }); + expect(buildToolAgentContext(config, "pi-session")).toEqual({ session_id: "pi-session" }); + expect(record.agent_context).toEqual({ session_id: "pi-session" }); }); });