Cut SQL retries: Redshift dialect rules, stated row/time limits, resolved datasource in the log - #330
Conversation
…source in the log - get_datasource_schema carries `dialect_rules` for an engine with known gaps (Redshift today): what it rejects and what to write instead, before the SQL is written (#325). - execute_sql's description states the row cap and statement deadline, built from the same resolvers the executor enforces; `tools.statement_limits()` exposes both (#326). - tool_calls.datasource records the datasource the call resolved to, published by the handler and read back through typed_outcome_overrides; new `datasource_source` column (migration 025) says whether the client named it (#328). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fy statement_limits' purpose Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Address the critical import-time parsing issue and the listed dialect-rule and datasource-provenance issues before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds Redshift dialect guidance, exposes SQL row/time limits, and records resolved datasource provenance to reduce avoidable retries.
Changes:
- Adds engine-specific rules to schema responses.
- Documents effective
execute_sqllimits. - Tracks datasource resolution in activity logs.
File summaries
| File | Summary and findings |
|---|---|
tests/test_tool_calls_resolved_datasource.py |
Tests explicit and resolved datasource logging. |
tests/test_schema_dialect_rules.py |
Tests dialect rules across schema paths and engines. |
tests/test_execute_sql_states_limits.py |
Tests configured SQL limit descriptions. |
packages/agami-core/src/tools.py |
Critical: invalid Unicode digits can prevent module import during registry construction. Nit: cancellation timing is overstated for some executors. |
packages/agami-core/src/sql_dialect_rules.py |
Moderate: preserve empty-count semantics, SQL NULL booleans, and array-valued ARRAY_AGG results. |
packages/agami-core/src/model_store.py |
Moderate: add list_sessions round-trip coverage for explicit and resolved datasource provenance. |
packages/agami-core/src/migrations/core/025_tool_calls_datasource_source.sql |
Adds the datasource provenance column. |
packages/agami-core/src/contracts.py |
Extends schema and activity-log contracts. |
packages/agami-core/pyproject.toml |
Packages the new dialect-rules module. |
CHANGELOG.md |
Documents the changes. |
Review details
Suppressed comments (5)
packages/agami-core/src/model_store.py:635
- The new tests only query
SELECT *after insertion; none exercisesmodel_store.list_sessions, whose_TOOL_CALL_COLSprojection is the path the Activity UI reads. A regression that dropsdatasource_sourcefrom this SELECT would still pass the insert assertions while silently hiding the provenance from the UI. Add a round-trip assertion throughlist_sessionsfor the resolved and explicit values.
"client_model, datasource_source"
packages/agami-core/src/sql_dialect_rules.py:22
COUNT(*) FILTER (WHERE c)returns 0 for an empty input, but the proposedSUM(CASE ...)returns NULL there. Since this is presented as a general rewrite, it can change an ungrouped empty result; preserve the count semantics withCOALESCEor explicitly limit the rule to non-empty grouped inputs.
- No FILTER on aggregates. COUNT(*) FILTER (WHERE c) -> SUM(CASE WHEN c THEN 1 ELSE 0 END); a \
filtered average -> AVG(CASE WHEN c THEN v END) (NULL, not 0, in the ELSE).
packages/agami-core/src/sql_dialect_rules.py:32
- This rewrite maps SQL
NULLbooleans to'false', because aCASE WHEN col ... ELSE ...treats both false and unknown as the ELSE branch. A boolean cast/string conversion preserves NULL, so following this rule can change query results; use explicit true/false branches with an implicit NULL ELSE.
- A BOOLEAN cannot be cast to VARCHAR or passed to a string function (BTRIM, CONCAT). Use \
CASE WHEN col THEN 'true' ELSE 'false' END.
packages/agami-core/src/sql_dialect_rules.py:25
ARRAY_AGGis not a string-aggregation rewrite: replacing it withLISTAGGchanges an array-valued result into text and can change element/null semantics. A client following this blanket rule can return the wrong type even when the SQL succeeds; scopeLISTAGGtoSTRING_AGGand explain that array results have no generic text-equivalent rewrite.
- No STRING_AGG or ARRAY_AGG. Use LISTAGG(col, ', ') WITHIN GROUP (ORDER BY col). The delimiter must \
be a constant, and every ordered aggregate in one SELECT (LISTAGG, PERCENTILE_CONT, MEDIAN) must use \
the same ordering.
packages/agami-core/src/tools.py:3201
- The description promises cancellation immediately after
timeout_sfor every executor, but BigQuery applies its native bound attimeout_s + 5and injected executors are only stopped waiting attimeout_s + 10; the underlying work may continue. This makes the client-facing enforcement statement inaccurate. Describe this as the configured deadline/bound without promising cancellation, or make every executor honor cancellation at the stated value.
f"statement still running after {limits['timeout_s']}s is cancelled. Plan for both before "
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| "than the deployment row ceiling (refused rather than trimmed, so a partial answer " | ||
| "never arrives looking whole).\n" | ||
| # The numbers behind the two limits above (#326); see `_execute_sql_limits_sentence`. | ||
| + _execute_sql_limits_sentence() |
There was a problem hiding this comment.
Fixed in 14edc1f. _resolve_row_cap now uses isdecimal, matching _resolve_timeout_s, so ² / ① / abc / -5 / 0 fall back to the default instead of raising while the registry builds. The vendored plugins/agami/lib/execute_sql.py got the identical change (parity tests pass). Covered by test_an_unusable_row_cap_falls_back_instead_of_raising.
…tes, reader coverage - _resolve_row_cap uses isdecimal (as _resolve_timeout_s does): the registry now resolves it at import, so a value like `²` would have stopped `tools` importing. Vendored copy updated too. - Redshift rules: COUNT(CASE WHEN c THEN 1 END) keeps 0 on empty input; boolean-to-text keeps NULL; LISTAGG replaces STRING_AGG only, ARRAY_AGG has no text equivalent. - Limits sentence says "refused" rather than promising cancellation on every executor. - Tests: unusable row-cap values fall back; datasource_source round-trips through list_sessions; rewrites keep the result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed Copilot's suppressed comments in 14edc1f:
|
Closes #325, closes #326, closes #328.
Three changes that cut
execute_sqlretries — each one a warehouse round trip plus a full client turn — or make them visible in the activity log.#325 — Redshift dialect rules on the schema response
sql_dialect_rules.py: per-engine rules keyed by the model'sStorageType; Redshift is the only entry. Read-path only.get_datasource_schemaaddsdialect_rulesbeside theprompt_examplespointer on both branches — inside the size-budget loop on the budgeted one, so the ~1.6k chars are measured.DatasourceSchemaResult; the tool description and the shared instructions tell the client to follow it.#326 —
execute_sqlstates the row cap and deadlinetools.statement_limits()returns{max_rows, timeout_s}from the executor's own resolvers;_execute_sql_limits_sentence()puts both numbers, and what to do about each, into the description when the registry is built.AGAMI_SQL_MAX_ROWS/AGAMI_SQL_TIMEOUT_Safter importingtoolswould see a stale description — nothing in core or the hosted entrypoint does.statement_limits()is public for the admin Settings screen that will show the limits in force. Per-organisation editing is Let an organisation's administrator set the row cap and statement deadline #329.#328 — the activity log records the resolved datasource
_resolve_call_datasource, which publishes the result on a ContextVar;reset_typed_outcomeclears it andtyped_outcome_overrideshands it torecord_tool_callbeside (not as part of) the outcome trio.tool_calls.datasourceis now the resolved datasource; newdatasource_sourcecolumn (migration 025):explicit/resolved/ NULL.Decisions
default/resolvedrather than empty — it is what the server tried.datasource_sourcederives from the argument when no handler published a value, so an embedder that states nothing keeps today's behaviour.Tests
test_schema_dialect_rules.py,test_execute_sql_states_limits.py,test_tool_calls_resolved_datasource.py(17 tests: rules on both branches and absent for other engines; limits from env overrides; explicit/resolved/NULL rows through migration 025; each handler publishes via a caller-owned Context; no leak into the next tool).origin/mainata365b10(pre-existing).Review
/agami-sdlc:review: 0 must-fix. Applied: Redshift ordered-aggregate and reserved-identifier rules widened; unused fixture removed. Not applied: a transport-level end-to-end test throughbuild_server, and reading back through_TOOL_CALL_COLS— the handler test mirrors the transport's context handling and the column is exercised by insert.🤖 Generated with Claude Code