Merge pull request #25 from living-ip/codex/leo-telegram-auto-resume-20260630
Some checks are pending
CI / lint-and-test (push) Waiting to run

Auto-resume Leo paid research in Telegram
This commit is contained in:
twentyOne2x 2026-06-30 20:07:31 +02:00 committed by GitHub
commit 29429d6385
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 236 additions and 5 deletions

View file

@ -43,9 +43,11 @@ voice_definition: |
unless the hosted Leo HTTP route returns retained payment/readback evidence.
Ordinary addressed/private chat may be routed into smart research when the
request clearly asks for sourced, current, market, or evidence-backed work.
Explicit /smart_research remains available for narrow canaries. Paid smart
research remains fail-closed unless the server-side allow flag, allowed chat
id, cap, and retained approval-ref file are all present.
Explicit /smart_research remains available for narrow canaries. Quote-first
paid research should auto-resume in the same Telegram chat after the settled
work order is visible; pasted work_order_id values are fallback/debug only.
Paid smart research remains fail-closed unless the server-side allow flag,
allowed chat id, cap, and retained approval-ref file are all present.
Do not request or expose private keys, bot tokens, wallet exports, seed phrases,
or raw secret values.

View file

@ -51,8 +51,10 @@ voice_definition: |
payment/readback evidence.
When addressed or used in private chat, clear requests for fresh sourced
research should go through Leo's hosted research route. First return a paid
research quote and checkout link. Only resume paid execution after a
work_order_id or payment receipt is present.
research quote and checkout link. After payment, continue automatically in
the same Telegram chat when the settled work order is visible. Treat a
work_order_id or payment receipt pasted by the user as a fallback/debug path,
not the normal UX.
# ─── Learnings ───────────────────────────────────────────────────────────
learnings_file: agents/leo/learnings.md

View file

@ -55,6 +55,7 @@ from http_chat_proxy import (
DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES,
build_chat_proxy_payload,
build_smart_research_proxy_payload,
classify_smart_research_auto_resume_response,
extract_auto_smart_research_followup_goal,
extract_auto_smart_research_goal,
extract_paid_work_order_id,
@ -101,6 +102,7 @@ AGENT_RESPOND_TO_PRIVATE_CHATS = False
AGENT_MENTION_ALIASES = ["@teleo", "@FutAIrdBot"]
AGENT_SMART_RESEARCH_COMMAND_PREFIXES = list(DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES)
AGENT_AUTO_SMART_RESEARCH_FROM_CHAT = False
smart_research_auto_resume_tasks: set[tuple[int, int]] = set()
# Rate limits
MAX_RESPONSE_PER_USER_PER_HOUR = 30
@ -748,6 +750,145 @@ def _smart_research_payment_gate(chat_id: int) -> dict:
}
def _smart_research_auto_resume_enabled() -> bool:
return os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME", "1") != "0"
def _smart_research_auto_resume_attempts() -> int:
try:
attempts = int(os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME_ATTEMPTS", "24"))
except ValueError:
return 24
return min(max(attempts, 1), 120)
def _smart_research_auto_resume_interval_seconds() -> float:
try:
interval = float(os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME_INTERVAL_SECONDS", "10"))
except ValueError:
return 10.0
return min(max(interval, 2.0), 120.0)
def _should_start_smart_research_auto_resume_poll(
*,
paid_work_order_id: str | None,
http_status: int,
proxy_body: dict,
) -> bool:
if paid_work_order_id or not _smart_research_auto_resume_enabled():
return False
if not isinstance(proxy_body, dict):
return False
if proxy_body.get("status") != "payment_authorization_required":
return False
checkout = proxy_body.get("checkout")
return isinstance(checkout, dict) and bool(checkout.get("checkoutUrl") or checkout.get("checkout_url"))
async def _poll_smart_research_auto_resume(
msg,
context: ContextTypes.DEFAULT_TYPE,
*,
research_goal: str,
username: str | None,
) -> None:
"""Poll Leo's chat-bound research route until the x402 work order is visible."""
if not AGENT_HTTP_RESEARCH_PROXY_URL:
return
key = (msg.chat_id, msg.message_id)
if key in smart_research_auto_resume_tasks:
return
smart_research_auto_resume_tasks.add(key)
attempts = _smart_research_auto_resume_attempts()
interval = _smart_research_auto_resume_interval_seconds()
try:
for attempt in range(attempts):
await asyncio.sleep(interval)
payload = build_smart_research_proxy_payload(
research_goal=research_goal,
source="telegram",
agent=AGENT_NAME.lower(),
chat_id=msg.chat_id,
message_id=msg.message_id,
username=username,
include_synthesis=True,
)
try:
status, proxy_body, proxy_reply = await post_chat_proxy(
url=AGENT_HTTP_RESEARCH_PROXY_URL,
payload=payload,
timeout_seconds=90,
)
except Exception as exc:
logger.info(
"%s smart research auto-resume poll %d/%d failed: %s",
AGENT_NAME,
attempt + 1,
attempts,
exc,
)
continue
classification = classify_smart_research_auto_resume_response(status, proxy_body)
if classification == "pending":
continue
if classification == "ready" and proxy_reply:
await _reply_text_native(msg, proxy_reply, do_quote=True)
if msg.from_user:
entry = {
"user": research_goal[:500],
"bot": proxy_reply[:500],
"username": username or "anonymous",
}
user_key = (msg.chat_id, msg.from_user.id)
history = conversation_history.setdefault(user_key, [])
history.append(entry)
if len(history) > MAX_HISTORY_USER:
history.pop(0)
chat_key = (msg.chat_id, 0)
chat_history = conversation_history.setdefault(chat_key, [])
chat_history.append(entry)
if len(chat_history) > MAX_HISTORY_CHAT:
chat_history.pop(0)
user_response_times[msg.from_user.id].append(time.time())
_record_transcript(
msg,
proxy_reply,
is_bot=True,
rio_response=proxy_reply,
internal={
"agent": AGENT_NAME.lower(),
"http_research_proxy_auto_resume": True,
"http_status": status,
"schema": proxy_body.get("schema") if isinstance(proxy_body, dict) else None,
},
)
logger.info(
"%s auto-resumed paid smart research in Telegram chat_id=%s message_id=%s",
AGENT_NAME,
msg.chat_id,
msg.message_id,
)
return
logger.info(
"%s smart research auto-resume poll stopped on classification=%s status=%s",
AGENT_NAME,
classification,
status,
)
return
logger.info(
"%s smart research auto-resume poll expired chat_id=%s message_id=%s attempts=%s",
AGENT_NAME,
msg.chat_id,
msg.message_id,
attempts,
)
finally:
smart_research_auto_resume_tasks.discard(key)
async def _market_context_for_message(
text: str,
extra_terms: list[str] | tuple[str, ...] = (),
@ -1279,6 +1420,26 @@ async def handle_tagged(update: Update, context: ContextTypes.DEFAULT_TYPE):
await _reply_text_native(msg, proxy_reply, do_quote=True)
if _should_start_smart_research_auto_resume_poll(
paid_work_order_id=paid_work_order_id,
http_status=status,
proxy_body=proxy_body,
):
context.application.create_task(
_poll_smart_research_auto_resume(
msg,
context,
research_goal=smart_research_goal,
username=user.username if user else None,
)
)
logger.info(
"%s started smart research auto-resume poll chat_id=%s message_id=%s",
AGENT_NAME,
msg.chat_id,
msg.message_id,
)
if user:
username = user.username or "anonymous"
key = (msg.chat_id, user.id)

View file

@ -197,6 +197,35 @@ def smart_research_payment_fields_for_message(
return {"allow_paid_execution": False}
def classify_smart_research_auto_resume_response(
http_status: int,
payload: dict[str, Any] | None,
) -> str:
"""Classify a poll response for Telegram auto-resume.
Returns:
- pending: the payment/work order is not visible yet; keep polling silently.
- ready: the route has a final user-facing reply or a visible paid-work-order blocker.
- ignore: the response is unrelated to an auto-resume poll.
"""
if not isinstance(payload, dict):
return "ignore"
status = str(payload.get("status") or "").strip()
auto_resume = payload.get("autoResume")
paid_work_order = payload.get("paidWorkOrder")
auto_activated = isinstance(auto_resume, dict) and auto_resume.get("activated") is True
work_order_settled = (
isinstance(paid_work_order, dict) and paid_work_order.get("status") == "settled"
)
if status == "payment_authorization_required" and not auto_activated and not work_order_settled:
return "pending"
if auto_activated or work_order_settled:
return "ready"
if http_status < 400 and status and status != "payment_authorization_required":
return "ready"
return "ignore"
def build_smart_research_proxy_payload(
*,
research_goal: str,

View file

@ -13,6 +13,7 @@ from agent_config import load_agent_config # noqa: E402
from http_chat_proxy import ( # noqa: E402
build_chat_proxy_payload,
build_smart_research_proxy_payload,
classify_smart_research_auto_resume_response,
extract_auto_smart_research_followup_goal,
extract_auto_smart_research_goal,
extract_paid_work_order_id,
@ -169,6 +170,42 @@ def test_smart_research_payment_fields_resume_uses_capped_gate():
assert payment_fields == configured_gate
@pytest.mark.parametrize(
("http_status", "payload", "expected"),
[
(
402,
{
"status": "payment_authorization_required",
"autoResume": {"activated": False},
},
"pending",
),
(
200,
{
"status": "source_only_done",
"autoResume": {"activated": True},
"paidWorkOrder": {"status": "settled"},
},
"ready",
),
(
402,
{
"status": "payment_authorization_required",
"autoResume": {"activated": True},
"paidWorkOrder": {"status": "settled"},
},
"ready",
),
(500, {"error": "temporary"}, "ignore"),
],
)
def test_classify_smart_research_auto_resume_response(http_status, payload, expected):
assert classify_smart_research_auto_resume_response(http_status, payload) == expected
@pytest.mark.parametrize(
("message", "expected"),
[