Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions deploy/agami.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ AGAMI_ADMIN_PASSWORD=
# Override the bundled-Postgres password if you like (it's never exposed, so the default is fine).
# POSTGRES_PASSWORD=

# Optional: how long ONE statement may run, in whole SECONDS (unset = 30). This is the deployment's
# availability budget — a query that outruns it is cancelled on the warehouse and the caller is told
# to narrow it. Raise it if your warehouse is slow and your users would rather wait; lower it to keep
# a runaway query from holding a connection. The executor's other time bounds are derived from this
# one, so this is the single number to tune.
# AGAMI_SQL_TIMEOUT_S=30 # per-statement budget (default 30 seconds)

# Optional free registration key (best-effort; absent = full run).
# AGAMI_LICENSE_KEY=

Expand Down
652 changes: 613 additions & 39 deletions packages/agami-core/src/execute_sql.py

Large diffs are not rendered by default.

27 changes: 17 additions & 10 deletions packages/agami-core/src/guardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,15 @@
RULE_COLUMN_SCOPE = "column_scope"
RULE_SELECT_STAR = "select_star"
RULE_MODEL_UNAVAILABLE = "model_unavailable"
# Declared and pinned below, with NO producer today. The contract reserves it for a **per-statement**
# timeout the guard imposes — a bound whose subject is the statement, so "narrow the query" is a fix
# we can honestly name. The subprocess supervisor's kill is NOT that bound and must not borrow this
# rule: it stops a child that never returned, without knowing what the child was doing when it
# stopped, so it is a `failed`/`timeout` (see `FailureKind` below and contract §3).
# Produced by the **per-statement** timeout the executor imposes: a watchdog cancels a statement that
# outlives the configured budget, and `execute_sql.execute_guarded` turns that into this refusal. Its
# subject is the statement, which is what earns it a rule at all — "narrow the query" is a fix we can
# honestly name. The subprocess supervisor's kill is NOT that bound and must not borrow this rule: it
# stops a child that never returned, without knowing what the child was doing when it stopped, so it
# is a `failed`/`timeout` (see `FailureKind` below and contract §3). Every engine the executor speaks
# to is wired, with one recorded residual: BigQuery has no connection to cancel, so there the bound is
# server-side only and a client-side stall comes back as the executor never returning rather than as a
# statement we stopped.
RULE_RESOURCE_LIMIT = "resource_limit"
RULE_UNPARSEABLE = "unparseable"

Expand Down Expand Up @@ -90,8 +94,8 @@
RULE_SELECT_STAR: "out_of_scope",
RULE_MODEL_UNAVAILABLE: "undetermined",
# A bound we imposed, not a property of the statement: neither unsafe nor out of scope — we
# simply did not determine the answer within the bound. Pinned here while unproduced so the gate
# that eventually imposes a per-statement timeout fills a constant rather than inventing one.
# simply did not determine the answer within the bound. Pinned before it had a producer, so the
# gate that imposes the per-statement timeout filled a constant rather than inventing one.
RULE_RESOURCE_LIMIT: "undetermined",
RULE_UNPARSEABLE: "undetermined",
RULE_MODEL_SAFETY: "undetermined",
Expand Down Expand Up @@ -165,9 +169,12 @@ def refuse(rule: str, *, detail: str, remediation: str) -> Refusal:
in connect, credential resolution or model load, where "narrow the query" is the wrong fix. So an
unresponsive executor is `failed` / `timeout`, and its message says only that we stopped waiting
(guardrail contract §3). A **per-statement** timeout is the other case — its subject IS the
statement, so it is a refusal carrying `RULE_RESOURCE_LIMIT` — and nothing imposes one yet, which is
why that rule is pinned with no producer. Driver-level connect/login timeouts fold into the connect
failure the executor already reports as `auth` (exit 4), because that is what the driver raises.
statement, so it is a refusal carrying `RULE_RESOURCE_LIMIT`, and the executor now imposes one: a
watchdog cancels the statement through the driver and the outcome leaves the chokepoint on the
refusal channel rather than this one. The two therefore coexist and stay distinguishable in the audit
trail, which is the whole point of splitting them. Driver-level connect/login timeouts fold into the
connect failure the executor already reports as `auth` (exit 4), because that is what the driver
raises.

`column_not_found`, `table_not_found`, `permission` and `network` are DECLARED BUT UNREACHABLE:
producing them means parsing driver text, and sanitizing driver text belongs to the error-hardening
Expand Down
7 changes: 4 additions & 3 deletions packages/agami-core/src/mcp_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,10 @@ async def _call_tool(name: str, arguments: dict) -> list:
raised = False
try:
# Run the tool handler OFF the event loop. The heavy handlers block for the whole query —
# execute_sql shells out to a subprocess (up to 240 s) and hits the warehouse — so on the
# loop a single slow query would freeze every other in-flight request. This completes
# ACE-048 (which off-loaded the KDF/OIDC/audit calls but left the handler on the loop).
# execute_sql runs it under the executor's own bounds (the per-statement budget, plus the
# supervisor's slack on the fork path) and hits the warehouse — so on the loop a single
# slow query would freeze every other in-flight request. This completes ACE-048 (which
# off-loaded the KDF/OIDC/audit calls but left the handler on the loop).
# `run_blocking` (anyio.to_thread) copies the request context into the worker thread, so
# the org-scoped model cache (ACE-045, read via `_current_org_ctx`) stays tenant-correct.
result_text = await run_blocking(meta["handler"], arguments or {})
Expand Down
33 changes: 27 additions & 6 deletions packages/agami-core/src/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,7 @@ def _child_failure_message(returncode: int, stderr: str | None) -> str:


def _executor_truncated(stderr: str | None) -> bool:
"""True if execute_sql flagged a bounded-fetch truncation (ACE-038/044). The executor emits a
"""True if execute_sql flagged a bounded-fetch truncation (ACE-044). The executor emits a
non-error `{"truncated": {"row_cap": N}}` line on stderr alongside any other notices; scan for it."""
for line in (stderr or "").splitlines():
line = line.strip()
Expand Down Expand Up @@ -1205,7 +1205,7 @@ def _emit(
columns = list(env.data.columns)
rows = [["" if v is None else str(v) for v in row] for row in env.data.rows]
truncated = env.data.truncated
# Backstop only: the executor already caps at the source (ACE-038/044) and flags it. This
# Backstop only: the executor already caps at the source (ACE-044) and flags it. This
# catches a result that slipped past that bound, and marks it truncated rather than
# presenting a trimmed result as complete.
if max_rows is not None and len(rows) > max_rows:
Expand Down Expand Up @@ -1414,22 +1414,43 @@ def tool_execute_sql(args: dict[str, Any]) -> str:
# materializes the whole result — not a client-side trim after the fact.
cmd += ["--max-rows", str(max_rows)]

# The supervisor's bound is the OUTERMOST of the four time bounds, and it is DERIVED from the
# same resolver every inner layer reads rather than being a number of its own. A fixed 240s
# inverted that order for any statement budget approaching it: the supervisor fired FIRST, so a
# statement we could have cancelled and refused precisely came back instead as a
# `failed`/`timeout` naming nothing the caller can act on. Imported lazily for the same
# reason `_run_in_process` does it.
#
# Resolved HERE and enforced on a child that re-resolves for itself, which only works because the
# resolver reads the environment and nothing else: the child inherits `os.environ` (no `env=`
# below) and therefore reaches the identical number. A request-scoped override would be the one
# thing that could break that — it would outrank the environment on this side of the fork and be
# invisible on the other, so a parent bound of 65s could sit against a child budget of 300s and
# fire first, inverting the order this whole family exists to hold. There is deliberately no such
# override; `_resolve_timeout_s` documents why, and a test pins that the budget keeps exactly one
# configuration surface.
import execute_sql

supervisor_timeout_s = execute_sql._resolve_timeout_s() + execute_sql._SUPERVISOR_SKEW_S

started = time.monotonic()
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=240,
timeout=supervisor_timeout_s,
)
except subprocess.TimeoutExpired:
# A `failed`/`timeout`, NOT a `refused`/`resource_limit` — and the reason is what we can
# OBSERVE, not who acted. The bound is ours, but all it tells us is that the child never came
# back; it does not say the STATEMENT ran long. The child may have hung in connect, in
# credential resolution or in loading the model, and on any of those "narrow the query" is
# advice pointing at the wrong thing. A refusal must name a fix, so a kill we cannot attribute
# is not one. (Guardrail contract §3: an unresponsive executor is `failed`. The per-statement
# timeout a later gate imposes IS a refusal, and `RULE_RESOURCE_LIMIT` is reserved for it.)
# is not one. (Guardrail contract §3: an unresponsive executor is `failed`. The executor's own
# per-statement deadline IS a refusal and carries `RULE_RESOURCE_LIMIT`; it can attribute the
# cancel to the statement because it is the thing it cancelled. The two bounds coexist, and
# this one must not borrow the other's rule.)
return _emit(
_envelope("failed", failure=Failure(
kind="timeout",
Expand Down Expand Up @@ -1466,7 +1487,7 @@ def tool_execute_sql(args: dict[str, Any]) -> str:
env, sql=sql, execution_ms=execution_ms, profile=profile, args=args,
)

# Parse the RFC-4180 CSV emitted on stdout. The executor caps at the source (ACE-038/044) and
# Parse the RFC-4180 CSV emitted on stdout. The executor caps at the source (ACE-044) and
# flags it on stderr; carry that flag so a truncated result is never presented as complete. The
# `max_rows` backstop is applied by `_emit`, the same one the in-process path gets.
reader = csv.reader(io.StringIO(proc.stdout))
Expand Down
Loading
Loading