Close a tool-set escape, a nil panic, and a mutated Flow - #9
Merged
Conversation
Three findings, all confirmed against the code and reproduced before fixing. **A model step could widen an empty tool set.** With `tools: []` on the flow and `tools: [something]` on the step, toolsFor returned the step's own list — so a server the flow deliberately does not have was put in front of the model. The half of this worth naming is that the `call` path had always answered the opposite way: allowServer refuses with "the flow declares no servers", and its comment says in as many words that an empty set is not "everything is allowed". One flow, two access policies, decided by the kind of step. That is this repo's own invariant — a mechanism on the model's path must exist on the call path and back — and here the paths disagreed about a restriction. Both now hand out nothing. This CHANGES BEHAVIOUR for a skill that names tools per step while the flow declares none: those steps now get an empty set. No shipped example does that; the CHANGELOG says so plainly. **A missing Deps.Runner panicked.** call and delegate had always reported a missing executor as an ordinary step failure, subject to on_error. An instruction step dereferenced nil and took the process down — a stack trace where "you did not pass Deps.Runner" belonged, and no chance for the skill's own policy to have a say. Now symmetric, with a test that a step saying `on_error: continue` still continues. **Validate rewrote the description it was given.** It needs the steps in the shape execution reads them — profiles folded in, a save_as beside a call moved into it — and did that in place. So a caller holding a parsed Flow saw the engine's working copy rather than the file they parsed, and two turns over one *Flow wrote to the same structs from two goroutines. "Parse once, run many times" is the natural way to use a skill engine, so it has to be the safe one. Validation now works on a deep copy of the steps and execution runs that copy. Only the steps are copied — assets, vars and profiles are read and never written. cloneSteps says so in a comment, so the next pass that starts writing somewhere else knows where to add itself. **And one property the race detector found while proving the third.** Eight concurrent turns share one Deps — which is exactly how an embedder lives, one model client for the application — so the executors are called concurrently too, not just the callbacks. Said on Deps and in both READMEs; the test double now carries the lock a real client already has. EngineVersion 2.2.3 -> 2.2.4. The format did not change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A model step could widen an empty tool set, and closing that changes what
such a step receives. If a skill of yours names tools per step while the flow
declares none, those steps now get an empty set. No shipped example does this.
Details below and in the CHANGELOG.
Format 2.2.3 → 2.2.4 (engine fixes; no field added, changed or removed).
What
Three findings, reported by a reviewer, all confirmed against the code and
reproduced by a failing test before anything was fixed.
1. A model step could widen an empty tool set
With
tools: []on the flow andtools: [private-server]on the step,toolsForreturned the step's own list — putting a server the flowdeliberately does not have in front of the model.
The half worth naming is that this was an asymmetry, not just a bug. The
callpath had always answered the opposite way:One flow, two access policies, chosen by the kind of step. That is this repo's
own invariant — a mechanism on the model's path must exist on the
callpathand back — and here the two paths disagreed about a restriction. Both now hand
out nothing, and a test asserts they agree.
Narrowing a non-empty set is untouched: that is what the field is for.
2. A missing
Deps.Runnerpanickedcallanddelegatehad always reported a missing executor as an ordinary stepfailure, subject to
on_error. An instruction step dereferenced nil and tookthe process down — a stack trace where "you did not pass Deps.Runner" belonged,
and no chance for the skill's own policy to have a say.
Now symmetric, with a test that a step declaring
on_error: continuedoescontinue.
3.
Validaterewrote the description it was givenValidation needs the steps in the shape execution reads them — profiles folded
in, a
save_asbeside a call moved into it — and it did that in place. Twoconsequences:
Flowsaw the engine's working copy instead of thefile they parsed;
*Flowwrote to the same structs from two goroutines."Parse once, run many times" is the natural way to use a skill engine, so it has
to be the safe one.
Validatenow works on a deep copy of the steps andexecution runs that copy. Only the steps are copied — assets, vars and profiles
are read and never written;
cloneStepssays so in a comment, so a future passthat starts writing elsewhere knows where to add itself.
And one property found while proving the third
The concurrency test runs eight turns over one
Deps— which is how an embedderactually lives, one model client for the whole application. The race detector
promptly caught the test's own runner: so the executors are called
concurrently too, not only the callbacks.
Documented on
Depsand in both READMEs. The test double now carries the lock areal HTTP client already has, because that is what it is standing in for.
Testing
go vet ./...cleango test ./... -count=1all greengo test ./... -raceall green[private-server], the nil-runner test panicked, and the concurrency testcould not even run until the panic was fixed
Format & API compatibility
EngineVersion2.2.3 → 2.2.4CHANGELOG.mdleads with the behaviour change and what to do about itnormalized()is unexported,Validate()keeps itssignature and its meaning
Checklist
🤖 Generated with Claude Code