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
90 changes: 82 additions & 8 deletions agent/agency_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);
Expand All @@ -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()


Expand Down Expand Up @@ -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/<thread>.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)
83 changes: 22 additions & 61 deletions agent/telegram_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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"<blockquote expandable>💭 <b>Why</b>\n"
f"<blockquote expandable>📎 <b>Context</b>\n"
f"{_html_mod.escape(description, quote=False)}</blockquote>"
)
if prompt:
parts.append(
f"<blockquote expandable>📝 <b>Original action prompt</b>\n"
f"<code>{_html_mod.escape(prompt, quote=False)}</code></blockquote>"
f"{_html_mod.escape(prompt, quote=False)}</blockquote>"
)
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
Expand Down Expand Up @@ -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/<thread>.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"
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down
Loading