strands-plan-tool 0.1.0__py3-none-any.whl

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,62 @@
1
+ """Declarative JSONata workflow plans for agent tool chains.
2
+
3
+ The model writes one :class:`WorkflowPlan` describing which tools to call, in what
4
+ order, and how each step's arguments derive from earlier results. The plan executes
5
+ locally in one batch and the model sees only the aggregated result, so a dependency
6
+ chain of depth D costs one model round trip instead of D.
7
+ """
8
+
9
+ from .binder import Binder, JsonataBinder
10
+ from .errors import (
11
+ AgentInterruptedError,
12
+ BindEvaluationError,
13
+ DanglingReferenceError,
14
+ DuplicateStepError,
15
+ InvalidPlanShapeError,
16
+ PlanCycleError,
17
+ PlanError,
18
+ StepLimitError,
19
+ ToolExecutionError,
20
+ ToolNotPlannableError,
21
+ )
22
+ from .executor import ToolInvoker, execute_plan, levels_of, summarize
23
+ from .graph import dependencies_of, plan_levels
24
+ from .models import (
25
+ MAX_STEPS,
26
+ JsonValue,
27
+ OnError,
28
+ PlanResult,
29
+ PlanStep,
30
+ StepOutcome,
31
+ StepStatus,
32
+ WorkflowPlan,
33
+ )
34
+
35
+ __all__ = [
36
+ "MAX_STEPS",
37
+ "AgentInterruptedError",
38
+ "BindEvaluationError",
39
+ "Binder",
40
+ "DanglingReferenceError",
41
+ "DuplicateStepError",
42
+ "InvalidPlanShapeError",
43
+ "JsonValue",
44
+ "JsonataBinder",
45
+ "OnError",
46
+ "PlanCycleError",
47
+ "PlanError",
48
+ "PlanResult",
49
+ "PlanStep",
50
+ "StepLimitError",
51
+ "StepOutcome",
52
+ "StepStatus",
53
+ "ToolExecutionError",
54
+ "ToolInvoker",
55
+ "ToolNotPlannableError",
56
+ "WorkflowPlan",
57
+ "dependencies_of",
58
+ "execute_plan",
59
+ "levels_of",
60
+ "plan_levels",
61
+ "summarize",
62
+ ]
@@ -0,0 +1,93 @@
1
+ """Binding expressions to upstream results.
2
+
3
+ ``Binder`` is a protocol so the expression language is swappable: JSONata here, a
4
+ restricted JSON-pointer dialect for teaching material, both behind one seam.
5
+
6
+ ``JsonataBinder`` shuts JSONata's ``$eval`` on every expression it compiles. That is
7
+ the library's only dynamic-expression surface, and closing it is what makes a plan
8
+ fully inspectable before it runs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from typing import Protocol, runtime_checkable
15
+
16
+ import jsonata
17
+
18
+ from .models import JsonValue
19
+
20
+ __all__ = ["Binder", "JsonataBinder"]
21
+
22
+ _EVAL_DENIED = "$eval is not permitted inside a workflow plan binding"
23
+
24
+
25
+ @runtime_checkable
26
+ class Binder(Protocol):
27
+ """Evaluates one binding expression against the results collected so far."""
28
+
29
+ def bind(self, expression: str, env: Mapping[str, JsonValue]) -> JsonValue:
30
+ """Evaluate ``expression`` against ``env``.
31
+
32
+ Args:
33
+ expression: Source in whatever dialect this binder implements.
34
+ env: Read-only view of upstream results, shaped ``{"steps": {...}}``.
35
+
36
+ Returns:
37
+ The bound value.
38
+
39
+ Raises:
40
+ Exception: Any evaluation failure; the executor wraps it in
41
+ :class:`~strands_plan_tool.errors.BindEvaluationError`.
42
+ """
43
+ ...
44
+
45
+
46
+ def _deny_eval(*_args: JsonValue) -> JsonValue:
47
+ """Stand in for ``$eval`` so a plan cannot construct expressions at runtime."""
48
+ raise PermissionError(_EVAL_DENIED)
49
+
50
+
51
+ class JsonataBinder:
52
+ """JSONata binder with a per-expression compile cache and ``$eval`` disabled."""
53
+
54
+ __slots__ = ("_allow_eval", "_cache")
55
+
56
+ def __init__(self, *, allow_eval: bool = False) -> None:
57
+ """Create a binder.
58
+
59
+ Args:
60
+ allow_eval: Leave JSONata's ``$eval`` reachable. Defaults to ``False``;
61
+ enabling it means a plan's bindings can no longer be fully analysed
62
+ before execution.
63
+ """
64
+ self._cache: dict[str, jsonata.Jsonata] = {}
65
+ self._allow_eval = allow_eval
66
+
67
+ def bind(self, expression: str, env: Mapping[str, JsonValue]) -> JsonValue:
68
+ """Evaluate a JSONata expression against the collected results.
69
+
70
+ Args:
71
+ expression: JSONata source, for example ``steps.lookup.id``.
72
+ env: Read-only view shaped ``{"steps": {<id>: <result>}}``.
73
+
74
+ Returns:
75
+ The evaluated value.
76
+
77
+ Raises:
78
+ PermissionError: The expression called ``$eval`` while it was disabled.
79
+ """
80
+ compiled = self._cache.get(expression)
81
+ if compiled is None:
82
+ compiled = jsonata.Jsonata(expression)
83
+ if not self._allow_eval:
84
+ # Instance frame is a child of the library's static frame, so this
85
+ # shadows $eval for this expression only and never mutates the global.
86
+ compiled.register_lambda("eval", _deny_eval)
87
+ self._cache[expression] = compiled
88
+
89
+ # Passed through without copying: JSONata is a query evaluator and only reads
90
+ # its input, so copying here would make every binding O(prior results) and the
91
+ # whole plan quadratic. Enforced by test_binder_does_not_mutate_the_environment.
92
+ result: JsonValue = compiled.evaluate(env)
93
+ return result
@@ -0,0 +1,182 @@
1
+ """Typed errors for plan validation and execution.
2
+
3
+ Every failure mode a plan can hit has its own class, so a caller can match on the
4
+ kind rather than parsing a message. Nothing in this package raises a bare
5
+ ``Exception`` or swallows one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = [
11
+ "AgentInterruptedError",
12
+ "BindEvaluationError",
13
+ "DanglingReferenceError",
14
+ "DuplicateStepError",
15
+ "InvalidPlanShapeError",
16
+ "PlanCycleError",
17
+ "PlanError",
18
+ "StepLimitError",
19
+ "ToolExecutionError",
20
+ "ToolNotPlannableError",
21
+ ]
22
+
23
+
24
+ class PlanError(Exception):
25
+ """Base class for every plan validation or binding failure."""
26
+
27
+
28
+ class DuplicateStepError(PlanError):
29
+ """Two steps declared the same id."""
30
+
31
+ def __init__(self, step_id: str) -> None:
32
+ """Record the duplicated id.
33
+
34
+ Args:
35
+ step_id: The id that appeared more than once.
36
+ """
37
+ super().__init__(f"duplicate step id: {step_id!r}")
38
+ self.step_id = step_id
39
+
40
+
41
+ class DanglingReferenceError(PlanError):
42
+ """A step referenced an id that no step declares."""
43
+
44
+ def __init__(self, step_id: str, missing: str, *, field: str) -> None:
45
+ """Record where the dangling reference was found.
46
+
47
+ Args:
48
+ step_id: The step holding the bad reference.
49
+ missing: The referenced id that does not exist.
50
+ field: Which field carried it, for example ``after`` or ``returns``.
51
+ """
52
+ super().__init__(f"step {step_id!r} {field} references unknown step {missing!r}")
53
+ self.step_id = step_id
54
+ self.missing = missing
55
+ self.field = field
56
+
57
+
58
+ class PlanCycleError(PlanError):
59
+ """The dependency graph contains a cycle, so no execution order exists."""
60
+
61
+ def __init__(self, remaining: tuple[str, ...]) -> None:
62
+ """Record the steps that could never become ready.
63
+
64
+ Args:
65
+ remaining: Ids still blocked when the topological sort stalled.
66
+ """
67
+ super().__init__(f"dependency cycle among steps: {', '.join(sorted(remaining))}")
68
+ self.remaining = remaining
69
+
70
+
71
+ class StepLimitError(PlanError):
72
+ """The plan declared more steps than the configured ceiling allows."""
73
+
74
+ def __init__(self, count: int, limit: int) -> None:
75
+ """Record the overrun.
76
+
77
+ Args:
78
+ count: How many steps the plan declared.
79
+ limit: The configured maximum.
80
+ """
81
+ super().__init__(f"plan declares {count} steps, limit is {limit}")
82
+ self.count = count
83
+ self.limit = limit
84
+
85
+
86
+ class BindEvaluationError(PlanError):
87
+ """A JSONata binding expression failed to compile or evaluate.
88
+
89
+ Raised instead of substituting ``None``: a binding that silently yields nothing
90
+ would hand a downstream tool an argument the plan never intended.
91
+ """
92
+
93
+ def __init__(self, step_id: str, parameter: str, expression: str, cause: str) -> None:
94
+ """Record which binding failed and why.
95
+
96
+ Args:
97
+ step_id: The step whose binding failed.
98
+ parameter: The argument name the expression was bound to.
99
+ expression: The offending JSONata source.
100
+ cause: Human-readable reason from the evaluator.
101
+ """
102
+ super().__init__(
103
+ f"step {step_id!r} binding {parameter!r} failed: {cause} (in {expression!r})"
104
+ )
105
+ self.step_id = step_id
106
+ self.parameter = parameter
107
+ self.expression = expression
108
+ self.cause = cause
109
+
110
+
111
+ class ToolNotPlannableError(PlanError):
112
+ """A step named a tool that is not on the plannable allowlist.
113
+
114
+ Most often this means the tool can raise a human-in-the-loop interrupt, which the
115
+ SDK refuses to service from inside a plan. The tool is still fully available to the
116
+ model through the ordinary loop; it just cannot be batched.
117
+ """
118
+
119
+ def __init__(self, step_id: str, tool: str, allowed: tuple[str, ...]) -> None:
120
+ """Record the refused tool and what was permitted.
121
+
122
+ Args:
123
+ step_id: The step naming the tool.
124
+ tool: The refused tool name.
125
+ allowed: The permitted tool names, sorted.
126
+ """
127
+ super().__init__(
128
+ f"step {step_id!r} calls {tool!r}, which cannot be run inside a plan. "
129
+ f"Call it directly instead. Plannable tools: {', '.join(allowed) or '(none)'}"
130
+ )
131
+ self.step_id = step_id
132
+ self.tool = tool
133
+ self.allowed = allowed
134
+
135
+
136
+ class ToolExecutionError(PlanError):
137
+ """A planned tool ran and reported failure.
138
+
139
+ Distinguished from a transport or binding error so a step's ledger entry names the
140
+ tool's own complaint rather than a generic exception.
141
+ """
142
+
143
+ def __init__(self, tool: str, detail: str) -> None:
144
+ """Record the failing tool and its message.
145
+
146
+ Args:
147
+ tool: The tool that failed.
148
+ detail: The tool's error text.
149
+ """
150
+ super().__init__(f"tool {tool!r} failed: {detail}")
151
+ self.tool = tool
152
+ self.detail = detail
153
+
154
+
155
+ class InvalidPlanShapeError(PlanError):
156
+ """The submitted plan payload does not match the schema.
157
+
158
+ Distinct from the semantic errors above: the plan was not even well-formed, so no
159
+ graph could be built from it. Carries the validator's own message so the model can see
160
+ which field it got wrong.
161
+ """
162
+
163
+ def __init__(self, detail: str) -> None:
164
+ """Record the validation detail.
165
+
166
+ Args:
167
+ detail: The validator's message.
168
+ """
169
+ super().__init__(f"plan does not match the required shape: {detail}")
170
+ self.detail = detail
171
+
172
+
173
+ class AgentInterruptedError(PlanError):
174
+ """The agent was already in an interrupt state when the plan was submitted.
175
+
176
+ The SDK refuses every direct tool call while an interrupt is outstanding, so a plan
177
+ submitted in that state would fail on its first step.
178
+ """
179
+
180
+ def __init__(self) -> None:
181
+ """Describe the refusal."""
182
+ super().__init__("agent has an outstanding interrupt; resolve it before submitting a plan")
@@ -0,0 +1,307 @@
1
+ """Local plan execution, scheduled for latency.
2
+
3
+ Tool invocation sits behind :class:`ToolInvoker`, so this engine has no agent-framework
4
+ import and the Strands adapter is a thin wrapper. That seam is also why the latency claim
5
+ holds: steps execute in this process, with no hop back to the model provider between them.
6
+
7
+ Why this scheduler
8
+ ------------------
9
+ The plan is a **DAG**, not a tree: a step may have several dependencies and several
10
+ dependents, so in-degree above one is normal and tree structures (BST, heap) are the wrong
11
+ shape. A heap would only buy priority ordering, which does not shorten the critical path.
12
+
13
+ Ordering therefore comes from Kahn's algorithm over in-degree counts, which is
14
+ BFS-flavoured. A DFS topological sort is the one choice to avoid: it yields a single linear
15
+ sequence, and executing a sequence serialises steps that never depended on each other, so
16
+ wall clock becomes the *sum* of every step instead of the longest path.
17
+
18
+ :func:`~strands_plan_tool.graph.plan_levels` groups steps into levels, and levels are the right
19
+ way to *describe* a plan -- the level count is its depth, and that is the number of model
20
+ round trips the plan replaces. But levels are the wrong way to *run* one: a level barrier
21
+ makes every step in level N+1 wait for the slowest step in level N, even when it never
22
+ depended on it. A 2000ms step alongside a 5ms step delays the 5ms step's dependent by
23
+ 1995ms for nothing.
24
+
25
+ So execution is barrier-free and dependency-triggered: every step awaits exactly its own
26
+ dependencies and starts the instant its last one lands. The event loop is the scheduler,
27
+ each step is one task, and each dependency edge is one await -- O(1) amortised per step and
28
+ per edge, O(V + E) overall, the same complexity as the level walk but with wall clock bound
29
+ by the critical path rather than by the sum of per-level maxima.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import time
36
+ from collections.abc import Iterable, Mapping, Sequence
37
+ from typing import Protocol, assert_never, runtime_checkable
38
+
39
+ from .binder import Binder, JsonataBinder
40
+ from .errors import BindEvaluationError
41
+ from .graph import dependencies_of, plan_levels
42
+ from .models import (
43
+ MAX_STEPS,
44
+ JsonValue,
45
+ OnError,
46
+ PlanResult,
47
+ PlanStep,
48
+ StepOutcome,
49
+ StepStatus,
50
+ WorkflowPlan,
51
+ )
52
+
53
+ __all__ = ["ToolInvoker", "execute_plan", "levels_of", "summarize"]
54
+
55
+
56
+ @runtime_checkable
57
+ class ToolInvoker(Protocol):
58
+ """Runs one named tool with resolved arguments."""
59
+
60
+ async def __call__(self, name: str, args: Mapping[str, JsonValue]) -> JsonValue:
61
+ """Invoke ``name``.
62
+
63
+ Args:
64
+ name: Registered tool name.
65
+ args: Fully resolved arguments; the invoker must not mutate them.
66
+
67
+ Returns:
68
+ The tool's result as a JSON-compatible value.
69
+ """
70
+ ...
71
+
72
+
73
+ async def execute_plan(
74
+ plan: WorkflowPlan,
75
+ invoker: ToolInvoker,
76
+ *,
77
+ binder: Binder | None = None,
78
+ max_steps: int = MAX_STEPS,
79
+ allowed_tools: Iterable[str] | None = None,
80
+ ) -> PlanResult:
81
+ """Validate a plan, run it with barrier-free dispatch, and aggregate one result.
82
+
83
+ Each step starts the moment its own dependencies complete rather than waiting for a
84
+ level to finish, so wall clock tracks the critical path. Per-step hot-path cost is
85
+ O(1): one dict write for the result, one dict hit for the compiled expression, and no
86
+ rescan of the graph or of prior results.
87
+
88
+ Args:
89
+ plan: The plan to execute.
90
+ invoker: Callable that runs a single tool.
91
+ binder: Expression evaluator. Defaults to :class:`~strands_plan_tool.binder.JsonataBinder`
92
+ with ``$eval`` disabled.
93
+ max_steps: Ceiling on step count.
94
+ allowed_tools: Tool names this plan may call; ``None`` disables the check.
95
+
96
+ Returns:
97
+ Only the steps named in ``plan.returns``, plus a ledger covering every step in
98
+ level order.
99
+
100
+ Raises:
101
+ strands_plan_tool.errors.PlanError: The plan is malformed or names a tool it may not
102
+ call. Nothing was executed.
103
+ """
104
+ levels = plan_levels(plan, max_steps=max_steps, allowed_tools=allowed_tools)
105
+ depth_of = {step.id: depth for depth, level in enumerate(levels) for step in level}
106
+ deps_of = {step.id: dependencies_of(step) for step in plan.steps}
107
+ resolver = binder if binder is not None else JsonataBinder()
108
+
109
+ # Single owner: `results` is owned by this call, and `env` is a deliberate read-only
110
+ # alias of it built once rather than per step, keeping binding O(1) instead of copying
111
+ # every prior result for every step.
112
+ #
113
+ # A plain dict is safe under this concurrency because asyncio is cooperative and
114
+ # `_resolve_args` reads `env` synchronously -- there is no await between reading a
115
+ # result and using it, so no other step can interleave a write mid-read.
116
+ results: dict[str, JsonValue] = {}
117
+ env: dict[str, JsonValue] = {"steps": results}
118
+
119
+ outcomes: dict[str, StepOutcome] = {}
120
+ tasks: dict[str, asyncio.Task[None]] = {}
121
+ halted = asyncio.Event()
122
+
123
+ def skipped(step: PlanStep) -> StepOutcome:
124
+ return StepOutcome(
125
+ id=step.id,
126
+ tool=step.tool,
127
+ status=StepStatus.SKIPPED,
128
+ level=depth_of[step.id],
129
+ duration_ms=0.0,
130
+ )
131
+
132
+ async def run(step: PlanStep) -> None:
133
+ """Await only this step's dependencies, then run it. Never raises."""
134
+ dependencies = deps_of[step.id]
135
+ for dependency in dependencies:
136
+ await tasks[dependency]
137
+
138
+ unusable = any(outcomes[d].status is not StepStatus.OK for d in dependencies)
139
+ if halted.is_set() or unusable:
140
+ outcomes[step.id] = skipped(step)
141
+ return
142
+
143
+ outcome, value = await _run_step(step, depth_of[step.id], invoker, resolver, env)
144
+ outcomes[step.id] = outcome
145
+
146
+ if outcome.status is StepStatus.OK:
147
+ results[step.id] = value
148
+ return
149
+
150
+ match plan.on_error:
151
+ case OnError.FAIL_FAST:
152
+ halted.set()
153
+ case OnError.CONTINUE:
154
+ pass
155
+ case _ as unreachable:
156
+ assert_never(unreachable)
157
+
158
+ # Spawned in level order purely so every `tasks[dependency]` exists before it is
159
+ # awaited; the tasks themselves synchronise on dependencies, not on levels.
160
+ for level in levels:
161
+ for step in level:
162
+ tasks[step.id] = asyncio.create_task(run(step))
163
+
164
+ await asyncio.gather(*tasks.values())
165
+
166
+ ledger = tuple(outcomes[step.id] for level in levels for step in level)
167
+ return PlanResult(
168
+ returned={sid: results[sid] for sid in plan.returns if sid in results},
169
+ ledger=ledger,
170
+ levels=len(levels),
171
+ inference_passes_saved=max(len(levels) - 1, 0),
172
+ final=plan.final,
173
+ )
174
+
175
+
176
+ async def _run_step(
177
+ step: PlanStep,
178
+ depth: int,
179
+ invoker: ToolInvoker,
180
+ binder: Binder,
181
+ env: Mapping[str, JsonValue],
182
+ ) -> tuple[StepOutcome, JsonValue]:
183
+ """Resolve one step's arguments and invoke its tool.
184
+
185
+ A binding failure is a step failure, never a substituted ``None``: handing a tool an
186
+ argument the plan did not intend is worse than reporting that the plan was wrong.
187
+
188
+ Args:
189
+ step: The step to run.
190
+ depth: Level index, recorded on the outcome for reporting.
191
+ invoker: Tool runner.
192
+ binder: Expression evaluator.
193
+ env: Read-only binding environment shaped ``{"steps": {...}}``.
194
+
195
+ Returns:
196
+ The outcome, and the tool's value when it succeeded.
197
+ """
198
+ started = time.perf_counter()
199
+
200
+ def elapsed() -> float:
201
+ return (time.perf_counter() - started) * 1000.0
202
+
203
+ def failure(reason: str) -> tuple[StepOutcome, JsonValue]:
204
+ return (
205
+ StepOutcome(
206
+ id=step.id,
207
+ tool=step.tool,
208
+ status=StepStatus.FAILED,
209
+ level=depth,
210
+ duration_ms=elapsed(),
211
+ error=reason,
212
+ ),
213
+ None,
214
+ )
215
+
216
+ try:
217
+ args = _resolve_args(step, binder, env)
218
+ except BindEvaluationError as exc:
219
+ return failure(str(exc))
220
+
221
+ try:
222
+ value = await invoker(step.tool, args)
223
+ except Exception as exc: # noqa: BLE001 - a tool failure is data, not a crash
224
+ return failure(f"{type(exc).__name__}: {exc}")
225
+
226
+ return (
227
+ StepOutcome(
228
+ id=step.id,
229
+ tool=step.tool,
230
+ status=StepStatus.OK,
231
+ level=depth,
232
+ duration_ms=elapsed(),
233
+ ),
234
+ value,
235
+ )
236
+
237
+
238
+ def _resolve_args(
239
+ step: PlanStep,
240
+ binder: Binder,
241
+ env: Mapping[str, JsonValue],
242
+ ) -> dict[str, JsonValue]:
243
+ """Merge literal args with evaluated bindings, bindings winning.
244
+
245
+ Args:
246
+ step: The step whose arguments to build.
247
+ binder: Expression evaluator.
248
+ env: Read-only binding environment shaped ``{"steps": {...}}``.
249
+
250
+ Returns:
251
+ A fresh dict; the step's own ``args`` is never mutated.
252
+
253
+ Raises:
254
+ BindEvaluationError: An expression failed to compile or evaluate.
255
+ """
256
+ resolved = dict(step.args)
257
+
258
+ for parameter, expression in step.bind.items():
259
+ try:
260
+ resolved[parameter] = binder.bind(expression, env)
261
+ except Exception as exc:
262
+ raise BindEvaluationError(step.id, parameter, expression, str(exc)) from exc
263
+
264
+ return resolved
265
+
266
+
267
+ def summarize(result: PlanResult) -> str:
268
+ """Render a one-line-per-step ledger for logs and demos.
269
+
270
+ Args:
271
+ result: A completed plan result.
272
+
273
+ Returns:
274
+ Human-readable multi-line summary.
275
+ """
276
+ lines: list[str] = []
277
+ for outcome in result.ledger:
278
+ match outcome.status:
279
+ case StepStatus.OK:
280
+ mark = "ok "
281
+ case StepStatus.FAILED:
282
+ mark = "FAILED "
283
+ case StepStatus.SKIPPED:
284
+ mark = "skipped"
285
+ case _ as unreachable:
286
+ assert_never(unreachable)
287
+ detail = f" {outcome.error}" if outcome.error else ""
288
+ lines.append(
289
+ f" L{outcome.level} {mark} {outcome.id:<14} {outcome.tool:<20}"
290
+ f" {outcome.duration_ms:7.1f}ms{detail}"
291
+ )
292
+ return "\n".join(lines)
293
+
294
+
295
+ def levels_of(plan: WorkflowPlan) -> Sequence[Sequence[PlanStep]]:
296
+ """Expose validated levels without executing, for inspection and tests.
297
+
298
+ Args:
299
+ plan: The plan to lay out.
300
+
301
+ Returns:
302
+ Levels in execution order.
303
+
304
+ Raises:
305
+ strands_plan_tool.errors.PlanError: The plan is malformed.
306
+ """
307
+ return plan_levels(plan)