diff --git a/apps/backend/agents/coder.py b/apps/backend/agents/coder.py index 88d814f17..4997f81e4 100644 --- a/apps/backend/agents/coder.py +++ b/apps/backend/agents/coder.py @@ -130,39 +130,6 @@ logger = logging.getLogger(__name__) -def _augment_direct_api_prompt( - prompt: str, - provider_name: str, - project_dir: Path, - spec_dir: Path, - agent_type: str, - metadata: dict[str, object] | None, -) -> str: - """Append enabled plugins' prompt augmentations to a Direct-API agent message. - - The Claude SDK path augments the system prompt inside ``create_client()``, - so this is a no-op for the ``claude`` provider to avoid double-applying. - Direct providers (OpenAI, Codex, ...) carry their instructions in the - message, so the same plugin contributions are appended there. Any failure - degrades to the original prompt rather than breaking the build. - """ - if provider_name == "claude": - return prompt - try: - from plugins.runtime import apply_plugin_prompt_augmentations - - return apply_plugin_prompt_augmentations( - prompt, - project_dir, - spec_dir, - agent_type, - metadata=metadata, - ) - except Exception as exc: - logger.warning("Failed to apply plugin prompt augmentations: %s", exc) - return prompt - - # ============================================================================= # FILE VALIDATION UTILITIES # ============================================================================= @@ -1326,14 +1293,19 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: # SDK path augments the system prompt inside create_client(); direct # providers carry their instructions in the message, so append the same # plugin contributions there (no-op for claude to avoid double-applying). - prompt = _augment_direct_api_prompt( - prompt, - provider.name, - project_dir, - spec_dir, - agent_type_for_session, - runtime_metadata, - ) + try: + from plugins.runtime import augment_direct_api_prompt + + prompt = augment_direct_api_prompt( + prompt, + provider.name, + project_dir, + spec_dir, + agent_type_for_session, + runtime_metadata, + ) + except Exception as exc: + logger.warning("Failed to apply plugin prompt augmentations: %s", exc) runtime_phase = ( "planning" if current_log_phase == LogPhase.PLANNING else "coding" diff --git a/apps/backend/agents/planner.py b/apps/backend/agents/planner.py index 1ffd5d104..baa1aea3d 100644 --- a/apps/backend/agents/planner.py +++ b/apps/backend/agents/planner.py @@ -268,6 +268,24 @@ async def run_followup_planner( # Generate follow-up planner prompt prompt = get_followup_planner_prompt(spec_dir) + # Plugin prompt augmentation for Direct-API / Codex backends. The Claude SDK + # path augments the system prompt inside create_client(); direct providers + # carry their instructions in the message, so append the same plugin + # contributions there (no-op for claude to avoid double-applying). + try: + from plugins.runtime import augment_direct_api_prompt + + prompt = augment_direct_api_prompt( + prompt, + provider_name, + project_dir, + spec_dir, + "planner", + None, + ) + except Exception as exc: + logger.warning("Failed to apply plugin prompt augmentations: %s", exc) + # Run prevention scanner before planning print_status("Running prevention scanner...", "progress") try: diff --git a/apps/backend/plugins/runtime.py b/apps/backend/plugins/runtime.py index d57dffa79..3ac462ebb 100644 --- a/apps/backend/plugins/runtime.py +++ b/apps/backend/plugins/runtime.py @@ -186,6 +186,37 @@ def apply_plugin_prompt_augmentations( return append_prompt_augmentations(base_prompt, contributions) +def augment_direct_api_prompt( + prompt: str, + provider_name: str, + project_dir: Path, + spec_dir: Path, + agent_type: str, + metadata: dict[str, Any] | None = None, +) -> str: + """Append plugin prompt augmentations to a Direct-API agent message. + + The Claude SDK path augments the system prompt inside ``create_client()``, + so this is a no-op for the ``claude`` provider to avoid double-applying. + Direct providers (OpenAI, Codex, ...) carry their instructions in the + message, so the same plugin contributions are appended there. Any failure + degrades to the original prompt rather than breaking the run. + """ + if provider_name == "claude": + return prompt + try: + return apply_plugin_prompt_augmentations( + prompt, + project_dir, + spec_dir, + agent_type, + metadata=metadata, + ) + except Exception as exc: + logger.warning("Failed to apply plugin prompt augmentations: %s", exc) + return prompt + + async def _maybe_await(value: Any) -> Any: if inspect.isawaitable(value): return await value diff --git a/docs/guides/plugin-development.md b/docs/guides/plugin-development.md index 49fe93f9f..998c70a15 100644 --- a/docs/guides/plugin-development.md +++ b/docs/guides/plugin-development.md @@ -223,7 +223,7 @@ write hooks defensively: | `before_session` / `after_session` / `on_message` | ✅ | ✅ | ✅ | | `pre_tool` (block) | ✅ | ✅ | ❌ (opaque CLI loop) | | `post_tool` (observe) | ✅ | ✅ (observational) | ❌ | -| `augment_prompt` | ✅ | see note | ❌ | +| `augment_prompt` | ✅ | ✅ | ✅ (via message) | Codex and other CLI backends run their own tool loop inside a separate process, so per-tool interception is not possible there — only prompt-level and lifecycle @@ -255,9 +255,11 @@ use a stable extension name: `Delete`, `Move`, `ApplyPatch`. The mapping lives i unless read-only hooking is explicitly enabled, so plugins don't run on every file read. -> **Note (`augment_prompt`)**: prompt augmentation currently reaches the Claude -> SDK backend only. Cross-backend prompt augmentation is tracked as separate -> work; do not rely on `augment_prompt` for Direct-API/Codex runs yet. +> **Note (`augment_prompt`)**: the Claude SDK path applies augmentation to the +> system prompt inside `create_client()`; Direct-API/Codex backends carry their +> instructions in the agent message, so the same plugin contributions are +> appended there instead (guarded on provider name so the Claude path is never +> augmented twice). Applied via `augment_direct_api_prompt` in `plugins.runtime`. --- diff --git a/tests/test_coder_prompt_augmentation.py b/tests/test_direct_api_prompt_augmentation.py similarity index 76% rename from tests/test_coder_prompt_augmentation.py rename to tests/test_direct_api_prompt_augmentation.py index d0921807f..bf1f71538 100644 --- a/tests/test_coder_prompt_augmentation.py +++ b/tests/test_direct_api_prompt_augmentation.py @@ -1,9 +1,10 @@ """Tests for cross-backend plugin prompt augmentation on the Direct-API path. The Claude SDK backend augments the system prompt inside ``create_client()``. -Direct providers carry their instructions in the agent message, so coder.py -appends the same plugin contributions to that message — but only for non-claude -providers, so the claude path is never augmented twice. +Direct providers (OpenAI, Codex, ...) carry their instructions in the agent +message, so ``augment_direct_api_prompt`` appends the same plugin contributions +there — but only for non-claude providers, so the claude path is never augmented +twice. Both the coder loop and the follow-up planner call this shared helper. """ from __future__ import annotations @@ -15,7 +16,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "apps" / "backend")) -from agents.coder import _augment_direct_api_prompt +from plugins.runtime import augment_direct_api_prompt _TARGET = "plugins.runtime.apply_plugin_prompt_augmentations" @@ -24,7 +25,7 @@ def test_claude_provider_is_not_augmented(tmp_path): # Claude already augments via create_client(); the message must be left # untouched so contributions are not applied twice. with patch(_TARGET) as apply_mock: - out = _augment_direct_api_prompt( + out = augment_direct_api_prompt( "BASE", "claude", tmp_path, tmp_path, "coder", None ) assert out == "BASE" @@ -34,7 +35,7 @@ def test_claude_provider_is_not_augmented(tmp_path): def test_direct_provider_is_augmented(tmp_path): augmented = "BASE\n\n# Plugin Runtime Instructions\n## demo\nhello" with patch(_TARGET, return_value=augmented) as apply_mock: - out = _augment_direct_api_prompt( + out = augment_direct_api_prompt( "BASE", "openai", tmp_path, tmp_path, "coder", {"subtask_id": "s1"} ) apply_mock.assert_called_once_with( @@ -45,7 +46,7 @@ def test_direct_provider_is_augmented(tmp_path): def test_codex_provider_is_augmented(tmp_path): with patch(_TARGET, return_value="BASE+X") as apply_mock: - out = _augment_direct_api_prompt( + out = augment_direct_api_prompt( "BASE", "codex", tmp_path, tmp_path, "planner", None ) apply_mock.assert_called_once() @@ -54,7 +55,7 @@ def test_codex_provider_is_augmented(tmp_path): def test_augmentation_failure_degrades_to_base_prompt(tmp_path): with patch(_TARGET, side_effect=RuntimeError("boom")): - out = _augment_direct_api_prompt( + out = augment_direct_api_prompt( "BASE", "openai", tmp_path, tmp_path, "coder", None ) assert out == "BASE"