From f6a17196b418f979594a15b129a60bcc91c63733 Mon Sep 17 00:00:00 2001 From: bux Date: Sat, 9 May 2026 01:15:23 +0000 Subject: [PATCH] agency: drop per-thread refine-context file; query the DB instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind=refine flow used to write the original card's title + description + prompt to /var/lib/bux/agency-refine-context/.txt on Edit-tap, then `run_task` would read + delete that file on the user's first reply to seed the worker agent with the original context. Two redundant surfaces — the data was always already in the suggestion row (title, description, prompt). The file was a duplicate-state cache that needed its own ownership/permissions handling on /var/lib/bux. Replaces with a DB lookup: agency_db.pop_refine_context_for_thread(db, thread_id) -> str | None Atomic SELECT + UPDATE: returns the assembled context AND flips a new `refine_context_injected` column from 0 → 1 in one call. So the context is injected exactly once, on the user's first reply in the worker topic. Subsequent replies in the same thread are normal lane runs. Schema migration via ALTER TABLE with duplicate-column swallow, mirroring how spawn_topic was added in #107. Pre-existing rows get refine_context_injected = 0 by default. `run_task` swaps the file pop for the DB call. The kind=refine handler stops calling `_agency_write_refine_context` (the function + its sibling `_agency_pop_refine_context` + the `_AGENCY_REFINE_CTX_DIR` constant + `_agency_build_refine_context_plain` are removed — agency_db owns the plain-text assembly now). The visible HTML context message in the new topic still renders via `_agency_build_refine_context`, just with the new "📎 Context" emoji + title (from #111). Net: one fewer state surface, one fewer ownership/permissions race, the same UX for the user (Edit → context shown → "what would you change?" → user replies → agent re-drafts with original in scope). Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/agency_db.py | 90 +++++++++++++++++++++++++++++++++++++++---- agent/telegram_bot.py | 83 +++++++++++---------------------------- 2 files changed, 104 insertions(+), 69 deletions(-) diff --git a/agent/agency_db.py b/agent/agency_db.py index 960686e..8a87696 100644 --- a/agent/agency_db.py +++ b/agent/agency_db.py @@ -70,6 +70,7 @@ def init_schema(db: sqlite3.Connection) -> None: worker_started_at INTEGER, worker_completed_at INTEGER, spawn_topic INTEGER NOT NULL DEFAULT 0, -- 1 = Yes-tap creates a fresh topic; 0 = run in-place + refine_context_injected INTEGER NOT NULL DEFAULT 0, -- 1 once the worker agent has been seeded with the original card created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) ); @@ -80,15 +81,19 @@ def init_schema(db: sqlite3.Connection) -> None: CREATE INDEX IF NOT EXISTS idx_sugg_worker_topic ON suggestions(worker_topic_id); """ ) - # Backfill spawn_topic on pre-existing tables. ALTER TABLE has no + # Backfill columns on pre-existing tables. ALTER TABLE has no # IF NOT EXISTS — swallow the duplicate-column error from re-runs. - try: - db.execute( - "ALTER TABLE suggestions ADD COLUMN spawn_topic INTEGER NOT NULL DEFAULT 0" - ) - except sqlite3.OperationalError as e: - if "duplicate column" not in str(e).lower(): - raise + for col, ddl in ( + ("spawn_topic", + "ALTER TABLE suggestions ADD COLUMN spawn_topic INTEGER NOT NULL DEFAULT 0"), + ("refine_context_injected", + "ALTER TABLE suggestions ADD COLUMN refine_context_injected INTEGER NOT NULL DEFAULT 0"), + ): + try: + db.execute(ddl) + except sqlite3.OperationalError as e: + if "duplicate column" not in str(e).lower(): + raise db.commit() @@ -292,3 +297,72 @@ def list_recent( "SELECT * FROM suggestions ORDER BY id DESC LIMIT ?", (limit,) ) return [dict(r) for r in cur.fetchall()] + + +def find_by_worker_topic( + db: sqlite3.Connection, thread_id: int | None +) -> dict[str, Any] | None: + """Return the suggestion whose worker_topic_id == thread_id, if any. + Filters out in-place rows (worker_topic_id == tg_thread_id).""" + if not thread_id or thread_id <= 0: + return None + cur = db.execute( + """ + SELECT * FROM suggestions + WHERE worker_topic_id = ? + AND (tg_thread_id IS NULL OR tg_thread_id != worker_topic_id) + ORDER BY id DESC LIMIT 1 + """, + (int(thread_id),), + ) + row = cur.fetchone() + return dict(row) if row is not None else None + + +def pop_refine_context_for_thread( + db: sqlite3.Connection, thread_id: int | None +) -> str | None: + """For Edit (refine) flows: at the user's first reply in the worker + topic, return the original card's context (title + description + + prompt) as a plain-text block, AND atomically mark the suggestion as + `refine_context_injected = 1` so subsequent calls return None. + + Replaces the file-based per-thread context cache the bot used to + write to /var/lib/bux/agency-refine-context/.txt. The DB + already holds the same content; querying it on the user's first + reply is one SELECT + UPDATE and avoids a separate state surface. + + Returns None when: + - thread isn't a worker topic for any suggestion + - the suggestion isn't in 'differently' (Edit-tapped) status + - context already injected on a prior call + """ + if not thread_id or thread_id <= 0: + return None + cur = db.execute( + """ + SELECT id, title, description, prompt + FROM suggestions + WHERE worker_topic_id = ? + AND status = 'differently' + AND refine_context_injected = 0 + ORDER BY id DESC LIMIT 1 + """, + (int(thread_id),), + ) + row = cur.fetchone() + if row is None: + return None + parts: list[str] = [f"Original agency card title:\n{row['title'] or ''}"] + desc = (row["description"] or "").strip() + if desc: + parts.append(f"\nOriginal context:\n{desc}") + prompt = (row["prompt"] or "").strip() + if prompt: + parts.append(f"\nOriginal action prompt:\n{prompt}") + db.execute( + "UPDATE suggestions SET refine_context_injected = 1, updated_at = ? WHERE id = ?", + (_now(), int(row["id"])), + ) + db.commit() + return "\n".join(parts) diff --git a/agent/telegram_bot.py b/agent/telegram_bot.py index 807207e..eabbab9 100644 --- a/agent/telegram_bot.py +++ b/agent/telegram_bot.py @@ -2850,8 +2850,6 @@ def _edit(self, rendered: str) -> None: import html as _html_mod # noqa: E402 -_AGENCY_REFINE_CTX_DIR = Path("/var/lib/bux/agency-refine-context") - # ── Agency-card "picked button" Style A helpers ───────────────────── # Wraps the picked label with arrows and bold-uppercase the letters so @@ -2930,7 +2928,11 @@ def _agency_build_refine_context(sugg_row: dict) -> str: """Build the visible HTML message that shows the user the original card content before they tell us what to change. Mirrors the canonical card layout (headline + expandable blocks) so it's - immediately recognizable.""" + immediately recognizable. + + The plain-text twin used to seed the worker agent on the user's + first reply is built directly from the DB row in + `agency_db.pop_refine_context_for_thread`.""" parts: list[str] = [] title = sugg_row.get("title") or "" parts.append( @@ -2941,58 +2943,17 @@ def _agency_build_refine_context(sugg_row: dict) -> str: prompt = (sugg_row.get("prompt") or "").strip() if description: parts.append( - f"
💭 Why\n" + f"
📎 Context\n" f"{_html_mod.escape(description, quote=False)}
" ) if prompt: parts.append( f"
📝 Original action prompt\n" - f"{_html_mod.escape(prompt, quote=False)}
" + f"{_html_mod.escape(prompt, quote=False)}
" ) return "\n".join(parts) -def _agency_build_refine_context_plain(sugg_row: dict) -> str: - """Build the plain-text context block fed to the agent on the - user's first reply. Same content as the visible HTML version - minus the markup.""" - title = sugg_row.get("title") or "" - description = (sugg_row.get("description") or "").strip() - prompt = (sugg_row.get("prompt") or "").strip() - out: list[str] = [f"Original agency card title:\n{title}"] - if description: - out.append(f"\nOriginal Why / context:\n{description}") - if prompt: - out.append(f"\nOriginal action prompt:\n{prompt}") - return "\n".join(out) - - -def _agency_write_refine_context(thread_id: int, sugg_row: dict) -> None: - """Persist the plain-text context for this thread so run_task can - pick it up on the user's next reply. One-shot: cleared after read.""" - _AGENCY_REFINE_CTX_DIR.mkdir(parents=True, exist_ok=True) - path = _AGENCY_REFINE_CTX_DIR / f"{int(thread_id)}.txt" - path.write_text(_agency_build_refine_context_plain(sugg_row)) - - -def _agency_pop_refine_context(thread_id: int) -> str | None: - """Read + delete the per-thread context file. Returns None if no - pending context for this thread (the common case for any - non-refine lane).""" - if thread_id <= 0: - return None - path = _AGENCY_REFINE_CTX_DIR / f"{int(thread_id)}.txt" - try: - text = path.read_text() - except FileNotFoundError: - return None - try: - path.unlink() - except FileNotFoundError: - pass - return text - - class Bot: def __init__(self, token: str, setup_token: str) -> None: self.token = token @@ -3347,14 +3308,17 @@ def run_task( agent = _agent_for(key, self.state) LOG.info("run_task lane=%s agent=%s", _lane_slug(key), agent) # Refine-context injection: if the user tapped Edit on an - # agency card and this is their first reply in the spawned - # topic, the kind=refine handler dropped a context file at - # /var/lib/bux/agency-refine-context/.txt. Pop it, - # prepend to the user's prompt, and let the agent re-draft - # with the original card in scope. One-shot — popped on first - # use; subsequent turns in the same topic are normal. + # agency card and this is their first reply in the worker + # topic, look up the suggestion via the DB and prepend its + # title + description + prompt to the user's message so the + # worker agent re-drafts with the original in scope. + # `pop_refine_context_for_thread` is atomic — it returns the + # context AND flips refine_context_injected=1 in one call, so + # subsequent turns in the same thread are normal. try: - refine_ctx = _agency_pop_refine_context(thread_id) + import agency_db as _agency_db + db = _agency_db.conn() + refine_ctx = _agency_db.pop_refine_context_for_thread(db, thread_id) if refine_ctx: prompt = ( "You are refining an earlier agency suggestion.\n\n" @@ -6141,10 +6105,11 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: LOG.exception("agency action dispatch failed") elif kind == "refine": # Show the full card context as visible messages so the user - # can see what they're refining, then persist the same context - # to a per-thread file. The lane handler reads the file on the - # user's next reply and prepends it to the agent's prompt so - # the re-draft has the original card in scope. + # can see what they're refining. The plain-text twin used to + # seed the worker agent on the user's first reply is read + # directly from the DB by `agency_db.pop_refine_context_for_thread` + # — no separate per-thread state file needed; the suggestion + # row already has title + description + prompt. ctx_text = _agency_build_refine_context(sugg_row) try: self.call( @@ -6156,10 +6121,6 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: ) except Exception: LOG.exception("agency refine context-display failed") - try: - _agency_write_refine_context(work_thread, sugg_row) - except Exception: - LOG.exception("agency refine context-persist failed") try: self.call( "sendMessage",