diff --git a/agent/agency-report b/agent/agency-report index a9dde55..5ede784 100755 --- a/agent/agency-report +++ b/agent/agency-report @@ -28,7 +28,7 @@ Custom buttons: pass --button (repeatable). Layout wraps in pairs of two. Canonical card layout (HTML parse mode). Order is locked; the *number* of expandable blocks is variable (0, 1, 2, N — your call): - [optional image] + [optional image — skip when text alone is clearer] headline ← write the specific action here: "Reply to on Slack: …" or "Merge PR #347" — *not* "Agency 95" @@ -52,6 +52,17 @@ Blocks are specified one of two ways: three variants A/B/C, or a single "context" block, or zero blocks (--info-only with just headline + source link). +Yes-tap routing: + • Default — Yes / Edit dispatch in the SAME thread the card lives in. + Follow-up cards inside an existing worker topic don't spawn nested + topics; the conversation stays continuous. + • --spawn-topic — Yes / Edit create a fresh forum topic and dispatch + the lane there instead. Use for the /agency loop's half-hourly cards + (so each cycle's cards live in their own threads) and for any other + case where the work warrants a dedicated topic. Agents inside an + existing worker topic may also set this to fork sub-tasks into yet + another topic — there is no auto-protection against that. + If --image (URL), --image-file (local path), or --image-text (auto-rendered placeholder) is given, the image renders above the body. For bodies that fit Telegram's 1024-char caption budget the card is sent via sendPhoto; @@ -531,6 +542,16 @@ def main() -> int: action="store_true", help="Drop the inline keyboard entirely (FYI cards with no action).", ) + p.add_argument( + "--spawn-topic", + action="store_true", + help="On Yes/Edit tap, spawn a fresh forum topic and dispatch " + "the lane there. Default: run in-place in the current thread. " + "Set this on agency-loop cards (so each cycle's cards live in " + "their own topics) and any other case where the work warrants " + "a dedicated thread. Agents inside an existing worker topic " + "may also set this to fork sub-tasks into yet another topic.", + ) p.add_argument( "--thread-id", type=int, @@ -573,6 +594,7 @@ def main() -> int: buttons=button_labels_for_db, chat_id=chat_id(), thread_id=args.thread_id, + spawn_topic=args.spawn_topic, ) body = _build_body(args) diff --git a/agent/agency_db.py b/agent/agency_db.py index 3dccada..e7eaee9 100644 --- a/agent/agency_db.py +++ b/agent/agency_db.py @@ -54,7 +54,7 @@ def init_schema(db: sqlite3.Connection) -> None: title TEXT NOT NULL, description TEXT NOT NULL, importance TEXT CHECK (importance IN ('high','med','low')) DEFAULT 'med', - source TEXT, -- e.g. slack-c-minerva, gmail-thread-19df, gh-pr-78 + source TEXT, -- e.g. slack-c-foo, gmail-thread-19df, gh-pr-78 prompt TEXT, -- the action that would run if user says yes buttons_json TEXT, -- JSON list of the labels shown tg_chat_id INTEGER, @@ -69,6 +69,7 @@ def init_schema(db: sqlite3.Connection) -> None: worker_topic_id INTEGER, -- TG topic where the resulting agent runs 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 created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) ); @@ -79,6 +80,15 @@ 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 + # 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 db.commit() @@ -97,13 +107,14 @@ def insert( buttons: list[str] | None = None, chat_id: int | None = None, thread_id: int | None = None, + spawn_topic: bool = False, ) -> int: cur = db.execute( """ INSERT INTO suggestions ( title, description, importance, source, prompt, buttons_json, - tg_chat_id, tg_thread_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + tg_chat_id, tg_thread_id, spawn_topic + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( title, @@ -114,6 +125,7 @@ def insert( json.dumps(buttons) if buttons is not None else None, chat_id, thread_id, + 1 if spawn_topic else 0, ), ) db.commit() diff --git a/agent/telegram_bot.py b/agent/telegram_bot.py index c03a179..b71e337 100644 --- a/agent/telegram_bot.py +++ b/agent/telegram_bot.py @@ -5914,25 +5914,6 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: msg_id = msg.get("message_id") - # Universal 🔥 reaction on any agency-button tap so the user - # can tell at a glance which cards have been answered, even - # after the keyboard is stripped. Fires for every kind - # (action / dismiss / refine / custom). setMessageReaction - # replaces prior reactions, so tapping again on a custom - # multi-button card keeps the fire in place rather than - # accumulating. - if msg_id: - try: - self.call( - "setMessageReaction", - chat_id=chat_id, - message_id=msg_id, - reaction=[{"type": "emoji", "emoji": "🔥"}], - is_big=False, - ) - except Exception: - LOG.exception("agency setMessageReaction failed") - # Lookup the suggestion row (for title + prompt) and record the # decision. Best-effort: a missing row (button posted out-of-band, # e.g. the legacy tg-buttons helper) means action/refine fall back @@ -5948,20 +5929,19 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: LOG.exception("agency_db record_decision failed") if kind == "custom" or not sugg_row: - self._agency_mark_picked(chat_id, msg_id, idx, kbd) + # Custom buttons stack — additive ✓ on the tapped one, + # leave others intact so the user can fire multiple in + # sequence (e.g. "Send draft A" then "also Send draft B"). + self._agency_mark_picked(chat_id, msg_id, idx, kbd, reset_others=False) self._agency_dispatch_custom(chat_id, target_thread, label, sender) return - # action / dismiss / refine — final decisions, strip keyboard. - try: - self.call( - "editMessageReplyMarkup", - chat_id=chat_id, - message_id=msg_id, - reply_markup={"inline_keyboard": []}, - ) - except Exception: - LOG.exception("agency keyboard strip failed") + # Default kinds (action / dismiss / refine): keep the keyboard + # visible so the user can re-tap to change their mind. Reset + # any prior ✓ so only the latest choice is highlighted, then + # mark the tapped button. No reaction needed — the ✓ on the + # button itself is the at-a-glance "what I picked" signal. + self._agency_mark_picked(chat_id, msg_id, idx, kbd, reset_others=True) if kind == "dismiss": try: @@ -5983,31 +5963,44 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: LOG.exception("agency_db set_status(dismissed) failed") return - # action / refine — spawn a fresh forum topic. + # action / refine — spawn a fresh forum topic OR run in-place, + # depending on what the posting agent asked for via the suggestion's + # spawn_topic flag (set by `agency-report --spawn-topic`). + # Default (spawn_topic=0): run in the same thread the card lives in. + # That keeps follow-up cards inside an existing worker topic from + # spawning more nested topics. The /agency loop should set + # --spawn-topic so each cycle's cards live in their own threads. + # No auto-protection: if an agent inside an existing worker topic + # explicitly sets --spawn-topic, we honor it and fork another topic. topic_title = (sugg_row.get("title") or label)[:128] + spawn = bool(sugg_row.get("spawn_topic")) new_thread_id = 0 - try: - res = self.call("createForumTopic", chat_id=chat_id, name=topic_title) - if res.get("ok"): - new_thread_id = int(res["result"].get("message_thread_id") or 0) - except Exception: - LOG.exception("createForumTopic failed") + if spawn: + try: + res = self.call("createForumTopic", chat_id=chat_id, name=topic_title) + if res.get("ok"): + new_thread_id = int(res["result"].get("message_thread_id") or 0) + except Exception: + LOG.exception("createForumTopic failed") + if not new_thread_id: + # Fallback to in-place dispatch so the user isn't stuck. + LOG.warning( + "agency: createForumTopic returned no thread; falling back to in-place" + ) + spawn = False - if not new_thread_id: - # Fallback to legacy same-topic dispatch so the user isn't stuck. - LOG.warning("agency: createForumTopic returned no thread; falling back to custom dispatch") - self._agency_dispatch_custom(chat_id, target_thread, label, sender) - return + # work_thread = where the lane actually runs. + work_thread = new_thread_id if spawn else target_thread try: - agency_db.set_worker_topic(db, sugg_row["id"], new_thread_id) + agency_db.set_worker_topic(db, sugg_row["id"], work_thread) except Exception: LOG.exception("agency_db set_worker_topic failed") if kind == "action": action_prompt = sugg_row.get("prompt") or label # Post the original prompt as the first visible message in the - # new topic so the user can see what the agent is being asked + # work thread so the user can see what the agent is being asked # to do. Otherwise run_task dispatches it as an internal lane # input and only the agent's *response* surfaces in TG, which # leaves the user guessing what the agent is working on. @@ -6015,7 +6008,7 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: self.call( "sendMessage", chat_id=chat_id, - message_thread_id=new_thread_id, + message_thread_id=work_thread or None, text=( "📋 Running this on your behalf\n" f"
{_html.escape(action_prompt, quote=False)}
" @@ -6026,7 +6019,7 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: LOG.exception("agency action prompt-display failed") try: self.run_task( - (chat_id, new_thread_id), + (chat_id, work_thread), action_prompt, reply_to=None, sender={ @@ -6042,7 +6035,7 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: 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-topic file. The lane handler reads the file on the + # 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. ctx_text = _agency_build_refine_context(sugg_row) @@ -6050,21 +6043,21 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: self.call( "sendMessage", chat_id=chat_id, - message_thread_id=new_thread_id, + message_thread_id=work_thread or None, text=ctx_text, parse_mode="HTML", ) except Exception: LOG.exception("agency refine context-display failed") try: - _agency_write_refine_context(new_thread_id, sugg_row) + _agency_write_refine_context(work_thread, sugg_row) except Exception: LOG.exception("agency refine context-persist failed") try: self.call( "sendMessage", chat_id=chat_id, - message_thread_id=new_thread_id, + message_thread_id=work_thread or None, text=( "👇 What would you change? Reply here with whatever's " "missing, off, or worth a different angle — I'll re-draft " @@ -6074,67 +6067,76 @@ def _handle_agency_callback(self, cb: dict, data: str) -> None: except Exception: LOG.exception("agency refine prompt post failed") - # URL-button reply in the original topic linking to the new one. - chat_str = str(chat_id).removeprefix("-100") - new_topic_url = f"https://t.me/c/{chat_str}/{new_thread_id}" - verb = "working" if kind == "action" else "refining" - try: - self.call( - "sendMessage", - chat_id=chat_id, - message_thread_id=target_thread or None, - text=f"→ {verb} in {_html.escape(topic_title[:80])}", - parse_mode="HTML", - reply_parameters={ - "message_id": msg_id, - "allow_sending_without_reply": True, - }, - reply_markup={ - "inline_keyboard": [[ - {"text": "🧵 Open thread", "url": new_topic_url} - ]] - }, - ) - except Exception: - LOG.exception("agency new-topic deeplink reply failed") + # If we spawned a fresh topic, post a URL-button reply in the + # original topic linking to the new one. For in-place runs the + # work is happening right here, so no deep-link is needed. + if spawn and new_thread_id: + chat_str = str(chat_id).removeprefix("-100") + new_topic_url = f"https://t.me/c/{chat_str}/{new_thread_id}" + verb = "working" if kind == "action" else "refining" + try: + self.call( + "sendMessage", + chat_id=chat_id, + message_thread_id=target_thread or None, + text=f"→ {verb} in {_html.escape(topic_title[:80])}", + parse_mode="HTML", + reply_parameters={ + "message_id": msg_id, + "allow_sending_without_reply": True, + }, + reply_markup={ + "inline_keyboard": [[ + {"text": "🧵 Open thread", "url": new_topic_url} + ]] + }, + ) + except Exception: + LOG.exception("agency new-topic deeplink reply failed") def _agency_mark_picked( - self, chat_id: int, msg_id: int | None, idx: int, kbd: list + self, + chat_id: int, + msg_id: int | None, + idx: int, + kbd: list, + reset_others: bool = False, ) -> None: - """Custom-button behavior: mark the tapped button with a "✓ " - prefix, leave the rest tappable so the user can stack actions - (e.g. tap multiple "Send draft X" buttons in sequence).""" + """Mark the tapped button with a "✓ " prefix, keep all buttons + tappable so the user can re-tap (default kinds) or stack actions + (custom buttons). + + reset_others=False — additive. Leaves prior ✓ marks intact so + multi-tap on custom buttons accumulates ("Send A" + "Send B"). + reset_others=True — exclusive. Strips ✓ from every other button + before marking the new one, so only the latest pick is + highlighted. Use for the default Yes/Skip/Edit set where + the user is changing their mind, not stacking. + """ picked_prefix = "✓ " try: - if idx >= 0 and kbd: - new_kbd: list[list[dict]] = [] - flat_i = -1 - marked = False - for row in kbd: - new_row: list[dict] = [] - for btn in row: - flat_i += 1 - text = btn.get("text") or "" - if flat_i == idx and not text.startswith(picked_prefix): - new_row.append({**btn, "text": picked_prefix + text}) - marked = True - else: - new_row.append(btn) - new_kbd.append(new_row) - if marked: - self.call( - "editMessageReplyMarkup", - chat_id=chat_id, - message_id=msg_id, - reply_markup={"inline_keyboard": new_kbd}, - ) - elif kbd: - self.call( - "editMessageReplyMarkup", - chat_id=chat_id, - message_id=msg_id, - reply_markup={"inline_keyboard": []}, - ) + if idx < 0 or not kbd: + return + new_kbd: list[list[dict]] = [] + flat_i = -1 + for row in kbd: + new_row: list[dict] = [] + for btn in row: + flat_i += 1 + text = btn.get("text") or "" + if flat_i == idx: + if not text.startswith(picked_prefix): + text = picked_prefix + text + elif reset_others and text.startswith(picked_prefix): + text = text[len(picked_prefix):] + new_row.append({**btn, "text": text}) + new_kbd.append(new_row) + self.call( + "editMessageReplyMarkup", + chat_id=chat_id, + message_id=msg_id, + reply_markup={"inline_keyboard": new_kbd}, + ) except Exception: LOG.exception("agency keyboard mark failed")