Skip to content

feat(workflow-executor): persist the AI reasoning of load-related-record, update-record and MCP steps - #1914

Open
PMerlet wants to merge 11 commits into
mainfrom
feat/persist-ai-reasoning-in-workflow-steps
Open

PMerlet wants to merge 11 commits into
mainfrom
feat/persist-ai-reasoning-in-workflow-steps

Conversation

@PMerlet

@PMerlet PMerlet commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

The three executors already ask the model to justify its answer and drop it. This keeps it, and records how a human ruled on it.

  • Each step now carries an aiSuggestion of its own, written whenever an AI produced the proposal: the value reasoning for update-record, the compared fields plus the field and record reasoning for load-related-record, and toolSelectionReasoning for mcp. It sits beside pendingData / executionParams / executionResult, none of which it touches, because the step summary replays those into every later step's prompt.
  • aiSuggestionRuling records how a human ruled: kept / value-changed on an update, kept / record-changed / relation-changed on a related record. Absent means no AI proposal was put to a human, so absent never reads as agreement.
  • A relation preview rewrites pendingData.suggestedField to whatever the human asked to see, so the ruling reads the AI's own relation from aiSuggestion.field instead.
  • mcp: a reasoning property is added to each candidate tool's schema before binding, under a reserved key when the tool declares its own, stripped from the args before the tool runs. A tool whose schema cannot be augmented is offered untouched rather than failing the step.
  • Nothing is persisted where the AI did not decide: Manual, degraded-to-Manual, xToOne relations, pre-recorded values. Each drop site logs at Info.

Front counterpart: ForestAdmin/forestadmin#9974. Supersedes #1674.

No breaking change: every added field is optional, StepOutcome is untouched, and runs persisted by 1.29.0 are read without them.

🤖 Generated with Claude Code

Note

Persist AI reasoning for update-record, load-related-record, and MCP steps

  • Adds optional suggestion and ruling fields to execution data types in step-execution-data.ts for update-record, load-related-record, and MCP steps.
  • McpStepExecutor.selectTool dynamically injects a collision-safe reasoning field into Zod or JSON schemas, extracts it from the AI response, and excludes it from the actual tool invocation arguments.
  • LoadRelatedRecordStepExecutor and UpdateRecordStepExecutor capture AI reasoning during selection, persist it before executing or awaiting confirmation, and attach a kept, record-changed, relation-changed, or value-changed ruling when a user confirms or overrides the AI's choice.
  • StepSummaryBuilder excludes AI justification text from generated context summaries.
  • Risk: MCP schema augmentation uses duck-typing in McpStepExecutor.withReasoningField; tools with unsupported or missing schemas fall back to normal execution without capturing reasoning.

Macroscope summarized 939af6d.

PMerlet and others added 3 commits September 22, 2026 12:09
…ord steps

Both AI calls of the step already ask the model to justify its answer, and
both justifications were dropped. Keep them, along with the fields the AI
compared the candidates on, in pendingData and — when the loaded record is the
one the AI suggested — in executionResult, so a run can be explained after the
fact. No-AI paths (Manual, degraded-to-Manual, xToOne) persist nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The step already asks the model why it picked that field and value, and threw
the answer away. Keep it in pendingData, then in executionResult next to the
written values. A pre-recorded value has no AI reasoning, and a user who
confirms a value different from the AI's makes the choice theirs, so the
justification is dropped — re-submitting the AI value unchanged keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each candidate tool is offered a top-level `reasoning` field before binding,
so the model explains its pick within the same forced tool call rather than in
an extra round-trip. Zod schemas (Forest connectors) are extended, JSON Schemas
(MCP servers) get the property injected — duck-typed, since ai-proxy and the
executor resolve different zod instances. The field is stripped from the args
before the real tool runs, and persisted as toolSelectionReasoning, which stays
client-side and survives the confirmation flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qltysh

qltysh Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

5 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 4): buildAiSuggestion 3
qlty Structure High total complexity (count = 56) 2

@qltysh

qltysh Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (4)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...ow-executor/src/executors/load-related-record-step-executor.ts96.2%471
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/base-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
...workflow-executor/src/executors/update-record-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/mcp-step-executor.ts100.0%
Total98.8%
🤖 Increase coverage with AI coding...
In the `feat/persist-ai-reasoning-in-workflow-steps` branch, add test coverage for this new code:

- `packages/workflow-executor/src/executors/load-related-record-step-executor.ts` -- Line 471

🚦 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.

@PMerlet PMerlet left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Spec: no Linear ticket is linked to this PR, so the functional check was limited to the PR description, which the diff matches.

PMerlet and others added 2 commits September 22, 2026 14:39
…asoning

Compare a confirmed record id by value, so the justification survives the
confirmation the front actually sends, and only when the suggested relation is
the one followed. Move the update-record justification to executionParams,
written before the mutation and kept out of later steps' context by a formatter
of its own. Never let re-reading the suggested value abort the update the user
asked for. Ask an MCP tool that owns `reasoning` for the justification under a
reserved key instead, and strip only the key that was injected. Persist the MCP
justification in the write-ahead marker too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Probing a tool's schema for `.extend` threw when a parameterless tool declared
none, failing the step before the model was ever called. Such a tool is now
offered untouched, so it runs as it did and only its selection goes unexplained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PMerlet and others added 3 commits September 22, 2026 14:56
…elected

The model answers with a tool name alone, and the tool behind it is the first
of that name, so a second source exposing the same name must not overwrite the
first one's key. It kept the last, which stripped the wrong argument and lost
the justification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The executors already compute that answer to decide whether the justification
survives, then drop it, leaving a consumer to re-derive the disagreement by
diffing the proposal against what was written, per step type. It is persisted
as `aiSuggestionOverridden`, written only where an AI proposal was actually put
to a human, so its absence never reads as agreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty field list was persisted as the comparison the AI made, though it
means no field-selection pass ran at all, and a blank or non-string answer was
persisted as a justification. Both are now left out, the record justification
replaces the field list as what says an AI ranked anything, and a tool selected
without a justification is logged, which separates a silent model from an
injection that never reached the provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PMerlet and others added 2 commits September 22, 2026 15:41
…oposal

A boolean said a human disagreed without saying about what, and on a related
record two disagreements were folded into one: following another relation and
picking another record. The second has no meaning once the first happened, so
`aiSuggestionRuling` names which one it was, `kept` / `record-changed` /
`relation-changed` there and `kept` / `value-changed` on an update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kept only what naming cannot carry: why two record ids are compared as text,
why an empty field list is not an empty comparison, why a tool schema is
duck-typed, and why re-reading a suggested value must not abort the update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n prompts

It lived in pendingData and executionParams, which the step summary replays
into every later step. A formatter guarded the one path where the step
completed, and the step summary has two others: a proposal the human rejected,
and one they handled themselves. The justification now sits beside them, in
`aiSuggestion`, which no summary path reads, so the formatter is gone with the
leak it half-covered.

The ruling also reads the relation from that suggestion. A relation preview
rewrites pendingData.suggestedField to whatever the human asked to see, so
comparing against it recorded every relation override as `kept`, with the AI's
justification attached to a relation the human chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant