File size: 6,484 Bytes
3218196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4a9ab2
 
 
 
3218196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# app/ai/tools/dynamic_executor.py
"""
Dynamic Tool Executor β€” handles execution of 'api' and 'db_query' tools
that are defined in tools_config.yaml but have no hardcoded handler in brain.py.
"""

from typing import Any, Dict, Tuple

import httpx
from structlog import get_logger

from app.config import settings

logger = get_logger(__name__)

# Base URL for internal API calls (same server).
# Uses INTERNAL_BASE_URL env var when set (e.g. in Docker/cloud), otherwise
# falls back to localhost so local development works without any configuration.
_INTERNAL_BASE = settings.INTERNAL_BASE_URL or f"http://127.0.0.1:{settings.SERVER_PORT}"


async def execute_dynamic_tool(
    tool_name: str,
    tool_def: Dict[str, Any],
    params: Dict[str, Any],
    state: Any,  # AgentState β€” avoid circular import
) -> Tuple[bool, str, Any]:
    """
    Execute a dynamic (non-builtin) tool.

    Returns the same (success, message, result_data) tuple as execute_tool().
    """
    handler_type = tool_def.get("handler_type", "")

    try:
        if handler_type == "api":
            return await _execute_api_tool(tool_name, tool_def, params, state)
        elif handler_type == "db_query":
            return await _execute_db_query_tool(tool_name, tool_def, params, state)
        else:
            logger.warning("Unknown dynamic handler_type", tool=tool_name, handler_type=handler_type)
            return False, f"Unsupported handler type: {handler_type}", None
    except Exception as exc:
        logger.error("Dynamic tool execution failed", tool=tool_name, error=str(exc), exc_info=exc)
        return False, f"Tool {tool_name} failed: {str(exc)}", None


# ── API Tool Executor ────────────────────────────────────────

async def _execute_api_tool(
    tool_name: str,
    tool_def: Dict[str, Any],
    params: Dict[str, Any],
    state: Any,
) -> Tuple[bool, str, Any]:
    """
    Call an internal API endpoint defined in handler_config.
    Supports GET and POST. Path parameters are interpolated from params.
    """
    config = tool_def.get("handler_config", {})
    method = config.get("method", "GET").upper()
    endpoint = config.get("endpoint", "")
    auth_required = config.get("auth_required", False)

    if not endpoint:
        return False, f"No endpoint configured for tool {tool_name}", None

    # Interpolate path parameters (e.g., /api/users/public/{user_id})
    url = _INTERNAL_BASE + endpoint
    used_path_params = set()
    for key, value in params.items():
        placeholder = "{" + key + "}"
        if placeholder in url:
            url = url.replace(placeholder, str(value))
            used_path_params.add(key)

    # Remaining params become query params (GET) or body (POST)
    remaining_params = {k: v for k, v in params.items() if k not in used_path_params}

    # Build headers
    headers = {"Content-Type": "application/json"}
    if auth_required:
        # Use the user_id to create an internal service token
        # or pass user context via a trusted header
        headers["X-Internal-User-Id"] = state.user_id
        headers["X-Internal-Service"] = "aida-agent"

    logger.info(
        "Executing API tool",
        tool=tool_name,
        method=method,
        url=url,
        params=remaining_params,
    )

    async with httpx.AsyncClient(timeout=15.0) as client:
        if method == "GET":
            resp = await client.get(url, params=remaining_params, headers=headers)
        elif method == "POST":
            resp = await client.post(url, json=remaining_params, headers=headers)
        elif method == "PUT":
            resp = await client.put(url, json=remaining_params, headers=headers)
        elif method == "DELETE":
            resp = await client.delete(url, params=remaining_params, headers=headers)
        else:
            return False, f"Unsupported HTTP method: {method}", None

    if resp.status_code >= 400:
        logger.warning(
            "API tool returned error",
            tool=tool_name,
            status=resp.status_code,
            body=resp.text[:300],
        )
        return False, f"API error ({resp.status_code}): {resp.text[:200]}", None

    try:
        data = resp.json()
    except Exception:
        data = {"raw": resp.text[:500]}

    return True, f"Tool {tool_name} executed successfully", data


# ── DB Query Tool Executor ───────────────────────────────────

async def _execute_db_query_tool(
    tool_name: str,
    tool_def: Dict[str, Any],
    params: Dict[str, Any],
    state: Any,
) -> Tuple[bool, str, Any]:
    """
    Execute a MongoDB aggregation pipeline defined in handler_config.
    Supports optional parameter injection (e.g., location filter).
    """
    from app.database import get_db

    config = tool_def.get("handler_config", {})
    collection_name = config.get("collection", "")
    pipeline_template = config.get("pipeline", [])
    result_format = config.get("result_format", "")

    if not collection_name or not pipeline_template:
        return False, f"Incomplete db_query config for tool {tool_name}", None

    # Deep-copy pipeline so we don't mutate the config
    import copy
    pipeline = copy.deepcopy(pipeline_template)

    # Inject optional filters from params
    location = params.get("location")
    if location:
        # Prepend a location filter match stage
        location_match = {"$match": {"location": {"$regex": location, "$options": "i"}}}
        pipeline.insert(0, location_match)

    logger.info(
        "Executing DB query tool",
        tool=tool_name,
        collection=collection_name,
        pipeline_stages=len(pipeline),
    )

    db = await get_db()
    collection = db[collection_name]
    cursor = collection.aggregate(pipeline)
    results = await cursor.to_list(length=100)

    if not results:
        return True, "No data found matching the criteria", []

    # Format results using the template if provided
    if result_format:
        formatted_lines = []
        for doc in results:
            try:
                line = result_format.format(**doc)
                formatted_lines.append(line)
            except (KeyError, ValueError):
                formatted_lines.append(str(doc))
        summary = "\n".join(formatted_lines)
    else:
        summary = str(results[:10])

    return True, summary, results