fix(providers): pass agent system_prompt as native system parameter in Claude provider#293
Conversation
The warning no longer claims that Claude ignores system_prompt entirely. The rationale is now provider-neutral: omitting prompt sends an empty user message to the model on every provider, which almost always indicates a latent authoring mistake. The test pins that the warning still fires for system_prompt without prompt and contains no ignore/entirely claims. Refs microsoft#289
…o Anthropic API The agent execution path now sends the rendered agent.system_prompt as the native top-level system parameter on every messages.create() call: main loop, tool-use iterations, parse recovery, interrupt partial output, and retries. Empty/whitespace system prompts are normalized away and not sent. As a secondary fix, the OutputValidator rubric now reaches the model on the claude provider through the same native system parameter. Refs microsoft#289
…meter in Claude provider Add a System Prompt section to docs/providers/claude.md describing the mapping, the every-call-path behavior, whitespace normalization, and the deferred caching follow-up. Update the stale wording in the conductor skill's execution reference. Refs microsoft#289
jrob5756
left a comment
There was a problem hiding this comment.
Thank you for contributing to Conductor! A couple things that I caught... this fixes the missing Claude system prompt path, but the current normalization changes non-empty prompt text. I also left two wording corrections and two coverage requests for the retry and interrupt branches.
If you can wrap these up, we can merge! Thanks again.
| # Done before the per-model max_tokens warning so the warning logic | ||
| # accounts for thinking-aware caps. | ||
| thinking = self._resolve_thinking_for_agent(agent, model) | ||
| system_prompt = (agent.system_prompt or "").strip() or None |
There was a problem hiding this comment.
Using .strip() here changes every non-empty system prompt with leading or trailing whitespace. The other providers pass the rendered value through unchanged, so Claude should only use trimming to detect a blank prompt. An exact-equality test with a multiline templated prompt would catch this.
| system_prompt = (agent.system_prompt or "").strip() or None | |
| raw_system_prompt = agent.system_prompt | |
| system_prompt = ( | |
| raw_system_prompt if raw_system_prompt and raw_system_prompt.strip() else None | |
| ) |
There was a problem hiding this comment.
Fixed in bd0aa63. .strip() is now used only as a blank-prompt predicate — the rendered value is forwarded verbatim:
raw_system_prompt = agent.system_prompt
system_prompt = (
raw_system_prompt if raw_system_prompt and raw_system_prompt.strip() else None
)This matches the pass-through behavior of the Copilot, Claude Agent SDK, and Hermes providers. The new retry and in-flight interrupt tests also pin verbatim forwarding with a leading-whitespace / trailing-newline prompt (" Preserve me exactly\n").
| "This sends an empty user message to the model on every provider, " | ||
| "which almost always indicates a latent authoring mistake. Move " |
There was a problem hiding this comment.
on every provider isn't true for Copilot, which sends the system and user sections as one non-empty SDK prompt. Describing the missing workflow task prompt keeps the warning accurate across providers.
| "This sends an empty user message to the model on every provider, " | |
| "which almost always indicates a latent authoring mistake. Move " | |
| "The user-authored task prompt is empty, which almost always " | |
| "indicates a latent authoring mistake. Move " |
There was a problem hiding this comment.
Fixed in bd0aa63 — the warning (and the comment above it) now uses your suggested wording:
The user-authored task prompt is empty, which almost always indicates a latent authoring mistake. Move ...
The test in tests/test_config/test_validator.py now also pins the new phrasing and asserts the old "empty user message" / "on every provider" claims are absent.
| - For-each `source` format and reserved names | ||
| - Stale agent references and undeclared explicit-mode dependencies in `prompt`, `system_prompt`, `command`, `args`, `working_dir`, `input_mapping`, parallel-group inputs, and workflow `output:` templates | ||
| - Warning when an agent defines `system_prompt` but no `prompt:` (unusual — system prompts are only meaningful alongside a user prompt) | ||
| - Warning when an agent defines `system_prompt` but no `prompt:` (unusual — the user message would be empty) |
There was a problem hiding this comment.
This has the same provider-specific wording problem as the validator warning. Copilot doesn't send an empty SDK prompt; the workflow's task prompt is what is missing.
| - Warning when an agent defines `system_prompt` but no `prompt:` (unusual — the user message would be empty) | |
| - Warning when an agent defines `system_prompt` but no `prompt:` (unusual because the user-authored task prompt would be empty) |
There was a problem hiding this comment.
Fixed in bd0aa63 — applied your suggestion verbatim:
- Warning when an agent defines
system_promptbut noprompt:(unusual because the user-authored task prompt would be empty)
|
|
||
| assert mock_client.messages.create.call_count == 2 | ||
| for call in mock_client.messages.create.call_args_list: | ||
| assert call.kwargs["system"] == "Rendered tool-loop instructions" |
There was a problem hiding this comment.
These two calls are turns within one execution, not the outer retry loop. Since the PR promises the system prompt survives retries, please add a retryable failure followed by success and assert both requests receive the same system value.
There was a problem hiding this comment.
Added in bd0aa63: test_retry_loop_passes_system_prompt_on_every_attempt. The first messages.create raises a retryable ProviderError(status_code=503), the second succeeds, and the test asserts both requests carry the same verbatim system value (" Preserve me exactly\n"), plus len(provider.get_retry_history()) == 1 to prove the outer _execute_with_retry loop actually re-ran. Backoff is neutralized via RetryConfig(base_delay=0, max_delay=0, jitter=0) and a patched asyncio.sleep.
| return_value=_tool_response("emit_output", {"result": "partial"}) | ||
| ) | ||
| interrupt_signal = asyncio.Event() | ||
| interrupt_signal.set() |
There was a problem hiding this comment.
Setting the event before execute() only covers the between-turn branch. The in-flight race takes a different path, so please start a blocked API call, set the event after it begins, and assert the partial-output request keeps the system prompt.
There was a problem hiding this comment.
Added in bd0aa63: test_in_flight_interrupt_partial_output_passes_system_prompt. The first messages.create signals call_started and then blocks on an event that is never released; the test sets the interrupt only after the call has begun, so the provider takes the asyncio.wait(FIRST_COMPLETED) race branch, cancels the in-flight task, and issues the partial-output request. The test asserts that request keeps the verbatim system prompt (trailing newline included) and that the appended interrupt message is present. The pre-existing between-turn test is kept as-is.
…ovider Address PR microsoft#293 review feedback: - Use .strip() only as a blank-prompt predicate; forward the rendered system_prompt unchanged, matching Copilot / Claude Agent SDK / Hermes pass-through behavior. Leading/trailing whitespace in templated prompts is now preserved end-to-end. - Reword the system_prompt-without-prompt validator warning: the accurate provider-neutral rationale is an empty user-authored task prompt (Copilot merges system+user into one non-empty SDK prompt, so "empty user message on every provider" was wrong). Same wording fix in the conductor skill execution reference. - Add regression coverage for the two previously untested branches: the outer retry loop (retryable 503 then success — both attempts send the same system kwarg) and the in-flight interrupt race (blocked API call cancelled mid-flight, partial-output request keeps system_prompt). - Pin verbatim forwarding in the OutputValidator test (rubric keeps its trailing newline). Refs microsoft#289
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #293 +/- ##
=======================================
Coverage ? 89.36%
=======================================
Files ? 72
Lines ? 12786
Branches ? 0
=======================================
Hits ? 11426
Misses ? 1360
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Summary
Fixes #289. The native
claudeprovider (Anthropic Messages API) silently droppedagent.system_prompt. It is now passed as the native top-levelsystemparameter ofmessages.create()on every API call in the agent execution path: main loop, tool-use iterations, parse recovery, interrupt partial output, and retries. Empty/whitespace system prompts are normalized away and not sent.Secondary fix: the
OutputValidatorrubric (synthetic agent withprompt=""and a formattedsystem_prompt) now reaches the model on the claude provider through the same nativesystemparameter.Changes
src/conductor/providers/claude.py): thread a keyword-with-defaultsystem_prompt: str | None = None(appended at the end of each private signature, mirroring the existingthinkingprecedent) through_execute_with_retry→_execute_agentic_loop→_request_partial_output/_execute_with_parse_recovery→_execute_api_call, wherekwargs["system"]is set only when non-empty after strip-normalization. PublicAgentProvider.executesignature unchanged.src/conductor/config/validator.py): the system_prompt-without-prompt warning no longer claims Claude ignoressystem_prompt; rationale is now provider-neutral (empty user message on every provider).tests/test_providers/test_claude_system_prompt.py(8 tests — system kwarg on plain execute / every tool-loop call / parse-recovery calls / interrupt partial-output call; absent for None/empty/whitespace; validator rubric arrives formatted, not raw) + negative assertions intests/test_config/test_validator.py.docs/providers/claude.md; wording fix in the conductor skill's execution reference.Testing
main).ruff checkclean;conductor validateshows the rewritten warning on a real workflow.Notes
Behavior change (intended): claude agents that define
system_promptnow actually receive those instructions; previously they were silently dropped.