the new governed "agent" workflow node
(primitives/agent_gate.py + primitives/agent_loop.py) lets an LLM propose
tool calls at run time, each checked through a composed gate before it can
execute: tool allowlist, live-execution policy, spend cap, team approval.
Every agent node starts with max_spend_cents=0 by default -- the module's
own docs describe this as deliberate: an agent may not spend anything
unless a workflow author explicitly raises the cap.
The spend-cap step estimates a proposed action's cost with the SAME
extractor the static DAG plan-time gate already uses
(workflow_engine._estimate_amount_cents), which returns a
(amount_cents, unbounded) pair. unbounded=True means the amount could not
be resolved to a concrete number at this point (an unresolved template
binding, or an amount field present but null) -- this station's own
comments describe that flag as existing specifically to stop a real charge
from ever being silently treated as "$0, so no cap check needed", following
a prior real-money incident where a nested/templated amount planned as $0
for a workflow that genuinely charged.
The agent node's default spend estimator (agent_gate._default_estimate())
calls the same extractor but keeps only the amount and DISCARDS theunbounded flag entirely. The gate then does `if est and (spent + est) >
cap:` -- since a discarded-as-unbounded amount is exactly 0, and 0 is
falsy, the whole spend-cap comparison is skipped, regardless of what cap
is. This reintroduces, in the brand-new agent path, precisely the failure
class the unbounded flag was added elsewhere to prevent.
Reproduction steps:
- Extract a clean station-v0.73 tarball, sys.path.insert(0, "workbench").
- from primitives import agent_gate; import workflow_engine as WE
- args = {"amount_cents": "{{ prior_step.output.total }}"}
(a plausible shape: an agent step passing along an amount from an
earlier tool call's output that hasn't resolved to a concrete number)
- Confirm WE._estimate_amount_cents(args, args) == (0, True) -- correctly
flagged unbounded by the real estimator.
- Confirm agent_gate._default_estimate()(args) == 0 -- the production
wrapper has already thrown away the True flag.
- Build a real gate via agent_gate.build_agent_gate(..., max_spend_cents=0,
tools=["stripe.charge_create"], ...) with resolve_node/allows_live/
team_gate stubbed to permissive stand-ins (isolating the estimator, the
piece under test) and call gate("stripe.charge_create", args, 0, 0).
Expected: an action whose cost cannot be determined should never be
waved through a cap the workflow was explicitly configured (or defaulted)
to $0 -- unresolved must fail closed, exactly like the static DAG path
already does for the same shape of estimate.
Actual: gate() returns ("proceed", None). A stripe.charge_create action
with an unresolved amount is approved to execute against a $0 spend cap.
Root cause: workbench/primitives/agent_gate.py, _default_estimate()
(~line 50-57):
def _default_estimate():
try:
from workflow_engine import _estimate_amount_cents as est
except ImportError:
from workbench.workflow_engine import _estimate_amount_cents as est
return lambda args: (est(args, args) or (0, False))[0]
This is not just an unused library default: the actual production call site
that wires up every real agent node, workbench/workflow_engine.py
_run_agent_node() (~line 883-888), doesn't even use it -- it builds its OWN
inline estimator with the identical bug:
gate = _AG.build_agent_gate(
ws=ws, run_id=run_id or "wfrun", tools=tools,
max_spend_cents=budget.get("max_spend_cents", 0),
...
estimate_spend=lambda a: (_estimate_amount_cents(a, a) or (0, False))[0])
Both places independently keep [0] -- only the amount half of the
(amount, unbounded) tuple. The gate() closure then does:
est = int(estimate_spend(args) or 0)
if est and (int(spent_cents) + est) > cap:
return "blocked", {...}
An unbounded result collapses to est=0, if est and ... is False, the cap
check never runs, and execution proceeds.
Suggested fix: keep the full (amount, unbounded) pair through BOTH
estimators (the library default in agent_gate.py and the inline lambda in
workflow_engine.py._run_agent_node) and treat unbounded exactly like the
static DAG plan path does -- fail closed (block, or force human/team
approval) rather than treating it as a free action, regardless of how large
or small cap is.