Auto-resume Leo paid research in Telegram

This commit is contained in:
twentyOne2x 2026-06-30 20:05:28 +02:00
parent 06a8b547ab
commit 85e1f78b97
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. unless the hosted Leo HTTP route returns retained payment/readback evidence.
Ordinary addressed/private chat may be routed into smart research when the Ordinary addressed/private chat may be routed into smart research when the
request clearly asks for sourced, current, market, or evidence-backed work. request clearly asks for sourced, current, market, or evidence-backed work.
Explicit /smart_research remains available for narrow canaries. Paid smart Explicit /smart_research remains available for narrow canaries. Quote-first
research remains fail-closed unless the server-side allow flag, allowed chat paid research should auto-resume in the same Telegram chat after the settled
id, cap, and retained approval-ref file are all present. 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, Do not request or expose private keys, bot tokens, wallet exports, seed phrases,
or raw secret values. or raw secret values.

View file

@ -51,8 +51,10 @@ voice_definition: |
payment/readback evidence. payment/readback evidence.
When addressed or used in private chat, clear requests for fresh sourced 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 should go through Leo's hosted research route. First return a paid
research quote and checkout link. Only resume paid execution after a research quote and checkout link. After payment, continue automatically in
work_order_id or payment receipt is present. 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 ───────────────────────────────────────────────────────────
learnings_file: agents/leo/learnings.md learnings_file: agents/leo/learnings.md

View file

@ -55,6 +55,7 @@ from http_chat_proxy import (
DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES, DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES,
build_chat_proxy_payload, build_chat_proxy_payload,
build_smart_research_proxy_payload, build_smart_research_proxy_payload,
classify_smart_research_auto_resume_response,
extract_auto_smart_research_followup_goal, extract_auto_smart_research_followup_goal,
extract_auto_smart_research_goal, extract_auto_smart_research_goal,
extract_paid_work_order_id, extract_paid_work_order_id,
@ -101,6 +102,7 @@ AGENT_RESPOND_TO_PRIVATE_CHATS = False
AGENT_MENTION_ALIASES = ["@teleo", "@FutAIrdBot"] AGENT_MENTION_ALIASES = ["@teleo", "@FutAIrdBot"]
AGENT_SMART_RESEARCH_COMMAND_PREFIXES = list(DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES) AGENT_SMART_RESEARCH_COMMAND_PREFIXES = list(DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES)
AGENT_AUTO_SMART_RESEARCH_FROM_CHAT = False AGENT_AUTO_SMART_RESEARCH_FROM_CHAT = False
smart_research_auto_resume_tasks: set[tuple[int, int]] = set()
# Rate limits # Rate limits
MAX_RESPONSE_PER_USER_PER_HOUR = 30 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( async def _market_context_for_message(
text: str, text: str,
extra_terms: list[str] | tuple[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) 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: if user:
username = user.username or "anonymous" username = user.username or "anonymous"
key = (msg.chat_id, user.id) key = (msg.chat_id, user.id)

View file

@ -197,6 +197,35 @@ def smart_research_payment_fields_for_message(
return {"allow_paid_execution": False} 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( def build_smart_research_proxy_payload(
*, *,
research_goal: str, 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 from http_chat_proxy import ( # noqa: E402
build_chat_proxy_payload, build_chat_proxy_payload,
build_smart_research_proxy_payload, build_smart_research_proxy_payload,
classify_smart_research_auto_resume_response,
extract_auto_smart_research_followup_goal, extract_auto_smart_research_followup_goal,
extract_auto_smart_research_goal, extract_auto_smart_research_goal,
extract_paid_work_order_id, extract_paid_work_order_id,
@ -169,6 +170,42 @@ def test_smart_research_payment_fields_resume_uses_capped_gate():
assert payment_fields == configured_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( @pytest.mark.parametrize(
("message", "expected"), ("message", "expected"),
[ [