From 86a8a9ef9f7ccdf4ae3f9155646479df3b77b5a3 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 1 Sep 2026 11:21:21 -0700 Subject: [PATCH] feat: add append system instruction mode Signed-off-by: Ajay Thorve --- .agents/skills/contribute-adapter/SKILL.md | 5 + .../nemo_fabric_adapter_contract/models.py | 2 +- .../schemas/adapter-descriptor.schema.json | 27 ++ .../schemas/agent-config.schema.json | 5 + .../src/generated/adapter-descriptor.ts | 13 + .../typescript/src/generated/agent-config.ts | 2 +- adapters/README.md | 2 +- adapters/claude/README.md | 4 +- adapters/claude/claude.fabric-adapter.json | 3 +- .../nemo_fabric_adapters/claude/adapter.py | 19 +- adapters/codex/README.md | 3 +- adapters/codex/codex.fabric-adapter.json | 3 +- .../src/nemo_fabric_adapters/codex/adapter.py | 12 +- .../common/instructions.py | 40 +++ adapters/deepagents/README.md | 3 +- .../deepagents/deepagents.fabric-adapter.json | 3 +- .../deepagents/adapter.py | 13 +- adapters/hermes/README.md | 2 +- adapters/hermes/hermes.fabric-adapter.json | 3 +- .../nemo_fabric_adapters/hermes/adapter.py | 6 + adapters/mini-swe-agent/README.md | 3 +- .../mini-swe-agent.fabric-adapter.json | 3 +- .../mini_swe_agent/adapter.py | 12 +- adapters/typescript/pi/pi.fabric-adapter.json | 3 +- adapters/typescript/pi/src/pi-sdk.ts | 16 +- adapters/typescript/pi/test/pi-sdk.test.mjs | 25 +- .../claude/claude.fabric-adapter.json | 3 +- .../adapters/codex/codex.fabric-adapter.json | 3 +- .../deepagents/deepagents.fabric-adapter.json | 3 +- .../hermes/hermes.fabric-adapter.json | 3 +- crates/fabric-core/src/config.rs | 241 +++++++++++++++++- crates/fabric-core/src/doctor.rs | 43 ++++ crates/fabric-core/src/schema.rs | 27 ++ docs/adapter-contract/adapter-descriptor.md | 14 +- .../normalized-configuration.md | 18 ++ docs/integrations/harness/claude.mdx | 3 + docs/integrations/harness/codex.mdx | 4 +- docs/integrations/harness/deepagents.mdx | 3 +- docs/integrations/harness/hermes.mdx | 3 +- docs/integrations/harness/mini-swe-agent.mdx | 3 +- docs/integrations/harness/pi.mdx | 2 +- .../nemo_fabric.models.md | 2 +- .../config/enum-instructionmode.mdx | 7 + .../config/struct-adapterconfigsupport.mdx | 8 +- docs/sdk/python.mdx | 7 +- .../claude/claude.fabric-adapter.json | 3 +- .../hermes/hermes.fabric-adapter.json | 3 +- examples/langgraph_custom_agent/README.md | 10 +- .../adapter/configuration.py | 16 +- .../email-phishing.fabric-adapter.json | 3 +- .../consumer/__main__.py | 11 +- .../langgraph_custom_agent/consumer/config.py | 10 +- external/nat/README.md | 9 +- external/nat/examples/calculator.py | 3 +- external/nat/examples/email_phishing.py | 3 +- external/nat/nat.fabric-adapter.json | 3 +- .../src/nemo_fabric_adapters/nat/adapter.py | 11 +- .../adapter-descriptor.schema.json | 27 ++ .../adapter-contract/agent-config.schema.json | 5 + schemas/sdk/agent.schema.json | 5 + schemas/sdk/run-plan.schema.json | 17 ++ .../src/nemo_fabric/models.py | 2 +- .../src/nemo_fabric/types.py | 4 +- skills/nemo-fabric-build-adapter/SKILL.md | 8 +- skills/nemo-fabric-integrate/SKILL.md | 5 +- .../references/config-mapping.md | 12 +- tests/adapter_contract/test_agent_config.py | 19 ++ .../test_adapters_common_instructions.py | 48 ++++ tests/adapters/test_claude_adapter.py | 13 + tests/adapters/test_codex_adapter.py | 12 + tests/adapters/test_deepagents.py | 33 +++ tests/adapters/test_external_nat_adapter.py | 14 +- tests/adapters/test_hermes_adapter.py | 24 ++ tests/adapters/test_mini_swe_agent.py | 15 ++ tests/adapters/test_pi_adapter.py | 1 + .../test_configuration.py | 16 ++ .../langgraph_custom_agent/test_consumer.py | 10 + .../langgraph_custom_agent/test_contract.py | 1 + tests/python/test_sdk_contract.py | 39 +++ 79 files changed, 959 insertions(+), 85 deletions(-) create mode 100644 adapters/common/src/nemo_fabric_adapters/common/instructions.py create mode 100644 tests/adapters/test_adapters_common_instructions.py diff --git a/.agents/skills/contribute-adapter/SKILL.md b/.agents/skills/contribute-adapter/SKILL.md index 28b059c05..22001d367 100644 --- a/.agents/skills/contribute-adapter/SKILL.md +++ b/.agents/skills/contribute-adapter/SKILL.md @@ -50,6 +50,11 @@ Keep descriptor claims, implementation, focused tests, public documentation, catalog entries, and packaged metadata synchronized. Start with the narrowest truthful capability set. +For `instructions.system`, keep `config.system_instruction_modes`, planning +behavior, direct adapter validation, and target-native composition synchronized. +New descriptors must declare their exact `replace` and `append` support rather +than relying on the legacy omitted-value behavior. + ## Repository Evidence In addition to the evidence required by the public skill, include: diff --git a/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py b/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py index c340e4248..65e2428a6 100644 --- a/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py @@ -172,7 +172,7 @@ class AgentInstructionConfig(AgentContractBlock): """One normalized instruction value.""" content: str - mode: Literal["replace"] = "replace" + mode: Literal["replace", "append"] = "replace" def _validate(self) -> None: _nonblank(self.content, "content") diff --git a/adapter-contract/typescript/schemas/adapter-descriptor.schema.json b/adapter-contract/typescript/schemas/adapter-descriptor.schema.json index f36105f28..b9d202950 100644 --- a/adapter-contract/typescript/schemas/adapter-descriptor.schema.json +++ b/adapter-contract/typescript/schemas/adapter-descriptor.schema.json @@ -87,6 +87,18 @@ "type": "string" }, "type": "array" + }, + "system_instruction_modes": { + "description": "Exact system-instruction modes supported by this adapter.\n\nAn omitted value preserves compatibility with descriptors that predate\nmode discovery and means `replace` when `instructions.system` is accepted.", + "items": { + "$ref": "#/$defs/InstructionMode" + }, + "minItems": 1, + "type": [ + "array", + "null" + ], + "uniqueItems": true } }, "type": "object" @@ -209,6 +221,21 @@ }, "type": "object" }, + "InstructionMode": { + "description": "How an instruction value is applied to the selected harness.", + "oneOf": [ + { + "const": "replace", + "description": "Replace the harness default instruction value.", + "type": "string" + }, + { + "const": "append", + "description": "Preserve the harness default and append this instruction after it.", + "type": "string" + } + ] + }, "RuntimeCapabilities": { "description": "Lifecycle behavior implemented by a resolved runtime path.", "properties": { diff --git a/adapter-contract/typescript/schemas/agent-config.schema.json b/adapter-contract/typescript/schemas/agent-config.schema.json index 8471c5454..c766c2f71 100644 --- a/adapter-contract/typescript/schemas/agent-config.schema.json +++ b/adapter-contract/typescript/schemas/agent-config.schema.json @@ -386,6 +386,11 @@ "const": "replace", "description": "Replace the harness default instruction value.", "type": "string" + }, + { + "const": "append", + "description": "Preserve the harness default and append this instruction after it.", + "type": "string" } ] }, diff --git a/adapter-contract/typescript/src/generated/adapter-descriptor.ts b/adapter-contract/typescript/src/generated/adapter-descriptor.ts index 707f52448..046e5b6e3 100644 --- a/adapter-contract/typescript/src/generated/adapter-descriptor.ts +++ b/adapter-contract/typescript/src/generated/adapter-descriptor.ts @@ -49,6 +49,10 @@ export type AdapterConfigField = | "mcp.auth.service_account" | "mcp.tool_filters" | "skills"; +/** + * How an instruction value is applied to the selected harness. + */ +export type InstructionMode = "replace" | "append"; /** * Adapter target categories understood by this Adapter Contract version. */ @@ -136,6 +140,15 @@ export type AdapterConfigSupport = { * Harness-native files generated by this adapter. */ generates?: string[]; + /** + * Exact system-instruction modes supported by this adapter. + * + * An omitted value preserves compatibility with descriptors that predate + * mode discovery and means `replace` when `instructions.system` is accepted. + * + * @minItems 1 + */ + system_instruction_modes?: [InstructionMode, ...InstructionMode[]] | null; } & JsonObject; /** * Runtime requirements. diff --git a/adapter-contract/typescript/src/generated/agent-config.ts b/adapter-contract/typescript/src/generated/agent-config.ts index acd0c42cf..69e8f240e 100644 --- a/adapter-contract/typescript/src/generated/agent-config.ts +++ b/adapter-contract/typescript/src/generated/agent-config.ts @@ -161,7 +161,7 @@ export interface AgentInstructionConfig { /** * How the instruction is applied. */ - mode?: "replace"; + mode?: "replace" | "append"; } /** * Named MCP servers routed to an adapter target. diff --git a/adapters/README.md b/adapters/README.md index e2ae505f1..0df0ffada 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -121,7 +121,7 @@ and additive extension maps because their support does not vary by adapter: | `models..base_url` | Yes | Yes | Yes | Yes | Yes | Yes; known catalog models only | | `models..temperature` | No | No | Yes | Yes | Yes | No | | `models..settings.` | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | -| `instructions.system` | Yes | Yes; base instructions | Yes | Yes | Yes | Yes; replaces Pi base instructions | +| `instructions.system` | `replace`, `append` | `replace`; base instructions | `replace` | `replace` | `replace` | `replace`; Pi base instructions | | `runtime.input_schema`, `.output_schema` | Core | Core | Core | Core | Core | Core | | `runtime.artifacts`, `.timeout_seconds` | Core | Core | Core | Core | Core | Core | | `runtime.max_turns` | Yes | No | No | Yes; iteration limit | Yes | No | diff --git a/adapters/claude/README.md b/adapters/claude/README.md index df8074375..2b6e5300d 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -84,7 +84,9 @@ Configure portable capabilities through the normalized `FabricConfig` fields: - `models` selects the Claude model. The native `anthropic` provider retains Claude authentication and endpoint discovery. Any other provider name must configure an Anthropic Messages-compatible `base_url` and `api_key_env`. -- `instructions.system` supplies the Claude system instructions. +- `instructions.system` supports `replace` and `append`. `replace` supplies the + complete Claude system prompt. `append` preserves the `claude_code` preset and + adds the configured content after it. - `runtime.max_turns` sets the Claude turn limit. - `runtime.timeout_seconds` sets the NeMo Fabric invocation deadline. - `environment.workspace` sets the Claude working directory, and diff --git a/adapters/claude/claude.fabric-adapter.json b/adapters/claude/claude.fabric-adapter.json index 01e6c8409..bc761d0ce 100644 --- a/adapters/claude/claude.fabric-adapter.json +++ b/adapters/claude/claude.fabric-adapter.json @@ -87,7 +87,8 @@ "tools.blocked", "mcp", "skills" - ] + ], + "system_instruction_modes": ["replace", "append"] }, "telemetry": { "providers": { diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 8d387deeb..6ad66de96 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -40,6 +40,7 @@ from nemo_fabric_adapter_contract.models import AgentRunStatus from nemo_fabric_adapter_contract.models import AgentUsage from nemo_fabric_adapter_contract.models import RuntimeContext +from nemo_fabric_adapters.common import instructions as common_instructions from nemo_fabric_adapters.common import lifecycle from nemo_fabric_adapters.common import relay_artifacts from nemo_fabric_adapters.common import relay_gateway @@ -644,10 +645,22 @@ def build_options( ) cli_path = os.environ.get("FABRIC_TEST_CLAUDE_CLI_PATH") - instructions = config.instructions - system_prompt = ( - instructions.system.content if instructions and instructions.system else None + instruction = common_instructions.system_instruction( + config, + adapter="Claude", + supported_modes={"replace", "append"}, ) + system_prompt: Any = None + if instruction is not None: + system_prompt = ( + instruction.content + if instruction.mode == "replace" + else { + "type": "preset", + "preset": "claude_code", + "append": instruction.content, + } + ) enabled_tools = config.tools.enabled if config.tools is not None else None allowed_tools = ( enabled_tools diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 0c9de1929..129c8c8bf 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -90,7 +90,8 @@ Use normalized `FabricConfig` fields for portable configuration: - `models` selects the Codex model. The native `openai` provider retains Codex authentication and endpoint discovery. Any other provider name must configure a Responses-compatible `base_url` and `api_key_env`. -- `instructions.system` maps to Codex base instructions. +- `instructions.system` supports `replace` and maps to Codex base instructions. + Codex rejects `append` during planning and direct adapter startup. - `runtime.timeout_seconds` sets the NeMo Fabric invocation deadline. - `environment.workspace` sets the working directory, and `environment.env` supplies explicit harness-visible variables. diff --git a/adapters/codex/codex.fabric-adapter.json b/adapters/codex/codex.fabric-adapter.json index 76747d5cb..03819ee12 100644 --- a/adapters/codex/codex.fabric-adapter.json +++ b/adapters/codex/codex.fabric-adapter.json @@ -104,7 +104,8 @@ "mcp", "mcp.auth.oauth2", "skills" - ] + ], + "system_instruction_modes": ["replace"] }, "telemetry": { "providers": { diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index 166ba8ffd..4db0395f1 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -49,6 +49,7 @@ from nemo_fabric_adapter_contract.models import McpOAuth2Config from nemo_fabric_adapter_contract.models import McpServiceAccountConfig from nemo_fabric_adapter_contract.models import RuntimeContext +from nemo_fabric_adapters.common import instructions as common_instructions import nemo_fabric_adapters.common.relay_gateway as relay_gateway import nemo_fabric_adapters.common.relay_hooks as relay_hooks import nemo_fabric_adapters.common.relay_artifacts as relay_artifacts @@ -1119,13 +1120,14 @@ def _thread_options( relay: CodexRelaySettings | None, ) -> dict[str, Any]: settings = _settings(config) + instruction = common_instructions.system_instruction( + config, + adapter="Codex", + supported_modes={"replace"}, + ) return { "approval_mode": approval_mode(config), - "base_instructions": ( - config.instructions.system.content - if config.instructions and config.instructions.system - else None - ), + "base_instructions": instruction.content if instruction else None, "config": thread_config(config, context, relay) or None, "cwd": str(resolve_cwd(context, base_dir)), "developer_instructions": _optional_string(settings, "developer_instructions"), diff --git a/adapters/common/src/nemo_fabric_adapters/common/instructions.py b/adapters/common/src/nemo_fabric_adapters/common/instructions.py new file mode 100644 index 000000000..a77311639 --- /dev/null +++ b/adapters/common/src/nemo_fabric_adapters/common/instructions.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared system-instruction mode validation for adapter hosts.""" + +from __future__ import annotations + +from collections.abc import Collection +from typing import Literal + +from nemo_fabric_adapter_contract.models import AgentConfig +from nemo_fabric_adapter_contract.models import AgentInstructionConfig +from nemo_fabric_adapters.common import lifecycle + + +def system_instruction( + config: AgentConfig, + *, + adapter: str, + supported_modes: Collection[Literal["replace", "append"]], +) -> AgentInstructionConfig | None: + """Return the configured instruction after validating adapter support.""" + + instruction = config.instructions.system if config.instructions else None + if instruction is None: + return None + + supported = sorted(set(supported_modes)) + if instruction.mode not in supported: + raise lifecycle.LifecycleError( + "unsupported_system_instruction_mode", + f"{adapter} does not support instructions.system.mode=" + f"{instruction.mode!r}; supported modes: {', '.join(supported)}", + metadata={ + "field": "instructions.system.mode", + "mode": instruction.mode, + "supported_modes": supported, + }, + ) + return instruction diff --git a/adapters/deepagents/README.md b/adapters/deepagents/README.md index 2602586bd..33d408e5b 100644 --- a/adapters/deepagents/README.md +++ b/adapters/deepagents/README.md @@ -52,7 +52,8 @@ NeMo Fabric maps the following into the harness: - The selected `models` role supplies `model`, `provider`, `api_key_env`, `base_url`, and `temperature`. -- `instructions.system` becomes the Deep Agents `system_prompt`. +- `instructions.system` supports `replace` and becomes the Deep Agents + `system_prompt`. Deep Agents rejects `append`. - `runtime.timeout_seconds` sets the NeMo Fabric invocation deadline. - `environment.workspace` roots the Deep Agents filesystem backend (`FilesystemBackend(root_dir=..., virtual_mode=True)`). `virtual_mode` diff --git a/adapters/deepagents/deepagents.fabric-adapter.json b/adapters/deepagents/deepagents.fabric-adapter.json index 77616458d..53e2f432e 100644 --- a/adapters/deepagents/deepagents.fabric-adapter.json +++ b/adapters/deepagents/deepagents.fabric-adapter.json @@ -172,7 +172,8 @@ "tools.blocked", "mcp", "skills" - ] + ], + "system_instruction_modes": ["replace"] }, "telemetry": { "providers": { diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index bf45b1263..f7441ef88 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -34,6 +34,7 @@ from nemo_fabric_adapter_contract.models import AgentRunStatus from nemo_fabric_adapter_contract.models import AgentUsage from nemo_fabric_adapter_contract.models import RuntimeContext +from nemo_fabric_adapters.common import instructions as common_instructions from nemo_fabric_adapters.common import lifecycle import nemo_fabric_adapters.common.utils as common_utils @@ -371,16 +372,16 @@ async def build_agent_kwargs( model: Any, settings: dict[str, Any], ) -> dict[str, Any]: - instructions = config.instructions + instruction = common_instructions.system_instruction( + config, + adapter="Deep Agents", + supported_modes={"replace"}, + ) kwargs: dict[str, Any] = { "model": model, "tools": await resolve_tools(config), # deepagents 0.5.x/0.6.x take the system prompt as ``system_prompt``. - "system_prompt": ( - instructions.system.content - if instructions and instructions.system - else None - ), + "system_prompt": instruction.content if instruction else None, "skills": resolve_skills(config), "backend": resolve_backend(runtime_context, base_dir), } diff --git a/adapters/hermes/README.md b/adapters/hermes/README.md index 395d3824e..b66619d70 100644 --- a/adapters/hermes/README.md +++ b/adapters/hermes/README.md @@ -47,7 +47,7 @@ The adapter receives a normalized payload from NeMo Fabric and materializes a na - selected model provider, model name, base URL, and temperature through `models`; -- `instructions.system` and `runtime.max_turns`; +- replacement `instructions.system` and `runtime.max_turns`; - workspace and explicit environment variables through `environment`; - invocation timeout through `runtime.timeout_seconds`; - NeMo Fabric skills as external skill directories for Hermes Agent; diff --git a/adapters/hermes/hermes.fabric-adapter.json b/adapters/hermes/hermes.fabric-adapter.json index 77be6e1e2..d47a186c9 100644 --- a/adapters/hermes/hermes.fabric-adapter.json +++ b/adapters/hermes/hermes.fabric-adapter.json @@ -82,7 +82,8 @@ "mcp", "mcp.auth.oauth2", "skills" - ] + ], + "system_instruction_modes": ["replace"] }, "telemetry": { "providers": { diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index c23083a59..e73c607ed 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -29,6 +29,7 @@ from nemo_fabric_adapter_contract.models import AgentRunResult from nemo_fabric_adapter_contract.models import AgentRunStatus from nemo_fabric_adapter_contract.models import RuntimeContext +from nemo_fabric_adapters.common import instructions as common_instructions from nemo_fabric_adapters.common import lifecycle from nemo_fabric_adapters.hermes import configuration from nemo_fabric_adapters.hermes import telemetry @@ -87,6 +88,11 @@ async def start(self, payload: dict[str, Any]) -> None: "hermes_invalid_config", "Hermes requires a validated AgentConfig", ) + common_instructions.system_instruction( + agent_config, + adapter="Hermes", + supported_modes={"replace"}, + ) runtime_context = RuntimeContext.from_mapping( payload.get("runtime_context") ) diff --git a/adapters/mini-swe-agent/README.md b/adapters/mini-swe-agent/README.md index 04c9ce2e7..92f729e56 100644 --- a/adapters/mini-swe-agent/README.md +++ b/adapters/mini-swe-agent/README.md @@ -28,7 +28,8 @@ The `harness` and `full` extras install the latest compatible mini-SWE-agent ## Configuration The adapter supports `models`, `models.base_url`, `models.temperature`, -`instructions.system`, `runtime.max_turns`, and `environment.workspace`. +replacement `instructions.system`, `runtime.max_turns`, and +`environment.workspace`. It rejects `append` system instructions. `runtime.timeout_seconds` sets the NVIDIA NeMo Fabric invocation deadline. Use `harness.settings.timeout` to set the maximum duration of one command; the default is `30` seconds. diff --git a/adapters/mini-swe-agent/mini-swe-agent.fabric-adapter.json b/adapters/mini-swe-agent/mini-swe-agent.fabric-adapter.json index 0fb2b552a..e70cb77f7 100644 --- a/adapters/mini-swe-agent/mini-swe-agent.fabric-adapter.json +++ b/adapters/mini-swe-agent/mini-swe-agent.fabric-adapter.json @@ -44,7 +44,8 @@ "models.temperature", "instructions.system", "runtime.max_turns" - ] + ], + "system_instruction_modes": ["replace"] }, "capabilities": { "service": false, diff --git a/adapters/mini-swe-agent/src/nemo_fabric_adapters/mini_swe_agent/adapter.py b/adapters/mini-swe-agent/src/nemo_fabric_adapters/mini_swe_agent/adapter.py index b3b36c307..71d7a049e 100644 --- a/adapters/mini-swe-agent/src/nemo_fabric_adapters/mini_swe_agent/adapter.py +++ b/adapters/mini-swe-agent/src/nemo_fabric_adapters/mini_swe_agent/adapter.py @@ -11,6 +11,7 @@ from typing import Any from nemo_fabric_adapter_contract import models as contract +from nemo_fabric_adapters.common import instructions as common_instructions from nemo_fabric_adapters.common import lifecycle from nemo_fabric_adapters.common import utils as common_utils @@ -49,6 +50,11 @@ def __init__(self) -> None: async def start(self, payload: dict[str, Any]) -> None: config: contract.AgentConfig = payload["config"] + instruction = common_instructions.system_instruction( + config, + adapter="mini-SWE-agent", + supported_modes={"replace"}, + ) from minisweagent.environments.local import LocalEnvironment from minisweagent.models.litellm_model import LitellmModel from nemo_fabric_adapters.mini_swe_agent.agents import ( @@ -100,11 +106,7 @@ async def start(self, payload: dict[str, Any]) -> None: self._environment = LocalEnvironment( **(config.harness.settings if config.harness else {}) ) - self._system_instruction = ( - config.instructions.system.content - if config.instructions and config.instructions.system - else "" - ) + self._system_instruction = instruction.content if instruction else "" agent_kwargs: dict[str, Any] = {} if self._relay_enabled: agent_kwargs["relay_model_name"] = self._model.config.model_name diff --git a/adapters/typescript/pi/pi.fabric-adapter.json b/adapters/typescript/pi/pi.fabric-adapter.json index 8c5d91271..00add517a 100644 --- a/adapters/typescript/pi/pi.fabric-adapter.json +++ b/adapters/typescript/pi/pi.fabric-adapter.json @@ -18,7 +18,8 @@ "tools.enabled", "tools.blocked", "skills" - ] + ], + "system_instruction_modes": ["replace"] }, "settings_schema": { "type": "object", diff --git a/adapters/typescript/pi/src/pi-sdk.ts b/adapters/typescript/pi/src/pi-sdk.ts index 2f467838d..a2092100c 100644 --- a/adapters/typescript/pi/src/pi-sdk.ts +++ b/adapters/typescript/pi/src/pi-sdk.ts @@ -406,6 +406,20 @@ class PiSdkSessionHandle implements PiSessionHandle { export class PiSdkSessionFactory implements PiSessionFactory { async create(input: AdapterStartInput): Promise { + const systemInstruction = input.config.instructions?.system; + if (systemInstruction?.mode === "append") { + throw new LifecycleError( + "unsupported_system_instruction_mode", + "Pi does not support instructions.system.mode='append'; supported modes: replace", + { + metadata: { + field: "instructions.system.mode", + mode: systemInstruction.mode, + supported_modes: ["replace"], + }, + }, + ); + } const pi = await loadPiSdk(); let workspace: string; try { @@ -442,7 +456,7 @@ export class PiSdkSessionFactory implements PiSessionFactory { noPromptTemplates: true, noThemes: true, noContextFiles: true, - systemPrompt: input.config.instructions?.system?.content, + systemPrompt: systemInstruction?.content, }); await resourceLoader.reload(); const extensionErrors = resourceLoader.getExtensions().errors; diff --git a/adapters/typescript/pi/test/pi-sdk.test.mjs b/adapters/typescript/pi/test/pi-sdk.test.mjs index d8b974e30..18377f2ab 100644 --- a/adapters/typescript/pi/test/pi-sdk.test.mjs +++ b/adapters/typescript/pi/test/pi-sdk.test.mjs @@ -7,7 +7,30 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { resolveCustomTools } from "../dist/pi-sdk.js"; +import { PiSdkSessionFactory, resolveCustomTools } from "../dist/pi-sdk.js"; + +test("rejects append system instructions before loading the Pi harness", async () => { + const factory = new PiSdkSessionFactory(); + + await assert.rejects( + factory.create({ + agentName: "pi-test", + baseDir: "/tmp", + config: { + instructions: { + system: { + content: "Follow repository policy.", + mode: "append", + }, + }, + }, + runtimeContext: {}, + }), + (error) => + error.code === "unsupported_system_instruction_mode" && + error.metadata.field === "instructions.system.mode", + ); +}); test("resolves and executes a workspace TypeScript tool factory", async () => { const workspace = await realpath(await mkdtemp(join(tmpdir(), "fabric-pi-tool-"))); diff --git a/crates/fabric-cli/assets/adapters/claude/claude.fabric-adapter.json b/crates/fabric-cli/assets/adapters/claude/claude.fabric-adapter.json index 01e6c8409..bc761d0ce 100644 --- a/crates/fabric-cli/assets/adapters/claude/claude.fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/claude/claude.fabric-adapter.json @@ -87,7 +87,8 @@ "tools.blocked", "mcp", "skills" - ] + ], + "system_instruction_modes": ["replace", "append"] }, "telemetry": { "providers": { diff --git a/crates/fabric-cli/assets/adapters/codex/codex.fabric-adapter.json b/crates/fabric-cli/assets/adapters/codex/codex.fabric-adapter.json index 76747d5cb..03819ee12 100644 --- a/crates/fabric-cli/assets/adapters/codex/codex.fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/codex/codex.fabric-adapter.json @@ -104,7 +104,8 @@ "mcp", "mcp.auth.oauth2", "skills" - ] + ], + "system_instruction_modes": ["replace"] }, "telemetry": { "providers": { diff --git a/crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json b/crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json index 77616458d..53e2f432e 100644 --- a/crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json @@ -172,7 +172,8 @@ "tools.blocked", "mcp", "skills" - ] + ], + "system_instruction_modes": ["replace"] }, "telemetry": { "providers": { diff --git a/crates/fabric-cli/assets/adapters/hermes/hermes.fabric-adapter.json b/crates/fabric-cli/assets/adapters/hermes/hermes.fabric-adapter.json index 77be6e1e2..d47a186c9 100644 --- a/crates/fabric-cli/assets/adapters/hermes/hermes.fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/hermes/hermes.fabric-adapter.json @@ -82,7 +82,8 @@ "mcp", "mcp.auth.oauth2", "skills" - ] + ], + "system_instruction_modes": ["replace"] }, "telemetry": { "providers": { diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 7e911afc3..8684f0348 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -81,6 +81,17 @@ pub enum InstructionMode { /// Replace the harness default instruction value. #[default] Replace, + /// Preserve the harness default and append this instruction after it. + Append, +} + +impl InstructionMode { + fn as_str(self) -> &'static str { + match self { + Self::Replace => "replace", + Self::Append => "append", + } + } } /// One portable instruction value. @@ -787,6 +798,13 @@ pub struct AdapterConfigSupport { /// Normalized NVIDIA NeMo Fabric config areas or policy paths accepted by this adapter. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub accepts: Vec, + /// Exact system-instruction modes supported by this adapter. + /// + /// An omitted value preserves compatibility with descriptors that predate + /// mode discovery and means `replace` when `instructions.system` is accepted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1), extend("uniqueItems" = true))] + pub system_instruction_modes: Option>, /// Harness-native files generated by this adapter. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub generates: Vec, @@ -795,6 +813,34 @@ pub struct AdapterConfigSupport { pub extensions: BTreeMap, } +impl AdapterConfigSupport { + fn supports_system_instruction_mode(&self, mode: InstructionMode) -> bool { + if !self + .accepts + .contains(&AdapterConfigField::SystemInstructions) + { + return false; + } + self.system_instruction_modes + .as_ref() + .map_or(mode == InstructionMode::Replace, |modes| { + modes.contains(&mode) + }) + } + + fn supported_system_instruction_modes(&self) -> Vec { + if !self + .accepts + .contains(&AdapterConfigField::SystemInstructions) + { + return Vec::new(); + } + self.system_instruction_modes + .clone() + .unwrap_or_else(|| vec![InstructionMode::Replace]) + } +} + /// Adapter-translated normalized NVIDIA NeMo Fabric configuration fields. #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, @@ -2400,17 +2446,35 @@ pub(crate) fn adapter_config_compatibility_issues( }; let mut issues = Vec::new(); - if config + if let Some(system) = config .instructions .as_ref() .and_then(|instructions| instructions.system.as_ref()) - .is_some() - && !accepts(AdapterConfigField::SystemInstructions) { - issues.push(incompatible( - "instructions.system".to_string(), - "the adapter does not declare an equivalent native mapping".to_string(), - )); + if !accepts(AdapterConfigField::SystemInstructions) { + issues.push(incompatible( + "instructions.system".to_string(), + "the adapter does not declare an equivalent native mapping".to_string(), + )); + } else if !descriptor + .config + .supports_system_instruction_mode(system.mode) + { + let supported = descriptor + .config + .supported_system_instruction_modes() + .into_iter() + .map(InstructionMode::as_str) + .collect::>() + .join(", "); + issues.push(incompatible( + "instructions.system.mode".to_string(), + format!( + "mode `{}` is not supported; supported modes: {supported}", + system.mode.as_str() + ), + )); + } } if config.runtime.max_turns.is_some() && !accepts(AdapterConfigField::MaxTurns) { issues.push(incompatible( @@ -2602,6 +2666,34 @@ fn validate_adapter_descriptor_shape(descriptor: &AdapterDescriptor, path: &Path ); } } + if let Some(modes) = &descriptor.config.system_instruction_modes { + if !descriptor + .config + .accepts + .contains(&AdapterConfigField::SystemInstructions) + { + return invalid_adapter_descriptor( + path, + "config.system_instruction_modes requires config.accepts to include `instructions.system`", + ); + } + if modes.is_empty() { + return invalid_adapter_descriptor( + path, + "config.system_instruction_modes must contain at least one mode", + ); + } + if modes + .iter() + .enumerate() + .any(|(index, mode)| modes[..index].contains(mode)) + { + return invalid_adapter_descriptor( + path, + "config.system_instruction_modes must not contain duplicate modes", + ); + } + } for (field, schema) in [ ("settings_schema", descriptor.settings_schema.as_ref()), ("model_schema", descriptor.model_schema.as_ref()), @@ -4924,6 +5016,101 @@ mod tests { })); } + #[test] + fn append_system_instruction_survives_planning_for_supported_adapter() { + let mut config = typed_config("nvidia.fabric.claude"); + config.instructions = Some(InstructionsConfig { + system: Some(InstructionConfig { + content: "Follow the repository review policy.".to_string(), + mode: InstructionMode::Append, + extensions: BTreeMap::new(), + }), + extensions: BTreeMap::new(), + }); + + let plan = resolve_run_plan_from_config( + config, + ResolveContext::new("/tmp/fabric-append-instruction"), + ) + .expect("Claude supports append instructions"); + + assert_eq!( + plan.config + .instructions + .as_ref() + .and_then(|instructions| instructions.system.as_ref()) + .map(|instruction| instruction.mode), + Some(InstructionMode::Append) + ); + assert_eq!( + plan.agent_config + .instructions + .as_ref() + .and_then(|instructions| instructions.system.as_ref()) + .map(|instruction| instruction.mode), + Some(InstructionMode::Append) + ); + } + + #[test] + fn unsupported_system_instruction_mode_fails_planning_at_exact_field() { + let mut config = typed_config("nvidia.fabric.codex"); + config.instructions = Some(InstructionsConfig { + system: Some(InstructionConfig { + content: "Follow the repository review policy.".to_string(), + mode: InstructionMode::Append, + extensions: BTreeMap::new(), + }), + extensions: BTreeMap::new(), + }); + + let error = resolve_run_plan_from_config( + config, + ResolveContext::new("/tmp/fabric-unsupported-instruction-mode"), + ) + .expect_err("Codex advertises replace-only instructions"); + + assert!(matches!( + error, + FabricError::AdapterCompatibility { + adapter_id, + field, + .. + } if adapter_id == "nvidia.fabric.codex" + && field == "instructions.system.mode" + )); + } + + #[test] + fn legacy_descriptor_instruction_claim_is_replace_only() { + let path = repository_root().join("adapters/claude/claude.fabric-adapter.json"); + let mut descriptor = load_adapter_descriptor(&path).expect("Claude descriptor"); + descriptor.config.system_instruction_modes = None; + let mut config = typed_config("nvidia.fabric.claude"); + config.instructions = Some(InstructionsConfig { + system: Some(InstructionConfig { + content: "Follow the repository review policy.".to_string(), + mode: InstructionMode::Replace, + extensions: BTreeMap::new(), + }), + extensions: BTreeMap::new(), + }); + + assert!(adapter_config_compatibility_issues(&config, Some(&descriptor)).is_empty()); + + config + .instructions + .as_mut() + .and_then(|instructions| instructions.system.as_mut()) + .expect("system instruction") + .mode = InstructionMode::Append; + let issues = adapter_config_compatibility_issues(&config, Some(&descriptor)); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].field, "instructions.system.mode"); + assert!(issues[0].reason.contains("supported modes: replace")); + } + #[test] fn empty_system_instruction_is_rejected() { let mut config = typed_config("nvidia.fabric.hermes"); @@ -5593,6 +5780,46 @@ mod tests { } } + #[test] + fn rejects_invalid_system_instruction_mode_claims() { + let path = repository_root().join("adapters/hermes/hermes.fabric-adapter.json"); + let descriptor = load_adapter_descriptor(&path).expect("Hermes descriptor"); + + let mut without_acceptance = descriptor.clone(); + without_acceptance + .config + .accepts + .retain(|field| *field != AdapterConfigField::SystemInstructions); + let error = validate_adapter_descriptor_shape(&without_acceptance, &path) + .expect_err("mode claims require system-instruction acceptance"); + assert!(matches!( + error, + FabricError::InvalidAdapterDescriptor { message, .. } + if message.contains("requires config.accepts") + )); + + let mut empty = descriptor.clone(); + empty.config.system_instruction_modes = Some(Vec::new()); + let error = validate_adapter_descriptor_shape(&empty, &path) + .expect_err("mode claims must not be empty"); + assert!(matches!( + error, + FabricError::InvalidAdapterDescriptor { message, .. } + if message.contains("at least one mode") + )); + + let mut duplicate = descriptor; + duplicate.config.system_instruction_modes = + Some(vec![InstructionMode::Replace, InstructionMode::Replace]); + let error = validate_adapter_descriptor_shape(&duplicate, &path) + .expect_err("mode claims must be unique"); + assert!(matches!( + error, + FabricError::InvalidAdapterDescriptor { message, .. } + if message.contains("duplicate modes") + )); + } + #[test] fn validates_repository_claude_settings_without_applying_defaults() { let mut config = typed_config("nvidia.fabric.claude"); diff --git a/crates/fabric-core/src/doctor.rs b/crates/fabric-core/src/doctor.rs index 61f517d13..2f0717f37 100644 --- a/crates/fabric-core/src/doctor.rs +++ b/crates/fabric-core/src/doctor.rs @@ -615,4 +615,47 @@ mod tests { && check.message.contains("tools.enabled") })); } + + #[test] + fn diagnoses_unsupported_system_instruction_mode_at_exact_field() { + let config: FabricConfig = serde_json::from_value(serde_json::json!({ + "schema_version": "fabric.agent/v1alpha1", + "metadata": {"name": "append-incompatible-agent"}, + "harness": { + "adapter_id": "nvidia.fabric.codex", + "resolution": "preinstalled" + }, + "instructions": { + "system": { + "content": "Follow the repository review policy.", + "mode": "append" + } + }, + "runtime": {} + })) + .expect("typed config"); + let base_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + + let strict_error = + resolve_run_plan_from_config(config.clone(), ResolveContext::new(&base_dir)) + .expect_err("strict planning must reject append for Codex"); + assert!(matches!( + strict_error, + FabricError::AdapterCompatibility { field, .. } + if field == "instructions.system.mode" + )); + + let plan = resolve_diagnostic_plan_from_config(config, ResolveContext::new(base_dir)) + .expect("diagnostic plan"); + let report = doctor_plan(&plan); + + assert_eq!(report.status, DoctorStatus::Fail); + assert!(report.checks.iter().any(|check| { + check.name == "config.unsupported" + && check.status == DoctorStatus::Fail + && check.metadata.get("field") + == Some(&Value::String("instructions.system.mode".to_string())) + && check.message.contains("supported modes: replace") + })); + } } diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index c69343703..48e6f82a3 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -320,6 +320,16 @@ mod tests { fn agent_schema_enforces_positive_invocation_limits() { let schema = generate_schema(SchemaName::Agent).expect("schema generation"); + assert_eq!( + schema["$defs"]["InstructionMode"]["oneOf"] + .as_array() + .expect("instruction modes") + .iter() + .map(|mode| mode["const"].clone()) + .collect::>(), + vec![serde_json::json!("replace"), serde_json::json!("append")] + ); + assert_eq!( schema["$defs"]["InstructionConfig"]["properties"]["content"]["minLength"], 1 @@ -422,6 +432,23 @@ mod tests { serde_json::json!(["object", "null"]) ); assert!(schema["properties"]["target_types"].is_object()); + assert_eq!( + schema["$defs"]["InstructionMode"]["oneOf"] + .as_array() + .expect("instruction modes") + .iter() + .map(|mode| mode["const"].clone()) + .collect::>(), + vec![serde_json::json!("replace"), serde_json::json!("append")] + ); + let system_instruction_modes = + &schema["$defs"]["AdapterConfigSupport"]["properties"]["system_instruction_modes"]; + assert_eq!(system_instruction_modes["minItems"], 1); + assert_eq!(system_instruction_modes["uniqueItems"], true); + assert!( + system_instruction_modes["items"].is_object(), + "system instruction modes must constrain each item" + ); assert_eq!( schema["properties"]["extension_schemas"]["propertyNames"]["enum"], serde_json::json!([ diff --git a/docs/adapter-contract/adapter-descriptor.md b/docs/adapter-contract/adapter-descriptor.md index c0bfccf1a..af3f4b05c 100644 --- a/docs/adapter-contract/adapter-descriptor.md +++ b/docs/adapter-contract/adapter-descriptor.md @@ -62,7 +62,8 @@ system instructions, and a target-applied turn limit: "models.base_url", "instructions.system", "runtime.max_turns" - ] + ], + "system_instruction_modes": ["replace", "append"] } ``` @@ -74,6 +75,17 @@ schema enum for the exact accepted values. Planning rejects configured normalized behavior outside the declared surface. NeMo Fabric does not silently remove unsupported fields. +When an adapter accepts `instructions.system`, declare the exact supported +modes in `config.system_instruction_modes`. `replace` discards the harness +default system instruction. `append` preserves the harness default and adds the +configured content after it. New descriptors must declare the supported modes +explicitly. For compatibility with descriptors created before mode discovery, +an omitted `system_instruction_modes` value means `replace` only. + +Mode declarations must be nonempty, unique, and accompanied by +`instructions.system` in `config.accepts`. Planning rejects an unsupported +configured mode at `instructions.system.mode` before the adapter starts. + ## Add Adapter-Owned Schemas Use an Adapter Descriptor schema for target-specific data that cannot be diff --git a/docs/adapter-contract/normalized-configuration.md b/docs/adapter-contract/normalized-configuration.md index ca716381c..8ebff2c2d 100644 --- a/docs/adapter-contract/normalized-configuration.md +++ b/docs/adapter-contract/normalized-configuration.md @@ -81,6 +81,24 @@ empty value can mean something different. For example, `tools.enabled: null` preserves the target's native selection, while `tools.enabled: []` explicitly selects no named tools. +## Apply System Instructions Explicitly + +An omitted `instructions.system` value preserves the harness's native system +instruction. When a system instruction is present, `mode` defaults to +`replace` for compatibility: + +- `replace` replaces the harness's native system instruction with `content`. +- `append` preserves the harness's native system instruction and adds `content` + after it. + +The selected Adapter Descriptor must accept `instructions.system`. When its +`config.system_instruction_modes` capability list is present, the list must +declare the selected mode. An omitted list preserves compatibility with legacy +descriptors and supports `replace` only. Planning and `doctor(...)` reject +unsupported modes at `instructions.system.mode`. Adapters must also validate +the mode at their direct startup boundary so callers that host an adapter +without NeMo Fabric planning receive the same fail-closed behavior. + ## Keep Fabric-Owned Context Out of AgentConfig `AgentConfig` does not contain adapter selection, installation policy, diff --git a/docs/integrations/harness/claude.mdx b/docs/integrations/harness/claude.mdx index 8c7eafb63..e168761d0 100644 --- a/docs/integrations/harness/claude.mdx +++ b/docs/integrations/harness/claude.mdx @@ -71,6 +71,9 @@ MCP servers, instructions, turn limit, tool policy, and telemetry. The resolved Claude descriptor accepts only these Claude-specific `harness.settings` keys: +Claude supports both `replace` and `append` system instructions. `append` +preserves the `claude_code` preset and adds the configured content after it. + - `permission_mode`: `default`, `acceptEdits`, `bypassPermissions`, `plan`, `dontAsk`, or `auto` - `max_budget_usd`: a number greater than `0` diff --git a/docs/integrations/harness/codex.mdx b/docs/integrations/harness/codex.mdx index 45c4eae08..f25a5584e 100644 --- a/docs/integrations/harness/codex.mdx +++ b/docs/integrations/harness/codex.mdx @@ -65,8 +65,8 @@ harness = HarnessConfig(adapter_id="nvidia.fabric.codex") Use normalized `FabricConfig` fields to configure the model, workspace, skills, MCP servers, and telemetry. The Codex adapter does not support normalized `tools.enabled` or `tools.blocked` policy. Configure base instructions through -`instructions.system`; the adapter passes that normalized value to the Codex -SDK as `base_instructions`. +`instructions.system` with `mode="replace"`; the adapter passes that normalized +value to the Codex SDK as `base_instructions`. The adapter rejects `append`. The resolved Codex descriptor accepts the following Codex-specific `harness.settings` keys. All keys are optional: diff --git a/docs/integrations/harness/deepagents.mdx b/docs/integrations/harness/deepagents.mdx index 0cd0567ce..ab4ba60d3 100644 --- a/docs/integrations/harness/deepagents.mdx +++ b/docs/integrations/harness/deepagents.mdx @@ -71,7 +71,8 @@ harness = HarnessConfig(adapter_id="nvidia.fabric.langchain.deepagents") ``` Use normalized `FabricConfig` fields to configure the model, workspace, skills, -MCP servers, blocked tools, and telemetry. Use +MCP servers, replacement system instructions, blocked tools, and telemetry. +The adapter rejects `append` system instructions. Use `harness.settings.deepagents` to configure the JSON-serializable Deep Agents-native `interrupt_on` and `subagents` options: diff --git a/docs/integrations/harness/hermes.mdx b/docs/integrations/harness/hermes.mdx index 05f4862a7..ef15a4243 100644 --- a/docs/integrations/harness/hermes.mdx +++ b/docs/integrations/harness/hermes.mdx @@ -57,7 +57,8 @@ harness = HarnessConfig(adapter_id="nvidia.fabric.hermes") ``` Use normalized `FabricConfig` fields to configure the model, workspace, skills, -MCP servers, instructions, turn limit, native tool selectors, and telemetry. +MCP servers, replacement system instructions, turn limit, native tool +selectors, and telemetry. The adapter rejects `append` system instructions. The Hermes descriptor validates the following `harness.settings` fields: diff --git a/docs/integrations/harness/mini-swe-agent.mdx b/docs/integrations/harness/mini-swe-agent.mdx index 7c287cd13..4832eacf2 100644 --- a/docs/integrations/harness/mini-swe-agent.mdx +++ b/docs/integrations/harness/mini-swe-agent.mdx @@ -52,7 +52,8 @@ harness = HarnessConfig( ``` The adapter supports `models`, `models.base_url`, `models.temperature`, -`instructions.system`, `runtime.max_turns`, and `environment.workspace`. Set +`instructions.system` with `replace` mode, `runtime.max_turns`, and +`environment.workspace`. The adapter rejects `append` system instructions. Set `models..api_key_env` to the environment variable containing the model-provider credential. diff --git a/docs/integrations/harness/pi.mdx b/docs/integrations/harness/pi.mdx index 10bda19fe..802525aba 100644 --- a/docs/integrations/harness/pi.mdx +++ b/docs/integrations/harness/pi.mdx @@ -78,7 +78,7 @@ For a source build, set `discovery.local_paths` to `adapters/typescript/pi/pi.fabric-adapter.json` instead. The adapter supports one selected model from Pi's catalog, an optional base URL -override, replacement system instructions, tool policy, explicit skill paths, +override, `replace` system instructions, tool policy, explicit skill paths, and explicit local Pi extensions. Set `models..api_key_env` to the name of the environment variable that contains the provider credential. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index e57b39fe5..3ad5f55ed 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -375,7 +375,7 @@ The model defines the following fields: | Field | Type | Required | Default | Constraints | Description | | --- | --- | --- | --- | --- | --- | | `content` | `str` | Yes | — | `MinLen(min_length=1), _PydanticGeneralMetadata(pattern='\\S')` | — | -| `mode` | `Literal['replace']` | No | `'replace'` | — | — | +| `mode` | `Literal['replace', 'append']` | No | `'replace'` | — | — | --- diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-instructionmode.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-instructionmode.mdx index d125d6d78..1aa441a9a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-instructionmode.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-instructionmode.mdx @@ -12,6 +12,7 @@ Generated from `cargo doc --no-deps -p nemo-fabric-core`. ```rust pub enum InstructionMode { Replace, + Append, } ``` @@ -25,6 +26,12 @@ How an instruction value is applied to the selected harness. Replace the harness default instruction value. +### `Append` + +
+ +Preserve the harness default and append this instruction after it. + ## Trait Implementations ### `impl Clone for InstructionMode` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx index c98e4617c..4439f355a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
Vec<AdapterConfigField>,\n    pub generates: Vec<PathBuf>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
Vec<AdapterConfigField>,\n    pub system_instruction_modes: Option<Vec<InstructionMode>>,\n    pub generates: Vec<PathBuf>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Adapter config support. @@ -19,6 +19,12 @@ Adapter config support. Normalized NVIDIA NeMo Fabric config areas or policy paths accepted by this adapter. +### `system_instruction_modes: Option>` + +Exact system-instruction modes supported by this adapter. + +An omitted value preserves compatibility with descriptors that predate mode discovery and means `replace` when `instructions.system` is accepted. + ### `generates: Vec` Harness-native files generated by this adapter. diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index d0168dd14..e4df922cd 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -81,6 +81,11 @@ async def main() -> None: asyncio.run(main()) ``` +Omit `instructions.system` to preserve the harness's native system instruction. +When present, `InstructionConfig.mode` defaults to `replace`. Use `append` only +with an adapter whose descriptor advertises that mode; planning rejects other +combinations before the runtime starts. + Use `base_dir` to resolve relative paths in an in-memory config. Before running in a new environment, call `plan(...)` to inspect adapter selection and `doctor(...)` to check runtime requirements. @@ -181,7 +186,7 @@ by adapter: | `models..base_url` | Yes | Yes | Yes | Yes | Yes | | `models..temperature` | No | No | Yes | Yes | Yes | | `models..settings.` | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | -| `instructions.system` | Yes | Yes; maps to Codex base instructions | Yes | Yes | Yes | +| `instructions.system` | `replace`, `append` | `replace`; maps to Codex base instructions | `replace` | `replace` | `replace` | | `runtime.input_schema`, `.output_schema` | Core | Core | Core | Core | Core | | `runtime.artifacts`, `.timeout_seconds` | Core | Core | Core | Core | Core | | `runtime.max_turns` | Yes | No | No | Yes; maps to Hermes iterations | Yes | diff --git a/examples/harbor/swebench/adapters/claude/claude.fabric-adapter.json b/examples/harbor/swebench/adapters/claude/claude.fabric-adapter.json index 01e6c8409..bc761d0ce 100644 --- a/examples/harbor/swebench/adapters/claude/claude.fabric-adapter.json +++ b/examples/harbor/swebench/adapters/claude/claude.fabric-adapter.json @@ -87,7 +87,8 @@ "tools.blocked", "mcp", "skills" - ] + ], + "system_instruction_modes": ["replace", "append"] }, "telemetry": { "providers": { diff --git a/examples/harbor/swebench/adapters/hermes/hermes.fabric-adapter.json b/examples/harbor/swebench/adapters/hermes/hermes.fabric-adapter.json index 77be6e1e2..d47a186c9 100644 --- a/examples/harbor/swebench/adapters/hermes/hermes.fabric-adapter.json +++ b/examples/harbor/swebench/adapters/hermes/hermes.fabric-adapter.json @@ -82,7 +82,8 @@ "mcp", "mcp.auth.oauth2", "skills" - ] + ], + "system_instruction_modes": ["replace"] }, "telemetry": { "providers": { diff --git a/examples/langgraph_custom_agent/README.md b/examples/langgraph_custom_agent/README.md index 6d4ef177c..cb33ad319 100644 --- a/examples/langgraph_custom_agent/README.md +++ b/examples/langgraph_custom_agent/README.md @@ -49,15 +49,17 @@ fields the adapter applies: "instructions.system", "mcp", "mcp.tool_filters" - ] + ], + "system_instruction_modes": ["replace", "append"] } ``` The minimum path configures only a model and instruction; MCP is an optional extension described below. NeMo Fabric projects configured values from `FabricConfig` into `AgentConfig`. The adapter resolves `models.default` into -`ChatOpenAI`, applies the normalized system instruction, compiles one graph -during `start`, and retains it for ordered invocations. The custom graph +`ChatOpenAI`, replaces or appends the normalized system instruction as +configured, compiles one graph during `start`, and retains it for ordered +invocations. The custom graph receives native dependencies; it does not parse either NeMo Fabric configuration type. @@ -87,7 +89,7 @@ Every variation returns an independent `FabricConfig`: | Variation | Consumer API or CLI | Southbound Effect | | --- | --- | --- | | Model | `--model` | `models.default.model` | -| Instruction | `with_system_instruction(...)` or `--system-instruction` | `instructions.system` | +| Instruction | `with_system_instruction(..., mode=...)`, `--system-instruction`, and `--system-instruction-mode` | `instructions.system` with `replace` or `append` | | Temperature | `with_temperature(...)` or `--temperature` | `models.default.temperature` | | stdio MCP | `with_url_inspector_mcp(...)` or `--mcp` | `mcp.servers` and per-server tool policy | diff --git a/examples/langgraph_custom_agent/adapter/configuration.py b/examples/langgraph_custom_agent/adapter/configuration.py index b6a63cb44..323e49dd8 100644 --- a/examples/langgraph_custom_agent/adapter/configuration.py +++ b/examples/langgraph_custom_agent/adapter/configuration.py @@ -12,6 +12,7 @@ from langchain_core.language_models import BaseChatModel from langchain_openai import ChatOpenAI from nemo_fabric_adapter_contract.models import AgentConfig +from nemo_fabric_adapters.common import instructions as common_instructions from nemo_fabric_adapters.common import lifecycle DEFAULT_SYSTEM_INSTRUCTION = ( @@ -91,10 +92,17 @@ def resolve_agent_dependencies(agent_config: AgentConfig) -> AgentDependencies: chat_model_options["temperature"] = model_config.temperature system_instruction = DEFAULT_SYSTEM_INSTRUCTION - if agent_config.instructions is not None: - system = agent_config.instructions.system - if system is not None: - system_instruction = system.content + instruction = common_instructions.system_instruction( + agent_config, + adapter="email-phishing", + supported_modes={"replace", "append"}, + ) + if instruction is not None: + system_instruction = ( + instruction.content + if instruction.mode == "replace" + else f"{DEFAULT_SYSTEM_INSTRUCTION}\n\n{instruction.content}" + ) return AgentDependencies( model=ChatOpenAI(**chat_model_options), diff --git a/examples/langgraph_custom_agent/adapter/email-phishing.fabric-adapter.json b/examples/langgraph_custom_agent/adapter/email-phishing.fabric-adapter.json index f5f6543a9..cf7cae8f4 100644 --- a/examples/langgraph_custom_agent/adapter/email-phishing.fabric-adapter.json +++ b/examples/langgraph_custom_agent/adapter/email-phishing.fabric-adapter.json @@ -14,7 +14,8 @@ "instructions.system", "mcp", "mcp.tool_filters" - ] + ], + "system_instruction_modes": ["replace", "append"] }, "telemetry": { "providers": { diff --git a/examples/langgraph_custom_agent/consumer/__main__.py b/examples/langgraph_custom_agent/consumer/__main__.py index f86b971ca..615717720 100644 --- a/examples/langgraph_custom_agent/consumer/__main__.py +++ b/examples/langgraph_custom_agent/consumer/__main__.py @@ -25,6 +25,11 @@ async def main() -> None: parser.add_argument("--variant", choices=("public", "frontier"), default="public") parser.add_argument("--model") parser.add_argument("--system-instruction") + parser.add_argument( + "--system-instruction-mode", + choices=("replace", "append"), + default="replace", + ) parser.add_argument("--temperature", type=float) parser.add_argument("--mcp", action="store_true") parser.add_argument("--relay", action="store_true") @@ -42,7 +47,11 @@ async def main() -> None: config_factory = frontier_config if args.variant == "frontier" else public_config config = config_factory(args.model) if args.model else config_factory() if args.system_instruction: - config = with_system_instruction(config, args.system_instruction) + config = with_system_instruction( + config, + args.system_instruction, + mode=args.system_instruction_mode, + ) if args.temperature is not None: config = with_temperature(config, args.temperature) if args.mcp: diff --git a/examples/langgraph_custom_agent/consumer/config.py b/examples/langgraph_custom_agent/consumer/config.py index c31e371ac..4381f790f 100644 --- a/examples/langgraph_custom_agent/consumer/config.py +++ b/examples/langgraph_custom_agent/consumer/config.py @@ -8,6 +8,7 @@ import os import sys from pathlib import Path +from typing import Literal from nemo_fabric import FabricConfig from nemo_fabric import DiscoveryConfig @@ -87,12 +88,17 @@ def frontier_config(model: str = FRONTIER_DEFAULT_MODEL) -> FabricConfig: ) -def with_system_instruction(base: FabricConfig, content: str) -> FabricConfig: +def with_system_instruction( + base: FabricConfig, + content: str, + *, + mode: Literal["replace", "append"] = "replace", +) -> FabricConfig: """Return an independent config with a different normalized instruction.""" config = base.model_copy(deep=True) config.instructions = InstructionsConfig( - system=InstructionConfig(content=content) + system=InstructionConfig(content=content, mode=mode) ) return config diff --git a/external/nat/README.md b/external/nat/README.md index ad4aef899..c4e7a81a6 100644 --- a/external/nat/README.md +++ b/external/nat/README.md @@ -25,7 +25,7 @@ installed NeMo Agent Toolkit components. | `AgentConfig` field | NeMo Agent Toolkit configuration | | --- | --- | | `models.` | `llms.`; every NeMo Fabric model-role name is preserved | -| `instructions.system` | Built-in `react_agent` workflow `additional_instructions`; other workflow types reject this field in the initial adapter | +| `instructions.system` | `append` only; maps to the built-in `react_agent` workflow `additional_instructions`. Other modes and workflow types are rejected. | | `workflow.entrypoint.kind=factory` | Resolve a NeMo Fabric-defined agent intent | | `workflow.entrypoint.ref=fabric.agent.react` | NeMo Agent Toolkit `react_agent` workflow factory | | `workflow.settings` | Remaining `workflow` component fields | @@ -34,6 +34,13 @@ installed NeMo Agent Toolkit components. | `mcp.servers.` | Generated `mcp_client` function group named `` | | `tools.enabled`, `tools.blocked` | Effective native NeMo Agent Toolkit workflow tool selection | +Configurations that set `instructions.system` must now select `mode="append"` +explicitly. Earlier adapter versions accepted an omitted mode while mapping the +instruction to additive `additional_instructions`; requiring the explicit mode +removes that ambiguity. The adapter rejects the contract default, `replace`, +because the built-in `react_agent` workflow does not expose a native replacement +seam. + The adapter loads installed `nat.components` entry points before validating the generated configuration with NeMo Agent Toolkit. A custom function or function group is supplied as an installed NeMo Agent Toolkit component package and diff --git a/external/nat/examples/calculator.py b/external/nat/examples/calculator.py index bb7689304..3bb27a35f 100644 --- a/external/nat/examples/calculator.py +++ b/external/nat/examples/calculator.py @@ -45,7 +45,8 @@ def build_config() -> FabricConfig: }, instructions=InstructionsConfig( system=InstructionConfig( - content="Use the calculator tools for arithmetic. Return a concise answer." + content="Use the calculator tools for arithmetic. Return a concise answer.", + mode="append", ) ), runtime=RuntimeConfig(input_schema="text", output_schema="message"), diff --git a/external/nat/examples/email_phishing.py b/external/nat/examples/email_phishing.py index 91c6b7f92..030903df2 100644 --- a/external/nat/examples/email_phishing.py +++ b/external/nat/examples/email_phishing.py @@ -57,7 +57,8 @@ def build_config() -> FabricConfig: }, instructions=InstructionsConfig( system=InstructionConfig( - content='State whether the email is "phishing" or "benign" and explain why.' + content='State whether the email is "phishing" or "benign" and explain why.', + mode="append", ) ), tools=tools, diff --git a/external/nat/nat.fabric-adapter.json b/external/nat/nat.fabric-adapter.json index 4672b0fa2..de37b4b34 100644 --- a/external/nat/nat.fabric-adapter.json +++ b/external/nat/nat.fabric-adapter.json @@ -49,7 +49,8 @@ "tools.blocked", "mcp", "mcp.tool_filters" - ] + ], + "system_instruction_modes": ["append"] }, "capabilities": { "cancellation": false, diff --git a/external/nat/src/nemo_fabric_adapters/nat/adapter.py b/external/nat/src/nemo_fabric_adapters/nat/adapter.py index f58a5b432..59d4da2fc 100644 --- a/external/nat/src/nemo_fabric_adapters/nat/adapter.py +++ b/external/nat/src/nemo_fabric_adapters/nat/adapter.py @@ -18,6 +18,7 @@ from contextlib import AsyncExitStack from typing import Any +from nemo_fabric_adapters.common import instructions as common_instructions from nemo_fabric_adapters.common import lifecycle import nemo_fabric_adapters.common.utils as common_utils from nemo_fabric_adapter_contract.models import AgentConfig @@ -231,8 +232,12 @@ def _is_react_agent(workflow: dict[str, Any]) -> bool: def _apply_system_instruction( config: dict[str, Any], agent_config: AgentConfig ) -> None: - instruction = agent_config.instructions - if instruction is None or instruction.system is None: + instruction = common_instructions.system_instruction( + agent_config, + adapter="NeMo Agent Toolkit", + supported_modes={"append"}, + ) + if instruction is None: return workflow = config["workflow"] @@ -251,7 +256,7 @@ def _apply_system_instruction( "workflow.settings.additional_instructions", ], ) - workflow["additional_instructions"] = instruction.system.content + workflow["additional_instructions"] = instruction.content def _string_list(value: Any, field: str, *, optional: bool = False) -> list[str] | None: diff --git a/schemas/adapter-contract/adapter-descriptor.schema.json b/schemas/adapter-contract/adapter-descriptor.schema.json index f36105f28..b9d202950 100644 --- a/schemas/adapter-contract/adapter-descriptor.schema.json +++ b/schemas/adapter-contract/adapter-descriptor.schema.json @@ -87,6 +87,18 @@ "type": "string" }, "type": "array" + }, + "system_instruction_modes": { + "description": "Exact system-instruction modes supported by this adapter.\n\nAn omitted value preserves compatibility with descriptors that predate\nmode discovery and means `replace` when `instructions.system` is accepted.", + "items": { + "$ref": "#/$defs/InstructionMode" + }, + "minItems": 1, + "type": [ + "array", + "null" + ], + "uniqueItems": true } }, "type": "object" @@ -209,6 +221,21 @@ }, "type": "object" }, + "InstructionMode": { + "description": "How an instruction value is applied to the selected harness.", + "oneOf": [ + { + "const": "replace", + "description": "Replace the harness default instruction value.", + "type": "string" + }, + { + "const": "append", + "description": "Preserve the harness default and append this instruction after it.", + "type": "string" + } + ] + }, "RuntimeCapabilities": { "description": "Lifecycle behavior implemented by a resolved runtime path.", "properties": { diff --git a/schemas/adapter-contract/agent-config.schema.json b/schemas/adapter-contract/agent-config.schema.json index 8471c5454..c766c2f71 100644 --- a/schemas/adapter-contract/agent-config.schema.json +++ b/schemas/adapter-contract/agent-config.schema.json @@ -386,6 +386,11 @@ "const": "replace", "description": "Replace the harness default instruction value.", "type": "string" + }, + { + "const": "append", + "description": "Preserve the harness default and append this instruction after it.", + "type": "string" } ] }, diff --git a/schemas/sdk/agent.schema.json b/schemas/sdk/agent.schema.json index dea77ac5c..7dfcd98a1 100644 --- a/schemas/sdk/agent.schema.json +++ b/schemas/sdk/agent.schema.json @@ -167,6 +167,11 @@ "const": "replace", "description": "Replace the harness default instruction value.", "type": "string" + }, + { + "const": "append", + "description": "Preserve the harness default and append this instruction after it.", + "type": "string" } ] }, diff --git a/schemas/sdk/run-plan.schema.json b/schemas/sdk/run-plan.schema.json index b37491c28..d93607d21 100644 --- a/schemas/sdk/run-plan.schema.json +++ b/schemas/sdk/run-plan.schema.json @@ -87,6 +87,18 @@ "type": "string" }, "type": "array" + }, + "system_instruction_modes": { + "description": "Exact system-instruction modes supported by this adapter.\n\nAn omitted value preserves compatibility with descriptors that predate\nmode discovery and means `replace` when `instructions.system` is accepted.", + "items": { + "$ref": "#/$defs/InstructionMode" + }, + "minItems": 1, + "type": [ + "array", + "null" + ], + "uniqueItems": true } }, "type": "object" @@ -1413,6 +1425,11 @@ "const": "replace", "description": "Replace the harness default instruction value.", "type": "string" + }, + { + "const": "append", + "description": "Preserve the harness default and append this instruction after it.", + "type": "string" } ] }, diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py index 1f037d7bf..6ea08ad62 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py @@ -171,7 +171,7 @@ class InstructionConfig(FabricBaseModel): """One portable instruction value.""" content: str = Field(min_length=1, pattern=r"\S") - mode: Literal["replace"] = "replace" + mode: Literal["replace", "append"] = "replace" @field_validator("content") @classmethod diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py index afe940abf..0dd057473 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py @@ -310,8 +310,8 @@ def __init__( mode: str = "replace", extra_fields: Mapping[str, Any] | None = None, ) -> None: - if mode != "replace": - raise FabricConfigError("instruction mode must be replace") + if not isinstance(mode, str) or mode not in {"replace", "append"}: + raise FabricConfigError("instruction mode must be replace or append") super().__init__( { "content": _required_text(content, "instruction content"), diff --git a/skills/nemo-fabric-build-adapter/SKILL.md b/skills/nemo-fabric-build-adapter/SKILL.md index fa29d157a..e98919278 100644 --- a/skills/nemo-fabric-build-adapter/SKILL.md +++ b/skills/nemo-fabric-build-adapter/SKILL.md @@ -54,6 +54,9 @@ translation: - Set the current `contract_version`, a globally stable `adapter_id`, `adapter_kind`, and runner binding. - Declare only normalized `config.accepts` fields the implementation enforces. +- When accepting `instructions.system`, declare the exact supported + `config.system_instruction_modes`. New descriptors must not rely on the + legacy omitted-value behavior, which means `replace` only. - Declare `mcp.auth.oauth2` or `mcp.auth.service_account` only when the adapter implements the corresponding MCP authentication mode. - Publish closed `settings_schema`, `model_schema`, `tool_definition_schema`, @@ -105,7 +108,10 @@ Accept a validated `AgentConfig` and translate each declared field once at the adapter boundary: - Resolve named model roles into target-native model clients or settings. -- Apply normalized instructions and runtime limits only when declared. +- Apply normalized instructions and runtime limits only when declared. Validate + `instructions.system.mode` at the adapter startup boundary as well as during + planning; `replace` discards the harness default, while `append` preserves it + and adds the configured content after it. - Convert MCP servers, tool definitions, tool policy, and skills into native target constructs. - Resolve workflow entry points and construction settings during `start` in diff --git a/skills/nemo-fabric-integrate/SKILL.md b/skills/nemo-fabric-integrate/SKILL.md index ddd6ea434..f4b419ebb 100644 --- a/skills/nemo-fabric-integrate/SKILL.md +++ b/skills/nemo-fabric-integrate/SKILL.md @@ -118,7 +118,10 @@ def to_fabric_config(job) -> FabricConfig: }, instructions=( InstructionsConfig( - system=InstructionConfig(content=job.system_instruction), + system=InstructionConfig( + content=job.system_instruction, + mode=job.system_instruction_mode, + ), ) if job.system_instruction is not None else None diff --git a/skills/nemo-fabric-integrate/references/config-mapping.md b/skills/nemo-fabric-integrate/references/config-mapping.md index ea55f7265..6acdd81df 100644 --- a/skills/nemo-fabric-integrate/references/config-mapping.md +++ b/skills/nemo-fabric-integrate/references/config-mapping.md @@ -21,7 +21,7 @@ Import these from the top-level `nemo_fabric` package: | `WorkflowConfig` | Registered `target_id` and immutable construction settings. | | `DiscoveryConfig` | Explicit local descriptor files and directories. | | `ModelConfig` | Provider, model, credentials (`api_key_env`), endpoint, and sampling. | -| `InstructionsConfig` / `InstructionConfig` | Portable agent instructions and replacement mode. | +| `InstructionsConfig` / `InstructionConfig` | Portable agent instructions with `replace` or `append` composition. | | `RuntimeConfig` | Input/output labels, artifact location, invocation timeout, and harness turn limit. | | `EnvironmentConfig` | Execution environment, workspace, and harness-visible variables. | | `ToolsConfig` / `ToolDefinitionConfig` | Named tool and tool-group definitions plus selection and blocking policy. | @@ -40,6 +40,11 @@ Claude and Codex validate every model role against their descriptor-owned `openai` paths require both `ModelConfig.base_url` and `ModelConfig.api_key_env`; undeclared `ModelConfig.settings` also fail planning. +Omit `instructions.system` to preserve the harness's native system instruction. +When present, `InstructionConfig.mode` defaults to `replace`; set it to +`append` only when the selected adapter descriptor advertises that mode. +Planning rejects unsupported modes before adapter startup. + ## Build And Shape Construct the nested config directly, then adjust capabilities with helper @@ -81,7 +86,10 @@ config = FabricConfig( }, instructions=( InstructionsConfig( - system=InstructionConfig(content=job.system_instruction), + system=InstructionConfig( + content=job.system_instruction, + mode=job.system_instruction_mode, + ), ) if job.system_instruction is not None else None diff --git a/tests/adapter_contract/test_agent_config.py b/tests/adapter_contract/test_agent_config.py index 3d87325d3..c844ca8ee 100644 --- a/tests/adapter_contract/test_agent_config.py +++ b/tests/adapter_contract/test_agent_config.py @@ -94,6 +94,25 @@ def test_agent_config_block_omits_empty_extensions(): assert AgentConfig().to_mapping() == {} +@pytest.mark.parametrize("mode", ["replace", "append"]) +def test_agent_instruction_modes_round_trip(mode: str): + instruction = AgentInstructionConfig.from_mapping( + {"content": "Follow repository policy.", "mode": mode} + ) + + assert instruction.to_mapping() == { + "content": "Follow repository policy.", + "mode": mode, + } + + +def test_agent_instruction_rejects_unknown_mode(): + with pytest.raises(ContractValidationError, match="mode"): + AgentInstructionConfig.from_mapping( + {"content": "Follow repository policy.", "mode": "prepend"} + ) + + def test_agent_config_blocks_reject_implicit_and_non_json_extensions(): with pytest.raises(TypeError, match="unexpected keyword argument"): AgentConfig(implicit_extension=True) # type: ignore[call-arg] diff --git a/tests/adapters/test_adapters_common_instructions.py b/tests/adapters/test_adapters_common_instructions.py new file mode 100644 index 000000000..8d4406d81 --- /dev/null +++ b/tests/adapters/test_adapters_common_instructions.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import nemo_fabric_adapters.common.instructions as common_instructions +import pytest +from nemo_fabric_adapter_contract.models import AgentConfig +from nemo_fabric_adapters.common import lifecycle + + +def _config(mode: str) -> AgentConfig: + return AgentConfig.from_mapping( + { + "instructions": { + "system": { + "content": "Follow repository policy.", + "mode": mode, + } + } + } + ) + + +@pytest.mark.parametrize("mode", ["replace", "append"]) +def test_system_instruction_returns_supported_mode(mode: str): + instruction = common_instructions.system_instruction( + _config(mode), + adapter="Test adapter", + supported_modes={"replace", "append"}, + ) + + assert instruction is not None + assert instruction.mode == mode + + +def test_system_instruction_reports_exact_unsupported_mode(): + with pytest.raises(lifecycle.LifecycleError) as caught: + common_instructions.system_instruction( + _config("append"), + adapter="Test adapter", + supported_modes={"replace"}, + ) + + assert caught.value.code == "unsupported_system_instruction_mode" + assert caught.value.metadata == { + "field": "instructions.system.mode", + "mode": "append", + "supported_modes": ["replace"], + } diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 60144e48b..dd8430300 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -233,6 +233,7 @@ def test_claude_descriptor_is_narrow_and_versioned(): "mcp", "skills", ], + "system_instruction_modes": ["replace", "append"], }, "telemetry": { "providers": { @@ -381,6 +382,18 @@ def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_p assert "ANTHROPIC_BASE_URL" not in options.env +def test_build_options_appends_to_claude_preset_system_prompt(claude_payload): + claude_payload["config"]["instructions"]["system"]["mode"] = "append" + + options = build_options(claude_payload) + + assert options.system_prompt == { + "type": "preset", + "preset": "claude_code", + "append": "Review carefully.", + } + + @pytest.mark.parametrize( "authentication", [ diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 2d9c14422..7ef75f77e 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -849,6 +849,17 @@ def test_sdk_closes_when_skill_registration_is_unavailable( assert client.closed is True +def test_thread_options_reject_append_system_instruction(codex_payload): + codex_payload["config"]["instructions"]["system"]["mode"] = "append" + config, context, base_dir = runtime_input(codex_payload) + + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + adapter._thread_options(config, context, base_dir, None) + + assert caught.value.code == "unsupported_system_instruction_mode" + assert caught.value.metadata["field"] == "instructions.system.mode" + + @pytest.mark.parametrize("transport", ["sse", "carrier-pigeon"]) def test_sdk_rejects_unsupported_mcp_transport(codex_payload, mock_codex, transport): configure_mcp( @@ -1693,6 +1704,7 @@ def test_descriptor_has_no_codex_binary_requirement(): "mcp.auth.oauth2", "skills", ] + assert descriptor["config"]["system_instruction_modes"] == ["replace"] assert descriptor["model_schema"]["if"]["properties"]["provider"] == { "const": "openai" } diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 41660d729..53d32dc62 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -12,6 +12,7 @@ from __future__ import annotations import importlib.machinery +import json import os import sys import types @@ -35,6 +36,18 @@ from nemo_fabric_adapter_contract.models import RuntimeContext from nemo_fabric_adapters.deepagents import adapter # noqa: E402 +ROOT = Path(__file__).resolve().parents[2] + + +def test_descriptor_declares_replace_system_instruction_mode(): + descriptor = json.loads( + (ROOT / "adapters/deepagents/deepagents.fabric-adapter.json").read_text( + encoding="utf-8" + ) + ) + + assert descriptor["config"]["system_instruction_modes"] == ["replace"] + def lifecycle_start_payload(payload: dict[str, Any]) -> dict[str, Any]: start = {key: value for key, value in payload.items() if key != "request"} @@ -1718,6 +1731,26 @@ async def test_stream_requests_subgraphs(tmp_path, make_payload, fake_sdks): assert fake_sdks["subgraphs"] is True +async def test_build_agent_kwargs_rejects_append_system_instruction( + tmp_path, make_payload +): + payload = make_payload(tmp_path) + payload["config"]["instructions"]["system"]["mode"] = "append" + config = AgentConfig.from_mapping(payload["config"]) + + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await adapter.build_agent_kwargs( + config, + RuntimeContext.from_mapping(payload["runtime_context"]), + payload["base_dir"], + MagicMock(), + config.harness.settings, + ) + + assert caught.value.code == "unsupported_system_instruction_mode" + assert caught.value.metadata["field"] == "instructions.system.mode" + + @pytest.mark.usefixtures("use_real_langgraph") async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload): pytest.importorskip("langchain.agents.middleware") diff --git a/tests/adapters/test_external_nat_adapter.py b/tests/adapters/test_external_nat_adapter.py index bb4059d3a..91d47042f 100644 --- a/tests/adapters/test_external_nat_adapter.py +++ b/tests/adapters/test_external_nat_adapter.py @@ -119,7 +119,7 @@ def make( } if instruction is not None: config["instructions"] = { - "system": {"content": instruction, "mode": "replace"} + "system": {"content": instruction, "mode": "append"} } if tools is not None or definitions: config["tools"] = {**deepcopy(tools or {}), "definitions": definitions} @@ -294,6 +294,7 @@ def test_descriptor_declares_exact_source_reference_contract(): "mcp", "mcp.tool_filters", ] + assert descriptor["config"]["system_instruction_modes"] == ["append"] settings_schema = descriptor["settings_schema"] assert settings_schema["properties"] == {} assert "required" not in settings_schema @@ -453,6 +454,17 @@ def test_system_instruction_rejects_duplicate_nat_instruction_source(make_payloa assert error.value.code == "nat_system_instruction_conflict" +def test_system_instruction_rejects_replace_mode(make_payload): + payload = make_payload(instruction="Portable instruction") + payload["config"].instructions.system.mode = "replace" + + with pytest.raises(adapter.lifecycle.LifecycleError) as error: + adapter.build_nat_config_mapping(payload["config"]) + + assert error.value.code == "unsupported_system_instruction_mode" + assert error.value.metadata["field"] == "instructions.system.mode" + + def test_react_agent_without_tool_names_defaults_to_empty_list(make_payload): payload = make_payload(workflow=_fabric_workflow(llm_name="default")) diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index 3e859f0e7..ab2e314e8 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -134,6 +134,30 @@ def test_descriptor_uses_the_typed_agent_config_contract(): "mcp.auth.oauth2", "skills", ] + assert descriptor["config"]["system_instruction_modes"] == ["replace"] + + +async def test_runtime_start_rejects_append_system_instruction(tmp_path: Path): + payload = { + "base_dir": str(tmp_path), + "config": _agent_config( + { + "instructions": { + "system": { + "content": "Follow repository policy.", + "mode": "append", + } + } + } + ), + "runtime_context": {}, + } + + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await adapter.HermesRuntime().start(payload) + + assert caught.value.code == "unsupported_system_instruction_mode" + assert caught.value.metadata["field"] == "instructions.system.mode" def test_write_hermes_relay_plugin_config_passes_through_v3( diff --git a/tests/adapters/test_mini_swe_agent.py b/tests/adapters/test_mini_swe_agent.py index 3f6d7e0b3..8656201cd 100644 --- a/tests/adapters/test_mini_swe_agent.py +++ b/tests/adapters/test_mini_swe_agent.py @@ -243,6 +243,7 @@ def test_mini_swe_agent_descriptor_is_narrow_and_versioned(): "instructions.system", "runtime.max_turns", ], + "system_instruction_modes": ["replace"], } assert descriptor["capabilities"] == { "service": False, @@ -257,6 +258,20 @@ def test_mini_swe_agent_descriptor_is_narrow_and_versioned(): } +async def test_runtime_start_rejects_append_system_instruction(mini_payload): + mini_payload["config"]["instructions"]["system"]["mode"] = "append" + start = { + **mini_payload, + "config": AgentConfig.from_mapping(mini_payload["config"]), + } + + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await adapter.MiniSweAgentRuntime().start(start) + + assert caught.value.code == "unsupported_system_instruction_mode" + assert caught.value.metadata["field"] == "instructions.system.mode" + + async def test_mini_swe_agent_maps_config_and_returns_normalized_output( mock_mini, mini_payload, monkeypatch: pytest.MonkeyPatch ): diff --git a/tests/adapters/test_pi_adapter.py b/tests/adapters/test_pi_adapter.py index d428ed177..ef4c48cd1 100644 --- a/tests/adapters/test_pi_adapter.py +++ b/tests/adapters/test_pi_adapter.py @@ -52,6 +52,7 @@ def test_pi_descriptor_declares_the_supported_surface(): "tools.blocked", "skills", ] + assert descriptor["config"]["system_instruction_modes"] == ["replace"] assert descriptor["capabilities"] == { "streaming": False, "cancellation": False, diff --git a/tests/examples/langgraph_custom_agent/test_configuration.py b/tests/examples/langgraph_custom_agent/test_configuration.py index 4b43818dc..db0cfc10c 100644 --- a/tests/examples/langgraph_custom_agent/test_configuration.py +++ b/tests/examples/langgraph_custom_agent/test_configuration.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os from copy import deepcopy import pytest @@ -12,6 +13,9 @@ from nemo_fabric_adapter_contract.models import AgentConfig from nemo_fabric_adapters.common import lifecycle +from examples.langgraph_custom_agent.adapter.configuration import ( + DEFAULT_SYSTEM_INSTRUCTION, +) from examples.langgraph_custom_agent.adapter.configuration import ( MODEL_REQUEST_TIMEOUT_SECONDS, ) @@ -55,6 +59,18 @@ def test_resolver_applies_every_advertised_model_and_instruction_field(monkeypat assert dependencies.system_instruction == "Use the extracted signals." +def test_resolver_appends_to_the_agent_default_instruction(): + os.environ["TEST_NVIDIA_API_KEY"] = "test-key" + mapping = _config_mapping() + mapping["instructions"]["system"]["mode"] = "append" + + dependencies = resolve_agent_dependencies(AgentConfig.from_mapping(mapping)) + + assert dependencies.system_instruction == ( + f"{DEFAULT_SYSTEM_INSTRUCTION}\n\nUse the extracted signals." + ) + + @pytest.mark.parametrize( ("mutate", "field"), [ diff --git a/tests/examples/langgraph_custom_agent/test_consumer.py b/tests/examples/langgraph_custom_agent/test_consumer.py index 82c0b95a5..72b4243f6 100644 --- a/tests/examples/langgraph_custom_agent/test_consumer.py +++ b/tests/examples/langgraph_custom_agent/test_consumer.py @@ -48,6 +48,16 @@ def test_instruction_and_temperature_variants_do_not_mutate_their_input(): assert temperature.models["default"].temperature == 0.4 +def test_system_instruction_variant_preserves_append_mode(): + config = with_system_instruction( + public_config(), + "Explain only the strongest signal.", + mode="append", + ) + + assert config.instructions.system.mode == "append" + + def test_relay_variant_is_additive_and_independent(): base = public_config() diff --git a/tests/examples/langgraph_custom_agent/test_contract.py b/tests/examples/langgraph_custom_agent/test_contract.py index 21dbc0200..85e14a39c 100644 --- a/tests/examples/langgraph_custom_agent/test_contract.py +++ b/tests/examples/langgraph_custom_agent/test_contract.py @@ -53,6 +53,7 @@ def test_descriptor_freezes_the_custom_agent_contract_surface(): "mcp", "mcp.tool_filters", ], + "system_instruction_modes": ["replace", "append"], }, "telemetry": { "providers": { diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index 9255a4b08..f7df930b7 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -642,6 +642,45 @@ def test_typed_config_serializes_normalized_execution_fields(): ToolsConfig(enabled=["browser"], blocked=["browser"]) +@pytest.mark.parametrize("mode", ["replace", "append"]) +def test_instruction_modes_round_trip(mode: str): + instruction = InstructionConfig(content="Follow repository policy.", mode=mode) # type: ignore[arg-type] + + assert instruction.to_mapping() == { + "content": "Follow repository policy.", + "mode": mode, + } + raw = _plan()["config"] + raw["instructions"] = { + "system": {"content": "Follow repository policy.", "mode": mode} + } + snapshot = _FabricConfigSnapshot.from_mapping(raw) + assert snapshot.to_mapping()["instructions"]["system"]["mode"] == mode + + +def test_instruction_rejects_unknown_mode(): + with pytest.raises(ValidationError, match="replace.*append"): + InstructionConfig(content="Follow repository policy.", mode="prepend") # type: ignore[arg-type] + + raw = _plan()["config"] + raw["instructions"] = { + "system": {"content": "Follow repository policy.", "mode": "prepend"} + } + with pytest.raises(FabricConfigError, match="replace or append"): + _FabricConfigSnapshot.from_mapping(raw) + + +@pytest.mark.parametrize("mode", [[], {}]) +def test_instruction_mapping_rejects_non_string_mode(mode): + raw = _plan()["config"] + raw["instructions"] = { + "system": {"content": "Follow repository policy.", "mode": mode} + } + + with pytest.raises(FabricConfigError, match="replace or append"): + _FabricConfigSnapshot.from_mapping(raw) + + @pytest.mark.parametrize("content", ["", " "]) def test_instruction_content_must_be_non_empty(content: str): with pytest.raises(ValidationError):