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",