strands-plan-tool 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheWitcherish
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,268 @@
1
+ Metadata-Version: 2.5
2
+ Name: strands-plan-tool
3
+ Version: 0.1.0
4
+ Summary: One tool that lets a Strands agent batch a dependent tool chain into a single model round trip
5
+ Project-URL: Homepage, https://github.com/TheWitcherish/strands-plan-tool
6
+ Project-URL: Repository, https://github.com/TheWitcherish/strands-plan-tool
7
+ Project-URL: Issues, https://github.com/TheWitcherish/strands-plan-tool/issues
8
+ Author: TheWitcherish
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,bedrock,jsonata,latency,llm,strands,tools,workflow
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: jsonata-python<0.8,>=0.7.0
21
+ Requires-Dist: pydantic>=2.13
22
+ Provides-Extra: dev
23
+ Requires-Dist: mypy>=1.11; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
25
+ Requires-Dist: pytest>=8.3; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Requires-Dist: strands-agents>=1.55.1; extra == 'dev'
28
+ Provides-Extra: strands
29
+ Requires-Dist: strands-agents>=1.55.1; extra == 'strands'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # strands-plan-tool
33
+
34
+ Ever noticed that when your agent needs three tool calls in a row, you wait for the
35
+ model three times? Not because the model is thinking — because it has to be *asked*
36
+ again before it can make the next call.
37
+
38
+ This package lets the model hand over one plan instead.
39
+
40
+ ```bash
41
+ uv run demo.py
42
+ ```
43
+
44
+ ## What it does
45
+
46
+ The model writes a `WorkflowPlan`: which tools to call, what depends on what, and how
47
+ each step's arguments derive from earlier results. JSONata expressions carry data from
48
+ one step into the next. The plan runs locally in one batch, and only the steps the plan
49
+ named in `returns` travel back to the model.
50
+
51
+ A dependency chain of depth D costs **one** model round trip instead of D.
52
+
53
+ ```python
54
+ from strands_plan_tool import PlanStep, WorkflowPlan, execute_plan
55
+
56
+ plan = WorkflowPlan(
57
+ steps=(
58
+ PlanStep(id="board", tool="read_notice_board"),
59
+ PlanStep(
60
+ id="beast", tool="consult_bestiary", bind={"beast": "steps.board.contracts[0].beast"}
61
+ ),
62
+ PlanStep(
63
+ id="satchel",
64
+ tool="pack_satchel",
65
+ bind={"sign": "steps.beast.weakness", "oil": "steps.beast.oil"},
66
+ ),
67
+ ),
68
+ returns=("satchel",),
69
+ final=True,
70
+ )
71
+
72
+ result = await execute_plan(plan, my_tool_invoker)
73
+ ```
74
+
75
+ `board` and `beast` did the work and never entered the model's context.
76
+
77
+ ## What it does not do
78
+
79
+ **It does not change the agentic loop.** The model emits exactly one tool call. From
80
+ the event loop's side that is an ordinary single tool call: dispatch, get a result,
81
+ call the model again. Everything the plan does happens inside that one call.
82
+
83
+ **It does not make the agent static.** The *model* writes the plan, not you. Planning is
84
+ one tool among many with tool choice left automatic, so the model elects to plan, per
85
+ turn, or ignores it and calls tools one at a time. Contrast an author-defined DAG,
86
+ which is fixed before the agent ever runs.
87
+
88
+ **It does not remove judgement — it defers it.** Inside a plan the model does not
89
+ re-deliberate between steps. That is the trade, and it is the same one you make writing
90
+ a shell pipeline instead of running commands interactively. Plan when the dependencies
91
+ are *mechanical*. Do not plan when each result needs a decision.
92
+
93
+ ## Where it pays off, and where it does not
94
+
95
+ The win is **depth, not width**. Independent tool calls in one turn are already
96
+ concurrent in most agent runtimes; a plan buys nothing there. It buys everything on the
97
+ shape that is actually common — a lookup, then a fan-out over its results, which is
98
+ depth-2 today because the model must see the ids before it can request them.
99
+
100
+ A plan costs more output tokens to generate than a single tool call, so there is a
101
+ break-even depth. Measure it on your own workload rather than assuming a win.
102
+
103
+ ## Safety
104
+
105
+ JSONata is a query language, not a programming language: no imports, no filesystem, no
106
+ network, no subprocess. Its one dynamic surface is `$eval`, which evaluates further
107
+ JSONata (never Python), and this package shuts it by default so a plan's bindings can
108
+ be read completely before anything executes.
109
+
110
+ ```python
111
+ JsonataBinder() # $eval raises PermissionError
112
+ JsonataBinder(allow_eval=True) # opt back in, and give up static inspectability
113
+ ```
114
+
115
+ A binding that fails is a **step failure**, never a substituted `None`. Handing a tool
116
+ an argument the plan did not intend is worse than reporting that the plan was wrong.
117
+
118
+ Cycles, duplicate ids, dangling references, and step-count overruns are all rejected
119
+ before any tool runs, so a malformed plan costs one cheap repair round trip and leaves
120
+ no side effects behind.
121
+
122
+ ## Architecture
123
+
124
+ The engine imports no agent framework. Tool invocation sits behind a `ToolInvoker`
125
+ protocol:
126
+
127
+ ```python
128
+ async def __call__(self, name: str, args: Mapping[str, JsonValue]) -> JsonValue: ...
129
+ ```
130
+
131
+ That seam is why the latency claim holds — steps run in this process, with no hop back
132
+ to the model provider between them — and it is where an agent-framework adapter attaches.
133
+
134
+ The boundary models are Pydantic v2 and frozen, so a TypeScript port mirrors
135
+ `models.py` as Zod schemas with the same field names.
136
+
137
+ ## Measured on a live model
138
+
139
+ Bedrock, `eu.anthropic.claude-sonnet-4-5-20250929-v1:0` in `eu-central-1`, 2026-09-17.
140
+ Two arms, same model, same tools, same prompt: `loop` is an ordinary agent, `plan` is the
141
+ same agent plus this plugin. Each tool sleeps 250ms, so the wall-clock gap is attributable
142
+ to model round trips rather than tool speed. Medians of 2 trials.
143
+
144
+ ```
145
+ depth | loop | plan | verdict
146
+ | wall trips tokens | wall trips tokens pl%|
147
+ ------+----------------------------+----------------------------+---------------
148
+ 1 | 3.16s 2.0 1309 | 3.47s 2.0 3534 0| model declined
149
+ 2 | 5.28s 3.0 2496 | 3.32s 1.0 1994 100| plan +37%
150
+ 3 | 8.53s 4.0 3932 | 4.08s 1.0 2146 100| plan +52%
151
+ 4 | 10.62s 5.0 5588 | 4.56s 1.0 2290 100| plan +57%
152
+ 5 | 11.32s 6.0 7962 | 5.49s 1.0 2522 100| plan +51%
153
+ ```
154
+
155
+ Reproduce it with `AWS_PROFILE=... AWS_REGION=... uv run python -m bench.breakeven`.
156
+
157
+ Three readings, and the second matters as much as the first:
158
+
159
+ - **Round trips stay flat at 1 while the loop's grow with depth.** That is the mechanism and
160
+ the whole claim. Break-even is **depth 2**; by depth 5 a plan is about half the wall clock
161
+ and roughly a third of the tokens (2522 against 7962).
162
+ - **At depth 1 the model declined to plan** (`pl% = 0`) and called the tool directly. Nobody
163
+ told it to. A one-call task is not worth a plan and it judged that correctly, which is what
164
+ "planning is elected, not imposed" looks like in a measurement.
165
+ - **A plan is not free.** At depth 1, where the model does plan if pushed, the schema costs
166
+ more tokens than it saves (3534 against 1309). Below depth 2 planning is the wrong tool.
167
+
168
+ ## Using it with Strands Agents
169
+
170
+ ```python
171
+ from strands import Agent
172
+ from strands_plan_tool.strands_adapter import WorkflowPlanPlugin
173
+
174
+ agent = Agent(
175
+ tools=[read_notice_board, consult_bestiary, pack_satchel],
176
+ plugins=[WorkflowPlanPlugin()],
177
+ )
178
+ ```
179
+
180
+ That registers one extra tool, `submit_workflow_plan`. Tool choice stays automatic, so the
181
+ model may plan or may keep calling tools one at a time.
182
+
183
+ ### Approval-gated tools cannot be planned
184
+
185
+ Verified against `strands-agents` 1.55.1 on 2026-09-15. A direct tool call — the path a
186
+ plan uses — refuses interrupts at two points in `strands/tools/_caller.py`:
187
+
188
+ - if the agent is already interrupted, any direct call raises
189
+ `RuntimeError("cannot directly call tool during interrupt")`;
190
+ - if a tool raises an interrupt during a direct call, the caller raises
191
+ `RuntimeError("cannot raise interrupt in direct tool call")` — and first calls
192
+ `_InterruptState.deactivate()`, which **clears** the agent's interrupts and context.
193
+
194
+ That second behaviour is why plannability is decided *before* execution: by the time the
195
+ error surfaces, the interrupt state is already gone, so catching it is too late. A tool that
196
+ can request approval is refused at validation, and the model is told to call it directly.
197
+ The gate keeps working; it just is not batchable.
198
+
199
+ Because this package cannot tell from outside which tools an intervention gates, attaching
200
+ to an agent that has interventions requires an explicit list, using the same pattern syntax
201
+ as `HumanInTheLoop.allowed_tools`:
202
+
203
+ ```python
204
+ WorkflowPlanPlugin(plannable=["*", "!delete_files"])
205
+ ```
206
+
207
+ Leave it out on an agent with interventions and construction fails loudly rather than
208
+ silently batching something that should have prompted a human.
209
+
210
+ ## Scheduling
211
+
212
+ The plan is a **DAG**, not a tree — a step may have several dependencies and several
213
+ dependents, so in-degree above one is normal and tree structures are the wrong shape.
214
+ Ordering comes from Kahn's algorithm over in-degree counts. A DFS topological sort is the
215
+ one choice to avoid: it produces a single linear sequence, and running a sequence serialises
216
+ steps that never depended on each other.
217
+
218
+ Levels are how a plan is *described* — the level count is its depth, and that is the number
219
+ of model round trips it replaces. Levels are not how it is *run*: a level barrier makes
220
+ every step in level N+1 wait for the slowest step in level N even when it never depended on
221
+ it. Execution is therefore barrier-free, with each step awaiting exactly its own
222
+ dependencies and starting the instant its last one lands.
223
+
224
+ Cost is O(1) amortised per step and per edge, O(V + E) overall, which is optimal — a plan
225
+ cannot be validated without being read. Wall clock is bound by the critical path rather
226
+ than by the sum of per-level maxima.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ uv sync --extra dev
232
+ uv run ruff check . && uv run ruff format --check .
233
+ uv run mypy src tests demo.py
234
+ uv run pytest -q
235
+ ```
236
+
237
+ ### A final plan ends the turn
238
+
239
+ A plan that sets `final` asserts its returned values answer the request. The plugin takes
240
+ the model at its word and ends the turn there, rather than sampling once more purely to have
241
+ the model restate the result. That is the difference between two round trips and one — and
242
+ between 5310 tokens and 2522 at depth 5, because the restatement is what carried the cost.
243
+
244
+ The trade is real and visible: the closing message becomes the plan's returned values as
245
+ JSON rather than prose.
246
+
247
+ ```python
248
+ WorkflowPlanPlugin(honor_final=False) # keep the prose, pay the extra round trip
249
+ ```
250
+
251
+ The early exit is only armed when every step succeeded. Ending a turn on a partial result
252
+ would hide the failure from the model, which is the one thing the ledger exists to prevent.
253
+
254
+ ## Status
255
+
256
+ The engine, its guards, the scheduler, and the Strands adapter are implemented and tested
257
+ (68 tests), and the latency claim is measured against a live Bedrock model — see the table
258
+ above. Two bugs the benchmark caught that the unit suite had not, both now covered by
259
+ regression tests:
260
+
261
+ - a `@tool` returning a dict arrives as a JSON *string* inside a content block, so the
262
+ obvious binding (`steps.board.beast`) could not resolve until `unwrap_tool_result` decoded
263
+ it — and a `status="error"` result was being passed through as if it were data;
264
+ - the SDK hands a nested Pydantic tool parameter through as the raw decoded dict, so
265
+ `plan.steps` raised `AttributeError` against a live model while every test stayed green,
266
+ because the tests called `run_plan` with an already-typed `WorkflowPlan`. Reported upstream
267
+ as [harness-sdk#4383](https://github.com/strands-agents/harness-sdk/issues/4383);
268
+ `coerce_plan` handles it locally either way.
@@ -0,0 +1,237 @@
1
+ # strands-plan-tool
2
+
3
+ Ever noticed that when your agent needs three tool calls in a row, you wait for the
4
+ model three times? Not because the model is thinking — because it has to be *asked*
5
+ again before it can make the next call.
6
+
7
+ This package lets the model hand over one plan instead.
8
+
9
+ ```bash
10
+ uv run demo.py
11
+ ```
12
+
13
+ ## What it does
14
+
15
+ The model writes a `WorkflowPlan`: which tools to call, what depends on what, and how
16
+ each step's arguments derive from earlier results. JSONata expressions carry data from
17
+ one step into the next. The plan runs locally in one batch, and only the steps the plan
18
+ named in `returns` travel back to the model.
19
+
20
+ A dependency chain of depth D costs **one** model round trip instead of D.
21
+
22
+ ```python
23
+ from strands_plan_tool import PlanStep, WorkflowPlan, execute_plan
24
+
25
+ plan = WorkflowPlan(
26
+ steps=(
27
+ PlanStep(id="board", tool="read_notice_board"),
28
+ PlanStep(
29
+ id="beast", tool="consult_bestiary", bind={"beast": "steps.board.contracts[0].beast"}
30
+ ),
31
+ PlanStep(
32
+ id="satchel",
33
+ tool="pack_satchel",
34
+ bind={"sign": "steps.beast.weakness", "oil": "steps.beast.oil"},
35
+ ),
36
+ ),
37
+ returns=("satchel",),
38
+ final=True,
39
+ )
40
+
41
+ result = await execute_plan(plan, my_tool_invoker)
42
+ ```
43
+
44
+ `board` and `beast` did the work and never entered the model's context.
45
+
46
+ ## What it does not do
47
+
48
+ **It does not change the agentic loop.** The model emits exactly one tool call. From
49
+ the event loop's side that is an ordinary single tool call: dispatch, get a result,
50
+ call the model again. Everything the plan does happens inside that one call.
51
+
52
+ **It does not make the agent static.** The *model* writes the plan, not you. Planning is
53
+ one tool among many with tool choice left automatic, so the model elects to plan, per
54
+ turn, or ignores it and calls tools one at a time. Contrast an author-defined DAG,
55
+ which is fixed before the agent ever runs.
56
+
57
+ **It does not remove judgement — it defers it.** Inside a plan the model does not
58
+ re-deliberate between steps. That is the trade, and it is the same one you make writing
59
+ a shell pipeline instead of running commands interactively. Plan when the dependencies
60
+ are *mechanical*. Do not plan when each result needs a decision.
61
+
62
+ ## Where it pays off, and where it does not
63
+
64
+ The win is **depth, not width**. Independent tool calls in one turn are already
65
+ concurrent in most agent runtimes; a plan buys nothing there. It buys everything on the
66
+ shape that is actually common — a lookup, then a fan-out over its results, which is
67
+ depth-2 today because the model must see the ids before it can request them.
68
+
69
+ A plan costs more output tokens to generate than a single tool call, so there is a
70
+ break-even depth. Measure it on your own workload rather than assuming a win.
71
+
72
+ ## Safety
73
+
74
+ JSONata is a query language, not a programming language: no imports, no filesystem, no
75
+ network, no subprocess. Its one dynamic surface is `$eval`, which evaluates further
76
+ JSONata (never Python), and this package shuts it by default so a plan's bindings can
77
+ be read completely before anything executes.
78
+
79
+ ```python
80
+ JsonataBinder() # $eval raises PermissionError
81
+ JsonataBinder(allow_eval=True) # opt back in, and give up static inspectability
82
+ ```
83
+
84
+ A binding that fails is a **step failure**, never a substituted `None`. Handing a tool
85
+ an argument the plan did not intend is worse than reporting that the plan was wrong.
86
+
87
+ Cycles, duplicate ids, dangling references, and step-count overruns are all rejected
88
+ before any tool runs, so a malformed plan costs one cheap repair round trip and leaves
89
+ no side effects behind.
90
+
91
+ ## Architecture
92
+
93
+ The engine imports no agent framework. Tool invocation sits behind a `ToolInvoker`
94
+ protocol:
95
+
96
+ ```python
97
+ async def __call__(self, name: str, args: Mapping[str, JsonValue]) -> JsonValue: ...
98
+ ```
99
+
100
+ That seam is why the latency claim holds — steps run in this process, with no hop back
101
+ to the model provider between them — and it is where an agent-framework adapter attaches.
102
+
103
+ The boundary models are Pydantic v2 and frozen, so a TypeScript port mirrors
104
+ `models.py` as Zod schemas with the same field names.
105
+
106
+ ## Measured on a live model
107
+
108
+ Bedrock, `eu.anthropic.claude-sonnet-4-5-20250929-v1:0` in `eu-central-1`, 2026-09-17.
109
+ Two arms, same model, same tools, same prompt: `loop` is an ordinary agent, `plan` is the
110
+ same agent plus this plugin. Each tool sleeps 250ms, so the wall-clock gap is attributable
111
+ to model round trips rather than tool speed. Medians of 2 trials.
112
+
113
+ ```
114
+ depth | loop | plan | verdict
115
+ | wall trips tokens | wall trips tokens pl%|
116
+ ------+----------------------------+----------------------------+---------------
117
+ 1 | 3.16s 2.0 1309 | 3.47s 2.0 3534 0| model declined
118
+ 2 | 5.28s 3.0 2496 | 3.32s 1.0 1994 100| plan +37%
119
+ 3 | 8.53s 4.0 3932 | 4.08s 1.0 2146 100| plan +52%
120
+ 4 | 10.62s 5.0 5588 | 4.56s 1.0 2290 100| plan +57%
121
+ 5 | 11.32s 6.0 7962 | 5.49s 1.0 2522 100| plan +51%
122
+ ```
123
+
124
+ Reproduce it with `AWS_PROFILE=... AWS_REGION=... uv run python -m bench.breakeven`.
125
+
126
+ Three readings, and the second matters as much as the first:
127
+
128
+ - **Round trips stay flat at 1 while the loop's grow with depth.** That is the mechanism and
129
+ the whole claim. Break-even is **depth 2**; by depth 5 a plan is about half the wall clock
130
+ and roughly a third of the tokens (2522 against 7962).
131
+ - **At depth 1 the model declined to plan** (`pl% = 0`) and called the tool directly. Nobody
132
+ told it to. A one-call task is not worth a plan and it judged that correctly, which is what
133
+ "planning is elected, not imposed" looks like in a measurement.
134
+ - **A plan is not free.** At depth 1, where the model does plan if pushed, the schema costs
135
+ more tokens than it saves (3534 against 1309). Below depth 2 planning is the wrong tool.
136
+
137
+ ## Using it with Strands Agents
138
+
139
+ ```python
140
+ from strands import Agent
141
+ from strands_plan_tool.strands_adapter import WorkflowPlanPlugin
142
+
143
+ agent = Agent(
144
+ tools=[read_notice_board, consult_bestiary, pack_satchel],
145
+ plugins=[WorkflowPlanPlugin()],
146
+ )
147
+ ```
148
+
149
+ That registers one extra tool, `submit_workflow_plan`. Tool choice stays automatic, so the
150
+ model may plan or may keep calling tools one at a time.
151
+
152
+ ### Approval-gated tools cannot be planned
153
+
154
+ Verified against `strands-agents` 1.55.1 on 2026-09-15. A direct tool call — the path a
155
+ plan uses — refuses interrupts at two points in `strands/tools/_caller.py`:
156
+
157
+ - if the agent is already interrupted, any direct call raises
158
+ `RuntimeError("cannot directly call tool during interrupt")`;
159
+ - if a tool raises an interrupt during a direct call, the caller raises
160
+ `RuntimeError("cannot raise interrupt in direct tool call")` — and first calls
161
+ `_InterruptState.deactivate()`, which **clears** the agent's interrupts and context.
162
+
163
+ That second behaviour is why plannability is decided *before* execution: by the time the
164
+ error surfaces, the interrupt state is already gone, so catching it is too late. A tool that
165
+ can request approval is refused at validation, and the model is told to call it directly.
166
+ The gate keeps working; it just is not batchable.
167
+
168
+ Because this package cannot tell from outside which tools an intervention gates, attaching
169
+ to an agent that has interventions requires an explicit list, using the same pattern syntax
170
+ as `HumanInTheLoop.allowed_tools`:
171
+
172
+ ```python
173
+ WorkflowPlanPlugin(plannable=["*", "!delete_files"])
174
+ ```
175
+
176
+ Leave it out on an agent with interventions and construction fails loudly rather than
177
+ silently batching something that should have prompted a human.
178
+
179
+ ## Scheduling
180
+
181
+ The plan is a **DAG**, not a tree — a step may have several dependencies and several
182
+ dependents, so in-degree above one is normal and tree structures are the wrong shape.
183
+ Ordering comes from Kahn's algorithm over in-degree counts. A DFS topological sort is the
184
+ one choice to avoid: it produces a single linear sequence, and running a sequence serialises
185
+ steps that never depended on each other.
186
+
187
+ Levels are how a plan is *described* — the level count is its depth, and that is the number
188
+ of model round trips it replaces. Levels are not how it is *run*: a level barrier makes
189
+ every step in level N+1 wait for the slowest step in level N even when it never depended on
190
+ it. Execution is therefore barrier-free, with each step awaiting exactly its own
191
+ dependencies and starting the instant its last one lands.
192
+
193
+ Cost is O(1) amortised per step and per edge, O(V + E) overall, which is optimal — a plan
194
+ cannot be validated without being read. Wall clock is bound by the critical path rather
195
+ than by the sum of per-level maxima.
196
+
197
+ ## Development
198
+
199
+ ```bash
200
+ uv sync --extra dev
201
+ uv run ruff check . && uv run ruff format --check .
202
+ uv run mypy src tests demo.py
203
+ uv run pytest -q
204
+ ```
205
+
206
+ ### A final plan ends the turn
207
+
208
+ A plan that sets `final` asserts its returned values answer the request. The plugin takes
209
+ the model at its word and ends the turn there, rather than sampling once more purely to have
210
+ the model restate the result. That is the difference between two round trips and one — and
211
+ between 5310 tokens and 2522 at depth 5, because the restatement is what carried the cost.
212
+
213
+ The trade is real and visible: the closing message becomes the plan's returned values as
214
+ JSON rather than prose.
215
+
216
+ ```python
217
+ WorkflowPlanPlugin(honor_final=False) # keep the prose, pay the extra round trip
218
+ ```
219
+
220
+ The early exit is only armed when every step succeeded. Ending a turn on a partial result
221
+ would hide the failure from the model, which is the one thing the ledger exists to prevent.
222
+
223
+ ## Status
224
+
225
+ The engine, its guards, the scheduler, and the Strands adapter are implemented and tested
226
+ (68 tests), and the latency claim is measured against a live Bedrock model — see the table
227
+ above. Two bugs the benchmark caught that the unit suite had not, both now covered by
228
+ regression tests:
229
+
230
+ - a `@tool` returning a dict arrives as a JSON *string* inside a content block, so the
231
+ obvious binding (`steps.board.beast`) could not resolve until `unwrap_tool_result` decoded
232
+ it — and a `status="error"` result was being passed through as if it were data;
233
+ - the SDK hands a nested Pydantic tool parameter through as the raw decoded dict, so
234
+ `plan.steps` raised `AttributeError` against a live model while every test stayed green,
235
+ because the tests called `run_plan` with an already-typed `WorkflowPlan`. Reported upstream
236
+ as [harness-sdk#4383](https://github.com/strands-agents/harness-sdk/issues/4383);
237
+ `coerce_plan` handles it locally either way.
@@ -0,0 +1 @@
1
+ """Marks bench as a package so `python -m bench.breakeven` resolves."""