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.
- strands_plan_tool/__init__.py +62 -0
- strands_plan_tool/binder.py +93 -0
- strands_plan_tool/errors.py +182 -0
- strands_plan_tool/executor.py +307 -0
- strands_plan_tool/graph.py +170 -0
- strands_plan_tool/models.py +115 -0
- strands_plan_tool/py.typed +0 -0
- strands_plan_tool/strands_adapter.py +431 -0
- strands_plan_tool-0.1.0.dist-info/METADATA +268 -0
- strands_plan_tool-0.1.0.dist-info/RECORD +12 -0
- strands_plan_tool-0.1.0.dist-info/WHEEL +4 -0
- strands_plan_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|