Skip to content

fix(workflow-executor): merge leading SystemMessages for Anthropic compat#1604

Merged
matthv merged 4 commits into
feat/prd-214-server-step-mapperfrom
fix/prd-413-anthropic-system-messages
May 28, 2026
Merged

fix(workflow-executor): merge leading SystemMessages for Anthropic compat#1604
matthv merged 4 commits into
feat/prd-214-server-step-mapperfrom
fix/prd-413-anthropic-system-messages

Conversation

@matthv
Copy link
Copy Markdown
Member

@matthv matthv commented May 28, 2026

Summary

When using AI_PROVIDER=anthropic, every AI-powered step crashed with:
```
Error: System messages are only permitted as the first passed message.
```

Anthropic's LangChain adapter extracts `messages[0]` as the `system` param and throws on any subsequent `SystemMessage`. OpenAI accepts them anywhere — that's why this only surfaced with Anthropic.

Every executor builds prompts as:

  • `buildContextMessage()` — `SystemMessage`
  • `buildPreviousStepsMessages()` — `SystemMessage[]`
  • step-specific `SystemMessage` (`GATEWAY_SYSTEM_PROMPT`, `READ_RECORD_SYSTEM_PROMPT`, etc.)
  • `HumanMessage(prompt)`

so the array starts with 2..N consecutive `SystemMessage`s. With Anthropic that crashes every step.

Fix

Two static helpers added to `BaseStepExecutor`, called in `invokeWithTools` before passing messages to the model:

  • `mergeLeadingSystemMessages` — joins consecutive leading `SystemMessage`s with blank lines into a single one. Preserves the context separation, no truncation. No change in individual executors.
  • `assertNoMidArraySystemMessages` — throws an invariant violation if a `SystemMessage` appears after a non-system message. The merge only covers leading ones; a mid-array `SystemMessage` would still crash with Anthropic. Surfacing it as a developer error here is safer than letting it leak to the provider.

Test plan

  • New tests in `base-step-executor.test.ts` covering: merge of multiple leading SystemMessages, pass-through for single SystemMessage, pass-through for no SystemMessage, invariant violation for mid-array SystemMessage.
  • `base-step-executor.test.ts` — 39/39 pass (35 existing + 4 new).
  • Verified live with `AI_PROVIDER=anthropic` + `AI_MODEL=claude-sonnet-4-6` on the `_example` agent: Get Data step now fetches successfully where it previously crashed.

fixes PRD-413

🤖 Generated with Claude Code

Note

Merge leading SystemMessages in BaseStepExecutor for Anthropic compatibility

  • Adds mergeLeadingSystemMessages to consolidate consecutive leading SystemMessage instances into one before model invocation, satisfying Anthropic's message format requirements.
  • Adds assertNoMidArraySystemMessages to throw synchronously if a SystemMessage appears after any non-system message in the array.
  • Both helpers are called in invokeWithTools before binding and invoking the model.
  • Behavioral Change: executor tests across multiple step types now expect fewer messages (merged system context in the first message), reflecting the new consolidation behavior.

Changes since #1604 opened

  • Modified system message handling in BaseStepExecutor to merge leading SystemMessage instances by serializing non-string content with JSON.stringify, filtering out falsy values, and throwing InvalidAiRequestError with position-specific details when a SystemMessage appears after non-system messages [44fd961]
  • Added InvalidAiRequestError class to workflow-executor package as a new WorkflowExecutorError subclass and exported it from the package root [44fd961]
  • Updated test suite to import and expect InvalidAiRequestError when validating system message positioning and updated error message regex expectations [44fd961]

Macroscope summarized 66ca13a.

…mpat

Anthropic's LangChain adapter only accepts a SystemMessage at index 0
(extracted as the `system` parameter); any subsequent SystemMessage
throws "System messages are only permitted as the first passed message".
OpenAI accepts them anywhere, so the crash only surfaces with
`AI_PROVIDER=anthropic`.

Every executor builds prompts with multiple consecutive SystemMessages
(context + previous steps + step-specific). Merge the leading ones into
a single SystemMessage in `invokeWithTools` before invoking the model,
joined by blank lines so the context separation is preserved.

Also add `assertNoMidArraySystemMessages` to surface a clear developer
error if a SystemMessage appears after a non-system message — the merge
only covers leading ones, and a mid-array one would still crash with
Anthropic. Better to fail at the boundary than leak to the provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@linear
Copy link
Copy Markdown

linear Bot commented May 28, 2026

PRD-413

matthv and others added 2 commits May 28, 2026 10:01
- Use `instanceof SystemMessage` instead of `_getType()` (no-underscore-dangle)
- Import `HumanMessage` directly instead of via jest.requireActual

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssages

The PRD-413 fix merges leading SystemMessages into a single one before
invoking the model. Step-executor tests that asserted the previous
shape (3..5 separate SystemMessages followed by a HumanMessage) now
assert that the merged content contains all the expected substrings,
followed by the HumanMessage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 28, 2026

Qlty


Coverage Impact

Unable to calculate total coverage change because base branch coverage was not found.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
New Coverage rating: A
packages/workflow-executor/src/executors/base-step-executor.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/errors.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/index.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

if (!(msg instanceof SystemMessage)) {
seenNonSystem = true;
} else if (seenNonSystem) {
throw new Error(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use workflow error 🙏

Copy link
Copy Markdown
Member

@Scra3 Scra3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 findings — 2 non-blocking issues, 1 suggestion. No blocking issues.

const merged = new SystemMessage(
messages
.slice(0, i)
.map(m => String(m.content))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): String(arr) returns "[object Object],..." for complex content — use typeof m.content === 'string' ? m.content : JSON.stringify(m.content) to avoid silent corruption if a SystemMessage is ever constructed with structured content.

messages
.slice(0, i)
.map(m => String(m.content))
.join('\n\n'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: add .filter(Boolean) before .join to prevent a stray leading \n\n when any leading SystemMessage has empty content.

if (!(msg instanceof SystemMessage)) {
seenNonSystem = true;
} else if (seenNonSystem) {
throw new Error(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: a plain Error is caught by BaseStepExecutor.execute() as 'Unexpected error during step execution' — the invariant message only surfaces in logs, not in the step outcome sent to the orchestrator. Is this intentional, or should it throw a WorkflowExecutorError with a user-facing message?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: create a dedicated InvalidAiRequestError extends WorkflowExecutorError — symmetrical with InvalidAIResponseError.

  • Console (dev): SystemMessage at position N appears after a non-system message — move all system context to the front of the messages array.
  • End user (UI): Step configuration error — please contact your administrator.

- Throw a dedicated InvalidAiRequestError (symmetric with InvalidAIResponseError)
  instead of a plain Error — the dev message lands in logs while the end user
  sees "Step configuration error — please contact your administrator."
- Include the position of the offending SystemMessage in the message.
- Handle non-string SystemMessage content via JSON.stringify (String(arr)
  would emit "[object Object],..." for structured content).
- Filter out empty content before joining so a blank SystemMessage doesn't
  leave a stray "\n\n" at the start of the merged prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@matthv matthv merged commit 4259a9d into feat/prd-214-server-step-mapper May 28, 2026
30 checks passed
@matthv matthv deleted the fix/prd-413-anthropic-system-messages branch May 28, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants