ghostdrive1 commited on
Commit
47f2fa7
·
1 Parent(s): d59842f

fix: wire real tavily_search into task_dispatcher, replace stub

Browse files
Files changed (1) hide show
  1. packages/brain/task_dispatcher.py +29 -84
packages/brain/task_dispatcher.py CHANGED
@@ -7,7 +7,7 @@ Replaces V3's single-shot LLM dispatch with a full ReAct-loop-backed orchestrato
7
 
8
  Responsibilities:
9
  1. Classify incoming task (search / code / browser / file / general / done)
10
- 2. Hydrate ToolRegistry with concrete stub tools (search, code_exec, browser_fetch, file_read)
11
  3. Pull channel AgentState from Redis (persist back after loop finishes)
12
  4. Call multi-provider key_rotation pool for LLM access (ALL 5 providers)
13
  5. Run ReActLoop (flash_mode=True for Groq)
@@ -17,11 +17,11 @@ Responsibilities:
17
  Informed by:
18
  - react_loop.py (this repo) : ReActLoop, ToolRegistry, AgentState, ActionResult
19
  - llm_router.py (this repo) : make_provider_llm_fn, multi-provider routing
 
20
  - browser-use/browser-use : dispatch pattern, no-vision Groq rule
21
  - OpenHands/codeact_agent : pending_actions, function_calling dispatch
22
  - SAGAR-TAMANG/friday-tony-stark : system-level tool dispatch (web.py, system.py pattern)
23
  - dexterai.org architecture : domain-specific action routing (intent → specialized handler)
24
- - manus.im / GenSpark / MiniMax : multi-step orchestration, streaming UX, tool-call chaining
25
 
26
  Design rules:
27
  - flash_mode=True default (Groq 8k ctx safe)
@@ -30,8 +30,7 @@ Design rules:
30
  - Response to Discord: NEVER leak === MEMORY GRAPH === or [COMPACTED HISTORY] blocks
31
  - Memory snippets: append to Redis list ultron:mem_buffer:{user_id} (flushed to Zilliz by memory worker)
32
  - max_iterations=5 default (hard ceiling from react_loop ABSOLUTE_MAX=10)
33
- - Tool stubs: all 4 tools are real async functions with TODO internals
34
- (search → Tavily free tier, code_exec → subprocess sandbox, browser_fetch → httpx, file_read → Redis CDN)
35
 
36
  Future bug risks (pre-registered):
37
  D1 [HIGH] If Redis is unavailable, AgentState load silently returns fresh state →
@@ -39,7 +38,6 @@ Future bug risks (pre-registered):
39
  D2 [HIGH] Groq key_rotation pool returns None (all keys exhausted) → llm_call_fn
40
  gets called with None key → provider raises 401 → consecutive_failures max hit
41
  → loop aborts with no user-facing error message. Need explicit AllKeysExhausted guard.
42
- (mitigated by make_provider_llm_fn internal guard, but pool.py must raise correctly)
43
  D3 [MED] task_type classifier uses keyword match → ambiguous tasks ("read the latest news"
44
  could be search OR browser_fetch) → wrong tool called first → wasted iteration.
45
  Fix: add a lightweight Groq classify call before loop (1 token, no tools).
@@ -51,9 +49,11 @@ Future bug risks (pre-registered):
51
  if user sends a message starting with "=== ". Add start-of-line anchor.
52
 
53
  Tool calls used this session:
54
- Github:get_file_contents x2 (task_dispatcher.py, llm_router.py),
55
- Github:create_or_update_file x1,
 
56
  Notion:notion-fetch x1
 
57
  """
58
 
59
  from __future__ import annotations
@@ -63,10 +63,9 @@ import json
63
  import logging
64
  import re
65
  import time
66
- from dataclasses import asdict
67
  from typing import Any, Optional
68
 
69
- import httpx # browser_fetch + Tavily search stub
70
 
71
  from packages.brain.react_loop import (
72
  ActionResult,
@@ -76,6 +75,7 @@ from packages.brain.react_loop import (
76
  ToolRegistry,
77
  )
78
  from packages.brain.llm_router import make_provider_llm_fn # V4 multi-provider router
 
79
 
80
  logger = logging.getLogger(__name__)
81
 
@@ -118,84 +118,49 @@ _STRIP_PATTERNS = [
118
 
119
 
120
  def strip_internal_blocks(text: str) -> str:
121
- """Remove all internal orchestration markers from text before sending to Discord.
122
-
123
- Bug D6: start-of-line anchor (^) in patterns prevents stripping valid user content.
124
- """
125
  for pat in _STRIP_PATTERNS:
126
  text = pat.sub("", text)
127
  return text.strip()
128
 
129
 
130
  # ---------------------------------------------------------------------------
131
- # Tool stubs real async functions (internals TODO, interface LOCKED)
132
  # ---------------------------------------------------------------------------
133
 
134
- async def _tool_search(params: dict) -> ActionResult:
135
- """Web search via Tavily free-tier API.
136
-
137
- params: {query: str, max_results: int = 5}
138
- TODO: inject TAVILY_API_KEY from config. Currently returns stub result.
139
- Groq tool_use pattern (friday/tools/web.py style): call API → extract snippets → return.
140
- """
141
- query = params.get("query", "")
142
- if not query:
143
- return ActionResult(success=False, error="search: query param missing")
144
-
145
- logger.info(f"[search stub] query='{query}'")
146
- return ActionResult(
147
- extracted_content=f"[SEARCH STUB] Results for: {query} — wire Tavily key to activate.",
148
- long_term_memory=f"search:{query}",
149
- )
150
-
151
-
152
  async def _tool_code_exec(params: dict) -> ActionResult:
153
- """Execute sandboxed Python code via subprocess.
154
-
155
- params: {code: str, timeout: int = 10}
156
- """
157
  code = params.get("code", "")
158
- timeout = min(int(params.get("timeout", 10)), 30)
159
  if not code:
160
  return ActionResult(success=False, error="code_exec: code param missing")
161
-
162
- logger.info(f"[code_exec stub] code length={len(code)}")
163
- return ActionResult(
164
- extracted_content=f"[CODE_EXEC STUB] Would run: {code[:200]}...",
165
- )
166
 
167
 
168
  async def _tool_browser_fetch(params: dict) -> ActionResult:
169
- """Fetch a URL and return text content (no vision, DOM text only for Groq token budget)."""
170
  url = params.get("url", "")
171
  if not url or not url.startswith(("http://", "https://")):
172
  return ActionResult(success=False, error="browser_fetch: invalid or missing url")
173
-
174
  try:
175
  async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
176
  resp = await client.get(url, headers={"User-Agent": "UltronBot/1.0"})
177
  resp.raise_for_status()
178
  text = re.sub(r"<[^>]+>", " ", resp.text)
179
  text = re.sub(r"\s+", " ", text).strip()[:3000]
180
- return ActionResult(
181
- extracted_content=text,
182
- long_term_memory=f"fetched:{url}",
183
- )
184
  except Exception as exc:
185
  logger.error(f"[browser_fetch] {url}: {exc}")
186
  return ActionResult(success=False, error=str(exc)[:300])
187
 
188
 
189
  async def _tool_file_read(params: dict) -> ActionResult:
190
- """Read a file from Redis CDN buffer."""
191
  file_key = params.get("file_key", "")
192
  if not file_key:
193
  return ActionResult(success=False, error="file_read: file_key param missing")
194
-
195
  logger.info(f"[file_read stub] key='{file_key}'")
196
- return ActionResult(
197
- extracted_content=f"[FILE_READ STUB] Key: {file_key} — wire Redis to activate.",
198
- )
199
 
200
 
201
  # ---------------------------------------------------------------------------
@@ -253,9 +218,13 @@ TOOL_SCHEMAS: dict[str, dict] = {
253
  # ---------------------------------------------------------------------------
254
 
255
  def build_tool_registry() -> ToolRegistry:
256
- """Build and return a ToolRegistry with all V4 stub tools registered."""
 
 
 
 
257
  registry = ToolRegistry()
258
- registry.register("search", _tool_search, TOOL_SCHEMAS["search"])
259
  registry.register("code_exec", _tool_code_exec, TOOL_SCHEMAS["code_exec"])
260
  registry.register("browser_fetch", _tool_browser_fetch, TOOL_SCHEMAS["browser_fetch"])
261
  registry.register("file_read", _tool_file_read, TOOL_SCHEMAS["file_read"])
@@ -263,7 +232,7 @@ def build_tool_registry() -> ToolRegistry:
263
 
264
 
265
  # ---------------------------------------------------------------------------
266
- # Task-type pre-classifier (keyword-based, replace with LLM in Phase 3 — bug D3)
267
  # ---------------------------------------------------------------------------
268
 
269
  def classify_task(message: str) -> str:
@@ -283,7 +252,6 @@ def classify_task(message: str) -> str:
283
  # ---------------------------------------------------------------------------
284
 
285
  async def _load_state(redis_client: Any, channel_id: str) -> Optional[dict]:
286
- """Load AgentState dict from Redis. Returns None if not found or Redis down."""
287
  if redis_client is None:
288
  return None
289
  try:
@@ -297,7 +265,6 @@ async def _load_state(redis_client: Any, channel_id: str) -> Optional[dict]:
297
 
298
 
299
  async def _save_state(redis_client: Any, channel_id: str, state: AgentState) -> None:
300
- """Persist AgentState to Redis. Always set TTL (bug D4)."""
301
  if redis_client is None:
302
  return
303
  try:
@@ -316,7 +283,6 @@ async def _save_state(redis_client: Any, channel_id: str, state: AgentState) ->
316
 
317
 
318
  async def _buffer_memory(redis_client: Any, user_id: str, snippet: str) -> None:
319
- """Append memory snippet to Redis buffer list. Trim to MEM_BUF_MAX (bug D5)."""
320
  if redis_client is None or not snippet:
321
  return
322
  try:
@@ -332,21 +298,7 @@ async def _buffer_memory(redis_client: Any, user_id: str, snippet: str) -> None:
332
  # ---------------------------------------------------------------------------
333
 
334
  class TaskDispatcher:
335
- """Orchestrates task execution for Ultron V4.
336
-
337
- Usage (from discord_bot.py or FastAPI handler)::
338
-
339
- dispatcher = TaskDispatcher(pool=key_pool, redis=redis_client)
340
- response = await dispatcher.dispatch(
341
- message="What's the current Bitcoin price?",
342
- channel_id="1234567890",
343
- user_id="ghost_uid",
344
- )
345
- # response is a clean string, safe to send to Discord
346
-
347
- LLM calls use make_provider_llm_fn(pool) from llm_router.py.
348
- Pool selects from ALL 5 providers: Groq, Cerebras, Together, OpenRouter, Gemini.
349
- """
350
 
351
  def __init__(
352
  self,
@@ -383,9 +335,6 @@ class TaskDispatcher:
383
  )
384
 
385
  registry = build_tool_registry()
386
-
387
- # KEY CHANGE: use make_provider_llm_fn from llm_router (all 5 providers)
388
- # Previously was _make_groq_llm_fn — Groq-only, wasted 4/5 of pool quota
389
  llm_call_fn = await make_provider_llm_fn(self.pool)
390
 
391
  loop = ReActLoop(
@@ -416,14 +365,12 @@ class TaskDispatcher:
416
  else "",
417
  )
418
  await _save_state(self.redis, channel_id, _state)
419
-
420
  if final_result and final_result.long_term_memory:
421
  await _buffer_memory(self.redis, user_id, final_result.long_term_memory)
422
 
423
  if final_result is None or (not final_result.success and final_result.error):
424
- response = (
425
- f"Sorry, I ran into an issue completing that task."
426
- + (f" ({final_result.error[:100]})" if final_result else "")
427
  )
428
  elif final_result.extracted_content:
429
  response = final_result.extracted_content
@@ -431,7 +378,6 @@ class TaskDispatcher:
431
  response = "Task completed, but no output was produced."
432
 
433
  response = strip_internal_blocks(response)
434
-
435
  if len(response) > 1800:
436
  response = response[:1797] + "..."
437
 
@@ -439,14 +385,13 @@ class TaskDispatcher:
439
 
440
 
441
  # ---------------------------------------------------------------------------
442
- # Module-level singleton factory (optional convenience for main.py)
443
  # ---------------------------------------------------------------------------
444
 
445
  _dispatcher_instance: Optional[TaskDispatcher] = None
446
 
447
 
448
  def get_dispatcher(pool: Any = None, redis: Any = None) -> TaskDispatcher:
449
- """Return or create the global TaskDispatcher singleton."""
450
  global _dispatcher_instance
451
  if _dispatcher_instance is None:
452
  _dispatcher_instance = TaskDispatcher(pool=pool, redis=redis)
 
7
 
8
  Responsibilities:
9
  1. Classify incoming task (search / code / browser / file / general / done)
10
+ 2. Hydrate ToolRegistry with concrete tools (search=Tavily, code_exec, browser_fetch, file_read)
11
  3. Pull channel AgentState from Redis (persist back after loop finishes)
12
  4. Call multi-provider key_rotation pool for LLM access (ALL 5 providers)
13
  5. Run ReActLoop (flash_mode=True for Groq)
 
17
  Informed by:
18
  - react_loop.py (this repo) : ReActLoop, ToolRegistry, AgentState, ActionResult
19
  - llm_router.py (this repo) : make_provider_llm_fn, multi-provider routing
20
+ - packages/tools/search.py (this repo): tavily_search — real Tavily + DDG fallback
21
  - browser-use/browser-use : dispatch pattern, no-vision Groq rule
22
  - OpenHands/codeact_agent : pending_actions, function_calling dispatch
23
  - SAGAR-TAMANG/friday-tony-stark : system-level tool dispatch (web.py, system.py pattern)
24
  - dexterai.org architecture : domain-specific action routing (intent → specialized handler)
 
25
 
26
  Design rules:
27
  - flash_mode=True default (Groq 8k ctx safe)
 
30
  - Response to Discord: NEVER leak === MEMORY GRAPH === or [COMPACTED HISTORY] blocks
31
  - Memory snippets: append to Redis list ultron:mem_buffer:{user_id} (flushed to Zilliz by memory worker)
32
  - max_iterations=5 default (hard ceiling from react_loop ABSOLUTE_MAX=10)
33
+ - search tool: REAL Tavily implementation (packages/tools/search.py), DDG fallback
 
34
 
35
  Future bug risks (pre-registered):
36
  D1 [HIGH] If Redis is unavailable, AgentState load silently returns fresh state →
 
38
  D2 [HIGH] Groq key_rotation pool returns None (all keys exhausted) → llm_call_fn
39
  gets called with None key → provider raises 401 → consecutive_failures max hit
40
  → loop aborts with no user-facing error message. Need explicit AllKeysExhausted guard.
 
41
  D3 [MED] task_type classifier uses keyword match → ambiguous tasks ("read the latest news"
42
  could be search OR browser_fetch) → wrong tool called first → wasted iteration.
43
  Fix: add a lightweight Groq classify call before loop (1 token, no tools).
 
49
  if user sends a message starting with "=== ". Add start-of-line anchor.
50
 
51
  Tool calls used this session:
52
+ Github:get_file_contents x3 (task_dispatcher.py, llm_router.py, v3 bot)
53
+ Github:create_or_update_file x1
54
+ Github:push_files x2
55
  Notion:notion-fetch x1
56
+ Notion:notion-update-page x1
57
  """
58
 
59
  from __future__ import annotations
 
63
  import logging
64
  import re
65
  import time
 
66
  from typing import Any, Optional
67
 
68
+ import httpx # browser_fetch
69
 
70
  from packages.brain.react_loop import (
71
  ActionResult,
 
75
  ToolRegistry,
76
  )
77
  from packages.brain.llm_router import make_provider_llm_fn # V4 multi-provider router
78
+ from packages.tools.search import tavily_search # REAL search (replaces stub)
79
 
80
  logger = logging.getLogger(__name__)
81
 
 
118
 
119
 
120
  def strip_internal_blocks(text: str) -> str:
121
+ """Remove all internal orchestration markers from text before sending to Discord."""
 
 
 
122
  for pat in _STRIP_PATTERNS:
123
  text = pat.sub("", text)
124
  return text.strip()
125
 
126
 
127
  # ---------------------------------------------------------------------------
128
+ # Toolssearch is REAL (Tavily), others still stub
129
  # ---------------------------------------------------------------------------
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  async def _tool_code_exec(params: dict) -> ActionResult:
132
+ """Execute sandboxed Python code via subprocess. STUB — Phase 7."""
 
 
 
133
  code = params.get("code", "")
 
134
  if not code:
135
  return ActionResult(success=False, error="code_exec: code param missing")
136
+ logger.info(f"[code_exec stub] len={len(code)}")
137
+ return ActionResult(extracted_content=f"[CODE_EXEC STUB] Would run: {code[:200]}...")
 
 
 
138
 
139
 
140
  async def _tool_browser_fetch(params: dict) -> ActionResult:
141
+ """Fetch URL text content (DOM text, no screenshots Groq token budget)."""
142
  url = params.get("url", "")
143
  if not url or not url.startswith(("http://", "https://")):
144
  return ActionResult(success=False, error="browser_fetch: invalid or missing url")
 
145
  try:
146
  async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
147
  resp = await client.get(url, headers={"User-Agent": "UltronBot/1.0"})
148
  resp.raise_for_status()
149
  text = re.sub(r"<[^>]+>", " ", resp.text)
150
  text = re.sub(r"\s+", " ", text).strip()[:3000]
151
+ return ActionResult(extracted_content=text, long_term_memory=f"fetched:{url}")
 
 
 
152
  except Exception as exc:
153
  logger.error(f"[browser_fetch] {url}: {exc}")
154
  return ActionResult(success=False, error=str(exc)[:300])
155
 
156
 
157
  async def _tool_file_read(params: dict) -> ActionResult:
158
+ """Read uploaded file from Redis CDN buffer. STUB — Phase 7."""
159
  file_key = params.get("file_key", "")
160
  if not file_key:
161
  return ActionResult(success=False, error="file_read: file_key param missing")
 
162
  logger.info(f"[file_read stub] key='{file_key}'")
163
+ return ActionResult(extracted_content=f"[FILE_READ STUB] Key: {file_key} — wire Redis CDN.")
 
 
164
 
165
 
166
  # ---------------------------------------------------------------------------
 
218
  # ---------------------------------------------------------------------------
219
 
220
  def build_tool_registry() -> ToolRegistry:
221
+ """Build and return a ToolRegistry with all V4 tools registered.
222
+
223
+ search: REAL (Tavily + DDG fallback via packages/tools/search.py)
224
+ code_exec, browser_fetch, file_read: stubs until Phase 7
225
+ """
226
  registry = ToolRegistry()
227
+ registry.register("search", tavily_search, TOOL_SCHEMAS["search"]) # REAL
228
  registry.register("code_exec", _tool_code_exec, TOOL_SCHEMAS["code_exec"])
229
  registry.register("browser_fetch", _tool_browser_fetch, TOOL_SCHEMAS["browser_fetch"])
230
  registry.register("file_read", _tool_file_read, TOOL_SCHEMAS["file_read"])
 
232
 
233
 
234
  # ---------------------------------------------------------------------------
235
+ # Task-type pre-classifier
236
  # ---------------------------------------------------------------------------
237
 
238
  def classify_task(message: str) -> str:
 
252
  # ---------------------------------------------------------------------------
253
 
254
  async def _load_state(redis_client: Any, channel_id: str) -> Optional[dict]:
 
255
  if redis_client is None:
256
  return None
257
  try:
 
265
 
266
 
267
  async def _save_state(redis_client: Any, channel_id: str, state: AgentState) -> None:
 
268
  if redis_client is None:
269
  return
270
  try:
 
283
 
284
 
285
  async def _buffer_memory(redis_client: Any, user_id: str, snippet: str) -> None:
 
286
  if redis_client is None or not snippet:
287
  return
288
  try:
 
298
  # ---------------------------------------------------------------------------
299
 
300
  class TaskDispatcher:
301
+ """Orchestrates task execution for Ultron V4."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
 
303
  def __init__(
304
  self,
 
335
  )
336
 
337
  registry = build_tool_registry()
 
 
 
338
  llm_call_fn = await make_provider_llm_fn(self.pool)
339
 
340
  loop = ReActLoop(
 
365
  else "",
366
  )
367
  await _save_state(self.redis, channel_id, _state)
 
368
  if final_result and final_result.long_term_memory:
369
  await _buffer_memory(self.redis, user_id, final_result.long_term_memory)
370
 
371
  if final_result is None or (not final_result.success and final_result.error):
372
+ response = "Sorry, I ran into an issue completing that task." + (
373
+ f" ({final_result.error[:100]})" if final_result else ""
 
374
  )
375
  elif final_result.extracted_content:
376
  response = final_result.extracted_content
 
378
  response = "Task completed, but no output was produced."
379
 
380
  response = strip_internal_blocks(response)
 
381
  if len(response) > 1800:
382
  response = response[:1797] + "..."
383
 
 
385
 
386
 
387
  # ---------------------------------------------------------------------------
388
+ # Singleton factory
389
  # ---------------------------------------------------------------------------
390
 
391
  _dispatcher_instance: Optional[TaskDispatcher] = None
392
 
393
 
394
  def get_dispatcher(pool: Any = None, redis: Any = None) -> TaskDispatcher:
 
395
  global _dispatcher_instance
396
  if _dispatcher_instance is None:
397
  _dispatcher_instance = TaskDispatcher(pool=pool, redis=redis)