From b4164ffc2956e587918c5735ee164d12b89c6419 Mon Sep 17 00:00:00 2001 From: flyingdoubleG <53119724+flyingdoubleG@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:54:53 +0800 Subject: [PATCH 1/2] Add Docker-free multi-turn SQLite text-to-SQL workflow (BIRD) Co-Authored-By: Claude Opus 5.5 --- examples/grpo_sqlite_sql/README.md | 70 ++++++++ examples/grpo_sqlite_sql/get_bird_data.py | 53 ++++++ examples/grpo_sqlite_sql/sqlite_sql.yaml | 81 +++++++++ tests/common/sqlite_sql_env_test.py | 109 ++++++++++++ trinity/common/workflows/__init__.py | 1 + .../workflows/envs/sqlite_sql/__init__.py | 0 .../envs/sqlite_sql/sqlite_sql_env.py | 162 ++++++++++++++++++ .../envs/sqlite_sql/sqlite_sql_workflow.py | 109 ++++++++++++ 8 files changed, 585 insertions(+) create mode 100644 examples/grpo_sqlite_sql/README.md create mode 100644 examples/grpo_sqlite_sql/get_bird_data.py create mode 100644 examples/grpo_sqlite_sql/sqlite_sql.yaml create mode 100644 tests/common/sqlite_sql_env_test.py create mode 100644 trinity/common/workflows/envs/sqlite_sql/__init__.py create mode 100644 trinity/common/workflows/envs/sqlite_sql/sqlite_sql_env.py create mode 100644 trinity/common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py diff --git a/examples/grpo_sqlite_sql/README.md b/examples/grpo_sqlite_sql/README.md new file mode 100644 index 00000000000..f899f174f23 --- /dev/null +++ b/examples/grpo_sqlite_sql/README.md @@ -0,0 +1,70 @@ +# Example: Multi-turn GRPO for Text-to-SQL on SQLite (BIRD) + +This example trains an agent that explores a real SQLite database with read-only queries and then +submits one SQL query, using the [BIRD](https://bird-bench.github.io/) benchmark. It complements the +[InterCode-SQL example](../grpo_intercode_sql/README.md): it needs **no Docker or MySQL server**, +because each rollout opens the `.sqlite` file directly. + +## How the workflow works + +`sqlite_sql_workflow` (`trinity/common/workflows/envs/sqlite_sql/`) gives the model the database +schema, the question and BIRD's external knowledge. At each turn the model replies with exactly one +of: + +- `SQL`: run a read-only query; the first `max_obs_rows` rows (or the error) come back + as the next user message +- `SQL`: submit the final query and end the episode + +The reward is BIRD / Spider execution accuracy: 1.0 if the submitted query returns the same set of +rows as the reference query, else 0.0. Episodes that run out of turns score 0 unless +`auto_submit: true`, which grades the last query that ran successfully. + +Because the reward comes from executing model-written SQL, every query runs in a sandbox: the +database is opened read-only behind an SQLite authorizer that only allows reads (no writes, `ATTACH`, +`PRAGMA` or extensions), one statement is executed per query, and a query is interrupted after +`timeout` seconds. Concurrent rollouts therefore cannot change the data other rollouts are scored +on. Experience `info` also records `gold_error` (the reference query fails) and `gold_empty` (the +reference returns no rows, so any query returning nothing would match), so these tasks can be +filtered. + +## Prepare data + +1. Download the BIRD train set and databases from , and BIRD + Mini-Dev (500 SQLite questions, disjoint from train) from + . +2. Create the taskset: + +```bash +python examples/grpo_sqlite_sql/get_bird_data.py \ + --train_json train/train.json \ + --test_json minidev/MINIDEV/mini_dev_sqlite.json +``` + +Each line of `bird_data/train.jsonl` and `bird_data/test.jsonl` has `db_id`, `question`, `evidence` +and `SQL`. Any dataset in this format with `//.sqlite` databases (e.g. Spider) +works the same way. + +## Run + +```bash +export BIRD_TRAIN_DB_ROOT=/path/to/train/train_databases +export BIRD_DEV_DB_ROOT=/path/to/minidev/MINIDEV/dev_databases +trinity run --config examples/grpo_sqlite_sql/sqlite_sql.yaml +``` + +Workflow arguments (`workflow_args`): + +| Argument | Default | Description | +|---|---|---| +| `db_root` | required | Directory with `/.sqlite` | +| `max_env_steps` | 5 | Maximum number of model turns | +| `max_obs_rows` | 10 | Rows of a query result shown to the model | +| `timeout` | 30 | Per-query timeout in seconds (the official BIRD evaluator uses 30) | +| `auto_submit` | false | Grade the last successful query if the model never submits | + +Notes on BIRD Mini-Dev as a reward source: + +- 16 of the 500 reference queries return the single value `1`, so a constant `SELECT 1` earns the + full reward on 3.2% of the tasks. Watch rollouts for constant answers on hard tasks. +- Two reference queries (question ids 518 and 701) take longer than 30 s on a typical machine, + so they score 0 for every model at the default timeout, as they do in the official evaluator. diff --git a/examples/grpo_sqlite_sql/get_bird_data.py b/examples/grpo_sqlite_sql/get_bird_data.py new file mode 100644 index 00000000000..e40a276b060 --- /dev/null +++ b/examples/grpo_sqlite_sql/get_bird_data.py @@ -0,0 +1,53 @@ +""" +Create the Trinity taskset for `sqlite_sql_workflow` from BIRD. + +Download the BIRD train set (https://bird-bench.github.io/) and BIRD Mini-Dev +(https://bird-bench.oss-cn-beijing.aliyuncs.com/minidev.zip), then run: + + python examples/grpo_sqlite_sql/get_bird_data.py \ + --train_json train/train.json \ + --test_json minidev/MINIDEV/mini_dev_sqlite.json + +This writes `examples/grpo_sqlite_sql/bird_data/train.jsonl` and `test.jsonl` with the fields +the workflow reads: `db_id`, `question`, `evidence` and `SQL`. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +FIELDS = ("db_id", "question", "evidence", "SQL") + + +def load(path: Path) -> list[dict]: + text = path.read_text(encoding="utf-8") + if path.suffix == ".jsonl": + return [json.loads(line) for line in text.splitlines() if line.strip()] + return json.loads(text) + + +def write(records: list[dict], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + for record in records: + row = {key: record.get(key) or "" for key in FIELDS} + f.write(json.dumps(row, ensure_ascii=False) + "\n") + print(f"Wrote {len(records)} tasks to {path}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--train_json", type=Path, required=True) + parser.add_argument("--test_json", type=Path, required=True) + parser.add_argument( + "--output_dir", type=Path, default=Path("examples/grpo_sqlite_sql/bird_data") + ) + args = parser.parse_args() + write(load(args.train_json), args.output_dir / "train.jsonl") + write(load(args.test_json), args.output_dir / "test.jsonl") + + +if __name__ == "__main__": + main() diff --git a/examples/grpo_sqlite_sql/sqlite_sql.yaml b/examples/grpo_sqlite_sql/sqlite_sql.yaml new file mode 100644 index 00000000000..a98b8a649ad --- /dev/null +++ b/examples/grpo_sqlite_sql/sqlite_sql.yaml @@ -0,0 +1,81 @@ +project: "SQLITE_SQL" +name: "BIRD_SQLITE_SQL_RFT" +checkpoint_root_dir: ${oc.env:TRINITY_CHECKPOINT_ROOT_DIR,./checkpoints} +algorithm: + algorithm_type: grpo + repeat_times: 8 + optimizer: + lr: 1e-6 +model: + model_path: ${oc.env:TRINITY_MODEL_PATH,Qwen/Qwen2.5-7B-Instruct} + max_prompt_tokens: 12288 + max_response_tokens: 1024 + max_model_len: 13312 +cluster: + node_num: 1 + gpu_per_node: 8 +buffer: + total_epochs: 1 + batch_size: 16 + explorer_input: + taskset: + name: bird_sqlite_sql + storage_type: file + path: 'examples/grpo_sqlite_sql/bird_data' + split: train + format: + prompt_key: 'question' + rollout_args: + temperature: 1.0 + logprobs: 0 + workflow_args: + db_root: ${oc.env:BIRD_TRAIN_DB_ROOT,train/train_databases} + max_env_steps: 5 + max_obs_rows: 10 + timeout: 30 + eval_tasksets: + - name: bird_minidev_eval + storage_type: file + path: 'examples/grpo_sqlite_sql/bird_data' + split: test + format: + prompt_key: 'question' + rollout_args: + temperature: 0.0 + logprobs: 0 + workflow_args: + db_root: ${oc.env:BIRD_DEV_DB_ROOT,minidev/MINIDEV/dev_databases} + max_env_steps: 5 + max_obs_rows: 10 + timeout: 30 + repeat_times: 1 + default_workflow_type: 'sqlite_sql_workflow' + trainer_input: + experience_buffer: + name: sqlite_sql_buffer + storage_type: queue + path: 'sqlite:///sqlite_sql.db' +explorer: + eval_on_startup: true + eval_interval: 20 + runner_per_model: 16 + max_timeout: 3600 + rollout_model: + engine_num: 2 + tensor_parallel_size: 2 + enable_prefix_caching: true + enforce_eager: true + dtype: bfloat16 + seed: 42 + gpu_memory_utilization: 0.7 + enable_chunked_prefill: true +synchronizer: + sync_method: 'nccl' + sync_interval: 8 + sync_timeout: 1200 +trainer: + save_interval: 100 + grad_clip: 1.0 + use_dynamic_bsz: true + max_token_len_per_gpu: 13312 + ulysses_sequence_parallel_size: 1 diff --git a/tests/common/sqlite_sql_env_test.py b/tests/common/sqlite_sql_env_test.py new file mode 100644 index 00000000000..ca3abfda2f5 --- /dev/null +++ b/tests/common/sqlite_sql_env_test.py @@ -0,0 +1,109 @@ +import sqlite3 + +import pytest + +from trinity.common.workflows.envs.sqlite_sql.sqlite_sql_env import ( + SQLiteSQLEnv, + db_file, + execute_sql, + parse_response, + schema_text, +) + +GOLD = "SELECT name FROM customers WHERE city = 'Hangzhou'" + + +@pytest.fixture() +def db_path(tmp_path): + path = db_file(str(tmp_path), "shop") + path.parent.mkdir() + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE customers(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, city TEXT); + INSERT INTO customers(name, city) VALUES ('Ann','Hangzhou'),('Bob','Shanghai'),('Cat','Hangzhou'); + """ + ) + conn.commit() + conn.close() + return path + + +def test_parse_response(): + assert parse_response("xSELECT 1;") == ("action", "SELECT 1") + assert parse_response("\n```sql\nSELECT 2\n```\n") == ("answer", "SELECT 2") + assert parse_response("SELECT 1 then SELECT 3") == ( + "answer", + "SELECT 3", + ) + # tags inside the thinking section are ignored + assert parse_response("SELECT 9") == (None, "") + assert parse_response("SELECT 1") == (None, "") + + +def test_schema_text_skips_internal_tables(db_path): + schema = schema_text(db_path) + assert "CREATE TABLE customers" in schema + assert "sqlite_sequence" not in schema + + +def test_explore_then_answer(db_path): + env = SQLiteSQLEnv(db_path, GOLD, max_obs_rows=2) + + obs = env.step("SELECT name, city FROM customers ORDER BY id") + assert not obs.done and obs.error is None + assert obs.observation.splitlines()[0] == "name | city" + assert "Ann | Hangzhou" in obs.observation and "Cat" not in obs.observation + assert "(3 rows in total)" in obs.observation + + result = env.step("SELECT name FROM customers WHERE city LIKE 'Hang%'") + assert result.done and result.reward == 1.0 + assert env.submitted and env.num_queries == 1 + + +def test_wrong_answer(db_path): + result = SQLiteSQLEnv(db_path, GOLD).step("SELECT name FROM customers") + assert result.done and result.reward == 0.0 + + +@pytest.mark.parametrize( + "sql", + [ + "DELETE FROM customers", + "UPDATE customers SET city = 'Hangzhou'", + "DROP TABLE customers", + "ATTACH DATABASE ':memory:' AS other", + "PRAGMA table_info(customers)", + "SELECT 1; DELETE FROM customers", + ], +) +def test_writes_are_rejected_and_do_not_reach_the_database(db_path, sql): + env = SQLiteSQLEnv(db_path, GOLD) + obs = env.step(f"{sql}") + assert obs.observation.startswith("Error:") and not obs.done + assert env.num_errors == 1 + rows, _, _ = execute_sql(db_path, "SELECT COUNT(*) FROM customers") + assert rows == [(3,)] + + +def test_runaway_query_is_interrupted(db_path): + env = SQLiteSQLEnv(db_path, GOLD, timeout=0.5) + obs = env.step( + "WITH RECURSIVE r(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM r) " + "SELECT COUNT(*) FROM r" + ) + assert obs.observation == "Error: timeout" + + +def test_invalid_format_keeps_episode_open(db_path): + obs = SQLiteSQLEnv(db_path, GOLD).step("I think the answer is Ann.") + assert not obs.done and obs.error == "no action" + + +def test_broken_or_empty_reference(db_path): + broken = SQLiteSQLEnv(db_path, "SELECT nope FROM customers") + assert broken.gold_error is not None + assert broken.step(f"{GOLD}").reward == 0.0 + + empty = SQLiteSQLEnv(db_path, "SELECT name FROM customers WHERE city = 'Beijing'") + assert empty.gold_empty diff --git a/trinity/common/workflows/__init__.py b/trinity/common/workflows/__init__.py index d862218397a..2db48c7bd21 100644 --- a/trinity/common/workflows/__init__.py +++ b/trinity/common/workflows/__init__.py @@ -36,6 +36,7 @@ "sciworld_workflow": "trinity.common.workflows.envs.sciworld.sciworld_workflow.SciWorldWorkflow", "webshop_workflow": "trinity.common.workflows.envs.webshop.webshop_workflow.WebShopWorkflow", "intercode_sql_workflow": "trinity.common.workflows.envs.intercode_sql.intercode_sql_workflow.InterCodeSQLWorkflow", + "sqlite_sql_workflow": "trinity.common.workflows.envs.sqlite_sql.sqlite_sql_workflow.SQLiteSQLWorkflow", # general multi-turn "step_wise_alfworld_workflow": "trinity.common.workflows.envs.alfworld.alfworld_workflow.StepWiseAlfworldWorkflow", # ruler/llm_as_a_judge diff --git a/trinity/common/workflows/envs/sqlite_sql/__init__.py b/trinity/common/workflows/envs/sqlite_sql/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_env.py b/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_env.py new file mode 100644 index 00000000000..e6bb156d54d --- /dev/null +++ b/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_env.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +"""A Docker-free, read-only SQLite environment for multi-turn text-to-SQL (BIRD / Spider layout). + +The agent may run exploratory queries and then submits one final query. The reward is BIRD / Spider +execution accuracy: 1.0 when the submitted query returns the same set of rows as the reference. + +Every rollout opens its own read-only connection behind an SQLite authorizer that only allows reads, +so concurrent rollouts on the same database file cannot write to it, ATTACH other files, run +PRAGMAs or load extensions, and a runaway query is interrupted after ``timeout`` seconds. +""" + +import re +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Tuple + +_ALLOWED_ACTIONS = { + sqlite3.SQLITE_SELECT, + sqlite3.SQLITE_READ, + sqlite3.SQLITE_FUNCTION, + sqlite3.SQLITE_RECURSIVE, +} +_DENIED_FUNCTIONS = {"load_extension", "readfile", "writefile", "edit", "fts3_tokenizer"} +_TAG_RE = re.compile(r"<(action|answer)>(.*?)", re.IGNORECASE | re.DOTALL) +_FENCE_RE = re.compile(r"^```[ \t]*(?:sql|sqlite)?[ \t]*\n?(.*?)```$", re.IGNORECASE | re.DOTALL) +_THINK_RE = re.compile(r".*?", re.DOTALL) + + +def _authorizer(action, arg1, arg2, db_name, trigger): + if action not in _ALLOWED_ACTIONS: + return sqlite3.SQLITE_DENY + if action == sqlite3.SQLITE_FUNCTION and arg2 and arg2.lower() in _DENIED_FUNCTIONS: + return sqlite3.SQLITE_DENY + return sqlite3.SQLITE_OK + + +def db_file(db_root: str, db_id: str) -> Path: + """``//.sqlite``, the BIRD and Spider database layout.""" + return Path(db_root) / db_id / f"{db_id}.sqlite" + + +def execute_sql( + path: Path, sql: str, timeout: float = 30.0, max_rows: int = 100_000 +) -> Tuple[Optional[List[Tuple]], Optional[List[str]], Optional[str]]: + """Run one read-only statement. Returns ``(rows, column_names, None)`` or ``(None, None, error)``.""" + sql = (sql or "").strip().rstrip(";").strip() + if not sql: + return None, None, "empty query" + if not Path(path).is_file(): + return None, None, f"database not found: {path}" + deadline = time.monotonic() + timeout + conn = None + try: + conn = sqlite3.connect( + f"{Path(path).resolve().as_uri()}?mode=ro", uri=True, check_same_thread=False + ) + conn.text_factory = lambda b: b.decode("utf-8", errors="replace") + conn.set_authorizer(_authorizer) + conn.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, 1000) + cursor = conn.execute(sql) + rows = cursor.fetchmany(max_rows + 1) + if len(rows) > max_rows: + return None, None, f"result exceeds {max_rows} rows" + columns = [d[0] for d in cursor.description or []] + return [tuple(r) for r in rows], columns, None + except sqlite3.OperationalError as e: + return None, None, "timeout" if "interrupted" in str(e) else f"OperationalError: {e}" + except Exception as e: # sqlite3.Warning (several statements), DatabaseError, ... + return None, None, f"{type(e).__name__}: {e}" + finally: + if conn is not None: + conn.close() + + +def schema_text(path: Path) -> str: + """The CREATE TABLE statements of every user table.""" + rows, _, error = execute_sql(path, "SELECT name, sql FROM sqlite_master WHERE type = 'table'") + if error: + raise ValueError(f"cannot read schema of {path}: {error}") + return "\n\n".join(sql for name, sql in rows if sql and name != "sqlite_sequence") + + +def parse_response(text: str) -> Tuple[Optional[str], str]: + """Return ``(kind, sql)`` for the last ```` or ```` tag outside ````.""" + matches = _TAG_RE.findall(_THINK_RE.sub("", text or "")) + if not matches: + return None, "" + kind, body = matches[-1] + body = body.strip() + fenced = _FENCE_RE.match(body) + if fenced: + body = fenced.group(1) + return kind.lower(), body.strip().rstrip(";").strip() + + +def format_result(rows: List[Tuple], columns: List[str], max_rows: int) -> str: + shown = rows[:max_rows] + lines = [" | ".join(columns)] + [" | ".join(str(v) for v in row) for row in shown] + more = f"\n... ({len(rows)} rows in total)" if len(rows) > max_rows else f"\n({len(rows)} rows)" + return "\n".join(lines) + more + + +@dataclass +class StepResult: + observation: str + done: bool + reward: float + error: Optional[str] = None + + +class SQLiteSQLEnv: + """One episode on one database. ``step`` takes a model response and returns a StepResult.""" + + def __init__( + self, + db_path: Path, + gold_sql: str, + timeout: float = 30.0, + max_obs_rows: int = 10, + max_rows: int = 100_000, + ): + self.db_path = Path(db_path) + self.timeout = timeout + self.max_obs_rows = max_obs_rows + self.max_rows = max_rows + self.gold_rows, _, self.gold_error = execute_sql(self.db_path, gold_sql, timeout, max_rows) + self.num_queries = 0 + self.num_errors = 0 + self.submitted = False + + @property + def gold_empty(self) -> bool: + return self.gold_error is None and not self.gold_rows + + def score(self, sql: str) -> Tuple[float, Optional[str]]: + rows, _, error = execute_sql(self.db_path, sql, self.timeout, self.max_rows) + if error or self.gold_error: + return 0.0, error + return float(set(rows) == set(self.gold_rows)), None + + def step(self, response_text: str) -> StepResult: + kind, sql = parse_response(response_text) + if kind is None: + return StepResult( + "Invalid format. Reply with one SQL to run a query, " + "or SQL to submit your final query.", + False, + 0.0, + "no action", + ) + if kind == "answer": + self.submitted = True + reward, error = self.score(sql) + return StepResult("Submitted.", True, reward, error) + self.num_queries += 1 + rows, columns, error = execute_sql(self.db_path, sql, self.timeout, self.max_rows) + if error: + self.num_errors += 1 + return StepResult(f"Error: {error}", False, 0.0, error) + return StepResult(format_result(rows, columns, self.max_obs_rows), False, 0.0) diff --git a/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py b/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py new file mode 100644 index 00000000000..3ee35950fc9 --- /dev/null +++ b/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +"""Multi-turn text-to-SQL workflow on local SQLite databases (BIRD / Spider), no Docker needed.""" + +from typing import List, Optional + +from trinity.common.experience import Experience +from trinity.common.models.model import ModelWrapper +from trinity.common.workflows.envs.sqlite_sql.sqlite_sql_env import ( + SQLiteSQLEnv, + db_file, + parse_response, + schema_text, +) +from trinity.common.workflows.workflow import MultiTurnWorkflow, Task + +SQLITE_SQL_SYSTEM_PROMPT = """ +You are a data analyst answering questions about a SQLite database. + +At each turn, think briefly and then reply with exactly one of: +- SQL to run a read-only SQLite query and see up to {max_obs_rows} rows of its result +- SQL to submit the final query that answers the question + +You have at most {max_env_steps} turns. Only the submitted query is graded: it is correct when it +returns the same rows as the reference answer. +""" + +USER_PROMPT = """Database schema: +{schema} + +Question: {question} +{evidence}""" + + +class SQLiteSQLWorkflow(MultiTurnWorkflow): + """Agent explores a SQLite database with read-only queries, then submits one query. + + Task fields (BIRD format): ``db_id``, ``question``, ``SQL`` (reference query), optional + ``evidence``. ``workflow_args``: ``db_root`` (required, ``//.sqlite``), + ``max_env_steps`` (default 5), ``max_obs_rows`` (default 10), ``timeout`` (seconds, default 30), + ``auto_submit`` (default False: grade the last successful query if the agent never submits). + """ + + is_async: bool = True + + def __init__( + self, + model: ModelWrapper, + task: Task, + auxiliary_models: Optional[List] = None, + ): + super().__init__(model=model, task=task, auxiliary_models=auxiliary_models) + args = task.workflow_args + self.raw_task = task.raw_task or {} + if not args.get("db_root"): + raise ValueError("SQLiteSQLWorkflow requires `workflow_args.db_root`.") + self.db_root = args["db_root"] + self.max_env_steps = int(args.get("max_env_steps", 5)) + self.max_obs_rows = int(args.get("max_obs_rows", 10)) + self.timeout = float(args.get("timeout", 30)) + self.auto_submit = bool(args.get("auto_submit", False)) + + def build_messages(self, db_path) -> List[dict]: + evidence = self.raw_task.get("evidence") + system = SQLITE_SQL_SYSTEM_PROMPT.format( + max_obs_rows=self.max_obs_rows, max_env_steps=self.max_env_steps + ).strip() + user = USER_PROMPT.format( + schema=schema_text(db_path), + question=self.raw_task["question"], + evidence=f"External knowledge: {evidence}" if evidence else "", + ).strip() + return [{"role": "system", "content": system}, {"role": "user", "content": user}] + + async def run_async(self) -> List[Experience]: + db_path = db_file(self.db_root, self.raw_task["db_id"]) + env = SQLiteSQLEnv( + db_path, self.raw_task["SQL"], timeout=self.timeout, max_obs_rows=self.max_obs_rows + ) + if env.gold_error: + self.logger.warning(f"Reference SQL fails on {db_path}: {env.gold_error}") + memory = self.build_messages(db_path) + reward, rounds, last_ok_sql = 0.0, 0, None + + for rounds in range(1, self.max_env_steps + 1): + responses = await self.model.chat_async(memory, n=1) + text = responses[0].response_text + memory.append({"role": "assistant", "content": text}) + result = env.step(text) + if result.done: + reward = result.reward + break + if result.error is None: + last_ok_sql = parse_response(text)[1] + memory.append({"role": "user", "content": result.observation}) + else: + if self.auto_submit and last_ok_sql: + reward, _ = env.score(last_ok_sql) + + info = { + "env_rounds": rounds, + "submitted": int(env.submitted), + "num_queries": env.num_queries, + "num_query_errors": env.num_errors, + "gold_error": int(env.gold_error is not None), + "gold_empty": int(env.gold_empty), + "final_reward": reward, + } + experience = await self.process_messages_to_experience_async(memory, reward, info) + return [experience] From c65b6b6a0114c97ff88b4c4d08ccf3213fd6a680 Mon Sep 17 00:00:00 2001 From: flyingdoubleG <53119724+flyingdoubleG@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:21:38 +0800 Subject: [PATCH 2/2] fix(sqlite_sql): pass the task's sampling args to the model The workflow called chat_async with n=1 only, so per-task rollout_args such as the eval taskset's temperature 0.0 in the example config were ignored. Forward asdict(task.rollout_args) like the frozen_lake workflow does, keeping n=1 since each turn needs a single response. Co-Authored-By: Claude Opus 5.5 --- .../common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py b/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py index 3ee35950fc9..0154b9f43af 100644 --- a/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py +++ b/trinity/common/workflows/envs/sqlite_sql/sqlite_sql_workflow.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Multi-turn text-to-SQL workflow on local SQLite databases (BIRD / Spider), no Docker needed.""" +from dataclasses import asdict from typing import List, Optional from trinity.common.experience import Experience @@ -80,9 +81,12 @@ async def run_async(self) -> List[Experience]: self.logger.warning(f"Reference SQL fails on {db_path}: {env.gold_error}") memory = self.build_messages(db_path) reward, rounds, last_ok_sql = 0.0, 0, None + # Honour the task's sampling settings (e.g. eval temperature); one response per turn. + rollout_args = asdict(self.task.rollout_args) + rollout_args["n"] = 1 for rounds in range(1, self.max_env_steps + 1): - responses = await self.model.chat_async(memory, n=1) + responses = await self.model.chat_async(memory, **rollout_args) text = responses[0].response_text memory.append({"role": "assistant", "content": text}) result = env.step(text)