Skip to content

WIP: ChainBuddy 2.0, an in-app assistant that proposes flow changes - #464

Draft
ianarawjo wants to merge 18 commits into
mainfrom
claude/wizardly-turing-e7476e
Draft

ianarawjo wants to merge 18 commits into
mainfrom
claude/wizardly-turing-e7476e

Conversation

@ianarawjo

@ianarawjo ianarawjo commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Work in progress: a first version of ChainBuddy 2.0, plus two fixes found along the way.

ChainBuddy 2.0

A chat panel in the bottom-right corner of the canvas. You ask for a flow or a change; ChainBuddy reads the canvas and proposes changes, drawn on the canvas for you to accept or reject:

  • New nodes and connections are dashed; nodes it would change or remove are outlined.
  • A card in the chat lists each change. Edits show before and after; for lists, only the items added or removed.
  • Nothing changes until you accept, and it never runs anything.

It supports the Prompt, TextFields and JavaScript Evaluator nodes for now, and uses the model chosen under AI Support, on OpenRouter or Ollama.

Everything lives in src/chainbuddy/, kept apart from the rest of the app. The app itself only changes by mounting the panel in App.tsx, plus a webpack rule in craco.config.js that bundles the knowledge files as text.

Folder Role
knowledge/ What ChainBuddy knows, and the rules it follows, written for people to read. Start with its README.
flowApi/ ChainBuddy's actions, and the checks on every proposal
adapters/ The only code that touches the store; translates node data to and from ChainBuddy's settings
model/, runtime/ A small streaming, tool-calling client for OpenRouter and Ollama, built on the openai SDK already in use, and the loop that runs tools
ui/ The panel and the proposal card

Commits

  1. Fix evaluators that return objects with several responses per prompt. Object scores failed as "Unsupported types" whenever a prompt collected more than one response. This affected JavaScript evaluators and in-browser Python evaluators.
  2. Stop the settings dialog updating the store during render. Removes a React warning, and a possible double save of settings.
  3. Add ChainBuddy 2.0.
  4. Read the canvas before proposing, and start over on a new flow. After New Flow, ChainBuddy used to work from its memory of the old flow.
  5. Remove proposed nodes left over from a saved flow.
  6. Pin @mantine/hooks to 6.0.21 so npm ci works again. Add the frontend packages the code imports but package.json left out #457 added @mantine/hooks at ^6.0.22, but the locked @mantine/core, dates, dropzone and prism (all 6.0.21) require exactly 6.0.21, so npm ci failed on main. The lockfile also gains the peer packages npm installs automatically, which it lacked. A plain npm ci, the tests and a production build all pass.
  7. Apply each proposal once, and all or nothing. Fixes from code review: a double-click on Accept (or a Reject meanwhile) could apply a proposal twice; a node deleted since the proposal left it half-applied; and accepting a proposal that replaced every node reset the conversation as if another flow had been opened.
  8. Let a loading flow finish before removing leftover nodes. After rebasing onto main, removing leftover proposed nodes as a flow loaded kept main's FlowLoadGuard (Don't autosave an empty flow over the saved one while a flow is loading #460) from ever seeing the load finish, so every save was refused for the rest of the session. The cleanup now waits for the canvas to settle; a test drives the real guard.
  9. Refuse {{double-brace}} variables. ChainForge reads {{country}} as a variable named {country, so the model would be sent a stray brace.
  10. One NodeKind per node type, in a registry. Everything ChainBuddy knows about a node type (settings, checks, how to read and write its data) is in one object in src/chainbuddy/nodes/; adding a type means adding one kind to NODE_KINDS. A test registers a stand-in Items Node kind to prove it.
  11. Move the conversation into a session hook, and notice new flows. ChainBuddyPanel only draws; useChainBuddySession runs the model and tracks proposals. Opening another flow now starts a new conversation and says so.
  12. Always bring proposals into view, and label them on the canvas. Proposed nodes are fitted into the canvas left of the chat panel once they have been measured. Additions, changes and removals get a stronger outline and a label, so a proposed edit is hard to miss.
  13. Show edits to unfinished nodes filled in. After New Flow, filling in the blank nodes used to look like nothing happened. An edit to an unfinished node now shows on the node, labelled "Proposed contents"; rejecting it (or reloading mid-proposal) restores the node. Edits to finished nodes are still only outlined.
  14. Keep proposals readable when they're wider than the free canvas. The view zooms out no further than 0.65; a wider proposal starts from its left and runs under the chat panel.
  15. Keep each message and proposal card separate in the chat. Text after a tool call was sometimes joined onto the previous message with no space, and a canvas created again (e.g. by hot reload) reused proposal ids, so a new proposal got no card.
  16. Decide connections by what outputs give and inputs accept. Each kind's output names what it gives (values, responses, scored_responses) and accepts lists what its inputs take, replacing per-kind lists of target node types. A new node type no longer means editing the others.
  17. Make the knowledge files describe only what exists. Planned features (running, results, undo, more actions) move to a Planned section; guides stop advising on runs; required settings are marked and tested.
  18. Gather model code in adapters/models.ts, and fold nodeData.ts away. Kinds use ChainForge's template parser directly (no more CanvasPort.inputsFor or simplified test parser). Also makes redrawing a node fail safe: an error while redrawing used to lose the node silently.

Testing

  • 1,350 front-end tests pass. The ChainBuddy tests include round trips through every supported node in the example flows, and a check that the knowledge files agree with the code.
  • Checked by hand in the app with Claude Haiku 4.5 on OpenRouter:
    • Building a flow from scratch, then running it.
    • Editing existing nodes, rejecting, and accepting.
    • Filling in the blank nodes of a New Flow.
  • A live harness, src/chainbuddy/__test__/liveAgent.test.ts, runs the tools against a real model. It's skipped unless CHAINBUDDY_LIVE is set, e.g. CHAINBUDDY_LIVE=openrouter:anthropic/claude-haiku-4.5.

Not done yet

  • No undo after accepting a change set.
  • An edited node that had already run can still show as up to date.
  • No reading of results, running of nodes, or asking the user questions yet.
  • Proposed nodes are real nodes until you decide. They can be autosaved while waiting, though they're now removed on reload.

🤖 Generated with Claude Code

ianarawjo and others added 18 commits September 21, 2026 22:53
check_typeof_vals classified evaluator results by the set of their values
rather than their kinds. Every object is a distinct value, so as soon as a
prompt collected more than one response, object scores (e.g.
{length, has_word}) were reported as "Unsupported types". The key check
behind it also passed plain objects to areSetsEqual, which expects Sets.

Classify by kind, as the Flask version already does, and compare each
object's keys as sets. A lone null score, which typeof calls an object,
now reports the usual unsupported-type error instead of crashing.

Affects JavaScript evaluators and Python evaluators run in the browser
(Pyodide); Python run through Flask was already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three state updates in GlobalSettingsModal called the zustand store setter
from inside a React state updater, which runs during render, so React warned
"Cannot update a component while rendering GlobalSettingsModal" for any store
subscriber. handleChangeSetting also saved to the backend or browser storage
from inside its updater, which React may run twice in development.

The store's global settings now follow the dialog's settings through one
effect, and every change goes through applySettings, which keeps a ref of
the latest settings so saves happen outside any updater and quick successive
changes don't overwrite each other. Loading still takes only the keys the
settings type defines.

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

A first version of ChainBuddy, ChainForge's in-app assistant. From the chat
panel in the bottom-right corner, it reads the canvas and proposes changes:
new nodes and connections are drawn dashed, nodes it would change or remove
are outlined, and a card in the chat lists each change (edits as before and
after). Nothing changes until the user accepts, and it never runs anything.
It supports the Prompt, TextFields and JavaScript Evaluator nodes, and uses
the model chosen under AI Support, on OpenRouter or Ollama.

Everything lives in src/chainbuddy/, kept apart from the rest of the app:
- knowledge/: a human-readable store of what ChainBuddy knows and the rules
  it works under (README, model instructions, one guide per node).
- flowApi/: its actions (get_flow, describe_node, list_models,
  propose_changes) and the checks on every proposal, e.g. model IDs must
  come from list_models, and new nodes' inputs must be connected.
- adapters/: the only code that touches the store; translates node data
  to and from ChainBuddy's settings, and shows or applies change sets.
- model/, runtime/: a small streaming, tool-calling client for OpenAI-style
  APIs (OpenRouter, Ollama) on the openai SDK already in use, and the loop.
- ui/: the panel and the proposal card.
The app changes by mounting the panel in App.tsx, plus a webpack rule that
bundles the knowledge files as text.

Tests round-trip every supported node in the example flows through the
adapters, and check the knowledge files agree with the code. A live harness
(liveAgent.test.ts, skipped unless CHAINBUDDY_LIVE is set) runs the tools
against a real model.

Known gaps: no undo after accepting; an edited node that had run can show as
up to date; the chat doesn't reset when another flow is opened.

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

After opening a New Flow in the middle of a conversation, ChainBuddy worked
from its memory of the previous flow: it skipped get_flow, so it didn't see
the blank starter nodes and added new ones beside them, or attached an
evaluator to the empty Prompt Node.

- propose_changes refuses unless get_flow was called since the user's last
  message, so edits made by hand between messages are seen too.
- The panel starts a fresh conversation when none of the nodes it last saw
  are left on the canvas, and says so in the chat.
- A change set can't connect to or from a node that stays blank (a Prompt
  Node with no text or models, a TextFields Node with no values).
- The instructions say to fill in a new flow's blank nodes rather than add
  new ones, and to compare models by listing them in one Prompt Node.
- A read-only setting repeated back unchanged is ignored rather than
  reported as a problem, which was causing needless retries.
- The live harness gains a scenario for the blank flow New Flow creates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proposed nodes are real nodes, drawn dashed, until the user accepts or
rejects them. If the flow was autosaved while a proposal waited, reloading
brought those nodes back with no card left to accept or reject them, so
changes the user never accepted stayed on the canvas.

The canvas adapter now removes proposed nodes and connections that no live
proposal owns, and clears leftover outlines, when the panel loads and
whenever a flow is opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#457 added @mantine/hooks at ^6.0.22, but the locked @mantine/core,
dates, dropzone and prism (all 6.0.21) each require @mantine/hooks at
exactly 6.0.21, so `npm ci` failed with a peer-dependency error. Match the
rest of the Mantine packages instead of upgrading them all.

The lockfile also lacked peer packages npm installs automatically
(@testing-library/dom, @popperjs/core, range-analyzer, a newer picocolors),
so npm reported it out of sync with package.json; syncing it adds those and
updates npm's dev flags on 15 entries. Nothing is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three bugs in accepting proposals, found in code review:

- Accept only marked a proposal done after applying it, which takes a few
  ticks, so a double-click (or a Reject meanwhile) ran on it again. A
  second run could add deferred connections twice, sending every input
  value twice. accept() now marks it "Applying…" first, and a second
  Accept or a Reject is ignored.
- A node deleted since the proposal made accept() throw partway, leaving
  the change set half-applied while the card said "Couldn't apply". It now
  checks every node the proposal needs first; if one is gone it changes
  nothing and says so. An unexpected failure midway now says some changes
  may already be applied.
- The panel only recorded the nodes it had seen after each reply, so
  accepting a proposal that replaced every node made the next message look
  like another flow, and the conversation was reset. Nodes an accepted
  proposal leaves now count as seen.

Adds the first tests of the store-backed canvas, against a small zustand
store standing in for ChainForge's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
After the rebase onto main, saving stopped working for the rest of the
session whenever a loaded flow held leftover proposed nodes. main's
FlowLoadGuard (#460) refuses saves until a loaded flow's nodes render
exactly as loaded, but ChainBuddy removed the leftovers as soon as the
nodes arrived, so the load never counted as finished: every save and
autosave logged "Not saving: a flow is still loading." and wrote nothing.

The cleanup now waits until the canvas has been still for a second, by
which time the load has finished. A test drives the real FlowLoadGuard
to cover the interaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Models used to other template languages write {{country}}. ChainForge
reads that as a variable named "{country", so ChainBuddy connected to an
input by that name, and the model would have been sent "What is the
capital of France}?". Proposals with {{ or }} in prompts or values are
now refused, with a note on ChainForge's single-brace syntax.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What ChainBuddy knew about each node type was spread across six files, in
parallel if-chains: its settings and checks (nodeSpecs.ts), what makes it
blank and an Evaluator-only message (validate.ts), card labels and
formatting (describe.ts), handles and data translation (nodeData.ts), the
guide list (knowledge/index.ts), and the node list typed into
instructions.md.

Each node type is now one NodeKind (nodes/prompt.ts, textfields.ts,
evaluator.ts): its name, guide, settings (label, check, read-only,
required, how list items show), output, inputs, what makes it blank,
handles, and the translation to and from node data. Kinds are pure; the
canvas passes in ChainForge's template parser and model lookup. The checks,
the proposal card, the canvas adapter, the tools and the model's list of
node types all read NODE_KINDS, and nodeSpecs.ts is gone.

Adding a node type is now: write its guide, write its NodeKind, list it in
nodes/index.ts. A test registers a stand-in Items Node kind and checks it
can be proposed, checked, connected to the existing Prompt Node, shown on
the card and translated, with nothing else changed.

The evaluator had three names; it's now "JavaScript Evaluator" everywhere,
and a test checks each guide's name matches its kind.

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

The chat panel held all the conversation logic: the messages, running the
model, decision notes, aborting, and a heuristic for noticing another flow
(seenNodes, handledAccepts and a check on every send) that had caused two
bugs. It now lives in useChainBuddySession; the panel (501 lines, now 277)
only draws what the hook returns.

Another flow is noticed by an event instead: the canvas adapter's
watchForFlowSwitch fires when a node update leaves none of the last node
ids, skipping empty updates (loading a flow empties the canvas first). The
new-conversation note now appears as soon as the flow opens, and a waiting
proposal is withdrawn. A run cut short by a flow switch or Start over no
longer writes its messages into the new conversation; a plain Stop still
keeps them.

The first version of the detector recursed until the stack overflowed: the
switch handler rejects the waiting proposal, which changes the store and
re-entered the detector before it had recorded the new flow's nodes. It now
records them first; a test reproduces the overflow on the old order.

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

Proposed nodes are fitted into the part of the canvas the chat panel
doesn't cover, once React Flow has measured them, instead of after a
fixed delay. Proposed additions, changes and removals get a stronger
outline and a label saying what they are and where to decide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
After New Flow, ChainBuddy fills in the blank TextFields and Prompt
nodes, but an edit only outlined the node, so the canvas looked
unchanged. An edit to an unfinished node (one its kind says is missing
something) is now shown on the node itself, labelled "Proposed contents".
The node keeps its old data under chainbuddyOriginal, so rejecting it,
or reloading a flow saved while the proposal waited, puts it back.
get_flow still reads the node as it was. Edits to finished nodes are
still only outlined.

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

Fitting a whole proposal left of the chat panel could zoom out until the
nodes were unreadable. The view now zooms out no further than 0.65; a
proposal that doesn't fit starts from its left and runs under the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Text that arrived after a tool call was sometimes joined onto the
model's previous message ("characters:I've proposed"): the state
updater read whether text was still streaming only when React ran it,
after the flag had changed. It's now read when the text arrives.

Proposal ids were counted per canvas, so a canvas created again (hot
reload, for one) reused ids the panel had already shown, and the new
proposal got no card. Ids are now unique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each node type listed the node types its output could connect to, so a
new node that others feed into meant editing their kinds. Now a kind's
output names what it gives (values, responses or scored_responses), and
`accepts` lists what its inputs take. A new node type fits in without
changing the others. The model is told what each type gives and accepts
up front. Drops the unused "view-only" support level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README described running nodes, reading results, undo, three more
actions and four tests as if they existed; they're now under Planned.
Rules list what's enforced, and where. The guides no longer advise on
runs ChainBuddy can't make, drop header fields nothing read and the
"Connects to" tables (now said in Inputs), mark required settings, and
say to keep the models a blank Prompt Node already has. The knowledge
test now also checks required, list and code settings, and what each
guide says its inputs accept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…a.ts away

nodeData.ts mixed three jobs. Node kinds now use ChainForge's template
parser directly, so `inputs(settings)` takes no parser, CanvasPort loses
inputsFor, and the stub canvas's simplified parser goes. Model IDs,
building a model's LLMSpec, and the list of models move to
adapters/models.ts. supportOf and inputsOf join the registry, and the
handle and data translation the canvas alone uses moves into canvas.ts.
The live test's environment gets a `window`, which the parser needs.

Also makes redrawing a node fail safe. It used to take the node off the
canvas before working out its new edges, so an error there (here, from
half-edited code during hot reload) lost the node, and the error was
swallowed. Everything that can fail now happens first, and errors are
logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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