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.
- strands_plan_tool-0.1.0/.gitignore +10 -0
- strands_plan_tool-0.1.0/LICENSE +21 -0
- strands_plan_tool-0.1.0/PKG-INFO +268 -0
- strands_plan_tool-0.1.0/README.md +237 -0
- strands_plan_tool-0.1.0/bench/__init__.py +1 -0
- strands_plan_tool-0.1.0/bench/breakeven.py +280 -0
- strands_plan_tool-0.1.0/bench/scenario.py +174 -0
- strands_plan_tool-0.1.0/demo.py +91 -0
- strands_plan_tool-0.1.0/demo_bedrock.py +125 -0
- strands_plan_tool-0.1.0/pyproject.toml +95 -0
- strands_plan_tool-0.1.0/src/strands_plan_tool/__init__.py +62 -0
- strands_plan_tool-0.1.0/src/strands_plan_tool/binder.py +93 -0
- strands_plan_tool-0.1.0/src/strands_plan_tool/errors.py +182 -0
- strands_plan_tool-0.1.0/src/strands_plan_tool/executor.py +307 -0
- strands_plan_tool-0.1.0/src/strands_plan_tool/graph.py +170 -0
- strands_plan_tool-0.1.0/src/strands_plan_tool/models.py +115 -0
- strands_plan_tool-0.1.0/src/strands_plan_tool/py.typed +0 -0
- strands_plan_tool-0.1.0/src/strands_plan_tool/strands_adapter.py +431 -0
- strands_plan_tool-0.1.0/tests/test_binder.py +50 -0
- strands_plan_tool-0.1.0/tests/test_executor.py +158 -0
- strands_plan_tool-0.1.0/tests/test_graph.py +160 -0
- strands_plan_tool-0.1.0/tests/test_scheduler.py +148 -0
- strands_plan_tool-0.1.0/tests/test_strands_adapter.py +436 -0
|
@@ -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."""
|