@openhands/extensions 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,8 @@ trigger phrase is detected it:
9
9
  3. Posts a reply in the Slack thread with a link to the conversation.
10
10
 
11
11
  On subsequent runs:
12
- - New replies in a tracked thread are forwarded to the running conversation.
12
+ - New replies in a tracked thread are forwarded only when they contain the
13
+ trigger phrase.
13
14
  - When the conversation reaches a terminal/idle state the agent's final
14
15
  response (or an error notice) is posted back to the Slack thread.
15
16
 
@@ -85,6 +86,18 @@ CONTEXT_MESSAGE_LIMIT = 15
85
86
  # How far back (seconds) to look for context when creating a new conversation.
86
87
  CONTEXT_LOOKBACK_SECONDS = 3600 # 1 hour of recent messages for context
87
88
 
89
+ # Keep completed conversations available for triggered follow-up replies for a
90
+ # short inactivity window. Polling conversations.replies is intentionally throttled:
91
+ # bot-token Slack apps may have a very small per-method quota, so each thread
92
+ # carries its own exponential backoff and each automation run polls at most one
93
+ # due thread. Start hot immediately after each bot reply, then back off
94
+ # on quiet threads.
95
+ THREAD_FOLLOWUP_WATCH_SECONDS = 300
96
+ THREAD_REPLY_INITIAL_BACKOFF_SECONDS = 5
97
+ THREAD_REPLY_MAX_BACKOFF_SECONDS = 300
98
+ THREAD_REPLY_BACKOFF_MULTIPLIER = 2
99
+ MAX_THREAD_REPLY_POLLS_PER_RUN = 1
100
+
88
101
 
89
102
  # ── Stdlib helpers ─────────────────────────────────────────────────────────────
90
103
 
@@ -108,6 +121,55 @@ def get_secret(name: str) -> str:
108
121
  return r.read().decode().strip()
109
122
 
110
123
 
124
+ def _is_loopback_openhands_url(url: str) -> bool:
125
+ return url.startswith((
126
+ "http://localhost",
127
+ "https://localhost",
128
+ "http://127.0.0.1",
129
+ "https://127.0.0.1",
130
+ ))
131
+
132
+
133
+ def _ngrok_public_openhands_url() -> str | None:
134
+ try:
135
+ with urllib.request.urlopen(
136
+ "http://127.0.0.1:4040/api/tunnels", timeout=2
137
+ ) as response:
138
+ tunnels = json.loads(response.read()).get("tunnels", [])
139
+ except Exception as exc:
140
+ print(f"Could not discover ngrok public URL: {exc}")
141
+ return None
142
+
143
+ for tunnel in tunnels:
144
+ public_url = str(tunnel.get("public_url", "")).rstrip("/")
145
+ addr = str(tunnel.get("config", {}).get("addr", ""))
146
+ if (
147
+ public_url.startswith("https://")
148
+ and ("localhost:8000" in addr or "127.0.0.1:8000" in addr)
149
+ ):
150
+ return public_url
151
+
152
+ for tunnel in tunnels:
153
+ public_url = str(tunnel.get("public_url", "")).rstrip("/")
154
+ if public_url.startswith("https://"):
155
+ return public_url
156
+
157
+ return None
158
+
159
+
160
+ def resolve_openhands_url() -> str:
161
+ try:
162
+ configured = get_secret("OPENHANDS_URL").rstrip()
163
+ except Exception:
164
+ configured = ""
165
+
166
+ configured = (configured or DEFAULT_OPENHANDS_URL).rstrip("/")
167
+ if not _is_loopback_openhands_url(configured):
168
+ return configured
169
+
170
+ return _ngrok_public_openhands_url() or configured
171
+
172
+
111
173
  def fire_callback(
112
174
  status: str = "COMPLETED",
113
175
  error: str | None = None,
@@ -195,8 +257,14 @@ def _slack_call(
195
257
  }
196
258
  data = json.dumps(body).encode() if body is not None else None
197
259
  req = urllib.request.Request(url, data=data, headers=headers, method=method)
198
- with urllib.request.urlopen(req) as r:
199
- result = json.loads(r.read())
260
+ try:
261
+ with urllib.request.urlopen(req) as r:
262
+ result = json.loads(r.read())
263
+ except urllib.error.HTTPError as exc:
264
+ if exc.code == 429:
265
+ retry_after = exc.headers.get("Retry-After", "60")
266
+ raise RuntimeError(f"Slack {endpoint}: rate_limited retry_after={retry_after}") from exc
267
+ raise
200
268
  if not result.get("ok"):
201
269
  raise RuntimeError(f"Slack {endpoint}: {result.get('error', 'unknown_error')}")
202
270
  return result
@@ -273,6 +341,36 @@ def thread_replies(token: str, channel: str, thread_ts: str, oldest: str) -> lis
273
341
  return [m for m in messages if m.get("ts") != thread_ts]
274
342
 
275
343
 
344
+ def _trigger_index(text: str) -> int:
345
+ """Return the start index of an exact trigger phrase match, or -1."""
346
+ lowered = text.lower()
347
+ trigger = TRIGGER_PHRASE.lower()
348
+ start = 0
349
+ while True:
350
+ idx = lowered.find(trigger, start)
351
+ if idx < 0:
352
+ return -1
353
+ before = lowered[idx - 1] if idx > 0 else ""
354
+ after_idx = idx + len(trigger)
355
+ after = lowered[after_idx] if after_idx < len(lowered) else ""
356
+ before_ok = not before or not (before.isalnum() or before in "_-")
357
+ after_ok = not after or not (after.isalnum() or after in "_-")
358
+ if before_ok and after_ok:
359
+ return idx
360
+ start = idx + 1
361
+
362
+
363
+ def _has_trigger(text: str) -> bool:
364
+ return _trigger_index(text) >= 0
365
+
366
+
367
+ def _request_after_trigger(text: str) -> str:
368
+ idx = _trigger_index(text)
369
+ if idx < 0:
370
+ return text
371
+ return text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
372
+
373
+
276
374
  def full_thread_history(
277
375
  token: str, channel: str, thread_ts: str,
278
376
  bot_user_id: str, bot_message_ts: list[str],
@@ -567,14 +665,99 @@ def _gather_channel_context(
567
665
  return context_lines
568
666
 
569
667
 
668
+ def _expire_inactive_thread_watches(active_convs: dict[str, dict], now: float) -> None:
669
+ for conv_key, rec in active_convs.items():
670
+ if rec.get("status") != "watching":
671
+ continue
672
+ watch_until = float(rec.get("watch_until") or 0)
673
+ if watch_until and now >= watch_until:
674
+ rec["status"] = "closed"
675
+ rec["closed_reason"] = "followup_watch_expired"
676
+ rec["closed_at"] = now
677
+ print(f" Follow-up watch expired for {conv_key}")
678
+
679
+
680
+ def _poll_due_thread_replies(
681
+ slack_token: str,
682
+ active_convs: dict[str, dict],
683
+ bot_user_id: str,
684
+ bot_message_ts: list[str],
685
+ ) -> list[tuple[str, dict]]:
686
+ now = time.time()
687
+ _expire_inactive_thread_watches(active_convs, now)
688
+ due: list[tuple[float, str, dict]] = []
689
+ for conv_key, rec in active_convs.items():
690
+ if rec.get("status") not in {"active", "watching"}:
691
+ continue
692
+ next_poll = float(rec.get("next_reply_poll_at") or 0)
693
+ if next_poll <= now:
694
+ due.append((next_poll, conv_key, rec))
695
+
696
+ reply_messages: list[tuple[str, dict]] = []
697
+ for _next_poll, conv_key, rec in sorted(due)[:MAX_THREAD_REPLY_POLLS_PER_RUN]:
698
+ cid = rec["channel_id"]
699
+ thread_ts = rec["thread_ts"]
700
+ oldest = rec.get("last_seen_reply_ts") or thread_ts
701
+ try:
702
+ replies = thread_replies(slack_token, cid, thread_ts, oldest)
703
+ except Exception as exc:
704
+ message = str(exc)
705
+ retry_after = THREAD_REPLY_MAX_BACKOFF_SECONDS
706
+ marker = "retry_after="
707
+ if marker in message:
708
+ raw = message.split(marker, 1)[1].split()[0]
709
+ try:
710
+ retry_after = max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, int(raw))
711
+ except ValueError:
712
+ pass
713
+ rec["next_reply_poll_at"] = now + retry_after
714
+ rec["reply_poll_backoff_seconds"] = min(
715
+ THREAD_REPLY_MAX_BACKOFF_SECONDS,
716
+ max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
717
+ )
718
+ print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
719
+ continue
720
+
721
+ if replies:
722
+ rec["last_seen_reply_ts"] = max(r.get("ts", oldest) for r in replies)
723
+
724
+ human_replies = [r for r in replies if _is_human_message(r, bot_user_id, bot_message_ts)]
725
+ triggered_replies = [
726
+ r for r in human_replies
727
+ if _has_trigger(r.get("text", "") or "")
728
+ ]
729
+ if triggered_replies:
730
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
731
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
732
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
733
+ for r in triggered_replies:
734
+ reply_messages.append((cid, r))
735
+ print(f" {conv_key}: {len(triggered_replies)} triggered follow-up reply/replies")
736
+ else:
737
+ if human_replies:
738
+ print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
739
+ current = int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)
740
+ next_backoff = min(
741
+ THREAD_REPLY_MAX_BACKOFF_SECONDS,
742
+ max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, current * THREAD_REPLY_BACKOFF_MULTIPLIER),
743
+ )
744
+ rec["reply_poll_backoff_seconds"] = next_backoff
745
+ rec["next_reply_poll_at"] = now + next_backoff
746
+ print(f" {conv_key}: no follow-ups; next reply poll in {next_backoff}s")
747
+
748
+ return reply_messages
749
+
750
+
570
751
  def _poll_new_messages(
571
752
  slack_token: str,
572
753
  use_search: bool,
573
754
  oldest_by_channel: dict[str, str],
574
755
  global_oldest: str,
575
756
  active_convs: dict[str, dict],
757
+ bot_user_id: str,
758
+ bot_message_ts: list[str],
576
759
  ) -> list[tuple[str, dict]]:
577
- """Collect and sort new top-level messages and thread replies from Slack."""
760
+ """Collect and sort new top-level messages and due thread replies from Slack."""
578
761
  new_messages: list[tuple[str, dict]] = []
579
762
 
580
763
  if use_search:
@@ -602,19 +785,9 @@ def _poll_new_messages(
602
785
  except Exception as exc:
603
786
  print(f" Warning: could not fetch history for {cid}: {exc}")
604
787
 
605
- reply_messages: list[tuple[str, dict]] = []
606
- for _conv_key, rec in active_convs.items():
607
- if rec.get("status") == "closed":
608
- continue
609
- cid = rec["channel_id"]
610
- thread_ts = rec["thread_ts"]
611
- oldest = oldest_by_channel.get(cid, global_oldest)
612
- try:
613
- replies = thread_replies(slack_token, cid, thread_ts, oldest)
614
- for r in replies:
615
- reply_messages.append((cid, r))
616
- except Exception as exc:
617
- print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
788
+ reply_messages = _poll_due_thread_replies(
789
+ slack_token, active_convs, bot_user_id, bot_message_ts
790
+ )
618
791
 
619
792
  return sorted(
620
793
  new_messages + reply_messages,
@@ -669,10 +842,7 @@ def _process_trigger_message(
669
842
  print(f" Warning: could not fetch thread history: {exc}")
670
843
 
671
844
  # Extract the user's request: the text that follows the trigger phrase.
672
- request_part = text
673
- idx = text.lower().find(TRIGGER_PHRASE.lower())
674
- if idx >= 0:
675
- request_part = text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
845
+ request_part = _request_after_trigger(text)
676
846
 
677
847
  initial_prompt = (
678
848
  f"You are an AI assistant responding to a Slack message.\n\n"
@@ -697,12 +867,17 @@ def _process_trigger_message(
697
867
  conv_id = create_conversation(agent_url, api_key, initial_prompt)
698
868
  conv_url = f"{openhands_url}/conversations/{conv_id}"
699
869
 
870
+ now = time.time()
700
871
  active_convs[conv_key] = {
701
872
  "conversation_id": conv_id,
702
873
  "channel_id": channel_id,
703
874
  "thread_ts": thread_root,
704
875
  "status": "active",
705
- "last_activity": time.time(),
876
+ "last_activity": now,
877
+ "last_seen_reply_ts": msg_ts,
878
+ "reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
879
+ "next_reply_poll_at": now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
880
+ "watch_until": now + THREAD_FOLLOWUP_WATCH_SECONDS,
706
881
  }
707
882
 
708
883
  link_text = f"🤖 On it! View progress here: {conv_url}"
@@ -760,8 +935,16 @@ def _check_conversation_completion(
760
935
  if ts_back:
761
936
  bot_message_ts.append(ts_back)
762
937
 
763
- rec["status"] = "closed"
764
- print(f" Posted summary for {conv_key}")
938
+ now = time.time()
939
+ rec["status"] = "watching"
940
+ rec["last_activity"] = now
941
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
942
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
943
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
944
+ print(
945
+ f" Posted summary for {conv_key}; watching for follow-ups "
946
+ f"until {rec['watch_until']:.0f}"
947
+ )
765
948
 
766
949
 
767
950
  # ── Main ───────────────────────────────────────────────────────────────────────
@@ -776,10 +959,7 @@ def main() -> str | None:
776
959
 
777
960
  slack_token, token_is_user = _resolve_slack_token()
778
961
 
779
- try:
780
- openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
781
- except Exception:
782
- openhands_url = DEFAULT_OPENHANDS_URL
962
+ openhands_url = resolve_openhands_url()
783
963
 
784
964
  # Raises RuntimeError immediately if the token is invalid - no point polling.
785
965
  bot_user_id_new, scopes = _slack_auth_test(slack_token)
@@ -808,7 +988,8 @@ def main() -> str | None:
808
988
  active_convs: dict[str, dict] = state.get("conversations", {})
809
989
 
810
990
  all_incoming = _poll_new_messages(
811
- slack_token, use_search, oldest_by_channel, global_oldest, active_convs
991
+ slack_token, use_search, oldest_by_channel, global_oldest,
992
+ active_convs, bot_user_id, bot_message_ts,
812
993
  )
813
994
 
814
995
  print(f" all_incoming: {len(all_incoming)} message(s), "
@@ -848,14 +1029,16 @@ def main() -> str | None:
848
1029
  thread_root: str = thread_ts if thread_ts and thread_ts != msg_ts else msg_ts
849
1030
  conv_key = f"{channel_id}:{thread_root}"
850
1031
 
851
- has_trigger = TRIGGER_PHRASE.lower() in text.lower()
1032
+ has_trigger = _has_trigger(text)
852
1033
  is_thread_reply = (
853
1034
  thread_ts is not None
854
1035
  and thread_ts != msg_ts
855
1036
  )
1037
+ tracked_rec = active_convs.get(conv_key)
856
1038
  is_reply_in_tracked = (
857
1039
  is_thread_reply
858
- and conv_key in active_convs
1040
+ and tracked_rec is not None
1041
+ and tracked_rec.get("status") in {"active", "watching"}
859
1042
  )
860
1043
 
861
1044
  print(f" EVAL: ts={msg_ts} trigger={has_trigger} reply={is_thread_reply} "
@@ -863,20 +1046,39 @@ def main() -> str | None:
863
1046
  f"text={text[:60]!r}")
864
1047
 
865
1048
  # ── Case A: reply in a thread that has a tracked conversation ──────────
866
- # Route to the existing conversation regardless of its status (active or
867
- # closed). If the conversation was closed, re-activate it so the agent
868
- # processes the new message and the completion check fires again later.
1049
+ # Route to the existing conversation while it is active or in its
1050
+ # follow-up watch window, but only when the reply includes the trigger
1051
+ # phrase. Once the watch expires, a new trigger in the thread creates a
1052
+ # fresh conversation instead.
869
1053
  if is_reply_in_tracked:
870
1054
  rec = active_convs[conv_key]
871
- print(f" → Case A: Forwarding reply {msg_ts} → conversation {rec['conversation_id']}")
1055
+ rec["last_seen_reply_ts"] = max(rec.get("last_seen_reply_ts", msg_ts), msg_ts)
1056
+ if not has_trigger:
1057
+ processed_ts.add(msg_ts)
1058
+ print(f" → Case A ignored (tracked reply without trigger): {msg_ts}")
1059
+ continue
1060
+
1061
+ request_part = _request_after_trigger(text)
1062
+ print(f" → Case A: Forwarding triggered reply {msg_ts} → conversation {rec['conversation_id']}")
872
1063
  try:
873
- send_to_conversation(agent_url, api_key, rec["conversation_id"],
874
- f"User replied in Slack thread: {text}")
1064
+ send_to_conversation(
1065
+ agent_url, api_key, rec["conversation_id"],
1066
+ (
1067
+ f"User <@{msg.get('user', '?')}> replied in Slack thread.\n"
1068
+ f"The reply was activated by the trigger phrase: `{TRIGGER_PHRASE}`\n"
1069
+ f"Full reply: {text}\n"
1070
+ f"User request: {request_part or '(no explicit request — use your best judgement)'}"
1071
+ ),
1072
+ )
1073
+ now = time.time()
875
1074
  rec["status"] = "active"
876
- rec["last_activity"] = time.time()
1075
+ rec["last_activity"] = now
1076
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
1077
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
1078
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
877
1079
  except Exception as exc:
878
1080
  print(f" Warning: failed to forward reply: {exc}")
879
- if has_trigger and can_react:
1081
+ if can_react:
880
1082
  add_reaction(slack_token, channel_id, msg_ts)
881
1083
  processed_ts.add(msg_ts)
882
1084
  continue
@@ -922,7 +1124,7 @@ def main() -> str | None:
922
1124
  print(f" last_poll set to {effective_last_poll}")
923
1125
 
924
1126
  for conv_key, rec in list(active_convs.items()):
925
- if rec.get("status") != "closed":
1127
+ if rec.get("status") == "active":
926
1128
  _check_conversation_completion(
927
1129
  conv_key, rec, agent_url, api_key, slack_token, bot_message_ts,
928
1130
  )