diff --git a/agents/nemo-agent-local-spec/AGENT-SPEC.md b/agents/nemo-studio-copilot-spec/AGENT-SPEC.md similarity index 88% rename from agents/nemo-agent-local-spec/AGENT-SPEC.md rename to agents/nemo-studio-copilot-spec/AGENT-SPEC.md index 347fe2eb32..7cc07f1538 100644 --- a/agents/nemo-agent-local-spec/AGENT-SPEC.md +++ b/agents/nemo-studio-copilot-spec/AGENT-SPEC.md @@ -1,13 +1,13 @@ --- -name: nemo-agent-local +name: nemo-studio-copilot created_timestamp: 2026-07-28T20:09:49Z author: Danielle Ali and Codex --- -# Agent Spec: nemo-agent-local +# Agent Spec: nemo-studio-copilot > This file is the durable contract for the local NeMo Platform assistant. -> Keep it aligned with the implementation under `agents/nemo-agent-local/`. +> Keep it aligned with the implementation under `agents/nemo-studio-copilot/`. ## Role @@ -81,15 +81,15 @@ The mission is grounded in the current deployment proof of concept and its imple ## Evaluation Setup -Unit coverage lives in `agents/nemo-agent-local/tests/test_nemo_agent.py`. Run it with: +Unit coverage lives in `agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py`. Run it with: ```bash -uv run --frozen pytest agents/nemo-agent-local/tests/test_nemo_agent.py -v +uv run --frozen pytest agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py -v ``` -The NAT evaluation workflow is `agents/nemo-agent-local/src/nemo_agent/nemo-eval.yml`, with inputs in `nemo-eval-data.json`. It currently evaluates workspace creation, workspace listing, and model/provider listing with a tunable RAG judge weighted for coverage (0.5), correctness (0.3), and relevance (0.2). No formal aggregate pass threshold is defined. +The NAT evaluation workflow is `agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot-eval.yml`, with inputs in `nemo-studio-copilot-eval-data.json`. It currently evaluates workspace creation, workspace listing, and model/provider listing with a tunable RAG judge weighted for coverage (0.5), correctness (0.3), and relevance (0.2). No formal aggregate pass threshold is defined. -Manual Studio validation is documented in `agents/nemo-agent-local/tests/smoke_test.md`. Current coverage gaps include fileset listing, missing-workspace clarification, fast-path failure containment, destructive-action ambiguity, iteration limits, retry limits, cancellation, and end-to-end latency thresholds. +Manual Studio validation is documented in `agents/nemo-studio-copilot/tests/smoke_test.md`. Current coverage gaps include fileset listing, missing-workspace clarification, fast-path failure containment, destructive-action ambiguity, iteration limits, retry limits, cancellation, and end-to-end latency thresholds. ## Change Scope diff --git a/agents/nemo-agent-local/.dockerignore b/agents/nemo-studio-copilot/.dockerignore similarity index 68% rename from agents/nemo-agent-local/.dockerignore rename to agents/nemo-studio-copilot/.dockerignore index bad91b0534..221f90de42 100644 --- a/agents/nemo-agent-local/.dockerignore +++ b/agents/nemo-studio-copilot/.dockerignore @@ -17,5 +17,5 @@ build/ .venv/ node_modules/ tests/ -src/nemo_agent/nemo-eval.yml -src/nemo_agent/nemo-eval-data.json +src/nemo_studio_copilot/nemo-studio-copilot-eval.yml +src/nemo_studio_copilot/nemo-studio-copilot-eval-data.json diff --git a/agents/nemo-agent-local/Dockerfile b/agents/nemo-studio-copilot/Dockerfile similarity index 91% rename from agents/nemo-agent-local/Dockerfile rename to agents/nemo-studio-copilot/Dockerfile index fd3bb88d5b..e62cbacd19 100644 --- a/agents/nemo-agent-local/Dockerfile +++ b/agents/nemo-studio-copilot/Dockerfile @@ -41,11 +41,11 @@ RUN --mount=type=cache,id=uv_cache,target=/root/.cache/uv,sharing=locked \ uv pip install . && \ chmod -R a+rX /opt/uv /workspace/.venv -LABEL org.opencontainers.image.title="builtin-nemo-agent" \ +LABEL org.opencontainers.image.title="nemo-studio-copilot" \ org.opencontainers.image.version="0.1.0" \ org.opencontainers.image.authors="NVIDIA" \ org.opencontainers.image.created="2026-07-22T20:51:42.179931+00:00" \ - org.opencontainers.image.description="Built-in NeMo Platform agent — a LangChain Deep Agent that uses the NeMo SDK to perform platform tasks." \ + org.opencontainers.image.description="NeMo Copilot — a LangChain Deep Agent that uses the NeMo SDK to perform platform tasks." \ org.opencontainers.image.revision="" \ org.opencontainers.image.source="" \ com.nemo.agent.id="790bd61b98d0" \ @@ -53,7 +53,7 @@ LABEL org.opencontainers.image.title="builtin-nemo-agent" \ com.nemo.agent.nat-version="1.8.0" \ com.nemo.agent.contract-version="0.0.0" -ENV NAT_CONFIG_FILE=/workspace/src/nemo_agent/nemo-agent.yml +ENV NAT_CONFIG_FILE=/workspace/src/nemo_studio_copilot/nemo-studio-copilot.yml ENV PATH="/workspace/.venv/bin:$PATH" diff --git a/agents/nemo-agent-local/pyproject.toml b/agents/nemo-studio-copilot/pyproject.toml similarity index 61% rename from agents/nemo-agent-local/pyproject.toml rename to agents/nemo-studio-copilot/pyproject.toml index 28d1a90f95..8282eec482 100644 --- a/agents/nemo-agent-local/pyproject.toml +++ b/agents/nemo-studio-copilot/pyproject.toml @@ -6,9 +6,9 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "builtin-nemo-agent" +name = "nemo-studio-copilot" version = "0.1.0" -description = "Built-in NeMo Platform agent — a LangChain Deep Agent that uses the NeMo SDK to perform platform tasks." +description = "NeMo Copilot — a LangChain Deep Agent that uses the NeMo SDK to perform platform tasks." requires-python = ">=3.11,<3.14" dependencies = [ "nvidia-nat-core>=1.8.0,<1.9", @@ -21,13 +21,13 @@ dependencies = [ ] [project.entry-points."nat.components"] -nemo_agents_nemo_agent = "nemo_agent.register" -# Custom NAT workflow type that wraps the nemo-agent graph with permissive -# input/output schemas. See src/nemo_agent/wrapper.py for the full rationale — +nemo_studio_copilot = "nemo_studio_copilot.register" +# Custom NAT workflow type that wraps the nemo-studio-copilot graph with permissive +# input/output schemas. See src/nemo_studio_copilot/wrapper.py for the full rationale — # in short, NAT 1.6.0's stock `langgraph_wrapper` doesn't accept the # `{"input_message": ...}` shape that `nvidia-nat-eval` posts and crashes on # deep-agent state deltas that don't carry `messages`. -nemo_agents_nemo_agent_wrapper = "nemo_agent.wrapper" +nemo_studio_copilot_wrapper = "nemo_studio_copilot.wrapper" [tool.hatch.build.targets.wheel] -packages = ["src/nemo_agent"] +packages = ["src/nemo_studio_copilot"] diff --git a/agents/nemo-agent-local/src/nemo_agent/nemo-eval-data.json b/agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot-eval-data.json similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/nemo-eval-data.json rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot-eval-data.json diff --git a/agents/nemo-agent-local/src/nemo_agent/nemo-eval.yml b/agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot-eval.yml similarity index 81% rename from agents/nemo-agent-local/src/nemo_agent/nemo-eval.yml rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot-eval.yml index dc6695093d..abeea3776b 100644 --- a/agents/nemo-agent-local/src/nemo_agent/nemo-eval.yml +++ b/agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot-eval.yml @@ -1,9 +1,9 @@ -# Eval config for the built-in nemo-agent. +# Eval config for the built-in nemo-studio-copilot. # -# Uses the custom `nemo_agent_wrapper` workflow type (defined in -# src/nemo_agent/wrapper.py) so the same config works whether the agent runs +# Uses the custom `nemo_studio_copilot_wrapper` workflow type (defined in +# src/nemo_studio_copilot/wrapper.py) so the same config works whether the agent runs # inline (no --agent) or against the deployed agent's `/generate/full` endpoint -# (--agent nemo-agent). The wrapper accepts both the `{"messages": [...]}` +# (--agent nemo-studio-copilot). The wrapper accepts both the `{"messages": [...]}` # shape used by chat clients and the `{"input_message": "..."}` shape that # `nvidia-nat-eval`'s remote workflow client posts to `/generate/full`. # @@ -13,13 +13,13 @@ # # Run inline (in-process, no deployed agent needed): # nemo agents evaluate run \ -# --eval-config plugins/nemo-agents/builtin_agents/nemo-agent/src/nemo_agent/nemo-eval.yml \ +# --eval-config agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot-eval.yml \ # --output ./eval-out # -# Run against the deployed agent (requires `nemo agents deploy nemo-agent`): +# Run against the deployed agent (requires `nemo agents deploy nemo-studio-copilot`): # nemo agents evaluate run \ -# --eval-config plugins/nemo-agents/builtin_agents/nemo-agent/src/nemo_agent/nemo-eval.yml \ -# --agent nemo-agent \ +# --eval-config agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot-eval.yml \ +# --agent nemo-studio-copilot \ # --output ./eval-out llms: @@ -47,7 +47,7 @@ llms: # only meaningful when something resolves it back to the upstream model. # # Caveat: the agent's tools (`nemo_api`, `check_status`) still call platform - # APIs via the Python SDK. The current `nemo-eval-data.json` exercises + # APIs via the Python SDK. The current `nemo-studio-copilot-eval-data.json` exercises # platform resources, including workspace and model/provider operations, so # bypassing the gateway is not an LLM-only smoke test. A fully self-contained # bypass run requires a separate pure-LLM dataset that does not invoke @@ -67,16 +67,16 @@ llms: # max_tokens: 1024 workflow: - _type: nemo_agent_wrapper + _type: nemo_studio_copilot_wrapper description: NeMo Platform assistant using LangChain Deep Agents eval: general: max_concurrency: 1 - output_dir: eval/nemo-agent + output_dir: eval/nemo-studio-copilot dataset: _type: json - file_path: nemo-eval-data.json + file_path: nemo-studio-copilot-eval-data.json structure: question_key: input_message answer_key: expected_output diff --git a/agents/nemo-agent-local/src/nemo_agent/nemo-agent.remote.yml b/agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot.remote.yml similarity index 94% rename from agents/nemo-agent-local/src/nemo_agent/nemo-agent.remote.yml rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot.remote.yml index 1170127829..6c49d66fe6 100644 --- a/agents/nemo-agent-local/src/nemo_agent/nemo-agent.remote.yml +++ b/agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot.remote.yml @@ -18,5 +18,5 @@ llms: request_timeout: 120 workflow: - _type: nemo_agent_wrapper + _type: nemo_studio_copilot_wrapper description: NeMo Platform assistant using LangChain Deep Agents diff --git a/agents/nemo-agent-local/src/nemo_agent/nemo-agent.yml b/agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot.yml similarity index 86% rename from agents/nemo-agent-local/src/nemo_agent/nemo-agent.yml rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot.yml index 2637cba157..daffb955b1 100644 --- a/agents/nemo-agent-local/src/nemo_agent/nemo-agent.yml +++ b/agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot.yml @@ -1,4 +1,4 @@ -# NeMo Platform Agent — NAT workflow configuration +# NeMo Copilot — NAT workflow configuration # # A LangChain Deep Agent that uses the NeMo SDK to perform platform tasks. # Deliberately configured as the "expensive baseline" for the optimization @@ -6,13 +6,12 @@ # # Deploy: # uv pip install -e plugins/nemo-agents -# uv pip install -e plugins/nemo-agents/builtin_agents/nemo-agent -# nemo agents create --name nemo-agent \ -# --agent-config plugins/nemo-agents/builtin_agents/nemo-agent/src/nemo_agent/nemo-agent.yml -# nemo agents deploy --agent nemo-agent +# nemo agents create --name nemo-studio-copilot \ +# --agent-config agents/nemo-studio-copilot/src/nemo_studio_copilot/nemo-studio-copilot.yml +# nemo agents deploy --agent nemo-studio-copilot # # Invoke: -# nemo agents invoke --agent nemo-agent --input "Create a workspace called demo" +# nemo agents invoke --agent nemo-studio-copilot --input "Create a workspace called demo" llms: agent: @@ -73,9 +72,9 @@ llms: # max_tokens: 49152 workflow: - # Custom workflow type registered by `nemo-agents-nemo-agent`. Wraps the same + # Custom workflow type registered by `nemo_studio_copilot_wrapper`. Wraps the same # deep-agent graph as `langgraph_wrapper` would, but with input/output schemas # that survive both the eval client's `{"input_message": ...}` payload and the - # deep-agent's wider state shape. See src/nemo_agent/wrapper.py. - _type: nemo_agent_wrapper + # deep-agent's wider state shape. See src/nemo_studio_copilot/wrapper.py. + _type: nemo_studio_copilot_wrapper description: NeMo Platform assistant using LangChain Deep Agents diff --git a/agents/nemo-agent-local/src/nemo_agent/register.py b/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py similarity index 93% rename from agents/nemo-agent-local/src/nemo_agent/register.py rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py index 3d124964a0..46439b566d 100644 --- a/agents/nemo-agent-local/src/nemo_agent/register.py +++ b/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py @@ -51,7 +51,7 @@ SKILLS_DIR = Path(__file__).parent / "skills" DEFAULT_WORKSPACE = "default" DEFAULT_RECURSION_LIMIT = 24 -STUDIO_CALLBACK_PATH = "/studio/api/coding-agents/mcp/{session_id}" +STUDIO_CALLBACK_PATH = "/studio/api/copilot/mcp/{session_id}" STUDIO_CALLBACK_TIMEOUT_SECONDS = 3600.0 _READ_ONLY_SDK_ACTIONS = frozenset( { @@ -80,14 +80,38 @@ ) _FAST_PATH_ANALYSIS = re.compile(r"\b(analy[sz]e|audit|compare|explain|investigate|why)\b", re.IGNORECASE) _DIRECT_LIST_RESOURCES = ( - (re.compile(r"\bworkspaces?\b", re.IGNORECASE), "workspaces"), - (re.compile(r"\bmodels?\b", re.IGNORECASE), "models"), + # Match the requested resource before the contextual workspace qualifier. + # Provider prompts commonly include both "model" and "workspace", so the + # most specific resource must come first and workspace must come last. (re.compile(r"\bproviders?\b", re.IGNORECASE), "inference.providers"), (re.compile(r"\bfilesets?\b", re.IGNORECASE), "files.filesets"), (re.compile(r"\bdatasets?\b", re.IGNORECASE), "datasets"), (re.compile(r"\bbenchmarks?\b", re.IGNORECASE), "evaluation.benchmarks"), (re.compile(r"\bmetrics?\b", re.IGNORECASE), "evaluation.metrics"), + (re.compile(r"\bmodels?\b", re.IGNORECASE), "models"), + (re.compile(r"\bworkspaces?\b", re.IGNORECASE), "workspaces"), ) +_DIRECT_LIST_CONTEXT_TARGET = re.compile( + r"^\s*(?:list|show|what|which)\b.*?\b(?:" + r"(?P(?:model\s+)?providers?)|" + r"(?Pfilesets?)|" + r"(?Pdatasets?)|" + r"(?Pbenchmarks?)|" + r"(?Pmetrics?)|" + r"(?Pmodels?)|" + r"(?Pworkspaces?)" + r")\b", + re.IGNORECASE, +) +_DIRECT_LIST_CONTEXT_RESOURCES = { + "providers": "inference.providers", + "filesets": "files.filesets", + "datasets": "datasets", + "benchmarks": "evaluation.benchmarks", + "metrics": "evaluation.metrics", + "models": "models", + "workspaces": "workspaces", +} _client: NeMoPlatform | None = None @@ -108,6 +132,9 @@ def _direct_list_resource(messages: list[Any]) -> str | None: return None if _FAST_PATH_MUTATION.search(latest_user_text) or _FAST_PATH_ANALYSIS.search(latest_user_text): return None + context_target = _DIRECT_LIST_CONTEXT_TARGET.search(latest_user_text) + if context_target and context_target.lastgroup: + return _DIRECT_LIST_CONTEXT_RESOURCES[context_target.lastgroup] for pattern, resource_path in _DIRECT_LIST_RESOURCES: if pattern.search(latest_user_text): return resource_path @@ -147,7 +174,7 @@ def _list_resource_names(resource_path: str) -> str: resource = _resolve_resource(_get_client(), resource_path) serialized = _serialize(_call_sdk_method(resource, "list")) except Exception as exc: - logger.exception("nemo-agent: direct list fast path failed for resource=%s", resource_path) + logger.exception("nemo-studio-copilot: direct list fast path failed for resource=%s", resource_path) return _direct_list_error(resource_path, exc) if isinstance(serialized, dict): @@ -157,7 +184,7 @@ def _list_resource_names(resource_path: str) -> str: names = [item.get("name") for item in serialized if isinstance(item, dict) and isinstance(item.get("name"), str)] if not names: return f"No {resource_path.rsplit('.', maxsplit=1)[-1]} found in workspace '{_active_workspace()}'." - logger.info("nemo-agent: direct list fast path resource=%s count=%d", resource_path, len(names)) + logger.info("nemo-studio-copilot: direct list fast path resource=%s count=%d", resource_path, len(names)) return "\n".join(names) @@ -169,7 +196,7 @@ def _delete_fileset(name: str) -> str: _call_sdk_method(resource, "delete", {"name": name}) serialized = _serialize(_call_sdk_method(resource, "list")) except Exception as exc: - logger.exception("nemo-agent: direct fileset delete failed for name=%s", name) + logger.exception("nemo-studio-copilot: direct fileset delete failed for name=%s", name) if isinstance(exc, ValueError) and "workspace" in str(exc).lower(): return f"Which workspace should I use to delete fileset '{name}'?" return f"I couldn't delete fileset '{name}' in workspace '{workspace}': {type(exc).__name__}: {exc}" @@ -187,7 +214,7 @@ def _delete_fileset(name: str) -> str: if name in remaining_names: return f"I couldn't verify deletion: fileset '{name}' still exists in workspace '{workspace}'." - logger.info("nemo-agent: direct fileset delete name=%s workspace=%s", name, workspace) + logger.info("nemo-studio-copilot: direct fileset delete name=%s workspace=%s", name, workspace) return f"Deleted fileset '{name}' from workspace '{workspace}'." @@ -663,7 +690,7 @@ def check_status(service: str, job_name: str) -> str: class _StreamSafeGraph: """Wraps a CompiledStateGraph with NAT-compatible incremental streaming. - The custom ``nemo_agent_wrapper`` accepts Deep Agent state deltas that do + The custom ``nemo_studio_copilot_wrapper`` accepts Deep Agent state deltas that do not contain ``messages``. Delegate to the graph's real ``astream`` so NAT can forward chunks as each graph step completes instead of buffering the entire run behind ``ainvoke``. @@ -778,7 +805,9 @@ def _disable_nat_method_retries(model: Any) -> Any: object.__setattr__(model, name, types.MethodType(wrapped, model)) disabled.append(name) if disabled: - logger.info("nemo-agent: disabled NAT automatic retry wrappers for model methods: %s", ", ".join(disabled)) + logger.info( + "nemo-studio-copilot: disabled NAT automatic retry wrappers for model methods: %s", ", ".join(disabled) + ) return model @@ -829,7 +858,7 @@ def _build_backend() -> CompositeBackend: return CompositeBackend(default=StateBackend(), routes=routes) -def create_nemo_agent(config=None): +def create_nemo_studio_copilot(config=None): """Create the NeMo Platform Deep Agent. Args: @@ -845,14 +874,14 @@ def create_nemo_agent(config=None): skills = [str(SKILLS_DIR)] if discovered else None if SKILLS_DIR.is_dir() and not discovered: logger.warning( - "nemo-agent: SKILLS_DIR=%s is configured but no spec-compliant " + "nemo-studio-copilot: SKILLS_DIR=%s is configured but no spec-compliant " "skills were discovered (each skill must be a subdirectory with " "a SKILL.md starting with '---' YAML frontmatter). Agent will " "run without playbook scaffolding.", SKILLS_DIR, ) elif discovered: - logger.info("nemo-agent: loaded %d skills: %s", len(discovered), ", ".join(discovered)) + logger.info("nemo-studio-copilot: loaded %d skills: %s", len(discovered), ", ".join(discovered)) backend = _build_backend() graph = create_deep_agent( model=model, @@ -864,10 +893,10 @@ def create_nemo_agent(config=None): return _StreamSafeGraph(graph) -# Module-level graph factory. The active workflow type is `nemo_agent_wrapper` -# (see ./wrapper.py), which calls ``create_nemo_agent`` directly. This alias is +# Module-level graph factory. The active workflow type is `nemo_studio_copilot_wrapper` +# (see ./wrapper.py), which calls ``create_nemo_studio_copilot`` directly. This alias is # kept so the graph can also be loaded via ``_type: langgraph_wrapper`` with # ``graph: .../register.py:agent`` for ad-hoc debugging — note that path will -# hit the NAT 1.6.0 input/output schema bugs that ``nemo_agent_wrapper`` +# hit the NAT 1.6.0 input/output schema bugs that ``nemo_studio_copilot_wrapper`` # exists to work around. -agent = create_nemo_agent +agent = create_nemo_studio_copilot diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/.gitkeep b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/.gitkeep similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/.gitkeep rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/.gitkeep diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/auditor/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/auditor/SKILL.md similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/auditor/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/auditor/SKILL.md diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/benchmark-execution/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/benchmark-execution/SKILL.md similarity index 92% rename from agents/nemo-agent-local/src/nemo_agent/skills/benchmark-execution/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/benchmark-execution/SKILL.md index 0e3cb559ea..59f648bff0 100644 --- a/agents/nemo-agent-local/src/nemo_agent/skills/benchmark-execution/SKILL.md +++ b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/benchmark-execution/SKILL.md @@ -4,7 +4,7 @@ description: "Benchmark task execution contract: complete every numbered require --- # Benchmark execution contract -This skill defines the execution requirements that every nemo-agent run under +This skill defines the execution requirements that every nemo-studio-copilot run under `tests/agentic-use/` must satisfy so the canonical gate (`tests/agentic-use/passrate_token_policy_gate.py`) can score the run on verifier pass-rate and token totals. See diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/entities/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/entities/SKILL.md similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/entities/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/entities/SKILL.md diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/evaluator/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/evaluator/SKILL.md similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/evaluator/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/evaluator/SKILL.md diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/files/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/files/SKILL.md similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/files/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/files/SKILL.md diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/guardrails/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/guardrails/SKILL.md similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/guardrails/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/guardrails/SKILL.md diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/inference/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/inference/SKILL.md similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/inference/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/inference/SKILL.md diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/secrets/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/secrets/SKILL.md similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/secrets/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/secrets/SKILL.md diff --git a/agents/nemo-agent-local/src/nemo_agent/skills/workspace/SKILL.md b/agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/workspace/SKILL.md similarity index 100% rename from agents/nemo-agent-local/src/nemo_agent/skills/workspace/SKILL.md rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/skills/workspace/SKILL.md diff --git a/agents/nemo-agent-local/src/nemo_agent/wrapper.py b/agents/nemo-studio-copilot/src/nemo_studio_copilot/wrapper.py similarity index 80% rename from agents/nemo-agent-local/src/nemo_agent/wrapper.py rename to agents/nemo-studio-copilot/src/nemo_studio_copilot/wrapper.py index 2e81f0070d..a688d24b75 100644 --- a/agents/nemo-agent-local/src/nemo_agent/wrapper.py +++ b/agents/nemo-studio-copilot/src/nemo_studio_copilot/wrapper.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""NeMo agent wrapper — a NAT workflow type for the built-in nemo-agent. +"""NeMo Copilot wrapper — a NAT workflow type for the built-in nemo-studio-copilot. Why this exists --------------- @@ -32,29 +32,29 @@ How it works ------------ -* ``NemoAgentWrapperConfig`` registers ``_type: nemo_agent_wrapper`` with +* ``NemoStudioCopilotWrapperConfig`` registers ``_type: nemo_studio_copilot_wrapper`` with NAT via ``register_function`` at import time. -* ``NemoAgentWrapperInput`` accepts **either** ``messages`` (native LangGraph +* ``NemoStudioCopilotWrapperInput`` accepts **either** ``messages`` (native LangGraph state shape) **or** ``input_message`` (the eval client's shape) and normalizes the latter to a single ``user`` message via a ``model_validator(mode="after")`` that participates in the Pydantic core schema (so it survives FastAPI's TypeAdapter path). -* ``NemoAgentWrapperOutput.messages`` defaults to an empty list, so streaming +* ``NemoStudioCopilotWrapperOutput.messages`` defaults to an empty list, so streaming chunks without ``messages`` validate cleanly. -* ``NemoAgentWrapperFunction`` is a ``Function`` parametrized over those +* ``NemoStudioCopilotWrapperFunction`` is a ``Function`` parametrized over those schemas. ``_ainvoke`` and ``_astream`` build a ``{"messages": [...]}`` state for the graph, run it, and coerce each output (or chunk) into - ``NemoAgentWrapperOutput`` via ``_parse``. + ``NemoStudioCopilotWrapperOutput`` via ``_parse``. * ``_parse`` extracts ``messages`` from the outer dict, from a single-keyed node-update wrapper, or — if neither shape applies — yields an empty placeholder. This is the key behavioral difference from ``LanggraphWrapperFunction._parse_stream_output``: we never raise on a state delta that lacks ``messages``. -* The graph itself comes from :func:`nemo_agent.register.create_nemo_agent`. +* The graph itself comes from :func:`nemo_studio_copilot.register.create_nemo_studio_copilot`. No ``graph:`` field on the config: this wrapper is single-purpose. -Use it via ``_type: nemo_agent_wrapper`` in ``nemo-agent.yml`` / -``nemo-eval.yml``. Once the upstream NAT bugs are fixed we can drop this +Use it via ``_type: nemo_studio_copilot_wrapper`` in ``nemo-studio-copilot.yml`` / +``nemo-studio-copilot-eval.yml``. Once the upstream NAT bugs are fixed we can drop this module and revert to ``langgraph_wrapper``. """ @@ -75,7 +75,7 @@ from nat.cli.register_workflow import register_function from nat.data_models.api_server import ChatRequest, ChatResponse, ChatResponseChunk, Usage from nat.data_models.function import FunctionBaseConfig -from nemo_agent.register import create_nemo_agent +from nemo_studio_copilot.register import create_nemo_studio_copilot from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator logger = logging.getLogger(__name__) @@ -92,14 +92,14 @@ _MODEL_ERROR_MESSAGE = "The model service returned an error before I could complete that request. Please try again." -class NemoAgentWrapperConfig(FunctionBaseConfig, name="nemo_agent_wrapper"): - """Configuration for the nemo-agent NAT workflow. +class NemoStudioCopilotWrapperConfig(FunctionBaseConfig, name="nemo_studio_copilot_wrapper"): + """Configuration for the nemo-studio-copilot NAT workflow. Unlike ``langgraph_wrapper`` there is no ``graph`` field — the graph is - fixed (the built-in nemo-agent deep-agent), so the YAML is just:: + fixed (the built-in nemo-studio-copilot deep-agent), so the YAML is just:: workflow: - _type: nemo_agent_wrapper + _type: nemo_studio_copilot_wrapper description: NeMo Platform assistant using LangChain Deep Agents """ @@ -108,8 +108,8 @@ class NemoAgentWrapperConfig(FunctionBaseConfig, name="nemo_agent_wrapper"): description: str = "" -class NemoAgentWrapperInput(BaseModel): - """Input schema accepted by the nemo-agent ``/generate*`` routes. +class NemoStudioCopilotWrapperInput(BaseModel): + """Input schema accepted by the nemo-studio-copilot ``/generate*`` routes. Accepts either: @@ -137,7 +137,7 @@ class NemoAgentWrapperInput(BaseModel): studio_session_id: UUID | None = None @model_validator(mode="after") - def _ensure_messages(self) -> "NemoAgentWrapperInput": + def _ensure_messages(self) -> "NemoStudioCopilotWrapperInput": if self.messages is not None: return self if self.input_message is not None: @@ -146,7 +146,7 @@ def _ensure_messages(self) -> "NemoAgentWrapperInput": raise ValueError("Either 'messages' or 'input_message' is required") -class NemoAgentWrapperOutput(BaseModel): +class NemoStudioCopilotWrapperOutput(BaseModel): """Output schema for chunks and final results. ``messages`` defaults to an empty list (vs. required in @@ -200,35 +200,37 @@ def value(self) -> str: return "" -class NemoAgentWrapperFunction(Function[NemoAgentWrapperInput, NemoAgentWrapperOutput, NemoAgentWrapperOutput]): - """NAT ``Function`` wrapping the nemo-agent compiled LangGraph. +class NemoStudioCopilotWrapperFunction( + Function[NemoStudioCopilotWrapperInput, NemoStudioCopilotWrapperOutput, NemoStudioCopilotWrapperOutput] +): + """NAT ``Function`` wrapping the nemo-studio-copilot compiled LangGraph. Behavior mirrors ``LanggraphWrapperFunction`` (so the same converters work for both the chat-completions route and the raw generate routes), with two differences: - * **Input normalization** happens via ``NemoAgentWrapperInput``'s + * **Input normalization** happens via ``NemoStudioCopilotWrapperInput``'s validator, before this class is reached. * **Stream chunks** that lack ``messages`` are coerced to an empty output instead of raising — see :meth:`_parse`. """ - def __init__(self, *, config: NemoAgentWrapperConfig, graph: Any) -> None: + def __init__(self, *, config: NemoStudioCopilotWrapperConfig, graph: Any) -> None: super().__init__( config=config, description=config.description, converters=[ - NemoAgentWrapperFunction.convert_to_str, - NemoAgentWrapperFunction.convert_chat_request, - NemoAgentWrapperFunction.convert_str, - NemoAgentWrapperFunction.convert_to_chat_response, - NemoAgentWrapperFunction.convert_to_chat_response_chunk, + NemoStudioCopilotWrapperFunction.convert_to_str, + NemoStudioCopilotWrapperFunction.convert_chat_request, + NemoStudioCopilotWrapperFunction.convert_str, + NemoStudioCopilotWrapperFunction.convert_to_chat_response, + NemoStudioCopilotWrapperFunction.convert_to_chat_response_chunk, ], ) self._graph = graph @staticmethod - def _build_state(value: NemoAgentWrapperInput) -> dict[str, Any]: + def _build_state(value: NemoStudioCopilotWrapperInput) -> dict[str, Any]: """Project the validated input down to the LangGraph state shape. We deliberately forward only ``messages`` — that's all the deep @@ -240,7 +242,7 @@ def _build_state(value: NemoAgentWrapperInput) -> dict[str, Any]: return {"messages": convert_to_messages(messages)} @staticmethod - def _invocation_config(value: NemoAgentWrapperInput) -> RunnableConfig: + def _invocation_config(value: NemoStudioCopilotWrapperInput) -> RunnableConfig: if value.studio_session_id is None: return {} return { @@ -269,7 +271,7 @@ def _has_tool_calls(message: AIMessage | dict[str, Any]) -> bool: @staticmethod def _is_empty_assistant_message(message: Any) -> bool: if isinstance(message, AIMessage): - return not message.text.strip() and not NemoAgentWrapperFunction._has_tool_calls(message) + return not message.text.strip() and not NemoStudioCopilotWrapperFunction._has_tool_calls(message) if not isinstance(message, dict): return False role = message.get("role") or message.get("type") @@ -277,15 +279,15 @@ def _is_empty_assistant_message(message: Any) -> bool: return False content = message.get("content", "") if isinstance(content, str): - return not content.strip() and not NemoAgentWrapperFunction._has_tool_calls(message) + return not content.strip() and not NemoStudioCopilotWrapperFunction._has_tool_calls(message) if content in (None, []): - return not NemoAgentWrapperFunction._has_tool_calls(message) + return not NemoStudioCopilotWrapperFunction._has_tool_calls(message) return False @staticmethod def _drop_trailing_empty_assistant_messages(messages: list[Any]) -> list[Any]: sanitized = list(messages) - while sanitized and NemoAgentWrapperFunction._is_empty_assistant_message(sanitized[-1]): + while sanitized and NemoStudioCopilotWrapperFunction._is_empty_assistant_message(sanitized[-1]): sanitized.pop() return sanitized @@ -293,7 +295,7 @@ async def _ainvoke_with_empty_response_retry( self, state: dict[str, Any], config: RunnableConfig | None = None, - ) -> NemoAgentWrapperOutput: + ) -> NemoStudioCopilotWrapperOutput: """Retry once when the graph silently stops with an empty final answer. Random-routed weaker models can occasionally emit an empty ``stop`` @@ -307,7 +309,7 @@ async def _ainvoke_with_empty_response_retry( if parsed.value.strip(): return parsed - logger.warning("nemo_agent_wrapper received empty final response; prompting agent to continue") + logger.warning("nemo_studio_copilot_wrapper received empty final response; prompting agent to continue") messages = parsed.messages if parsed.messages else state.get("messages", []) messages = self._drop_trailing_empty_assistant_messages(messages) if not messages: @@ -322,17 +324,19 @@ async def _ainvoke_with_empty_response_retry( } return self._parse(await self._graph.ainvoke(retry_state, config=config)) - async def _ainvoke(self, value: NemoAgentWrapperInput) -> NemoAgentWrapperOutput: + async def _ainvoke(self, value: NemoStudioCopilotWrapperInput) -> NemoStudioCopilotWrapperOutput: try: return await self._ainvoke_with_empty_response_retry( self._build_state(value), self._invocation_config(value), ) except Exception as e: - logger.exception("nemo_agent_wrapper _ainvoke failed") + logger.exception("nemo_studio_copilot_wrapper _ainvoke failed") return self._error_output(e) - async def _astream(self, value: NemoAgentWrapperInput) -> AsyncGenerator[NemoAgentWrapperOutput, None]: + async def _astream( + self, value: NemoStudioCopilotWrapperInput + ) -> AsyncGenerator[NemoStudioCopilotWrapperOutput, None]: # Streaming consumers receive graph chunks as they arrive; the # empty-final-response retry lives only on the non-streaming # ``_ainvoke`` path because it requires having the full final state. @@ -343,14 +347,14 @@ async def _astream(self, value: NemoAgentWrapperInput) -> AsyncGenerator[NemoAge ): yield self._parse(chunk) except Exception as e: - logger.exception("nemo_agent_wrapper _astream failed") + logger.exception("nemo_studio_copilot_wrapper _astream failed") # The route has already sent HTTP 200 by the time a streaming # model call fails. Re-raising closes the stream without a final # chunk, which Studio renders as a silent agent. yield self._error_output(e) @staticmethod - def _error_output(error: Exception) -> NemoAgentWrapperOutput: + def _error_output(error: Exception) -> NemoStudioCopilotWrapperOutput: """Turn model transport failures into a safe, visible assistant reply.""" error_chain: list[str] = [] current: BaseException | None = error @@ -367,10 +371,10 @@ def _error_output(error: Exception) -> NemoAgentWrapperOutput: message = _TIMEOUT_MESSAGE else: message = _MODEL_ERROR_MESSAGE - return NemoAgentWrapperOutput(messages=[AIMessage(content=message)]) + return NemoStudioCopilotWrapperOutput(messages=[AIMessage(content=message)]) @staticmethod - def _parse(output: Any) -> NemoAgentWrapperOutput: + def _parse(output: Any) -> NemoStudioCopilotWrapperOutput: """Coerce any LangGraph output (final state OR stream chunk) into our output type. ``LangGraph.astream()`` yields different shapes depending on stream @@ -385,13 +389,13 @@ def _parse(output: Any) -> NemoAgentWrapperOutput: subagent state node) — neither carries messages. We extract ``messages`` if it appears at either nesting level, and - otherwise emit an empty ``NemoAgentWrapperOutput``. ``model_construct`` + otherwise emit an empty ``NemoStudioCopilotWrapperOutput``. ``model_construct`` bypasses validation since we've already chosen the data ourselves. """ if isinstance(output, dict): if "messages" in output: payload = {"messages": output["messages"], **{k: v for k, v in output.items() if k != "messages"}} - return NemoAgentWrapperOutput.model_construct(**payload) + return NemoStudioCopilotWrapperOutput.model_construct(**payload) # Scan all node deltas (not just the single-key shape): under # ``stream_mode="updates"`` LangGraph can fan out and emit a chunk # like ``{"agent": {"messages": [...]}, "tools": {...}}``. We pick @@ -401,8 +405,8 @@ def _parse(output: Any) -> NemoAgentWrapperOutput: for inner in output.values(): if isinstance(inner, dict) and "messages" in inner: payload = {"messages": inner["messages"], **{k: v for k, v in inner.items() if k != "messages"}} - return NemoAgentWrapperOutput.model_construct(**payload) - return NemoAgentWrapperOutput.model_construct(messages=[]) + return NemoStudioCopilotWrapperOutput.model_construct(**payload) + return NemoStudioCopilotWrapperOutput.model_construct(messages=[]) # Converters — called by NAT to translate between this Function's # native types and the chat-completions / string shapes that NAT's @@ -410,13 +414,13 @@ def _parse(output: Any) -> NemoAgentWrapperOutput: # the only delta is the output type they return is our wider one. @staticmethod - def convert_to_str(value: NemoAgentWrapperOutput) -> str: + def convert_to_str(value: NemoStudioCopilotWrapperOutput) -> str: if not value.messages: return "" return value.value @staticmethod - def _extract_usage(value: NemoAgentWrapperOutput) -> Usage: + def _extract_usage(value: NemoStudioCopilotWrapperOutput) -> Usage: """Best-effort token usage extraction from the final assistant message.""" candidates: list[dict[str, Any]] = [] extras = getattr(value, "__pydantic_extra__", None) @@ -487,11 +491,11 @@ def _extract_usage(value: NemoAgentWrapperOutput) -> Usage: ) @staticmethod - def convert_chat_request(value: ChatRequest) -> NemoAgentWrapperInput: + def convert_chat_request(value: ChatRequest) -> NemoStudioCopilotWrapperInput: """Translate NAT's ``ChatRequest`` (chat-completions route body) to our input. NAT's chat-completions handler hands us a ``ChatRequest`` per call; - converting it to ``NemoAgentWrapperInput.messages`` is what lets the + converting it to ``NemoStudioCopilotWrapperInput.messages`` is what lets the ``/chat/completions`` and ``/generate/full`` routes share a single downstream graph invocation. We unpack the role enum and any ``model_dump``-able content parts so the LangGraph state ends up with @@ -505,48 +509,48 @@ def convert_chat_request(value: ChatRequest) -> NemoAgentWrapperInput: content = [part.model_dump() if hasattr(part, "model_dump") else part for part in content] message_dicts.append({"role": role, "content": content}) extras = value.model_extra or {} - return NemoAgentWrapperInput( + return NemoStudioCopilotWrapperInput( messages=message_dicts, studio_session_id=extras.get("studio_session_id"), ) @staticmethod - def convert_str(value: str) -> NemoAgentWrapperInput: + def convert_str(value: str) -> NemoStudioCopilotWrapperInput: """Translate the ``/generate`` route body (a bare string) to our input. NAT's ``/generate`` route accepts a plain string and asks each registered ``str`` converter to lift it into the workflow's input type. We populate ``input_message`` (rather than ``messages``) so the - ``model_validator`` on :class:`NemoAgentWrapperInput` can take the + ``model_validator`` on :class:`NemoStudioCopilotWrapperInput` can take the canonical eval-client path that ``nvidia-nat-eval``'s ``remote_workflow.py`` uses against ``/generate/full`` — see the module docstring for the FastAPI/TypeAdapter rationale. """ - return NemoAgentWrapperInput(input_message=value) + return NemoStudioCopilotWrapperInput(input_message=value) @staticmethod - def convert_to_chat_response(value: NemoAgentWrapperOutput) -> ChatResponse: + def convert_to_chat_response(value: NemoStudioCopilotWrapperOutput) -> ChatResponse: # ``value.value`` already handles dict-backed messages from - # ``model_construct`` (see NemoAgentWrapperOutput.value), so we don't + # ``model_construct`` (see NemoStudioCopilotWrapperOutput.value), so we don't # have to reach into ``messages[-1].text`` here and risk # ``AttributeError`` when streaming chunks contain raw graph dicts. - return ChatResponse.from_string(value.value, usage=NemoAgentWrapperFunction._extract_usage(value)) + return ChatResponse.from_string(value.value, usage=NemoStudioCopilotWrapperFunction._extract_usage(value)) @staticmethod - def convert_to_chat_response_chunk(value: NemoAgentWrapperOutput) -> ChatResponseChunk: + def convert_to_chat_response_chunk(value: NemoStudioCopilotWrapperOutput) -> ChatResponseChunk: return ChatResponseChunk.from_string(value.value) -@register_function(config_type=NemoAgentWrapperConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) -async def register(config: NemoAgentWrapperConfig, b: Builder): - """Build the nemo-agent graph and yield a NAT-callable wrapper. +@register_function(config_type=NemoStudioCopilotWrapperConfig, framework_wrappers=[LLMFrameworkEnum.LANGCHAIN]) +async def register(config: NemoStudioCopilotWrapperConfig, b: Builder): + """Build the nemo-studio-copilot graph and yield a NAT-callable wrapper. - The graph factory in ``register.create_nemo_agent`` constructs the - Deep Agent graph. We hand it directly to ``NemoAgentWrapperFunction``. + The graph factory in ``register.create_nemo_studio_copilot`` constructs the + Deep Agent graph. We hand it directly to ``NemoStudioCopilotWrapperFunction``. NAT calls this once per workflow build and yields the function instance to the runtime; ``_ainvoke`` / ``_astream`` are then driven per request by the FastAPI route handlers. """ - graph = create_nemo_agent() - yield NemoAgentWrapperFunction(config=config, graph=graph) + graph = create_nemo_studio_copilot() + yield NemoStudioCopilotWrapperFunction(config=config, graph=graph) diff --git a/agents/nemo-agent-local/tests/smoke_test.md b/agents/nemo-studio-copilot/tests/smoke_test.md similarity index 90% rename from agents/nemo-agent-local/tests/smoke_test.md rename to agents/nemo-studio-copilot/tests/smoke_test.md index 8d4f7996ab..58dc2ddb78 100644 --- a/agents/nemo-agent-local/tests/smoke_test.md +++ b/agents/nemo-studio-copilot/tests/smoke_test.md @@ -1,4 +1,4 @@ -# NeMo Agent Smoke Test +# NeMo Copilot Smoke Test Covers all changes from the follow-up PR: fallback removal, SyncBuilder-only model resolution, and the `_StreamSafeGraph` Studio streaming fix. @@ -15,7 +15,7 @@ model resolution, and the `_StreamSafeGraph` Studio streaming fix. Run from the worktree root. All tests should pass. ```bash -uv run --frozen pytest plugins/nemo-agents/builtin_agents/nemo-agent/tests/test_nemo_agent.py -v +uv run --frozen pytest agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py -v ``` Verify specifically: @@ -34,7 +34,7 @@ Confirm the ChatOpenAI fallback and subprocess-based CLI tool are gone: ```bash # Should find zero matches in the runtime source. rg -n 'ChatOpenAI|NVIDIA_API_KEY|langchain_openai|nemo_cli|subprocess' \ - plugins/nemo-agents/builtin_agents/nemo-agent/src/nemo_agent/register.py + agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py ``` Expected: no output. @@ -63,15 +63,15 @@ with the real SyncBuilder and API-only platform tools. ```bash # nemo_api tool — workspace CRUD -nemo agents invoke --agent nemo-agent \ +nemo agents invoke --agent nemo-studio-copilot \ --input "List all workspaces on the platform" # nemo_api tool — model and provider discovery -nemo agents invoke --agent nemo-agent \ +nemo agents invoke --agent nemo-studio-copilot \ --input "List the available models and inference providers using the platform API." # Multi-step with nemo_api — create + verify -nemo agents invoke --agent nemo-agent \ +nemo agents invoke --agent nemo-studio-copilot \ --input "Create a workspace called 'smoke-test' with description 'PR follow-up smoke test', then list workspaces to confirm it exists" ``` @@ -90,7 +90,7 @@ This validates that `_StreamSafeGraph` makes agent chat work in the Studio UI. 2. Navigate to **Agents** in the left sidebar. -3. Click the **nemo-agent** row to open the agent panel. +3. Click the **nemo-studio-copilot** row to open the agent panel. 4. Switch to the **Chat Playground** tab. @@ -130,8 +130,8 @@ Without the `_StreamSafeGraph` fix, you would see one of: ## Part 6: Cleanup ```bash -nemo agents undeploy --agent nemo-agent -nemo agents delete nemo-agent +nemo agents undeploy --agent nemo-studio-copilot +nemo agents delete nemo-studio-copilot nemo workspaces delete smoke-test 2>/dev/null nemo secrets delete studio-test-key 2>/dev/null # Ctrl-C the nemo services run process diff --git a/agents/nemo-agent-local/tests/test_nemo_agent.py b/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py similarity index 85% rename from agents/nemo-agent-local/tests/test_nemo_agent.py rename to agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py index 8c1e34bf36..8bc6deefd1 100644 --- a/agents/nemo-agent-local/tests/test_nemo_agent.py +++ b/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py @@ -10,7 +10,7 @@ import yaml from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage from nat.data_models.api_server import ChatRequest -from nemo_agent.register import ( +from nemo_studio_copilot.register import ( DEFAULT_RECURSION_LIMIT, SKILLS_DIR, _active_workspace, @@ -28,10 +28,14 @@ _studio_callback_url, ask_user_question, check_status, - create_nemo_agent, + create_nemo_studio_copilot, nemo_api, ) -from nemo_agent.wrapper import NemoAgentWrapperConfig, NemoAgentWrapperFunction, NemoAgentWrapperOutput +from nemo_studio_copilot.wrapper import ( + NemoStudioCopilotWrapperConfig, + NemoStudioCopilotWrapperFunction, + NemoStudioCopilotWrapperOutput, +) from pydantic import BaseModel # The underlying functions wrapped by langchain's @tool decorator. @@ -41,7 +45,7 @@ _check_status = check_status.func # ty: ignore[unresolved-attribute] _ask_user_question = ask_user_question.func # ty: ignore[unresolved-attribute] -AGENT_CONFIG = Path(__file__).parents[1] / "src" / "nemo_agent" / "nemo-agent.yml" +AGENT_CONFIG = Path(__file__).parents[1] / "src" / "nemo_studio_copilot" / "nemo-studio-copilot.yml" TRUSTED_SESSION_ID = "00000000-0000-4000-8000-000000000001" TRUSTED_CONFIG = {"configurable": {"studio_session_id": TRUSTED_SESSION_ID}} @@ -170,8 +174,8 @@ def test_sdk_uses_deployment_platform_base_url(self, monkeypatch): monkeypatch.delenv("NEMO_BASE_URL", raising=False) with ( - patch("nemo_agent.register._client", None), - patch("nemo_agent.register.NeMoPlatform") as platform_client, + patch("nemo_studio_copilot.register._client", None), + patch("nemo_studio_copilot.register.NeMoPlatform") as platform_client, ): _get_client() @@ -184,8 +188,8 @@ def test_sdk_defaults_to_default_workspace(self, monkeypatch): monkeypatch.delenv("NMP_WORKSPACE", raising=False) with ( - patch("nemo_agent.register._client", None), - patch("nemo_agent.register.NeMoPlatform") as platform_client, + patch("nemo_studio_copilot.register._client", None), + patch("nemo_studio_copilot.register.NeMoPlatform") as platform_client, ): _get_client() @@ -194,9 +198,9 @@ def test_sdk_defaults_to_default_workspace(self, monkeypatch): def test_workspace_create(self, mock_client): with ( - patch("nemo_agent.register._get_client", return_value=mock_client), + patch("nemo_studio_copilot.register._get_client", return_value=mock_client), patch( - "nemo_agent.register._call_studio_tool", + "nemo_studio_copilot.register._call_studio_tool", return_value={"behavior": "allow"}, ), ): @@ -210,7 +214,7 @@ def test_workspace_create(self, mock_client): assert "test-workspace" in str(result) def test_workspace_list(self, mock_client): - with patch("nemo_agent.register._get_client", return_value=mock_client): + with patch("nemo_studio_copilot.register._get_client", return_value=mock_client): result = _nemo_api( resource="workspaces", action="list", @@ -220,7 +224,7 @@ def test_workspace_list(self, mock_client): assert isinstance(result, str) def test_nested_resource(self, mock_client): - with patch("nemo_agent.register._get_client", return_value=mock_client): + with patch("nemo_studio_copilot.register._get_client", return_value=mock_client): _nemo_api( resource="inference.providers", action="list", @@ -230,9 +234,9 @@ def test_nested_resource(self, mock_client): def test_studio_mutation_requires_approval(self, mock_client): with ( - patch("nemo_agent.register._get_client", return_value=mock_client), + patch("nemo_studio_copilot.register._get_client", return_value=mock_client), patch( - "nemo_agent.register._call_studio_tool", + "nemo_studio_copilot.register._call_studio_tool", return_value={"behavior": "deny", "message": "not now"}, ) as studio_tool, ): @@ -259,7 +263,7 @@ def test_studio_mutation_requires_approval(self, mock_client): ) def test_mutation_without_trusted_session_is_denied(self, mock_client): - with patch("nemo_agent.register._get_client", return_value=mock_client): + with patch("nemo_studio_copilot.register._get_client", return_value=mock_client): result = _nemo_api( resource="workspaces", action="create", @@ -282,7 +286,7 @@ def test_studio_callback_uses_deployment_reachable_base_url(self, monkeypatch): "session-id", studio_base_url="https://studio.example/studio", ) == ( - "http://host.docker.internal:8080/studio/api/coding-agents/mcp/session-id" + "http://host.docker.internal:8080/studio/api/copilot/mcp/session-id" "?workspace=developer-workspace&studio_base_url=https%3A%2F%2Fstudio.example%2Fstudio" ) @@ -311,7 +315,7 @@ def test_studio_callback_parses_blocking_sse_result(self): class TestCheckStatusTool: def test_evaluation_job_status(self, mock_client): - with patch("nemo_agent.register._get_client", return_value=mock_client): + with patch("nemo_studio_copilot.register._get_client", return_value=mock_client): result = _check_status( service="evaluation", job_name="job-123", @@ -324,7 +328,7 @@ def test_falls_through_to_next_method(self, mock_client): mock_client.evaluation.benchmark_jobs.get_status.return_value = FakeJobStatus( name="job-456", status="completed" ) - with patch("nemo_agent.register._get_client", return_value=mock_client): + with patch("nemo_studio_copilot.register._get_client", return_value=mock_client): result = _check_status(service="evaluation", job_name="job-456") assert "completed" in result.lower() @@ -390,23 +394,23 @@ def mock_graph(self, mock_llm): """Mock create_deep_agent to return a fake CompiledStateGraph.""" fake_graph = MagicMock() fake_graph.nodes = {"tools": MagicMock(bound=MagicMock(tools_by_name={"nemo_api": 1, "check_status": 1}))} - with patch("nemo_agent.register.create_deep_agent", return_value=fake_graph): + with patch("nemo_studio_copilot.register.create_deep_agent", return_value=fake_graph): yield fake_graph def test_create_agent_returns_stream_safe_graph(self, mock_graph): - from nemo_agent.register import _StreamSafeGraph + from nemo_studio_copilot.register import _StreamSafeGraph - graph = create_nemo_agent() + graph = create_nemo_studio_copilot() assert isinstance(graph, _StreamSafeGraph) def test_stream_safe_graph_delegates_attribute_access(self, mock_graph): - graph = create_nemo_agent() + graph = create_nemo_studio_copilot() _ = graph.nodes assert mock_graph.nodes is not None @pytest.mark.asyncio async def test_stream_safe_graph_forwards_incremental_chunks(self): - from nemo_agent.register import _StreamSafeGraph + from nemo_studio_copilot.register import _StreamSafeGraph class StreamingGraph: def __init__(self): @@ -438,7 +442,7 @@ async def astream(self, input_data, config=None, **kwargs): @pytest.mark.asyncio async def test_stream_safe_graph_defaults_to_assistant_message_chunks(self): - from nemo_agent.register import _StreamSafeGraph + from nemo_studio_copilot.register import _StreamSafeGraph class MessageStreamingGraph: def __init__(self): @@ -487,9 +491,9 @@ async def ainvoke(self, input_data, config=None, **kwargs): assert inner.calls[0][1] == {"recursion_limit": DEFAULT_RECURSION_LIMIT} def test_agent_has_expected_tools(self, mock_graph): - create_nemo_agent() + create_nemo_studio_copilot() - from nemo_agent.register import create_deep_agent + from nemo_studio_copilot.register import create_deep_agent tools = create_deep_agent.call_args.kwargs["tools"] tool_names = {agent_tool.name for agent_tool in tools} @@ -511,10 +515,10 @@ def test_skills_dir_exists(self): def test_create_agent_passes_backend_visible_skills(self, mock_graph): from deepagents.middleware.skills import _list_skills_with_errors # ty: ignore[unresolved-import] - graph = create_nemo_agent() + graph = create_nemo_studio_copilot() assert graph is not None - from nemo_agent.register import create_deep_agent + from nemo_studio_copilot.register import create_deep_agent kwargs = create_deep_agent.call_args.kwargs assert kwargs["skills"] == [str(SKILLS_DIR)] @@ -529,7 +533,7 @@ class TestAskUserQuestion: def test_routes_through_approval_prompt_and_returns_answers(self): with patch( - "nemo_agent.register._call_studio_tool", + "nemo_studio_copilot.register._call_studio_tool", return_value={"behavior": "allow", "updatedInput": {"Model": "70B"}}, ) as studio_tool: result = _ask_user_question( @@ -557,14 +561,14 @@ def test_routes_through_approval_prompt_and_returns_answers(self): def test_declined_returns_message(self): with patch( - "nemo_agent.register._call_studio_tool", + "nemo_studio_copilot.register._call_studio_tool", return_value={"behavior": "deny", "message": "dismissed"}, ): result = _ask_user_question(studio_session_id=TRUSTED_SESSION_ID, questions=self._QUESTIONS) assert result == "User declined to answer: dismissed" def test_invalid_json_is_reported_not_raised(self): - with patch("nemo_agent.register._call_studio_tool") as studio_tool: + with patch("nemo_studio_copilot.register._call_studio_tool") as studio_tool: result = _ask_user_question(studio_session_id=TRUSTED_SESSION_ID, questions="not json") assert result.startswith("Error: `questions` must be a JSON array string") studio_tool.assert_not_called() @@ -581,7 +585,7 @@ def test_invalid_json_is_reported_not_raised(self): ], ) def test_non_question_array_is_rejected_without_calling_studio(self, questions): - with patch("nemo_agent.register._call_studio_tool") as studio_tool: + with patch("nemo_studio_copilot.register._call_studio_tool") as studio_tool: result = _ask_user_question(studio_session_id=TRUSTED_SESSION_ID, questions=questions) assert result == "Error: `questions` must be a non-empty JSON array of question objects." studio_tool.assert_not_called() @@ -597,6 +601,10 @@ class TestDirectListFastPath: [ ("List the available workspaces. Return only their names.", "workspaces"), ("What models are available?", "models"), + ("List the available models in workspace default.", "models"), + ("List the model providers in workspace default.", "inference.providers"), + ("List models for provider X.", "models"), + ("List workspaces containing models.", "workspaces"), ("Show filesets", "files.filesets"), ], ) @@ -621,7 +629,10 @@ def test_list_resource_names_returns_only_names(self): resource = MagicMock() resource.list.return_value = [FakeWorkspace(name="danielleali"), FakeWorkspace(name="default")] - with patch("nemo_agent.register._resolve_resource", return_value=resource): + with ( + patch("nemo_studio_copilot.register._get_client", return_value=MagicMock()), + patch("nemo_studio_copilot.register._resolve_resource", return_value=resource), + ): result = _list_resource_names("workspaces") assert result == "danielleali\ndefault" @@ -630,7 +641,10 @@ def test_list_resource_names_asks_for_workspace_instead_of_falling_back(self): resource = MagicMock() resource.list.side_effect = ValueError("Missing workspace argument") - with patch("nemo_agent.register._resolve_resource", return_value=resource): + with ( + patch("nemo_studio_copilot.register._get_client", return_value=MagicMock()), + patch("nemo_studio_copilot.register._resolve_resource", return_value=resource), + ): result = _list_resource_names("files.filesets") assert result == "Which workspace should I use to list filesets?" @@ -640,7 +654,7 @@ async def test_stream_graph_bypasses_model_for_direct_list(self): inner = MagicMock() graph = _StreamSafeGraph(inner) - with patch("nemo_agent.register._list_resource_names", return_value="danielleali\nsystem\ndefault"): + with patch("nemo_studio_copilot.register._list_resource_names", return_value="danielleali\nsystem\ndefault"): chunks = [ chunk async for chunk in graph.astream( @@ -657,7 +671,7 @@ async def test_stream_graph_does_not_fall_back_after_direct_list_failure(self): graph = _StreamSafeGraph(inner) with patch( - "nemo_agent.register._list_resource_names", + "nemo_studio_copilot.register._list_resource_names", return_value="Which workspace should I use to list filesets?", ): chunks = [ @@ -697,7 +711,10 @@ def test_delete_fileset_executes_once_and_verifies_absence(self): resource = MagicMock() resource.list.return_value = [FakeWorkspace(name="other-fileset")] - with patch("nemo_agent.register._resolve_resource", return_value=resource): + with ( + patch("nemo_studio_copilot.register._get_client", return_value=MagicMock()), + patch("nemo_studio_copilot.register._resolve_resource", return_value=resource), + ): result = _delete_fileset("phishing_dataset") resource.delete.assert_called_once_with(name="phishing_dataset") @@ -708,7 +725,10 @@ def test_delete_fileset_reports_failure_without_model_fallback(self): resource = MagicMock() resource.delete.side_effect = RuntimeError("service unavailable") - with patch("nemo_agent.register._resolve_resource", return_value=resource): + with ( + patch("nemo_studio_copilot.register._get_client", return_value=MagicMock()), + patch("nemo_studio_copilot.register._resolve_resource", return_value=resource), + ): result = _delete_fileset("phishing_dataset") assert "I couldn't delete fileset 'phishing_dataset'" in result @@ -718,7 +738,10 @@ def test_delete_fileset_without_studio_session_retains_direct_behavior(self): resource = MagicMock() resource.list.return_value = [] - with patch("nemo_agent.register._resolve_resource", return_value=resource): + with ( + patch("nemo_studio_copilot.register._get_client", return_value=MagicMock()), + patch("nemo_studio_copilot.register._resolve_resource", return_value=resource), + ): result = _delete_fileset("phishing_dataset") assert result == "Deleted fileset 'phishing_dataset' from workspace 'default'." @@ -731,11 +754,11 @@ async def test_stream_graph_requires_studio_approval_before_direct_fileset_delet with ( patch( - "nemo_agent.register._delete_fileset", + "nemo_studio_copilot.register._delete_fileset", return_value="Deleted fileset 'phishing_dataset' from workspace 'default'.", ) as delete_fileset, patch( - "nemo_agent.register._call_studio_tool", + "nemo_studio_copilot.register._call_studio_tool", return_value={"behavior": "allow"}, ) as studio_tool, ): @@ -771,9 +794,9 @@ async def test_stream_graph_stops_when_studio_denies_direct_fileset_delete(self) graph = _StreamSafeGraph(inner) with ( - patch("nemo_agent.register._delete_fileset") as delete_fileset, + patch("nemo_studio_copilot.register._delete_fileset") as delete_fileset, patch( - "nemo_agent.register._call_studio_tool", + "nemo_studio_copilot.register._call_studio_tool", return_value={"behavior": "deny", "message": "not now"}, ), ): @@ -796,10 +819,10 @@ async def test_stream_graph_without_studio_session_rejects_direct_fileset_delete with ( patch( - "nemo_agent.register._delete_fileset", + "nemo_studio_copilot.register._delete_fileset", return_value="Deleted fileset 'phishing_dataset' from workspace 'default'.", ) as delete_fileset, - patch("nemo_agent.register._call_studio_tool") as studio_tool, + patch("nemo_studio_copilot.register._call_studio_tool") as studio_tool, ): chunks = [ chunk @@ -842,7 +865,7 @@ async def astream(self, state, config=None): raise self.error -class TestNemoAgentWrapper: +class TestNemoStudioCopilotWrapper: @pytest.mark.parametrize( ("messages", "expected"), [ @@ -852,9 +875,9 @@ class TestNemoAgentWrapper: ], ) def test_convert_to_str_normalizes_message_representations(self, messages, expected): - output = NemoAgentWrapperOutput.model_construct(messages=messages) + output = NemoStudioCopilotWrapperOutput.model_construct(messages=messages) - assert NemoAgentWrapperFunction.convert_to_str(output) == expected + assert NemoStudioCopilotWrapperFunction.convert_to_str(output) == expected def test_chat_request_converts_trusted_session_to_invocation_config(self): request = ChatRequest.model_validate( @@ -864,14 +887,14 @@ def test_chat_request_converts_trusted_session_to_invocation_config(self): } ) - value = NemoAgentWrapperFunction.convert_chat_request(request) + value = NemoStudioCopilotWrapperFunction.convert_chat_request(request) - assert NemoAgentWrapperFunction._invocation_config(value) == TRUSTED_CONFIG + assert NemoStudioCopilotWrapperFunction._invocation_config(value) == TRUSTED_CONFIG @pytest.mark.asyncio async def test_wrapper_passes_trusted_session_config_to_graph(self): graph = FakeWrapperGraph([{"messages": [AIMessage(content="done")]}]) - wrapper = NemoAgentWrapperFunction(config=NemoAgentWrapperConfig(), graph=graph) + wrapper = NemoStudioCopilotWrapperFunction(config=NemoStudioCopilotWrapperConfig(), graph=graph) value = wrapper.convert_chat_request( ChatRequest.model_validate( { @@ -895,10 +918,10 @@ async def test_wrapper_passes_trusted_session_config_to_graph(self): ], ) def test_stream_value_hides_tool_messages(self, message): - output = NemoAgentWrapperFunction._parse({"messages": [message]}) + output = NemoStudioCopilotWrapperFunction._parse({"messages": [message]}) assert output.value == "" - assert NemoAgentWrapperFunction.convert_to_chat_response_chunk(output).choices[0].delta.content == "" + assert NemoStudioCopilotWrapperFunction.convert_to_chat_response_chunk(output).choices[0].delta.content == "" @pytest.mark.asyncio @pytest.mark.parametrize( @@ -910,8 +933,8 @@ def test_stream_value_hides_tool_messages(self, message): ], ) async def test_stream_failure_emits_visible_terminal_message(self, error, expected): - wrapper = NemoAgentWrapperFunction( - config=NemoAgentWrapperConfig(), + wrapper = NemoStudioCopilotWrapperFunction( + config=NemoStudioCopilotWrapperConfig(), graph=FailingStreamingGraph(error), ) @@ -928,7 +951,7 @@ async def test_empty_final_response_prompts_continuation(self): {"messages": [AIMessage(content="completed")]}, ] ) - wrapper = NemoAgentWrapperFunction(config=NemoAgentWrapperConfig(), graph=graph) + wrapper = NemoStudioCopilotWrapperFunction(config=NemoStudioCopilotWrapperConfig(), graph=graph) result = await wrapper._ainvoke_with_empty_response_retry({"messages": [HumanMessage(content="do task")]}) @@ -948,7 +971,7 @@ async def test_empty_final_response_retry_falls_back_to_original_state(self): {"messages": [AIMessage(content="completed")]}, ] ) - wrapper = NemoAgentWrapperFunction(config=NemoAgentWrapperConfig(), graph=graph) + wrapper = NemoStudioCopilotWrapperFunction(config=NemoStudioCopilotWrapperConfig(), graph=graph) result = await wrapper._ainvoke_with_empty_response_retry({"messages": [HumanMessage(content="do task")]}) @@ -969,7 +992,7 @@ async def test_empty_final_response_retry_preserves_tool_call_messages(self): {"messages": [AIMessage(content="completed")]}, ] ) - wrapper = NemoAgentWrapperFunction(config=NemoAgentWrapperConfig(), graph=graph) + wrapper = NemoStudioCopilotWrapperFunction(config=NemoStudioCopilotWrapperConfig(), graph=graph) result = await wrapper._ainvoke_with_empty_response_retry({"messages": [HumanMessage(content="do task")]}) @@ -982,7 +1005,7 @@ async def test_empty_final_response_retry_preserves_tool_call_messages(self): @pytest.mark.asyncio async def test_non_empty_final_response_does_not_retry(self): graph = FakeWrapperGraph([{"messages": [AIMessage(content="completed")]}]) - wrapper = NemoAgentWrapperFunction(config=NemoAgentWrapperConfig(), graph=graph) + wrapper = NemoStudioCopilotWrapperFunction(config=NemoStudioCopilotWrapperConfig(), graph=graph) result = await wrapper._ainvoke_with_empty_response_retry({"messages": [HumanMessage(content="do task")]}) diff --git a/agents/nemo-agent-local/uv.lock b/agents/nemo-studio-copilot/uv.lock similarity index 99% rename from agents/nemo-agent-local/uv.lock rename to agents/nemo-studio-copilot/uv.lock index c488cb31a1..7fe1f03fb0 100644 --- a/agents/nemo-agent-local/uv.lock +++ b/agents/nemo-studio-copilot/uv.lock @@ -256,7 +256,7 @@ wheels = [ ] [[package]] -name = "builtin-nemo-agent" +name = "nemo-studio-copilot" version = "0.1.0" source = { editable = "." } dependencies = [ diff --git a/packages/nmp_platform/config/local.yaml b/packages/nmp_platform/config/local.yaml index d2d1164c88..364abe4103 100644 --- a/packages/nmp_platform/config/local.yaml +++ b/packages/nmp_platform/config/local.yaml @@ -183,3 +183,5 @@ files: studio: static_files_path: web/packages/studio/dist sandbox_enabled: true + feature_flags: + copilot_studio_enabled: true diff --git a/plugins/nemo-switchyard/vendor/switchyard/switchyard/lib/processors/intake_client.py b/plugins/nemo-switchyard/vendor/switchyard/switchyard/lib/processors/intake_client.py index 94954e6bb9..1f40059a23 100644 --- a/plugins/nemo-switchyard/vendor/switchyard/switchyard/lib/processors/intake_client.py +++ b/plugins/nemo-switchyard/vendor/switchyard/switchyard/lib/processors/intake_client.py @@ -32,7 +32,7 @@ ) _SDK_LOGIN_HINT = ( - "Run `uv run nmp auth login --base-url https://nmp.dev.aire.nvidia.com`, " + "Run `uv run nmp auth login --base-url https://platform.example.com`, " "or pass --intake-base-url and --intake-api-key." ) diff --git a/pyproject.toml b/pyproject.toml index 2cf661b83f..32e884a636 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -557,9 +557,9 @@ extra-paths = [ # nemo-agents plugin is not a workspace member; add its src so ty can # resolve nemo_agents_plugin imports when checking plugin test files. "plugins/nemo-agents/src", - # The built-in NeMo Agent is a standalone deployable with its own lockfile; - # add its src so ty can resolve nemo_agent imports from its tests. - "agents/nemo-agent-local/src", + # The built-in NeMo Copilot is a standalone deployable with its own lockfile; + # add its src so ty can resolve nemo_studio_copilot imports from its tests. + "agents/nemo-studio-copilot/src", # example-plugin is not a workspace member; add its src so ty can # resolve nmp.example_plugin imports when checking plugin test files. "plugins/example-plugin/src", diff --git a/services/studio/src/nmp/studio/coding_agents.py b/services/studio/src/nmp/studio/copilot.py similarity index 93% rename from services/studio/src/nmp/studio/coding_agents.py rename to services/studio/src/nmp/studio/copilot.py index a26afed727..be2caf4270 100644 --- a/services/studio/src/nmp/studio/coding_agents.py +++ b/services/studio/src/nmp/studio/copilot.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Local coding-agent bridge for Studio.""" +"""Local copilot bridge for Studio.""" import asyncio import json @@ -23,12 +23,12 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse from nmp.common.entities.constants import NAME_PATTERN from nmp.studio import studio_links -from nmp.studio.coding_agent_artifacts import ( +from nmp.studio.copilot_artifacts import ( ChatArtifactsResponse, InputSelectionTool, answer_selection_pairs, record_answer_selections, - record_coding_agent_model, + record_copilot_model, record_spec_text_artifacts, record_tool_artifacts, record_tool_name, @@ -36,7 +36,7 @@ record_workspace_artifact, string_value, ) -from nmp.studio.coding_agent_mcp_tools import ( +from nmp.studio.copilot_mcp_tools import ( APPROVAL_TOOL_NAME, CLAUDE_MCP_SERVER_NAME, JOB_PROGRESS_TOOL_NAME, @@ -45,28 +45,28 @@ SELECT_DATASET_FILE_TOOL_NAME, SELECT_EVAL_CONFIG_TOOL_NAME, SELECT_MODEL_TOOL_NAME, - STUDIO_CODING_AGENT_CONTEXT, + STUDIO_COPILOT_CONTEXT, STUDIO_LINK_TOOL_NAME, allowed_mcp_tools, permission_prompt_tool, ) -from nmp.studio.coding_agent_skills import ClaudeSkillResponse, DuplicateSkillError, list_claude_skill_responses +from nmp.studio.copilot_skills import ClaudeSkillResponse, DuplicateSkillError, list_claude_skill_responses from pydantic import BaseModel, ConfigDict, Field from starlette.routing import NoMatchFound logger = logging.getLogger(__name__) -router = APIRouter(prefix="/v2/coding-agents") +router = APIRouter(prefix="/v2/copilot") -MCP_ROUTE_NAME = "studio_coding_agent_mcp" -PUBLIC_MCP_ROUTE_NAME = "studio_coding_agent_public_mcp" -PUBLIC_MCP_UNSUPPORTED_METHOD_ROUTE_NAME = "studio_coding_agent_public_mcp_unsupported_method" -PUBLIC_MCP_PATH = "/studio/api/coding-agents/mcp/{session_id}" +MCP_ROUTE_NAME = "studio_copilot_mcp" +PUBLIC_MCP_ROUTE_NAME = "studio_copilot_public_mcp" +PUBLIC_MCP_UNSUPPORTED_METHOD_ROUTE_NAME = "studio_copilot_public_mcp_unsupported_method" +PUBLIC_MCP_PATH = "/studio/api/copilot/mcp/{session_id}" CLAUDE_MCP_TOOL_TIMEOUT_MS = 2_147_483_647 MCP_KEEPALIVE_INTERVAL_SECONDS = 15 -DEFAULT_STUDIO_CODING_AGENT_NAME = "nemo-agent-local-poc" -DEFAULT_STUDIO_CODING_AGENT_BASE_URL = "http://127.0.0.1:8080" -STUDIO_CODING_AGENT_TIMEOUT_SECONDS = 600.0 +DEFAULT_STUDIO_COPILOT_NAME = "nemo-studio-copilot" +DEFAULT_STUDIO_COPILOT_BASE_URL = "http://127.0.0.1:8080" +STUDIO_COPILOT_TIMEOUT_SECONDS = 600.0 MAX_RETAINED_SESSIONS = 100 MAX_RETAINED_TURNS_PER_SESSION = 50 @@ -81,13 +81,13 @@ class NewSessionResponse(BaseModel): - """Response returned when Studio starts a new coding-agent session.""" + """Response returned when Studio starts a new copilot session.""" session_id: str class MessageRequest(BaseModel): - """A user message to send to the local coding agent.""" + """A user message to send to the local copilot.""" message: str = Field(min_length=1) studio_base_url: str | None = Field(default=None, min_length=1) @@ -332,7 +332,7 @@ def _build_studio_system_prompt( current_studio_route = _trimmed_string(studio_pathname) or "unknown" destinations = studio_links.STUDIO_LINK_DESTINATIONS if enabled_destinations is None else enabled_destinations lines = [ - "You are NeMo Agent, running inside NeMo Studio.", + "You are NeMo Copilot, running inside NeMo Studio.", f"Current Studio workspace: {workspace or 'unknown'}", f"Studio UI base URL: {normalized_base_url or 'unknown'}", f"Current Studio route path: {current_studio_route}", @@ -434,15 +434,15 @@ def _build_studio_system_prompt( return "\n".join(lines) -def _build_nemo_agent_system_prompt( +def _build_copilot_system_prompt( session_id: str, workspace: str | None, studio_base_url: str | None, studio_pathname: str | None, enabled_destinations: Mapping[str, studio_links.StudioLinkDestination] | None = None, ) -> str: - """Adapt the existing Studio coding-agent rules to the deployed NeMo agent tools.""" - context = STUDIO_CODING_AGENT_CONTEXT + """Adapt the existing Studio copilot rules to the deployed NeMo Copilot tools.""" + context = STUDIO_COPILOT_CONTEXT prompt = _build_studio_system_prompt( workspace, studio_base_url, @@ -470,9 +470,9 @@ def _build_nemo_agent_system_prompt( [ context, prompt, - "Your identity in this interface is NeMo Agent.", + "Your identity in this interface is NeMo Copilot.", "Do not describe yourself as a different assistant or provider.", - "Deployed NeMo agent callback behavior:", + "Deployed NeMo Copilot callback behavior:", ( f"For every select_agent, select_model, select_dataset_file, select_eval_config, " f"job_progress, studio_link, and ask_user_question call, pass studio_session_id='{session_id}'." @@ -614,7 +614,7 @@ def _summarize_history_session(path: Path) -> HistorySummary: message = entry.get("message") if isinstance(message, dict): - record_coding_agent_model(summary.chat_artifacts, string_value(message.get("model"))) + record_copilot_model(summary.chat_artifacts, string_value(message.get("model"))) usage_identity = _usage_identity(entry, message) if usage_identity is None or usage_identity not in seen_usage_events: summary.token_count += _usage_token_count(message.get("usage")) @@ -685,7 +685,7 @@ def _extract_assistant_parts(content: Any) -> list[dict[str, Any]]: @router.post("/sessions", response_model=NewSessionResponse) def create_session() -> NewSessionResponse: - """Create a new local coding-agent session.""" + """Create a new local copilot session.""" session_id = str(uuid.uuid4()) _session_conversations[session_id] = [] _session_mtimes[session_id] = time.time() @@ -695,7 +695,7 @@ def create_session() -> NewSessionResponse: @router.get("/history/sessions", response_model=list[HistorySessionResponse]) def list_history_sessions() -> list[HistorySessionResponse]: - """List active retained NeMo Agent sessions.""" + """List active retained NeMo Copilot sessions.""" _evict_oldest_sessions() sessions: list[HistorySessionResponse] = [] for session_id, messages in _session_conversations.items(): @@ -796,7 +796,7 @@ def _history_user_interaction_texts( @router.get("/history/sessions/{session_id}", response_model=SessionHistoryResponse) def get_session_history(session_id: str) -> SessionHistoryResponse: - """Load a NeMo Agent session or legacy Claude history for replay.""" + """Load a NeMo Copilot session or legacy Claude history for replay.""" sid = _validate_session_id(session_id) conversation = _session_conversations.get(sid) if conversation is not None: @@ -896,7 +896,7 @@ def _mcp_url( return f"{url}?{urlencode(query_params)}" if query_params else url except NoMatchFound: continue - raise RuntimeError("Studio coding-agent MCP route is not mounted") + raise RuntimeError("Studio copilot MCP route is not mounted") def _build_claude_argv( @@ -932,7 +932,7 @@ def _build_claude_argv( "--allowedTools", ",".join(allowed_mcp_tools(CLAUDE_MCP_SERVER_NAME)), "--append-system-prompt", - STUDIO_CODING_AGENT_CONTEXT, + STUDIO_COPILOT_CONTEXT, "--permission-prompt-tool", permission_prompt_tool(CLAUDE_MCP_SERVER_NAME), ] @@ -1013,7 +1013,7 @@ async def _blocking_mcp_tool_response( async def _request_permission(session_id: str, args: dict[str, Any]) -> dict[str, Any]: queue = _session_streams.get(session_id) if queue is None: - return {"behavior": "deny", "message": "no active Studio coding-agent session"} + return {"behavior": "deny", "message": "no active Studio copilot session"} request_id = str(uuid.uuid4()) loop = asyncio.get_running_loop() @@ -1046,7 +1046,7 @@ async def _request_permission(session_id: str, args: dict[str, Any]) -> dict[str async def _request_agent_input(session_id: str, kind: str, args: dict[str, Any]) -> dict[str, Any]: queue = _session_streams.get(session_id) if queue is None: - return {"status": "error", "message": "no active Studio coding-agent session"} + return {"status": "error", "message": "no active Studio copilot session"} request_id = str(uuid.uuid4()) loop = asyncio.get_running_loop() @@ -1213,15 +1213,15 @@ async def _stream_claude( task.cancel() -def _studio_coding_agent_name() -> str: - return _trimmed_string(os.environ.get("STUDIO_CODING_AGENT_NAME")) or DEFAULT_STUDIO_CODING_AGENT_NAME +def _studio_copilot_name() -> str: + return _trimmed_string(os.environ.get("STUDIO_COPILOT_NAME")) or DEFAULT_STUDIO_COPILOT_NAME -def _studio_coding_agent_base_url() -> str: +def _studio_copilot_base_url() -> str: base_url = ( - _trimmed_string(os.environ.get("STUDIO_CODING_AGENT_BASE_URL")) + _trimmed_string(os.environ.get("STUDIO_COPILOT_BASE_URL")) or _trimmed_string(os.environ.get("NMP_BASE_URL")) - or DEFAULT_STUDIO_CODING_AGENT_BASE_URL + or DEFAULT_STUDIO_COPILOT_BASE_URL ) parsed = urlparse(base_url) if ( @@ -1232,7 +1232,7 @@ def _studio_coding_agent_base_url() -> str: or parsed.query or parsed.fragment ): - raise RuntimeError("The configured coding-agent base URL is invalid") + raise RuntimeError("The configured copilot base URL is invalid") return base_url.rstrip("/") @@ -1251,17 +1251,20 @@ def _workspace_path_segment(workspace: str) -> str: return quote(workspace, safe="") -def _studio_coding_agent_url(workspace: str) -> str: +def _studio_copilot_url(workspace: str) -> str: # MessageRequest validates workspace against the platform's restricted entity-name # pattern before it can become part of this internal request path. return ( - f"{_studio_coding_agent_base_url()}/apis/agents/v2/workspaces/{_workspace_path_segment(workspace)}" - f"/agents/{quote(_studio_coding_agent_name(), safe='')}/-/v1/chat/completions" + f"{_studio_copilot_base_url()}/apis/agents/v2/workspaces/{_workspace_path_segment(workspace)}" + f"/agents/{quote(_studio_copilot_name(), safe='')}/-/v1/chat/completions" ) -def _coding_agent_request_headers(request: Request) -> dict[str, str]: - """Forward end-user auth context when the Studio service invokes the agent gateway.""" +def _copilot_request_headers(request: Request, agent_url: str) -> dict[str, str]: + """Forward end-user auth context only over an encrypted connection.""" + if urlparse(agent_url).scheme != "https": + return {} + forwarded = {} for name in ("authorization", "cookie"): value = request.headers.get(name) @@ -1270,29 +1273,29 @@ def _coding_agent_request_headers(request: Request) -> dict[str, str]: return forwarded -def _nemo_agent_response(body: Any) -> tuple[str, str]: +def _copilot_response(body: Any) -> tuple[str, str]: if not isinstance(body, dict): - raise RuntimeError("NeMo Agent returned a non-object response") + raise RuntimeError("NeMo Copilot returned a non-object response") choices = body.get("choices") if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): - raise RuntimeError("NeMo Agent response did not include a choice") + raise RuntimeError("NeMo Copilot response did not include a choice") message = choices[0].get("message") if not isinstance(message, dict) or not isinstance(message.get("content"), str): - raise RuntimeError("NeMo Agent response did not include assistant text") - model = _trimmed_string(body.get("model")) or _studio_coding_agent_name() + raise RuntimeError("NeMo Copilot response did not include assistant text") + model = _trimmed_string(body.get("model")) or _studio_copilot_name() return message["content"], model -def _nemo_agent_error_detail(exc: httpx.HTTPError | RuntimeError | ValueError) -> str: +def _copilot_error_detail(exc: httpx.HTTPError | RuntimeError | ValueError) -> str: """Return a safe client-facing error without leaking exception or upstream response details.""" if isinstance(exc, httpx.HTTPStatusError): - return f"The deployed NeMo Agent returned HTTP {exc.response.status_code}." + return f"The deployed NeMo Copilot returned HTTP {exc.response.status_code}." if isinstance(exc, httpx.HTTPError): - return "The deployed NeMo Agent could not be reached." - return "The deployed NeMo Agent returned an invalid response." + return "The deployed NeMo Copilot could not be reached." + return "The deployed NeMo Copilot returned an invalid response." -def _nemo_agent_request_payload( +def _copilot_request_payload( messages: list[dict[str, str]], studio_session_id: str, ) -> dict[str, Any]: @@ -1303,7 +1306,7 @@ def _nemo_agent_request_payload( } -async def _invoke_nemo_agent( +async def _invoke_copilot( agent_url: str, headers: Mapping[str, str], messages: list[dict[str, str]], @@ -1311,7 +1314,7 @@ async def _invoke_nemo_agent( ) -> tuple[str, str]: timeout = httpx.Timeout( connect=10.0, - read=STUDIO_CODING_AGENT_TIMEOUT_SECONDS, + read=STUDIO_COPILOT_TIMEOUT_SECONDS, write=60.0, pool=10.0, ) @@ -1322,20 +1325,20 @@ async def _invoke_nemo_agent( response = await client.post( agent_url, headers=dict(headers), - json=_nemo_agent_request_payload(messages, studio_session_id), + json=_copilot_request_payload(messages, studio_session_id), ) response.raise_for_status() - return _nemo_agent_response(response.json()) + return _copilot_response(response.json()) -async def _stream_nemo_agent( +async def _stream_copilot( session_id: str, message: str, agent_url: str, headers: Mapping[str, str], studio_system_prompt: str, ) -> AsyncIterator[str]: - """Invoke the deployed NeMo agent while preserving Studio's blocking UI event protocol.""" + """Invoke the deployed NeMo Copilot while preserving Studio's blocking UI event protocol.""" if session_id in _session_streams: yield _sse( json.dumps({"message": "session already has an active stream"}), @@ -1357,7 +1360,7 @@ async def _stream_nemo_agent( ) request_messages = [*conversation, {"role": "user", "content": contextual_message}] invocation = asyncio.create_task( - _invoke_nemo_agent( + _invoke_copilot( agent_url, headers, request_messages, @@ -1405,7 +1408,7 @@ async def _stream_nemo_agent( { "type": "assistant", "message": { - "id": f"nemo-agent-{uuid.uuid4()}", + "id": f"nemo-studio-copilot-{uuid.uuid4()}", "model": model, "content": [{"type": "text", "text": assistant_text}], }, @@ -1417,9 +1420,9 @@ async def _stream_nemo_agent( invocation.cancel() raise except (httpx.HTTPError, RuntimeError, ValueError) as exc: - logger.exception("NeMo Agent invocation failed for session %s", session_id) + logger.exception("NeMo Copilot invocation failed for session %s", session_id) yield _sse( - json.dumps({"message": _nemo_agent_error_detail(exc)}), + json.dumps({"message": _copilot_error_detail(exc)}), event="error", ) finally: @@ -1433,13 +1436,14 @@ async def _stream_nemo_agent( @router.post("/sessions/{session_id}/messages") async def send_message(session_id: str, body: MessageRequest, request: Request) -> StreamingResponse: - """Send a message to the deployed NeMo Agent and stream Studio events.""" + """Send a message to the deployed NeMo Copilot and stream Studio events.""" sid = _validate_session_id(session_id) workspace = _validated_workspace_or_default(body.workspace) + agent_url = _studio_copilot_url(workspace) studio_base_url = _studio_base_url_from_request(body, request) studio_pathname = _studio_pathname_from_request(body, request) enabled_destinations = studio_links.enabled_destinations_from_request(request) - system_prompt = _build_nemo_agent_system_prompt( + system_prompt = _build_copilot_system_prompt( sid, workspace, studio_base_url, @@ -1447,14 +1451,15 @@ async def send_message(session_id: str, body: MessageRequest, request: Request) enabled_destinations, ) return StreamingResponse( - _stream_nemo_agent( + _stream_copilot( sid, body.message, - _studio_coding_agent_url(workspace), - _coding_agent_request_headers(request), + agent_url, + _copilot_request_headers(request, agent_url), system_prompt, ), media_type="text/event-stream", + headers={"Cache-Control": "no-store"}, ) @@ -1576,8 +1581,8 @@ async def mcp_endpoint(session_id: str, request: Request) -> Response: { "type": "assistant", "message": { - "id": f"nemo-agent-tool-{uuid.uuid4()}", - "model": _studio_coding_agent_name(), + "id": f"nemo-studio-copilot-tool-{uuid.uuid4()}", + "model": _studio_copilot_name(), "content": [ { "type": "tool_use", diff --git a/services/studio/src/nmp/studio/coding_agent_artifacts.py b/services/studio/src/nmp/studio/copilot_artifacts.py similarity index 97% rename from services/studio/src/nmp/studio/coding_agent_artifacts.py rename to services/studio/src/nmp/studio/copilot_artifacts.py index 4c965d98d9..49ceefc338 100644 --- a/services/studio/src/nmp/studio/coding_agent_artifacts.py +++ b/services/studio/src/nmp/studio/copilot_artifacts.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Artifact extraction for Studio coding-agent chat history.""" +"""Artifact extraction for Studio copilot chat history.""" import json import re @@ -20,14 +20,14 @@ class ChatSelectionArtifactResponse(BaseModel): class ChatFileArtifactResponse(BaseModel): - """A file touched by the local coding agent.""" + """A file touched by the local copilot.""" action: str path: str class ChatLinkArtifactResponse(BaseModel): - """A Studio link requested by the local coding agent.""" + """A Studio link requested by the local copilot.""" label: str destination: str | None = None @@ -49,7 +49,7 @@ class ChatArtifactsResponse(BaseModel): agent: str | None = None model: str | None = None model_source: str | None = None - coding_agent_model: str | None = None + copilot_model: str | None = None workspace: str | None = None selections: list[ChatSelectionArtifactResponse] = Field(default_factory=list) files: list[ChatFileArtifactResponse] = Field(default_factory=list) @@ -124,10 +124,10 @@ def record_tool_name(artifacts: ChatArtifactsResponse, tool_name: str) -> None: _append_unique_string(artifacts.tools, tool_name) -def record_coding_agent_model(artifacts: ChatArtifactsResponse, model: str | None) -> None: +def record_copilot_model(artifacts: ChatArtifactsResponse, model: str | None) -> None: if not model: return - artifacts.coding_agent_model = model + artifacts.copilot_model = model def _set_spec_model(artifacts: ChatArtifactsResponse, model: str) -> None: diff --git a/services/studio/src/nmp/studio/coding_agent_mcp_tools.py b/services/studio/src/nmp/studio/copilot_mcp_tools.py similarity index 98% rename from services/studio/src/nmp/studio/coding_agent_mcp_tools.py rename to services/studio/src/nmp/studio/copilot_mcp_tools.py index 0c93ce5afd..88212ef938 100644 --- a/services/studio/src/nmp/studio/coding_agent_mcp_tools.py +++ b/services/studio/src/nmp/studio/copilot_mcp_tools.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""MCP tool catalog for Studio coding-agent sessions.""" +"""MCP tool catalog for Studio copilot sessions.""" from typing import Any @@ -177,9 +177,9 @@ STUDIO_LINK_TOOL_NAME, ) -STUDIO_CODING_AGENT_CONTEXT = "\n".join( +STUDIO_COPILOT_CONTEXT = "\n".join( [ - "You are NeMo Agent, running inside NeMo Studio.", + "You are NeMo Copilot, running inside NeMo Studio.", ( "NeMo Studio and the NeMo Platform API are already installed, set up, and running for this " "workspace. Treat the platform as healthy and available." diff --git a/services/studio/src/nmp/studio/coding_agent_skills.py b/services/studio/src/nmp/studio/copilot_skills.py similarity index 99% rename from services/studio/src/nmp/studio/coding_agent_skills.py rename to services/studio/src/nmp/studio/copilot_skills.py index 39cdb04ea1..1bebd4c30b 100644 --- a/services/studio/src/nmp/studio/coding_agent_skills.py +++ b/services/studio/src/nmp/studio/copilot_skills.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Claude skill discovery helpers for the Studio coding-agent bridge.""" +"""Claude skill discovery helpers for the Studio copilot bridge.""" import logging from collections.abc import Iterable diff --git a/services/studio/src/nmp/studio/env_mappings.py b/services/studio/src/nmp/studio/env_mappings.py index 5d6b995608..d1b43a30ef 100644 --- a/services/studio/src/nmp/studio/env_mappings.py +++ b/services/studio/src/nmp/studio/env_mappings.py @@ -70,8 +70,8 @@ class EnvMapping: default="true", ), EnvMapping( - marker="STUDIO_UI_VITE_FF_CODING_AGENT_STUDIO_ENABLED", - config_path="studio.feature_flags.coding_agent_studio_enabled", + marker="STUDIO_UI_VITE_FF_COPILOT_STUDIO_ENABLED", + config_path="studio.feature_flags.copilot_studio_enabled", default="false", ), EnvMapping( diff --git a/services/studio/src/nmp/studio/service.py b/services/studio/src/nmp/studio/service.py index 7a05c52823..6cfc9d68b6 100644 --- a/services/studio/src/nmp/studio/service.py +++ b/services/studio/src/nmp/studio/service.py @@ -13,7 +13,7 @@ from fastapi.responses import HTMLResponse from nmp.common.http_clients import shared_async_http_client from nmp.common.service import RouterConfig, Service -from nmp.studio import coding_agents +from nmp.studio import copilot from nmp.studio.config import StudioConfig from nmp.studio.static_files import SPAStaticFiles from starlette.responses import Response @@ -65,7 +65,7 @@ def title(self) -> str: @property def description(self) -> str: """Service description for OpenAPI docs.""" - return "Serves the NeMo Studio web application and local coding-agent bridge" + return "Serves the NeMo Studio web application and local copilot bridge" def get_routers(self) -> List[RouterConfig]: """Return routers for the studio service. @@ -75,9 +75,9 @@ def get_routers(self) -> List[RouterConfig]: """ return [ RouterConfig( - coding_agents.router, - tag="Studio Coding Agents", - description="Local coding-agent bridge endpoints", + copilot.router, + tag="NeMo Copilot", + description="Local copilot bridge endpoints", ) ] @@ -91,12 +91,12 @@ def configure_app(self, app: FastAPI) -> None: app: The platform's FastAPI application """ self._mount_telemetry_proxy(app) - self._mount_coding_agent_mcp(app) + self._mount_copilot_mcp(app) self._mount_static_files(app) - def _mount_coding_agent_mcp(self, app: FastAPI) -> None: + def _mount_copilot_mcp(self, app: FastAPI) -> None: """Mount the auth-bypassed MCP callback before the /studio static app.""" - coding_agents.mount_public_mcp_route(app) + copilot.mount_public_mcp_route(app) def _get_config(self) -> StudioConfig: """Get the studio config, creating a default if none is set. diff --git a/services/studio/src/nmp/studio/studio_links.py b/services/studio/src/nmp/studio/studio_links.py index 5568376f04..49df086746 100644 --- a/services/studio/src/nmp/studio/studio_links.py +++ b/services/studio/src/nmp/studio/studio_links.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Studio link destinations and MCP tool helpers for the coding-agent bridge.""" +"""Studio link destinations and MCP tool helpers for the copilot bridge.""" from collections.abc import Mapping from copy import deepcopy @@ -32,10 +32,10 @@ class StudioLinkDestination: aliases=("workspace_home", "workspace_index"), ), "dashboard": StudioLinkDestination("Workspace dashboard", "/workspaces/{workspace}/dashboard"), - "code_agent": StudioLinkDestination( - "Code Agent", - "/workspaces/{workspace}/dashboard/code-agent", - aliases=("claude_code", "claude_code_chat", "coding_agent", "coding_agent_chat"), + "copilot": StudioLinkDestination( + "NeMo Copilot", + "/workspaces/{workspace}/dashboard/copilot", + aliases=("claude_code", "claude_code_chat", "copilot_chat"), ), "agents": StudioLinkDestination( "Agents", @@ -352,7 +352,7 @@ class StudioLinkDestination: } _STUDIO_LINK_DESTINATION_FEATURE_FLAGS: dict[str, tuple[str, ...]] = { - "code_agent": ("coding_agent_studio_enabled",), + "copilot": ("copilot_studio_enabled",), "agents": ("agents_enabled",), "agent": ("agents_enabled",), "agent_chat": ("agents_enabled",), @@ -413,7 +413,7 @@ class StudioLinkDestination: } _STUDIO_LINK_DESTINATION_ANY_FEATURE_FLAGS: dict[str, tuple[str, ...]] = { - "dashboard": ("dashboard_enabled", "coding_agent_studio_enabled"), + "dashboard": ("dashboard_enabled", "copilot_studio_enabled"), } _STUDIO_FEATURE_FLAG_MAPPINGS = { diff --git a/services/studio/tests/unit/test_coding_agents.py b/services/studio/tests/unit/test_copilot.py similarity index 82% rename from services/studio/tests/unit/test_coding_agents.py rename to services/studio/tests/unit/test_copilot.py index df05d53187..4939f2caab 100644 --- a/services/studio/tests/unit/test_coding_agents.py +++ b/services/studio/tests/unit/test_copilot.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Unit tests for the Studio local coding-agent bridge.""" +"""Unit tests for the Studio local copilot bridge.""" import asyncio import json @@ -13,29 +13,29 @@ import httpx import pytest -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient -from nmp.studio import coding_agent_artifacts, coding_agent_skills, coding_agents, studio_links +from nmp.studio import copilot, copilot_artifacts, copilot_skills, studio_links from nmp.studio.config import StudioConfig from nmp.studio.service import StudioService @pytest.fixture(autouse=True) -def reset_coding_agent_state(): +def reset_copilot_state(): """Reset module-level bridge state between tests.""" - coding_agents._initialized_sessions.clear() - coding_agents._session_streams.clear() - coding_agents._pending_permissions.clear() - coding_agents._pending_agent_inputs.clear() - coding_agents._session_conversations.clear() - coding_agents._session_mtimes.clear() + copilot._initialized_sessions.clear() + copilot._session_streams.clear() + copilot._pending_permissions.clear() + copilot._pending_agent_inputs.clear() + copilot._session_conversations.clear() + copilot._session_mtimes.clear() yield - coding_agents._initialized_sessions.clear() - coding_agents._session_streams.clear() - coding_agents._pending_permissions.clear() - coding_agents._pending_agent_inputs.clear() - coding_agents._session_conversations.clear() - coding_agents._session_mtimes.clear() + copilot._initialized_sessions.clear() + copilot._session_streams.clear() + copilot._pending_permissions.clear() + copilot._pending_agent_inputs.clear() + copilot._session_conversations.clear() + copilot._session_mtimes.clear() @pytest.fixture @@ -55,9 +55,9 @@ def service_client_with_feature_flags( def test_history_preserves_distinct_answers_under_an_agent_header(): - artifacts = coding_agent_artifacts.ChatArtifactsResponse() + artifacts = copilot_artifacts.ChatArtifactsResponse() question_labels_by_tool_use_id: dict[str, dict[str, str]] = {} - input_selection_tools_by_tool_use_id: dict[str, coding_agent_artifacts.InputSelectionTool] = {} + input_selection_tools_by_tool_use_id: dict[str, copilot_artifacts.InputSelectionTool] = {} questions = [ { "header": "Agent", @@ -71,7 +71,7 @@ def test_history_preserves_distinct_answers_under_an_agent_header(): }, ] - coding_agent_artifacts.record_tool_artifacts( + copilot_artifacts.record_tool_artifacts( artifacts, "AskUserQuestion", {"questions": questions}, @@ -79,7 +79,7 @@ def test_history_preserves_distinct_answers_under_an_agent_header(): question_labels_by_tool_use_id, input_selection_tools_by_tool_use_id, ) - coding_agent_artifacts.record_user_tool_result_artifacts( + copilot_artifacts.record_user_tool_result_artifacts( artifacts, [ { @@ -104,9 +104,9 @@ def test_history_preserves_distinct_answers_under_an_agent_header(): def test_history_records_studio_picker_results_as_selections(): - artifacts = coding_agent_artifacts.ChatArtifactsResponse() + artifacts = copilot_artifacts.ChatArtifactsResponse() question_labels_by_tool_use_id: dict[str, dict[str, str]] = {} - input_selection_tools_by_tool_use_id: dict[str, coding_agent_artifacts.InputSelectionTool] = {} + input_selection_tools_by_tool_use_id: dict[str, copilot_artifacts.InputSelectionTool] = {} picker_calls = [ ("toolu_agent", "mcp__nemo_studio__select_agent", {}, {"agent": "calculator-agent"}), ( @@ -130,7 +130,7 @@ def test_history_records_studio_picker_results_as_selections(): ] for tool_use_id, tool_name, tool_input, _result in picker_calls: - coding_agent_artifacts.record_tool_artifacts( + copilot_artifacts.record_tool_artifacts( artifacts, tool_name, tool_input, @@ -139,7 +139,7 @@ def test_history_records_studio_picker_results_as_selections(): input_selection_tools_by_tool_use_id, ) - coding_agent_artifacts.record_user_tool_result_artifacts( + copilot_artifacts.record_user_tool_result_artifacts( artifacts, [ { @@ -180,8 +180,8 @@ def _inference_source_dir(root: Path) -> Path: return source_dir -def _inference_skill(source_dir: Path) -> coding_agent_skills.Skill: - return coding_agent_skills.Skill( +def _inference_skill(source_dir: Path) -> copilot_skills.Skill: + return copilot_skills.Skill( name="inference", description="Use NeMo Platform inference.", version="0.1", @@ -212,7 +212,7 @@ def test_vendored_load_skills_from_root_loads_selected_root_without_registry_pri encoding="utf-8", ) - loaded = coding_agent_skills.load_skills_from_root( + loaded = copilot_skills.load_skills_from_root( tmp_path / "packages" / "nemo_platform_ext" / "skills", source_plugin="platform", source_dist="nemo-platform-ext", @@ -226,7 +226,7 @@ def test_vendored_load_skills_from_root_loads_selected_root_without_registry_pri def test_create_session_returns_uuid(service_client: TestClient): - response = service_client.post("/v2/coding-agents/sessions") + response = service_client.post("/v2/copilot/sessions") assert response.status_code == 200 uuid.UUID(response.json()["session_id"]) @@ -236,23 +236,23 @@ def test_create_session_evicts_least_recently_updated_session( service_client: TestClient, monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(coding_agents, "MAX_RETAINED_SESSIONS", 2) - first_session_id = service_client.post("/v2/coding-agents/sessions").json()["session_id"] - coding_agents._session_mtimes[first_session_id] = 1 - second_session_id = service_client.post("/v2/coding-agents/sessions").json()["session_id"] - coding_agents._session_mtimes[second_session_id] = 2 + monkeypatch.setattr(copilot, "MAX_RETAINED_SESSIONS", 2) + first_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] + copilot._session_mtimes[first_session_id] = 1 + second_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] + copilot._session_mtimes[second_session_id] = 2 - third_session_id = service_client.post("/v2/coding-agents/sessions").json()["session_id"] + third_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] - assert set(coding_agents._session_conversations) == {second_session_id, third_session_id} - assert set(coding_agents._session_mtimes) == {second_session_id, third_session_id} + assert set(copilot._session_conversations) == {second_session_id, third_session_id} + assert set(copilot._session_mtimes) == {second_session_id, third_session_id} def test_retain_recent_turns_caps_complete_user_assistant_pairs(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(coding_agents, "MAX_RETAINED_TURNS_PER_SESSION", 2) + monkeypatch.setattr(copilot, "MAX_RETAINED_TURNS_PER_SESSION", 2) conversation = [{"role": role, "content": f"{role}-{turn}"} for turn in range(3) for role in ("user", "assistant")] - coding_agents._retain_recent_turns(conversation) + copilot._retain_recent_turns(conversation) assert conversation == [ {"role": "user", "content": "user-1"}, @@ -264,13 +264,13 @@ def test_retain_recent_turns_caps_complete_user_assistant_pairs(monkeypatch: pyt def test_list_history_sessions_includes_retained_conversation(service_client: TestClient): session_id = str(uuid.uuid4()) - coding_agents._session_conversations[session_id] = [ + copilot._session_conversations[session_id] = [ {"role": "user", "content": "Help me build an agent"}, {"role": "assistant", "content": "What should it do?"}, ] - coding_agents._session_mtimes[session_id] = 42 + copilot._session_mtimes[session_id] = 42 - response = service_client.get("/v2/coding-agents/history/sessions") + response = service_client.get("/v2/copilot/history/sessions") assert response.status_code == 200 assert response.json() == [ @@ -287,7 +287,7 @@ def test_list_history_sessions_includes_retained_conversation(service_client: Te "agent": None, "model": None, "model_source": None, - "coding_agent_model": None, + "copilot_model": None, "workspace": None, "selections": [], "files": [], @@ -302,36 +302,36 @@ def test_list_history_sessions_includes_retained_conversation(service_client: Te def test_build_claude_argv_uses_new_session_then_resume_flag(): session_id = str(uuid.uuid4()) - argv = coding_agents._build_claude_argv(session_id, "hello", "http://test/mcp", "Studio context") + argv = copilot._build_claude_argv(session_id, "hello", "http://test/mcp", "Studio context") assert argv[:3] == ["claude", "-p", "hello"] assert "--output-format" in argv assert "stream-json" in argv mcp_config = json.loads(argv[argv.index("--mcp-config") + 1]) - assert mcp_config["mcpServers"][coding_agents.CLAUDE_MCP_SERVER_NAME] == { + assert mcp_config["mcpServers"][copilot.CLAUDE_MCP_SERVER_NAME] == { "type": "http", "url": "http://test/mcp", - "timeout": coding_agents.CLAUDE_MCP_TOOL_TIMEOUT_MS, + "timeout": copilot.CLAUDE_MCP_TOOL_TIMEOUT_MS, } assert "--allowedTools" in argv allowed_tools = argv[argv.index("--allowedTools") + 1].split(",") - assert f"mcp__{coding_agents.CLAUDE_MCP_SERVER_NAME}__select_agent" in allowed_tools - assert f"mcp__{coding_agents.CLAUDE_MCP_SERVER_NAME}__select_eval_config" in allowed_tools - assert f"mcp__{coding_agents.CLAUDE_MCP_SERVER_NAME}__select_dataset_file" in allowed_tools - assert f"mcp__{coding_agents.CLAUDE_MCP_SERVER_NAME}__select_model" in allowed_tools - assert f"mcp__{coding_agents.CLAUDE_MCP_SERVER_NAME}__job_progress" in allowed_tools - assert f"mcp__{coding_agents.CLAUDE_MCP_SERVER_NAME}__studio_link" in allowed_tools + assert f"mcp__{copilot.CLAUDE_MCP_SERVER_NAME}__select_agent" in allowed_tools + assert f"mcp__{copilot.CLAUDE_MCP_SERVER_NAME}__select_eval_config" in allowed_tools + assert f"mcp__{copilot.CLAUDE_MCP_SERVER_NAME}__select_dataset_file" in allowed_tools + assert f"mcp__{copilot.CLAUDE_MCP_SERVER_NAME}__select_model" in allowed_tools + assert f"mcp__{copilot.CLAUDE_MCP_SERVER_NAME}__job_progress" in allowed_tools + assert f"mcp__{copilot.CLAUDE_MCP_SERVER_NAME}__studio_link" in allowed_tools assert "--disallowedTools" not in argv assert "--append-system-prompt" in argv - assert argv[argv.index("--append-system-prompt") + 1] == coding_agents.STUDIO_CODING_AGENT_CONTEXT + assert argv[argv.index("--append-system-prompt") + 1] == copilot.STUDIO_COPILOT_CONTEXT assert "--permission-prompt-tool" in argv - assert f"mcp__{coding_agents.CLAUDE_MCP_SERVER_NAME}__approval_prompt" in argv + assert f"mcp__{copilot.CLAUDE_MCP_SERVER_NAME}__approval_prompt" in argv assert "--append-system-prompt" in argv assert "Studio context" in argv assert "--session-id" in argv assert session_id in argv - coding_agents._initialized_sessions.add(session_id) - resumed_argv = coding_agents._build_claude_argv(session_id, "again", "http://test/mcp", "Studio context") + copilot._initialized_sessions.add(session_id) + resumed_argv = copilot._build_claude_argv(session_id, "again", "http://test/mcp", "Studio context") assert "-r" in resumed_argv assert "--session-id" not in resumed_argv assert "--append-system-prompt" in resumed_argv @@ -348,11 +348,11 @@ def test_history_list_excludes_disk_only_sessions_but_direct_get_remains_availab project_dir.mkdir(parents=True) session_id = str(uuid.uuid4()) history = project_dir / f"{session_id}.jsonl" - first_prompt = coding_agents._build_claude_prompt( + first_prompt = copilot._build_claude_prompt( "first prompt", "default", "https://studio.test/studio", - "/workspaces/default/dashboard/code-agent", + "/workspaces/default/dashboard/copilot", ) history.write_text( "\n".join( @@ -497,15 +497,15 @@ def test_history_list_excludes_disk_only_sessions_but_direct_get_remains_availab ) ) - monkeypatch.setattr(coding_agents, "SERVER_CWD", workdir) - monkeypatch.setattr(coding_agents, "CLAUDE_PROJECTS_DIR", projects_dir) + monkeypatch.setattr(copilot, "SERVER_CWD", workdir) + monkeypatch.setattr(copilot, "CLAUDE_PROJECTS_DIR", projects_dir) - list_response = service_client.get("/v2/coding-agents/history/sessions") + list_response = service_client.get("/v2/copilot/history/sessions") assert list_response.status_code == 200 assert list_response.json() == [] - history_response = service_client.get(f"/v2/coding-agents/history/sessions/{session_id}") + history_response = service_client.get(f"/v2/copilot/history/sessions/{session_id}") assert history_response.status_code == 200 assert history_response.json() == { @@ -587,7 +587,7 @@ def test_history_list_excludes_disk_only_sessions_but_direct_get_remains_availab "agent": "cat-identifier", "model": "cloud, nvidia/llama-3.3-nemotron-super-49b-v1", "model_source": "spec", - "coding_agent_model": "claude-sonnet-4-6", + "copilot_model": "claude-sonnet-4-6", "workspace": "default", "selections": [{"label": "Agent", "value": "beach-finder"}], "files": [{"action": "Wrote", "path": "agents/beach-finder.yml"}], @@ -609,7 +609,7 @@ def test_history_list_excludes_disk_only_sessions_but_direct_get_remains_availab ], }, } - assert session_id in coding_agents._initialized_sessions + assert session_id in copilot._initialized_sessions def test_list_claude_skills_returns_claude_install_metadata( @@ -623,10 +623,10 @@ def test_list_claude_skills_returns_claude_install_metadata( installed_skill.write_text("# Installed") skill = _inference_skill(source_dir) - monkeypatch.setattr(coding_agents, "SERVER_CWD", tmp_path) - monkeypatch.setattr(coding_agent_skills, "load_skills", lambda: {"inference": skill}) + monkeypatch.setattr(copilot, "SERVER_CWD", tmp_path) + monkeypatch.setattr(copilot_skills, "load_skills", lambda: {"inference": skill}) - response = service_client.get("/v2/coding-agents/skills") + response = service_client.get("/v2/copilot/skills") assert response.status_code == 200 assert response.json() == [_expected_inference_skill_response(installed=True)] @@ -668,8 +668,8 @@ def test_history_interaction_text_restores_studio_picker_submissions( expected: str, ): assert ( - coding_agents._history_interaction_text( - coding_agents.HistoryToolUse(name=tool_name, input=tool_input), + copilot._history_interaction_text( + copilot.HistoryToolUse(name=tool_name, input=tool_input), result, ) == expected @@ -684,20 +684,20 @@ def test_load_claude_skills_falls_back_on_duplicate_skill_error( skill = _inference_skill(_inference_source_dir(tmp_path)) fallback_called = False - def fallback() -> dict[str, coding_agent_skills.Skill]: + def fallback() -> dict[str, copilot_skills.Skill]: nonlocal fallback_called fallback_called = True return {"inference": skill} monkeypatch.setattr( - coding_agent_skills, + copilot_skills, "load_skills", - lambda: (_ for _ in ()).throw(coding_agent_skills.DuplicateSkillError("vendored drift")), + lambda: (_ for _ in ()).throw(copilot_skills.DuplicateSkillError("vendored drift")), ) - monkeypatch.setattr(coding_agent_skills, "_load_skills_from_preferred_entry_points", fallback) + monkeypatch.setattr(copilot_skills, "_load_skills_from_preferred_entry_points", fallback) with caplog.at_level(logging.WARNING): - loaded = coding_agent_skills._load_claude_skills() + loaded = copilot_skills._load_claude_skills() assert loaded == {"inference": skill} assert fallback_called @@ -709,24 +709,24 @@ def test_list_claude_skills_returns_500_when_fallback_also_fails( monkeypatch: pytest.MonkeyPatch, ): monkeypatch.setattr( - coding_agent_skills, + copilot_skills, "load_skills", - lambda: (_ for _ in ()).throw(coding_agent_skills.DuplicateSkillError("registry drift")), + lambda: (_ for _ in ()).throw(copilot_skills.DuplicateSkillError("registry drift")), ) monkeypatch.setattr( - coding_agent_skills, + copilot_skills, "_load_skills_from_preferred_entry_points", - lambda: (_ for _ in ()).throw(coding_agent_skills.DuplicateSkillError("fallback drift")), + lambda: (_ for _ in ()).throw(copilot_skills.DuplicateSkillError("fallback drift")), ) - response = service_client.get("/v2/coding-agents/skills") + response = service_client.get("/v2/copilot/skills") assert response.status_code == 500 assert response.json()["detail"] == "fallback drift" def test_invalid_session_id_returns_400(service_client: TestClient): - response = service_client.get("/v2/coding-agents/history/sessions/not-a-uuid") + response = service_client.get("/v2/copilot/history/sessions/not-a-uuid") assert response.status_code == 400 assert response.json()["detail"] == "session_id must be a UUID" @@ -738,21 +738,21 @@ async def test_stream_claude_hides_startup_oserror(monkeypatch: pytest.MonkeyPat async def fail_start(*args: Any, **kwargs: Any): raise OSError("secret local path") - monkeypatch.setattr(coding_agents.shutil, "which", lambda name: "/usr/bin/claude") - monkeypatch.setattr(coding_agents.asyncio, "create_subprocess_exec", fail_start) + monkeypatch.setattr(copilot.shutil, "which", lambda name: "/usr/bin/claude") + monkeypatch.setattr(copilot.asyncio, "create_subprocess_exec", fail_start) - chunks = [chunk async for chunk in coding_agents._stream_claude(session_id, "hello", "http://test/mcp")] + chunks = [chunk async for chunk in copilot._stream_claude(session_id, "hello", "http://test/mcp")] assert chunks == ['event: error\ndata: {"exit_code": null, "stderr": "Failed to start Claude Code process"}\n\n'] assert "secret local path" not in chunks[0] - assert session_id not in coding_agents._session_streams + assert session_id not in copilot._session_streams def test_mcp_initialize_and_tools_list(service_client: TestClient): session_id = str(uuid.uuid4()) initialize_response = service_client.post( - f"/v2/coding-agents/mcp/{session_id}", + f"/v2/copilot/mcp/{session_id}", json={ "jsonrpc": "2.0", "id": 1, @@ -761,7 +761,7 @@ def test_mcp_initialize_and_tools_list(service_client: TestClient): }, ) tools_response = service_client.post( - f"/v2/coding-agents/mcp/{session_id}", + f"/v2/copilot/mcp/{session_id}", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, ) @@ -803,7 +803,7 @@ def test_mcp_tools_list_includes_feature_flag_enabled_destinations(monkeypatch: session_id = str(uuid.uuid4()) tools_response = service_client.post( - f"/v2/coding-agents/mcp/{session_id}", + f"/v2/copilot/mcp/{session_id}", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, ) @@ -817,10 +817,10 @@ def test_mcp_tools_list_includes_feature_flag_enabled_destinations(monkeypatch: def test_build_studio_system_prompt_preserves_empty_enabled_destinations(): - prompt = coding_agents._build_studio_system_prompt( + prompt = copilot._build_studio_system_prompt( "default", "https://studio.test", - "/workspaces/default/dashboard/code-agent", + "/workspaces/default/dashboard/copilot", {}, ) @@ -831,16 +831,16 @@ def test_build_studio_system_prompt_preserves_empty_enabled_destinations(): def test_build_studio_system_prompt_includes_message_summary_contract(): - prompt = coding_agents._build_studio_system_prompt( + prompt = copilot._build_studio_system_prompt( "default", "https://studio.test", - "/workspaces/default/dashboard/code-agent", + "/workspaces/default/dashboard/copilot", {}, ) assert "Conditional message-summary behavior:" in prompt - assert coding_agents.STUDIO_MESSAGE_SUMMARY_START in prompt - assert coding_agents.STUDIO_MESSAGE_SUMMARY_END in prompt + assert copilot.STUDIO_MESSAGE_SUMMARY_START in prompt + assert copilot.STUDIO_MESSAGE_SUMMARY_END in prompt assert "title: " in prompt assert "summary: = ({ Create experiment - Coding agent + NeMo Copilot CLI command @@ -180,7 +180,7 @@ export const ExperimentCreateModal: FC = ({ - + ({ })); vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat', () => ({ - ClaudeCodeTopBarChat: () =>
, + ClaudeCodeTopBarChat: () =>
, })); vi.mock('@studio/constants/environment', async (importOriginal) => { @@ -89,30 +89,30 @@ describe('GlobalNav', () => { expect(screen.getByText('NeMo Studio')).toBeInTheDocument(); }); - it('starts collapsed on the Code Agent route when no preference is saved', async () => { + it('starts collapsed on the NeMo Copilot route when no preference is saved', async () => { createMatchMediaMock(true); await renderGlobalNav( - generatePath(ROUTES.workspace.claudeCodeChat, { workspace: 'test-workspace' }) + generatePath(ROUTES.workspace.copilotChat, { workspace: 'test-workspace' }) ); expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument(); expect(screen.queryByText('NeMo Studio')).not.toBeInTheDocument(); }); - it('respects a saved expanded preference on the Code Agent route', async () => { + it('respects a saved expanded preference on the NeMo Copilot route', async () => { localStorage.setItem(SIDE_NAV_OPEN_KEY, JSON.stringify('true')); createMatchMediaMock(true); await renderGlobalNav( - generatePath(ROUTES.workspace.claudeCodeChat, { workspace: 'test-workspace' }) + generatePath(ROUTES.workspace.copilotChat, { workspace: 'test-workspace' }) ); expect(screen.getByRole('button', { name: 'Collapse sidebar' })).toBeInTheDocument(); expect(screen.getByText('NeMo Studio')).toBeInTheDocument(); }); - it('uses the Code Agent default during in-app navigation', async () => { + it('uses the NeMo Copilot default during in-app navigation', async () => { createMatchMediaMock(true); const { GlobalNav } = await import('@studio/components/Layouts/GlobalNav/index'); const router = createMemoryRouter( @@ -132,7 +132,7 @@ describe('GlobalNav', () => { await act(async () => { await router.navigate( - generatePath(ROUTES.workspace.claudeCodeChat, { workspace: 'test-workspace' }) + generatePath(ROUTES.workspace.copilotChat, { workspace: 'test-workspace' }) ); }); @@ -234,13 +234,13 @@ describe('GlobalNav', () => { }); }); - describe('Code Agent top bar chat', () => { - it('mounts outside the dashboard and full Code Agent routes', async () => { + describe('NeMo Copilot top bar chat', () => { + it('mounts outside the dashboard and full NeMo Copilot routes', async () => { createMatchMediaMock(true); await renderGlobalNav('/workspaces/test-workspace/jobs'); - expect(screen.getByTestId('code-agent-top-bar-chat')).toBeInTheDocument(); + expect(screen.getByTestId('copilot-top-bar-chat')).toBeInTheDocument(); }); it('does not mount on the dashboard route', async () => { @@ -250,17 +250,17 @@ describe('GlobalNav', () => { generatePath(ROUTES.workspace.dashboard, { workspace: 'test-workspace' }) ); - expect(screen.queryByTestId('code-agent-top-bar-chat')).not.toBeInTheDocument(); + expect(screen.queryByTestId('copilot-top-bar-chat')).not.toBeInTheDocument(); }); - it('does not mount on the full Code Agent route', async () => { + it('does not mount on the full NeMo Copilot route', async () => { createMatchMediaMock(true); await renderGlobalNav( - generatePath(ROUTES.workspace.claudeCodeChat, { workspace: 'test-workspace' }) + generatePath(ROUTES.workspace.copilotChat, { workspace: 'test-workspace' }) ); - expect(screen.queryByTestId('code-agent-top-bar-chat')).not.toBeInTheDocument(); + expect(screen.queryByTestId('copilot-top-bar-chat')).not.toBeInTheDocument(); }); }); }); diff --git a/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx b/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx index c4aeb894e3..18cce2248e 100644 --- a/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx +++ b/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx @@ -111,11 +111,11 @@ export const GlobalNav: FC = ({ sideNav }) => { const isDashboardRoute = matchPath({ path: ROUTES.workspace.dashboard, end: true }, location.pathname) !== null; const isClaudeCodeChatRoute = - matchPath({ path: ROUTES.workspace.claudeCodeChat, end: true }, location.pathname) !== null; + matchPath({ path: ROUTES.workspace.copilotChat, end: true }, location.pathname) !== null; return ( {
- + - Coding agent + NeMo Copilot @@ -45,7 +45,7 @@ export const Empty = ({ experimentName, datasetName }: EmptyProps) => {
- + {
- + - Coding agent + NeMo Copilot @@ -45,7 +45,7 @@ export const Empty = ({ experimentName }: EmptyProps) => {
- + = ({
); diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx index 51ff52d348..8a233494c8 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx @@ -39,7 +39,7 @@ const renderRoute = () => { const router = createMemoryRouter( [ { path: ROUTES.workspace.dashboard, element: }, - { path: ROUTES.workspace.claudeCodeChat, element: }, + { path: ROUTES.workspace.copilotChat, element: }, ], { initialEntries: [route], @@ -73,7 +73,7 @@ describe('DashboardLandingRoute', () => { renderRoute(); expect(await screen.findByText('What would you like to do?')).toBeInTheDocument(); - const composer = screen.getByRole('textbox', { name: 'Message NeMo Agent' }); + const composer = screen.getByRole('textbox', { name: 'Message NeMo Copilot' }); expect(composer).toBeInTheDocument(); expect(screen.getByTestId('dashboard-landing-composer')).toHaveClass('rounded-lg'); expect(screen.getByTestId('dashboard-landing-composer')).not.toHaveClass('rounded-2xl'); @@ -92,7 +92,7 @@ describe('DashboardLandingRoute', () => { const user = userEvent.setup(); renderRoute(); - const composer = await screen.findByRole('textbox', { name: 'Message NeMo Agent' }); + const composer = await screen.findByRole('textbox', { name: 'Message NeMo Copilot' }); const sendButton = screen.getByRole('button', { name: 'Send message' }); expect(sendButton).toBeDisabled(); @@ -104,28 +104,28 @@ describe('DashboardLandingRoute', () => { }); }); - it('navigates to the NeMo Agent chat with the submitted prompt', async () => { + it('navigates to the NeMo Copilot chat with the submitted prompt', async () => { const user = userEvent.setup(); renderRoute(); await user.type( - await screen.findByRole('textbox', { name: 'Message NeMo Agent' }), + await screen.findByRole('textbox', { name: 'Message NeMo Copilot' }), 'Check repo' ); await user.click(screen.getByRole('button', { name: 'Send message' })); expect(await screen.findByTestId(CHAT_ROUTE_TEST_ID)).toHaveTextContent( - `${generatePath(ROUTES.workspace.claudeCodeChat, { workspace })}|Check repo` + `${generatePath(ROUTES.workspace.copilotChat, { workspace })}|Check repo` ); }); - it('clears the active Code Agent session before starting from the landing composer', async () => { + it('clears the active NeMo Copilot session before starting from the landing composer', async () => { const user = userEvent.setup(); localStorage.setItem(getClaudeCodeActiveSessionStorageKey(workspace), 'session-existing'); renderRoute(); await user.type( - await screen.findByRole('textbox', { name: 'Message NeMo Agent' }), + await screen.findByRole('textbox', { name: 'Message NeMo Copilot' }), 'Check repo' ); await user.click(screen.getByRole('button', { name: 'Send message' })); @@ -138,13 +138,13 @@ describe('DashboardLandingRoute', () => { renderRoute(); await user.type( - await screen.findByRole('textbox', { name: 'Message NeMo Agent' }), + await screen.findByRole('textbox', { name: 'Message NeMo Copilot' }), 'Check repo' ); await user.keyboard('{Enter}'); expect(await screen.findByTestId(CHAT_ROUTE_TEST_ID)).toHaveTextContent( - `${generatePath(ROUTES.workspace.claudeCodeChat, { workspace })}|Check repo` + `${generatePath(ROUTES.workspace.copilotChat, { workspace })}|Check repo` ); }); @@ -152,7 +152,7 @@ describe('DashboardLandingRoute', () => { const user = userEvent.setup(); renderRoute(); - const composer = await screen.findByRole('textbox', { name: 'Message NeMo Agent' }); + const composer = await screen.findByRole('textbox', { name: 'Message NeMo Copilot' }); await user.type(composer, 'Line one'); await user.keyboard('{Shift>}{Enter}{/Shift}'); diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx index 5c10d2c819..5c13da6b52 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx @@ -9,7 +9,7 @@ import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; import { writeStoredActiveSessionId } from '@studio/routes/agents/ClaudeCodeChatRoute/activeSessionStorage'; import { ClaudeCodeLayout } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout'; import type { ClaudeCodeChatRouteState } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; -import { getClaudeCodeChatRoute } from '@studio/routes/utils'; +import { getCopilotChatRoute } from '@studio/routes/utils'; import { Send, Terminal } from 'lucide-react'; import { type ChangeEvent, @@ -57,10 +57,10 @@ const LandingComposer = ({ data-tour="dashboard-get-started" >