File size: 17,606 Bytes
4ef118d
 
 
 
 
 
592cb1d
4ef118d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592cb1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ef118d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592cb1d
 
 
 
 
4ef118d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
"""
Local tool execution helpers (legacy support for non-Agno adapters).
"""

from __future__ import annotations

import ast
import asyncio
import json
import os
import re
from datetime import datetime
from typing import Any

import httpx

from .academic_domains import ACADEMIC_DOMAINS
from .skill_runtime import (
    execute_skill_script as execute_skill_script_runtime,
    install_skill_dependency as install_skill_dependency_runtime,
)
from .tool_registry import (
    AGENT_TOOLS as REGISTRY_AGENT_TOOLS,
)
from .tool_registry import (
    ALL_TOOLS as REGISTRY_ALL_TOOLS,
)
from .tool_registry import (
    GLOBAL_TOOLS as REGISTRY_GLOBAL_TOOLS,
)
from .tool_registry import (
    LOCAL_TOOLS as REGISTRY_LOCAL_TOOLS,
)
from .tool_registry import (
    get_tool_definitions_by_ids as list_tool_definitions_by_ids,
)
from .tool_registry import (
    list_tools as list_tool_registry,
)
from .tool_registry import (
    resolve_tool_name,
)

CUSTOM_TOOLS = REGISTRY_LOCAL_TOOLS
EXTERNAL_SEARCH_TOOL_NAMES = {
    "web_search_using_tavily",
    "web_search_with_tavily",
    "extract_url_content",
    "web_search",
    "search_news",
    "search_arxiv_and_return_articles",
    "search_wikipedia",
}
FIXED_SEARCH_MAX_RESULTS = 5


def _tool_timeout_seconds(default: float = 20.0) -> float:
    raw = os.getenv("QURIO_TOOL_TIMEOUT_SECONDS", str(default))
    try:
        value = float(raw)
        if value <= 0:
            return default
        return value
    except (TypeError, ValueError):
        return default


def _parse_loose_object(value: Any) -> dict[str, Any] | None:
    if isinstance(value, dict):
        return value
    if not isinstance(value, str):
        return None
    text = value.strip()
    if not text:
        return None
    for parser in (json.loads, ast.literal_eval):
        try:
            parsed = parser(text)
            if isinstance(parsed, dict):
                return parsed
        except Exception:
            continue
    return None


def _coerce_agent_memory_args(script_path: str, raw_args: Any) -> list[str] | None:
    normalized_script = str(script_path or "").strip().replace("\\", "/").lower()
    if normalized_script not in {"memory_store.py", "scripts/memory_store.py"}:
        return None

    payload = _parse_loose_object(raw_args)
    if not payload:
        return None

    command = str(
        payload.get("command")
        or payload.get("action")
        or payload.get("operation")
        or ""
    ).strip().lower()

    if command in {"categories", "folders", "list-categories", "list-folders", "inspect"}:
        return ["categories"]

    if command in {"recall", "search", "find", "lookup"}:
        keyword = str(
            payload.get("keyword")
            or payload.get("query")
            or payload.get("text")
            or payload.get("term")
            or ""
        ).strip()
        category = str(payload.get("category") or "").strip()
        if not keyword:
            return None
        args = ["search", "--keyword", keyword]
        if category:
            args.extend(["--category", category])
        return args

    if command in {"list", "ls"}:
        category = str(payload.get("category") or "").strip()
        args = ["list"]
        if category:
            args.extend(["--category", category])
        return args

    if command in {"delete", "remove"}:
        category = str(payload.get("category") or "").strip()
        slug = str(payload.get("slug") or payload.get("name") or "").strip()
        if not category or not slug:
            return None
        return ["delete", "--category", category, "--slug", slug]

    if command in {"save", "remember", "store"}:
        category = str(payload.get("category") or "").strip()
        slug = str(payload.get("slug") or payload.get("name") or "").strip()
        summary = str(payload.get("summary") or "").strip()
        content = str(payload.get("content") or payload.get("text") or "").strip()
        if not category or not slug or not summary or not content:
            return None
        args = [
            "save",
            "--category",
            category,
            "--slug",
            slug,
            "--summary",
            summary,
            "--content",
            content,
        ]
        title = str(payload.get("title") or "").strip()
        status = str(payload.get("status") or "").strip()
        tags = payload.get("tags")
        related = payload.get("related")
        overwrite = bool(payload.get("overwrite"))
        if title:
            args.extend(["--title", title])
        if status:
            args.extend(["--status", status])
        if isinstance(tags, list) and tags:
            args.extend(["--tags", ",".join(str(item).strip() for item in tags if str(item).strip())])
        if isinstance(related, list) and related:
            args.extend(["--related", ",".join(str(item).strip() for item in related if str(item).strip())])
        if overwrite:
            args.append("--overwrite")
        return args

    return None


def list_tools() -> list[dict[str, Any]]:
    return list_tool_registry()


def get_tool_definitions_by_ids(tool_ids: list[str]) -> list[dict[str, Any]]:
    return list_tool_definitions_by_ids(tool_ids)


async def execute_local_tool(
    tool_name: str,
    args: dict[str, Any],
    tool_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
    resolved_name = resolve_tool_name(tool_name)

    match resolved_name:
        case "Tavily_web_search":
            return await _execute_tavily_web_search(args, tool_config)
        case "web_search_using_tavily":
            return await _execute_tavily_web_search(args, tool_config)
        case "web_search_with_tavily":
            return await _execute_tavily_web_search(args, tool_config, search_depth="advanced")
        case "search_arxiv_and_return_articles":
            return await _execute_tavily_academic_search(args, tool_config)
        case "web_search":
            return await _execute_tavily_web_search(args, tool_config)
        case "search_news":
            return await _execute_tavily_web_search(args, tool_config)
        case "search_wikipedia":
            return await _execute_tavily_web_search(args, tool_config)
        case "local_time":
            return await _execute_local_time(args)
        case "summarize_text":
            return await _execute_summarize_text(args)
        case "extract_text":
            return await _execute_extract_text(args)
        case "json_repair":
            return await _execute_json_repair(args)
        case "interactive_form":
            return await _execute_interactive_form(args)
        case "install_skill_dependency":
            return await _execute_install_skill_dependency(args)
        case "execute_skill_script":
            return await _execute_execute_skill_script(args)
        case "webpage_reader":
            return await _execute_webpage_reader(args)
        case "Tavily_academic_search":
            return await _execute_tavily_academic_search(args, tool_config)
        case "extract_url_content":
            return await _execute_url_extract(args)
        case _:
            raise ValueError(f"Unknown local tool: {resolved_name}")


async def _execute_local_time(args: dict[str, Any]) -> dict[str, Any]:
    timezone = args.get("timezone") or "UTC"
    locale = args.get("locale") or "en-US"
    try:
        now = datetime.now()
        return {
            "timezone": timezone,
            "locale": locale,
            "formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
            "iso": now.isoformat(),
        }
    except Exception as exc:
        raise ValueError(f"Time error: {exc}")


def _split_sentences(text: str) -> list[str]:
    sentences = re.split(r"[.!?\u3002\uff01\uff1f]+", text or "")
    return [s.strip() for s in sentences if s.strip()]


async def _execute_summarize_text(args: dict[str, Any]) -> dict[str, Any]:
    text = args.get("text", "")
    max_sentences = args.get("max_sentences", 3)
    max_chars = args.get("max_chars", 600)
    sentences = _split_sentences(text)[:max_sentences]
    summary = " ".join(sentences)
    if len(summary) > max_chars:
        summary = summary[:max_chars].strip()
    return {"summary": summary}


async def _execute_extract_text(args: dict[str, Any]) -> dict[str, Any]:
    text = args.get("text", "")
    query = args.get("query", "").lower()
    max_sentences = args.get("max_sentences", 5)
    sentences = _split_sentences(text)
    matches = [s for s in sentences if query in s.lower()] if query else sentences
    return {"extracted": matches[:max_sentences]}


async def _execute_json_repair(args: dict[str, Any]) -> dict[str, Any]:
    text = args.get("text", "")
    try:
        data = json.loads(text)
        return {"valid": True, "repaired": text, "data": data}
    except json.JSONDecodeError:
        try:
            repaired = text.strip()
            repaired = re.sub(r",\s*}", "}", repaired)
            repaired = re.sub(r",\s*]", "]", repaired)
            data = json.loads(repaired)
            return {"valid": False, "repaired": repaired, "data": data}
        except Exception as exc:
            return {"valid": False, "error": f"Unable to repair JSON: {exc}"}


async def _execute_interactive_form(args: dict[str, Any]) -> dict[str, Any]:
    return {
        "form_id": args.get("id"),
        "title": args.get("title"),
        "fields": args.get("fields", []),
        "status": "pending_user_input",
    }


async def _execute_install_skill_dependency(args: dict[str, Any]) -> dict[str, Any]:
    skill_id = str(args.get("skill_id") or "").strip()
    package_name = str(args.get("package_name") or "").strip()
    if not skill_id or not package_name:
        return {
            "success": False,
            "error": "skill_id and package_name are required",
            "skill_id": skill_id or None,
            "package_name": package_name or None,
        }
    try:
        return await install_skill_dependency_runtime(skill_id, package_name)
    except (FileNotFoundError, ValueError, RuntimeError) as exc:
        return {
            "success": False,
            "error": str(exc),
            "skill_id": skill_id,
            "package_name": package_name,
        }


async def _execute_execute_skill_script(args: dict[str, Any]) -> dict[str, Any]:
    skill_id = str(args.get("skill_id") or "").strip()
    script_path = str(args.get("script_path") or "").strip()
    raw_args = args.get("args") or []
    timeout_seconds = args.get("timeout_seconds") or 60.0
    if not skill_id or not script_path:
        return {
            "success": False,
            "error": "skill_id and script_path are required",
            "skill_id": skill_id or None,
            "script_path": script_path or None,
        }
    if skill_id == "agent-memory":
        coerced_args = _coerce_agent_memory_args(script_path, raw_args)
        if coerced_args:
            raw_args = coerced_args

    if not isinstance(raw_args, list):
        raw_args = [str(raw_args)]
    try:
        return await execute_skill_script_runtime(
            skill_id=skill_id,
            script_path=script_path,
            args=[str(item) for item in raw_args],
            timeout_seconds=float(timeout_seconds),
        )
    except (FileNotFoundError, ValueError, RuntimeError) as exc:
        return {
            "success": False,
            "error": str(exc),
            "skill_id": skill_id,
            "script_path": script_path,
        }


async def _execute_webpage_reader(args: dict[str, Any]) -> dict[str, Any]:
    url = args.get("url", "").strip()
    normalized = re.sub(r"^https?://r\.jina\.ai/", "", url)
    request_url = f"https://r.jina.ai/{normalized}"

    try:
        async with httpx.AsyncClient(timeout=30.0) as client:
            response = await client.get(request_url, headers={"Accept": "text/plain"})
            response.raise_for_status()
            content = response.text
            return {
                "url": normalized,
                "content": content,
                "source": "jina.ai",
            }
    except httpx.HTTPError as exc:
        return {"error": f"Webpage read failed: {str(exc)}"}


def _resolve_tavily_api_key(tool_config: dict[str, Any] | None) -> str:
    if tool_config and tool_config.get("tavilyApiKey"):
        return str(tool_config["tavilyApiKey"])
    if tool_config and tool_config.get("searchProvider") == "tavily":
        if tool_config.get("searchApiKey"):
            return str(tool_config["searchApiKey"])
    env_key = os.getenv("TAVILY_API_KEY") or os.getenv("PUBLIC_TAVILY_API_KEY")
    return env_key or ""


async def _execute_tavily_web_search(
    args: dict[str, Any],
    tool_config: dict[str, Any] | None = None,
    search_depth: str = "basic",
) -> dict[str, Any]:
    query = str(args.get("query", "")).strip()
    max_results = FIXED_SEARCH_MAX_RESULTS
    if not query:
        raise ValueError("Missing required field: query")

    api_key = _resolve_tavily_api_key(tool_config)
    if not api_key:
        raise ValueError("Tavily API key not configured.")

    payload = {
        "api_key": api_key,
        "query": query,
        "search_depth": search_depth,
        "include_answer": True,
        "max_results": max_results,
    }

    async with httpx.AsyncClient(timeout=30.0) as client:
        response = await client.post("https://api.tavily.com/search", json=payload)
        response.raise_for_status()
        data = response.json()

    return {
        "answer": data.get("answer"),
        "results": [
            {
                "title": item.get("title"),
                "url": item.get("url"),
                "content": item.get("content"),
            }
            for item in data.get("results", []) or []
        ],
    }


async def _execute_tavily_academic_search(
    args: dict[str, Any],
    tool_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
    query = str(args.get("query", "")).strip()
    max_results = FIXED_SEARCH_MAX_RESULTS
    try:
        min_score = float(args.get("min_score", 0.9))
    except Exception:
        min_score = 0.9
    if not query:
        raise ValueError("Missing required field: query")

    api_key = _resolve_tavily_api_key(tool_config)
    if not api_key:
        raise ValueError("Tavily API key not configured.")

    payload = {
        "api_key": api_key,
        "query": query,
        "search_depth": "advanced",
        "include_domains": ACADEMIC_DOMAINS,
        "include_answer": True,
        "max_results": max_results,
    }

    async with httpx.AsyncClient(timeout=30.0) as client:
        response = await client.post("https://api.tavily.com/search", json=payload)
        response.raise_for_status()
        data = response.json()

    return {
        "answer": data.get("answer"),
        "results": [
            {
                "title": item.get("title"),
                "url": item.get("url"),
                "content": item.get("content"),
                "score": item.get("score"),
            }
            for item in data.get("results", []) or []
            if float(item.get("score") or 0.0) > min_score
        ],
        "query_type": "academic",
        "min_score": min_score,
    }


async def _execute_url_extract(args: dict[str, Any]) -> dict[str, Any]:
    urls = args.get("urls") or args.get("url") or ""
    if isinstance(urls, str):
        url_list = [u.strip() for u in urls.split(",") if u.strip()]
    elif isinstance(urls, list):
        url_list = [str(u).strip() for u in urls if str(u).strip()]
    else:
        url_list = []

    if not url_list:
        raise ValueError("Missing required field: urls")

    results = []
    for url in url_list:
        try:
            content_result = await _execute_webpage_reader({"url": url})
            results.append(
                {
                    "url": content_result.get("url") or url,
                    "content": content_result.get("content") or "",
                    "title": content_result.get("title") or "",
                }
            )
        except Exception as exc:
            results.append({"url": url, "error": str(exc)})

    return {"results": results}


def is_local_tool_name(tool_name: str) -> bool:
    resolved = resolve_tool_name(tool_name)
    return any(t["id"] == resolved for t in CUSTOM_TOOLS)


async def execute_tool_by_name(
    tool_name: str,
    args: dict[str, Any],
    tool_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
    resolved_name = resolve_tool_name(tool_name)
    if is_local_tool_name(resolved_name) or resolved_name in EXTERNAL_SEARCH_TOOL_NAMES:
        timeout_sec = _tool_timeout_seconds()
        try:
            return await asyncio.wait_for(
                execute_local_tool(resolved_name, args, tool_config),
                timeout=timeout_sec,
            )
        except asyncio.TimeoutError:
            return {
                "error": f"Tool '{resolved_name}' timed out after {timeout_sec:.1f}s",
                "timed_out": True,
                "tool": resolved_name,
                "args": args or {},
            }
        except Exception as exc:
            return {
                "error": str(exc),
                "timed_out": False,
                "tool": resolved_name,
                "args": args or {},
            }
    raise ValueError(f"Tool {resolved_name} not found")


GLOBAL_TOOLS = REGISTRY_GLOBAL_TOOLS
AGENT_TOOLS = REGISTRY_AGENT_TOOLS
ALL_TOOLS = REGISTRY_ALL_TOOLS