oskaragent 0.1.38a0__py3-none-any.whl
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.
- oskaragent/__init__.py +49 -0
- oskaragent/agent.py +1911 -0
- oskaragent/agent_config.py +328 -0
- oskaragent/agent_mcp_tools.py +102 -0
- oskaragent/agent_tools.py +962 -0
- oskaragent/helpers.py +175 -0
- oskaragent-0.1.38a0.dist-info/METADATA +29 -0
- oskaragent-0.1.38a0.dist-info/RECORD +30 -0
- oskaragent-0.1.38a0.dist-info/WHEEL +5 -0
- oskaragent-0.1.38a0.dist-info/licenses/LICENSE +21 -0
- oskaragent-0.1.38a0.dist-info/top_level.txt +2 -0
- tests/1a_test_basico.py +43 -0
- tests/1b_test_history.py +72 -0
- tests/1c_test_basico.py +61 -0
- tests/2a_test_tool_python.py +50 -0
- tests/2b_test_tool_calculator.py +54 -0
- tests/2c_test_tool_savefile.py +50 -0
- tests/3a_test_upload_md.py +46 -0
- tests/3b_test_upload_img.py +43 -0
- tests/3c_test_upload_pdf_compare.py +44 -0
- tests/4_test_RAG.py +56 -0
- tests/5_test_MAS.py +58 -0
- tests/6a_test_MCP_tool_CRM.py +77 -0
- tests/6b_test_MCP_tool_ITSM.py +72 -0
- tests/6c_test_MCP_tool_SQL.py +69 -0
- tests/6d_test_MCP_tool_DOC_SQL.py +45 -0
- tests/7a_test_BI_CSV.py +37 -0
- tests/7b_test_BI_SQL.py +47 -0
- tests/8a_test_external_tool.py +194 -0
- tests/helpers.py +60 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ast
|
|
5
|
+
import base64
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, date
|
|
11
|
+
|
|
12
|
+
from colorama import Fore
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from typing import Any, Callable, Iterable
|
|
17
|
+
|
|
18
|
+
from .agent_mcp_tools import exec_mcp_tool
|
|
19
|
+
from .helpers import ToolContext, ToolFn, log_msg, vlog
|
|
20
|
+
from .rag.knowledgebase import query_knowledge_base, search_web
|
|
21
|
+
from .helpers import create_working_folder
|
|
22
|
+
|
|
23
|
+
# Default tools available in every agent (always allowed)
|
|
24
|
+
DEFAULT_TOOL_NAMES: tuple[str, ...] = (
|
|
25
|
+
"calculator_tool",
|
|
26
|
+
"get_current_date_tool",
|
|
27
|
+
"get_current_time_tool",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Intent: restrict imports to reduce the attack surface when executing arbitrary code strings.
|
|
31
|
+
_ALLOWED_PYTHON_IMPORTS: set[str] = {
|
|
32
|
+
"asyncio",
|
|
33
|
+
"base64",
|
|
34
|
+
"collections",
|
|
35
|
+
"csv",
|
|
36
|
+
"datetime",
|
|
37
|
+
"io",
|
|
38
|
+
"itertools",
|
|
39
|
+
"json",
|
|
40
|
+
"math",
|
|
41
|
+
"matplotlib",
|
|
42
|
+
"numpy",
|
|
43
|
+
"os",
|
|
44
|
+
"pandas",
|
|
45
|
+
"pathlib",
|
|
46
|
+
"random",
|
|
47
|
+
"re",
|
|
48
|
+
"seaborn",
|
|
49
|
+
"statistics",
|
|
50
|
+
"time",
|
|
51
|
+
"typing",
|
|
52
|
+
"unicodedata",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class ToolExecutionResult:
|
|
58
|
+
"""Representa o resultado de uma ferramenta com metadados de execução."""
|
|
59
|
+
|
|
60
|
+
value: Any | None
|
|
61
|
+
elapsed_seconds: float
|
|
62
|
+
error: str | None = None
|
|
63
|
+
|
|
64
|
+
def to_model_payload(self) -> Any:
|
|
65
|
+
"""Normaliza o resultado para ser devolvido ao modelo."""
|
|
66
|
+
if self.error:
|
|
67
|
+
return {"error": self.error}
|
|
68
|
+
return self.value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def calculator_tool(expression: str, *, ctx: ToolContext | None = None) -> Any:
|
|
72
|
+
"""Evaluate arithmetic expressions using a restricted math namespace.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
expression (str): Expressão Python focada em operações matemáticas.
|
|
76
|
+
ctx (ToolContext | None, optional): Contexto opcional usado para logs verbosos. Defaults to None.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Any: Resultado numérico da expressão avaliada.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: Quando a expressão contém construções não permitidas ou apresenta erro de sintaxe.
|
|
83
|
+
"""
|
|
84
|
+
print(f"{Fore.LIGHTBLUE_EX}Fazendo cálculo com a ferramenta 'calculator_tool'")
|
|
85
|
+
|
|
86
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
87
|
+
vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="calculator_tool")
|
|
88
|
+
|
|
89
|
+
# Whitelist public attributes from math
|
|
90
|
+
allowed_math = {k: v for k, v in vars(math).items() if not k.startswith("_")}
|
|
91
|
+
# Expose as bare names and under a `math` namespace
|
|
92
|
+
allowed_names: dict[str, Any] = dict(allowed_math)
|
|
93
|
+
allowed_names["math"] = SimpleNamespace(**allowed_math)
|
|
94
|
+
# A few safe builtins commonly used in numeric expressions
|
|
95
|
+
allowed_names.update({
|
|
96
|
+
"abs": abs,
|
|
97
|
+
"min": min,
|
|
98
|
+
"max": max,
|
|
99
|
+
"sum": sum,
|
|
100
|
+
"round": round,
|
|
101
|
+
"pow": pow,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
# Validate AST to allow only arithmetic expressions and math calls
|
|
105
|
+
allowed_builtin_names = {"abs", "min", "max", "sum", "round", "pow"}
|
|
106
|
+
allowed_math_names = set(allowed_math.keys())
|
|
107
|
+
|
|
108
|
+
def _is_safe(node: ast.AST) -> bool:
|
|
109
|
+
"""Check whether the AST node uses only whitelisted constructs.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
node (ast.AST): Nó produzido pelo parse da expressão fornecida.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
bool: `True` quando o nó é considerado seguro, caso contrário `False`.
|
|
116
|
+
"""
|
|
117
|
+
# Expression root
|
|
118
|
+
if isinstance(node, ast.Expression):
|
|
119
|
+
return _is_safe(node.body)
|
|
120
|
+
|
|
121
|
+
# Literals
|
|
122
|
+
if isinstance(node, (ast.Num, ast.Constant)):
|
|
123
|
+
return isinstance(getattr(node, "value", None), (int, float)) or isinstance(node, ast.Num)
|
|
124
|
+
|
|
125
|
+
# Names (variables/constants): must be in allowed (math names or allowed builtins or 'math')
|
|
126
|
+
if isinstance(node, ast.Name):
|
|
127
|
+
return node.id in allowed_math_names or node.id in allowed_builtin_names or node.id == "math"
|
|
128
|
+
|
|
129
|
+
# Attribute access: only math.<public_name>
|
|
130
|
+
if isinstance(node, ast.Attribute):
|
|
131
|
+
return isinstance(node.value, ast.Name) and node.value.id == "math" and node.attr in allowed_math_names
|
|
132
|
+
|
|
133
|
+
# Unary operations: +x, -x
|
|
134
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
|
|
135
|
+
return _is_safe(node.operand)
|
|
136
|
+
|
|
137
|
+
# Binary operations: + - * / // % **
|
|
138
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op,
|
|
139
|
+
(ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod,
|
|
140
|
+
ast.Pow)):
|
|
141
|
+
return _is_safe(node.left) and _is_safe(node.right)
|
|
142
|
+
|
|
143
|
+
# Calls: function(...) where function is allowed
|
|
144
|
+
if isinstance(node, ast.Call):
|
|
145
|
+
func_ok = False
|
|
146
|
+
if isinstance(node.func, ast.Name):
|
|
147
|
+
func_ok = node.func.id in allowed_math_names or node.func.id in allowed_builtin_names
|
|
148
|
+
elif isinstance(node.func, ast.Attribute):
|
|
149
|
+
func_ok = isinstance(node.func.value,
|
|
150
|
+
ast.Name) and node.func.value.id == "math" and node.func.attr in allowed_math_names
|
|
151
|
+
if not func_ok:
|
|
152
|
+
return False
|
|
153
|
+
# Validate args and keywords values only (no starargs/kwargs nodes in py3.12 AST here)
|
|
154
|
+
return all(_is_safe(a) for a in node.args) and all(_is_safe(kw.value) for kw in node.keywords)
|
|
155
|
+
|
|
156
|
+
# Parentheses/grouping is represented implicitly; tuples can appear only if used in args
|
|
157
|
+
if isinstance(node, ast.Tuple):
|
|
158
|
+
return all(_is_safe(elt) for elt in node.elts)
|
|
159
|
+
|
|
160
|
+
# Disallow everything else
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
tree = ast.parse(expression, mode="eval")
|
|
165
|
+
except SyntaxError as e:
|
|
166
|
+
raise ValueError(f"Expressão inválida: {e}")
|
|
167
|
+
if not _is_safe(tree):
|
|
168
|
+
raise ValueError("Expressão contém construções não permitidas para cálculo seguro.")
|
|
169
|
+
|
|
170
|
+
code = compile(tree, "<calc>", "eval")
|
|
171
|
+
result = eval(code, {"__builtins__": {}}, allowed_names)
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_current_date_tool(*, ctx: ToolContext | None = None) -> str:
|
|
176
|
+
"""Retrieve the current system date.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
ctx (ToolContext | None, optional): Contexto do agente usado apenas para logging. Defaults to None.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
str: Data atual formatada como `YYYY-MM-DD`.
|
|
183
|
+
"""
|
|
184
|
+
print(f"{Fore.LIGHTBLUE_EX}Consultando a data atual no calendário com a ferramenta 'get_current_date_tool'")
|
|
185
|
+
|
|
186
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
187
|
+
vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="get_current_date_tool")
|
|
188
|
+
|
|
189
|
+
return date.today().strftime("%Y-%m-%d")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_current_time_tool(*, ctx: ToolContext | None = None) -> str:
|
|
193
|
+
"""Retrieve the current system time.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
ctx (ToolContext | None, optional): Contexto do agente usado apenas para logging. Defaults to None.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
str: Hora atual formatada como `HH:MM:SS`.
|
|
200
|
+
"""
|
|
201
|
+
print(f"{Fore.LIGHTBLUE_EX}Consultando a hora atual no relógio com a ferramenta 'get_current_time_tool'")
|
|
202
|
+
|
|
203
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
204
|
+
vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="get_current_time_tool")
|
|
205
|
+
|
|
206
|
+
return datetime.now().time().strftime("%H:%M:%S")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def _run_code_async(code: str, output_dir: Path, *, message_id: str | None = None) -> Any:
|
|
210
|
+
"""Execute user-provided Python code inside an async runner.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
code (str): Script Python sincronizado/assíncrono a ser executado.
|
|
214
|
+
output_dir (Path | None, optional): Diretório onde artefatos (logs, imagens) serão gravados. Defaults to None.
|
|
215
|
+
message_id (str | None, optional): Identificador da mensagem usado para nomear arquivos de saída. Defaults to None.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Any: Valor produzido pelo script, incluindo estruturas com imagens em base64 quando aplicável.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
Exception: Propaga exceções geradas durante a execução do código do usuário.
|
|
222
|
+
"""
|
|
223
|
+
local_vars: dict[str, Any] = {}
|
|
224
|
+
captured_paths: list[str] = []
|
|
225
|
+
# Exec context is intentionally permissive to support arbitrary instructions.
|
|
226
|
+
import random as _rand
|
|
227
|
+
|
|
228
|
+
def _patched_show(*args, **kwargs): # type: ignore[no-untyped-def]
|
|
229
|
+
"""Persist matplotlib figures to disk instead of displaying them.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
*args: Argumentos posicionais encaminhados para `matplotlib.pyplot.show`.
|
|
233
|
+
**kwargs: Argumentos nomeados encaminhados para `matplotlib.pyplot.show`.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
None: A função intercepta a chamada padrão e salva as figuras como arquivos PNG.
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
from matplotlib import pyplot as plt # type: ignore
|
|
240
|
+
nums = plt.get_fignums()
|
|
241
|
+
saved: list[str] = []
|
|
242
|
+
for number in nums:
|
|
243
|
+
figure = plt.figure(number)
|
|
244
|
+
# File name must follow: "<message_id>-<X>.png" where X is [1..100]
|
|
245
|
+
msg_id = str(message_id) if message_id else "default"
|
|
246
|
+
for _ in range(5):
|
|
247
|
+
x = _rand.randint(1, 100)
|
|
248
|
+
fname = f"{msg_id}-{x}.png"
|
|
249
|
+
path = output_dir / fname
|
|
250
|
+
if not path.exists():
|
|
251
|
+
break
|
|
252
|
+
else:
|
|
253
|
+
# Fallback (very unlikely) after retries
|
|
254
|
+
x = _rand.randint(101, 1000)
|
|
255
|
+
fname = f"{msg_id}-{x}.png"
|
|
256
|
+
|
|
257
|
+
path = output_dir / fname
|
|
258
|
+
figure.savefig(path)
|
|
259
|
+
saved.append(str(path))
|
|
260
|
+
captured_paths.extend(saved)
|
|
261
|
+
except Exception as _e: # noqa: F841
|
|
262
|
+
pass
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
# Prefer a headless backend for matplotlib operations
|
|
266
|
+
try:
|
|
267
|
+
os.environ.setdefault("MPLBACKEND", "Agg")
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
# Proactively patch pyplot.show before user code runs
|
|
272
|
+
try:
|
|
273
|
+
import matplotlib # type: ignore
|
|
274
|
+
# Ensure non-interactive backend if not already set
|
|
275
|
+
try:
|
|
276
|
+
import matplotlib.pyplot as _plt # type: ignore
|
|
277
|
+
_plt.show = _patched_show # type: ignore[attr-defined]
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
# Expose optional modules (pandas may be missing in some environments)
|
|
284
|
+
try:
|
|
285
|
+
import pandas as _pd # type: ignore
|
|
286
|
+
except ImportError:
|
|
287
|
+
_pd = None
|
|
288
|
+
import unicodedata as _unicodedata
|
|
289
|
+
import re as _re
|
|
290
|
+
try:
|
|
291
|
+
import seaborn as _sns # type: ignore
|
|
292
|
+
except ImportError:
|
|
293
|
+
_sns = None
|
|
294
|
+
|
|
295
|
+
# Capture stdout/stderr produced by the executed code
|
|
296
|
+
import io
|
|
297
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
298
|
+
|
|
299
|
+
exec_globals: dict[str, Any] = {"asyncio": asyncio, "os": os, "unicodedata": _unicodedata, "base64": base64, "re": _re}
|
|
300
|
+
if _pd is not None:
|
|
301
|
+
exec_globals["pandas"] = _pd
|
|
302
|
+
exec_globals["pd"] = _pd
|
|
303
|
+
if _sns is not None:
|
|
304
|
+
exec_globals["seaborn"] = _sns
|
|
305
|
+
exec_globals["sns"] = _sns
|
|
306
|
+
stdout_buf, stderr_buf = io.StringIO(), io.StringIO()
|
|
307
|
+
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
|
308
|
+
exec(code, exec_globals, local_vars)
|
|
309
|
+
|
|
310
|
+
# If no images were captured via patched show, try to save any open figures
|
|
311
|
+
if not captured_paths:
|
|
312
|
+
try:
|
|
313
|
+
from matplotlib import pyplot as _plt2 # type: ignore
|
|
314
|
+
nums2 = _plt2.get_fignums()
|
|
315
|
+
if nums2:
|
|
316
|
+
for num in nums2:
|
|
317
|
+
fig = _plt2.figure(num)
|
|
318
|
+
mid2 = str(message_id) if message_id else "default"
|
|
319
|
+
base_dir2 = output_dir
|
|
320
|
+
for _ in range(5):
|
|
321
|
+
x2 = _rand.randint(1, 100)
|
|
322
|
+
fname2 = f"{mid2}-{x2}.png"
|
|
323
|
+
path2 = base_dir2 / fname2
|
|
324
|
+
if not path2.exists():
|
|
325
|
+
break
|
|
326
|
+
else:
|
|
327
|
+
x2 = _rand.randint(1, 100)
|
|
328
|
+
fname2 = f"{mid2}-{x2}.png"
|
|
329
|
+
path2 = base_dir2 / fname2
|
|
330
|
+
|
|
331
|
+
path2.parent.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
fig.savefig(path2)
|
|
333
|
+
captured_paths.append(str(path2))
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
# Build result structure
|
|
338
|
+
result_val = local_vars.get("result")
|
|
339
|
+
|
|
340
|
+
# Persist script output to file (always). Filename must be prefixed by `message_id`.
|
|
341
|
+
try:
|
|
342
|
+
base_dir = output_dir
|
|
343
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
mid = str(message_id) if message_id else "default"
|
|
345
|
+
out_file = base_dir / f"{mid}_python_output.json"
|
|
346
|
+
payload: dict[str, Any] = {
|
|
347
|
+
"stdout": stdout_buf.getvalue(),
|
|
348
|
+
"stderr": stderr_buf.getvalue(),
|
|
349
|
+
"result": result_val,
|
|
350
|
+
"images": captured_paths[:],
|
|
351
|
+
}
|
|
352
|
+
try:
|
|
353
|
+
import json as _json
|
|
354
|
+
out_file.write_text(_json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
355
|
+
except Exception:
|
|
356
|
+
# Fallback to a plain-text serialization
|
|
357
|
+
text = (
|
|
358
|
+
"# Script output (stdout)\n" + payload["stdout"] +
|
|
359
|
+
"\n# Errors (stderr)\n" + payload["stderr"] +
|
|
360
|
+
"\n# Result\n" + str(payload["result"]) +
|
|
361
|
+
"\n# Images\n" + "\n".join(payload["images"])
|
|
362
|
+
)
|
|
363
|
+
out_file.write_text(text, encoding="utf-8")
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
if captured_paths:
|
|
368
|
+
images: list[dict[str, str]] = []
|
|
369
|
+
for p in captured_paths:
|
|
370
|
+
try:
|
|
371
|
+
data = Path(p).read_bytes()
|
|
372
|
+
images.append({"path": p, "base64": base64.b64encode(data).decode("ascii")})
|
|
373
|
+
except Exception:
|
|
374
|
+
images.append({"path": p, "base64": ""})
|
|
375
|
+
if result_val is None:
|
|
376
|
+
return {"images": images}
|
|
377
|
+
return {"result": result_val, "images": images}
|
|
378
|
+
return result_val
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _run_coro_in_thread(coro): # type: ignore[no-untyped-def]
|
|
382
|
+
"""Execute an async coroutine inside a dedicated worker thread.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
coro: Coroutine que será executada utilizando `asyncio.run`.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Any: Resultado produzido pela coroutine após a conclusão.
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
BaseException: Repropaga exceções levantadas pela coroutine.
|
|
392
|
+
"""
|
|
393
|
+
result_holder: dict[str, Any] = {}
|
|
394
|
+
error_holder: dict[str, BaseException] = {}
|
|
395
|
+
|
|
396
|
+
def _target():
|
|
397
|
+
"""Wrapper responsável por executar a coroutine e capturar resultados.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
None: Apenas atualiza `result_holder` ou `error_holder`.
|
|
401
|
+
"""
|
|
402
|
+
try:
|
|
403
|
+
result_holder["value"] = asyncio.run(coro)
|
|
404
|
+
except BaseException as e: # capture to re-raise in caller thread
|
|
405
|
+
error_holder["error"] = e
|
|
406
|
+
|
|
407
|
+
t = threading.Thread(target=_target, daemon=True)
|
|
408
|
+
t.start()
|
|
409
|
+
t.join()
|
|
410
|
+
if "error" in error_holder:
|
|
411
|
+
raise error_holder["error"]
|
|
412
|
+
return result_holder.get("value")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _validate_python_imports(code: str, allowed_modules: set[str]) -> None:
|
|
416
|
+
"""Validate import statements within a Python code string.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
code (str): Script Python a ser analisado.
|
|
420
|
+
allowed_modules (set[str]): Conjunto de módulos permitidos para import.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
ImportError: Quando o script tenta importar um módulo não permitido.
|
|
424
|
+
SyntaxError: Quando o script não é um Python válido.
|
|
425
|
+
"""
|
|
426
|
+
# Intent: block modules that can expand runtime capabilities beyond the intended sandbox.
|
|
427
|
+
tree = ast.parse(code)
|
|
428
|
+
for node in ast.walk(tree):
|
|
429
|
+
if isinstance(node, ast.Import):
|
|
430
|
+
for alias in node.names:
|
|
431
|
+
module_name = alias.name.split(".")[0]
|
|
432
|
+
if module_name not in allowed_modules:
|
|
433
|
+
raise ImportError(f"Import de módulo não permitido: {module_name}")
|
|
434
|
+
elif isinstance(node, ast.ImportFrom):
|
|
435
|
+
# Import relativo é bloqueado para evitar acesso arbitrário ao filesystem local.
|
|
436
|
+
if node.level and node.level > 0:
|
|
437
|
+
raise ImportError("Import relativo não permitido.")
|
|
438
|
+
module_name = (node.module or "").split(".")[0]
|
|
439
|
+
if module_name and module_name not in allowed_modules:
|
|
440
|
+
raise ImportError(f"Import de módulo não permitido: {module_name}")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def execute_python_code_tool(code: str, *, ctx: ToolContext | None = None) -> Any:
|
|
444
|
+
"""Execute Python code in a sandbox honoring the agent session context.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
code (str): Script Python a ser executado, preferencialmente atribuindo saídas à variável `result`.
|
|
448
|
+
ctx (ToolContext | None, optional): Contexto da ferramenta contendo diretórios de trabalho e ids. Defaults to None.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Any: Resultado retornado pela execução (_stdout_, _stderr_ e possíveis imagens).
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
ImportError: Quando o script tenta importar módulos não permitidos.
|
|
455
|
+
BaseException: Propaga quaisquer erros levantados durante a execução do código do usuário.
|
|
456
|
+
"""
|
|
457
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
458
|
+
vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="ao")
|
|
459
|
+
|
|
460
|
+
working_folder = create_working_folder(Path(ctx.working_folder), getattr(ctx, "session_id", "default_session_id"))
|
|
461
|
+
out_dir = Path(working_folder)
|
|
462
|
+
|
|
463
|
+
script_file = Path(out_dir, "script.py")
|
|
464
|
+
script_file.write_text(code, encoding="utf-8")
|
|
465
|
+
|
|
466
|
+
if not code or not code.strip():
|
|
467
|
+
return "Erro: o script Python não foi informado."
|
|
468
|
+
|
|
469
|
+
# Intent: keep outputs/artefacts stored inside a per-session working folder.
|
|
470
|
+
session_id = getattr(ctx, "session_id", "default_session_id") if ctx else "default_session_id"
|
|
471
|
+
message_id = getattr(ctx, "message_id", None) if ctx else None
|
|
472
|
+
base_folder = Path(getattr(ctx, "working_folder", None) or "_oskar_working_folder")
|
|
473
|
+
output_dir = Path(create_working_folder(base_folder, session_id))
|
|
474
|
+
|
|
475
|
+
_validate_python_imports(code, _ALLOWED_PYTHON_IMPORTS)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
res = _run_coro_in_thread(_run_code_async(code, output_dir, message_id=message_id))
|
|
479
|
+
return res
|
|
480
|
+
except ImportError:
|
|
481
|
+
raise
|
|
482
|
+
except BaseException as exc:
|
|
483
|
+
# Intent: surface execution errors while keeping verbose logs for diagnostics.
|
|
484
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
485
|
+
vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}; error={exc}", func="execute_python_code_tool")
|
|
486
|
+
raise
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def retriever_tool(source_name: str, query: str, *, top_k: int = 5,
|
|
491
|
+
ctx: ToolContext | None = None) -> list[str]:
|
|
492
|
+
"""Retrieve relevant documents via the configured vector store.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
source_name (str): Nome da fonte de dados.
|
|
496
|
+
query (str): Texto usado como chave de busca semântica.
|
|
497
|
+
top_k (int, optional): Quantidade de documentos que devem ser retornados. Defaults to 4.
|
|
498
|
+
ctx (ToolContext | None, optional): Contexto utilizado para logs verbosos. Defaults to None.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
list[str]: Lista de conteúdos/documentos similares ou vazia quando não há resultados.
|
|
502
|
+
"""
|
|
503
|
+
print(f"{Fore.LIGHTBLUE_EX}Recuperando documentos de um vector store com a ferramenta 'retriever_tool'")
|
|
504
|
+
|
|
505
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
506
|
+
vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="retriever_tool")
|
|
507
|
+
|
|
508
|
+
retrievers = getattr(ctx, "retrievers")
|
|
509
|
+
if retrievers is None:
|
|
510
|
+
return []
|
|
511
|
+
|
|
512
|
+
for retriever_info in retrievers:
|
|
513
|
+
if retriever_info["name"] != source_name:
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
results = query_knowledge_base(query=query,
|
|
518
|
+
knowledge_base_name=retriever_info['name'],
|
|
519
|
+
knowledge_base_folder=retriever_info["details"]['kb_path'],
|
|
520
|
+
num_of_itens=top_k)
|
|
521
|
+
return results
|
|
522
|
+
except Exception as e:
|
|
523
|
+
# Log error with details and return a structured error item to surface the issue
|
|
524
|
+
vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func=f"retriever_tool ==> ERROR: {str(e)}")
|
|
525
|
+
|
|
526
|
+
return []
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def search_web_tool(query: str, *, ctx: ToolContext | None = None) -> str | None:
|
|
530
|
+
"""Proxy to perform web searches when allowed by the environment.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
query (str): Termo que será consultado externamente.
|
|
534
|
+
ctx (ToolContext | None, optional): Contexto do agente usado para logging. Defaults to None.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
list[dict[str, Any]]: Lista de resultados estruturados; vazia quando a funcionalidade não está disponível.
|
|
538
|
+
"""
|
|
539
|
+
print(f"{Fore.LIGHTBLUE_EX}Pesquisando informações na internet com a ferramenta 'search_web_tool'")
|
|
540
|
+
|
|
541
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
542
|
+
vlog(ctx, f"session={getattr(ctx, 'session_id', '-')}", func="search_web_tool")
|
|
543
|
+
|
|
544
|
+
return search_web(query)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def read_file_tool(pathname: str, *, as_base64: bool = False,
|
|
548
|
+
ctx: ToolContext | None = None) -> str:
|
|
549
|
+
"""Read file contents relative to the agent workspace.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
pathname (str): pathname do arquivo, incluindo a pasta mais o nome do arquivo que será lido.
|
|
553
|
+
as_base64 (bool, optional): Quando `True`, retorna o conteúdo codificado em base64. Defaults to False.
|
|
554
|
+
ctx (ToolContext | None, optional): Contexto usado para identificar sessão e diretórios. Defaults to None.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
str: Conteúdo textual (ou base64) do arquivo solicitado.
|
|
558
|
+
|
|
559
|
+
Raises:
|
|
560
|
+
FileNotFoundError: Se o arquivo não puder ser localizado.
|
|
561
|
+
OSError: Para erros de leitura de arquivo.
|
|
562
|
+
"""
|
|
563
|
+
print(f"{Fore.LIGHTBLUE_EX}Lendo arquivo com a ferramenta 'read_file_tool'")
|
|
564
|
+
|
|
565
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
566
|
+
vlog(ctx, f"[tool] read_file_tool() called; file={pathname}")
|
|
567
|
+
|
|
568
|
+
if not pathname:
|
|
569
|
+
return f"Erro: o nome do arquivo não foi informado"
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
if '\\' in pathname or '/' in pathname:
|
|
573
|
+
# foi informado um pathname completo
|
|
574
|
+
file_path = Path(pathname)
|
|
575
|
+
else:
|
|
576
|
+
# quando não é informado um pathname completo então considera o working folder
|
|
577
|
+
file_path = Path(create_working_folder(Path(ctx.working_folder), getattr(ctx, "session_id", "default_session_id"))) / pathname
|
|
578
|
+
|
|
579
|
+
if as_base64:
|
|
580
|
+
file_data = file_path.read_bytes().decode("ascii")
|
|
581
|
+
else:
|
|
582
|
+
file_data = file_path.read_text(encoding="utf-8")
|
|
583
|
+
return file_data
|
|
584
|
+
except Exception as e:
|
|
585
|
+
return f"Erro ao ler arquivo: {e}"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def write_file_tool(pathname: str, content: str, *,
|
|
589
|
+
ctx: ToolContext | None = None) -> str:
|
|
590
|
+
"""Persist textual content to disk within the agent session scope.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
pathname (str): pathname do arquivo, incluindo a pasta mais o nome do arquivo que será gravado.
|
|
594
|
+
content (str): Conteúdo textual a ser gravado.
|
|
595
|
+
ctx (ToolContext | None, optional): Contexto de execução contendo `session_id` e `message_id`. Defaults to None.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
str: Mensagem de status sobre a gravação do arquivo.
|
|
599
|
+
|
|
600
|
+
Raises:
|
|
601
|
+
OSError: Caso ocorra erro ao criar diretórios ou gravar o conteúdo.
|
|
602
|
+
"""
|
|
603
|
+
print(f"{Fore.LIGHTBLUE_EX}Gravando arquivo com a ferramenta 'write_file_tool'")
|
|
604
|
+
|
|
605
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
606
|
+
vlog(ctx, f"[tool] write_file_tool() called; file={pathname}")
|
|
607
|
+
|
|
608
|
+
if not pathname:
|
|
609
|
+
return f"Erro: o nome do arquivo não foi informado"
|
|
610
|
+
|
|
611
|
+
message_id = getattr(ctx, "message_id", None) if ctx else None
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
if '\\' in pathname or '/' in pathname:
|
|
615
|
+
# foi informado um pathname completo
|
|
616
|
+
file_path = Path(pathname)
|
|
617
|
+
else:
|
|
618
|
+
# quando não é informado um pathname completo então considera o working folder
|
|
619
|
+
safe_filename = f"{message_id}_{pathname}" if message_id else pathname
|
|
620
|
+
file_path = Path(create_working_folder(Path(ctx.working_folder), getattr(ctx, "session_id", "default_session_id"))) / safe_filename
|
|
621
|
+
|
|
622
|
+
file_path.write_text(content, encoding="utf-8")
|
|
623
|
+
return "Arquivo salvo com sucesso."
|
|
624
|
+
except Exception as e:
|
|
625
|
+
return f"Erro ao salvar o arquivo: {e}"
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def ask_to_agent_tool(agent_name: str, question: str, *, ctx: ToolContext | None = None) -> str:
|
|
629
|
+
"""Forward a prompt to a subordinate agent registered in the context.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
agent_name (str): Identificador único do agente que receberá a pergunta.
|
|
633
|
+
question (str): Texto da consulta a ser encaminhada.
|
|
634
|
+
ctx (ToolContext | None, optional): Contexto contendo a lista de agentes subordinados. Defaults to None.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
str: Resposta formatada do agente consultado ou mensagem informando que não foi encontrado.
|
|
638
|
+
"""
|
|
639
|
+
if ctx and getattr(ctx, "is_verbose", False):
|
|
640
|
+
vlog(ctx, f"[tool] ask_to_agent_tool() called; session={getattr(ctx, 'session_id', '-')}")
|
|
641
|
+
|
|
642
|
+
subordinate_agents = getattr(ctx, "subordinate_agents", [])
|
|
643
|
+
agent = next((a for a in subordinate_agents if a.name == agent_name), None)
|
|
644
|
+
|
|
645
|
+
if agent is None:
|
|
646
|
+
vlog(ctx, f"[tool] ask_to_agent_tool() called; session={getattr(ctx, 'session_id', '-')}; agent {agent_name} not found")
|
|
647
|
+
return f"O agente {agent_name} não foi encontrado"
|
|
648
|
+
|
|
649
|
+
print(f"{Fore.LIGHTBLUE_EX}Consultando o agente '{agent.name}'")
|
|
650
|
+
|
|
651
|
+
result: dict[str, Any] = agent.answer(
|
|
652
|
+
question=question,
|
|
653
|
+
action="consult",
|
|
654
|
+
message_format="raw",
|
|
655
|
+
is_consult_prompt=True,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# submete a pergunta/pedido para o agente
|
|
659
|
+
answer = (result or {}).get("content") or ""
|
|
660
|
+
return f"Segue a resposta do agente:\n {answer}"
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def get_builtin_tools() -> dict[str, ToolFn]:
|
|
664
|
+
"""Return the registry of builtin tools available to oskaragent.
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
dict[str, ToolFn]: Mapeamento entre nomes de ferramentas e funções executáveis.
|
|
668
|
+
"""
|
|
669
|
+
tools = {
|
|
670
|
+
"calculator_tool": calculator_tool,
|
|
671
|
+
"get_current_date_tool": get_current_date_tool,
|
|
672
|
+
"get_current_time_tool": get_current_time_tool,
|
|
673
|
+
"execute_python_code_tool": execute_python_code_tool,
|
|
674
|
+
"retriever_tool": (
|
|
675
|
+
lambda source_name, query, top_k=4, *, ctx=None: retriever_tool(source_name, query, top_k=top_k, ctx=ctx)),
|
|
676
|
+
"search_web_tool": search_web_tool,
|
|
677
|
+
"read_file_tool": (
|
|
678
|
+
lambda pathname, as_base64=False, *, ctx=None: read_file_tool(pathname, as_base64=as_base64, ctx=ctx)),
|
|
679
|
+
"write_file_tool": (lambda pathname, content, *, ctx=None: write_file_tool(pathname, content, ctx=ctx)),
|
|
680
|
+
"ask_to_agent_tool": (lambda agent_name, question, *, ctx=None: ask_to_agent_tool(agent_name, question, ctx=ctx)),
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return tools
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def build_tool_schemas(tool_names: Iterable[str]) -> list[dict[str, Any]]:
|
|
687
|
+
"""Generate minimal JSON schema definitions for API-exposed tools.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
tool_names (Iterable[str]): Conjunto de nomes de ferramentas permitidos na sessão.
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
list[dict[str, Any]]: Lista de esquemas no formato esperado pela OpenAI Responses API.
|
|
694
|
+
"""
|
|
695
|
+
schemas: list[dict[str, Any]] = []
|
|
696
|
+
for name in tool_names:
|
|
697
|
+
if name == "calculator_tool":
|
|
698
|
+
schemas.append({
|
|
699
|
+
"type": "function",
|
|
700
|
+
"name": name,
|
|
701
|
+
"description": "Avalia uma expressão matemática Python segura. Essa função precisa receber como argumento uma expressão matemática Python segura.",
|
|
702
|
+
"parameters": {
|
|
703
|
+
"type": "object",
|
|
704
|
+
"properties": {
|
|
705
|
+
"expression": {"type": "string", "description": "Expressão matemática Python segura"},
|
|
706
|
+
},
|
|
707
|
+
"required": ["expression"],
|
|
708
|
+
"additionalProperties": False,
|
|
709
|
+
},
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
elif name == "get_current_date_tool":
|
|
713
|
+
schemas.append({
|
|
714
|
+
"type": "function",
|
|
715
|
+
"name": name,
|
|
716
|
+
"description": "Consulta a data atual, data de hoje informada pelo sistema.",
|
|
717
|
+
"parameters": {
|
|
718
|
+
"type": "object",
|
|
719
|
+
"properties": {},
|
|
720
|
+
"additionalProperties": False,
|
|
721
|
+
},
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
elif name == "get_current_time_tool":
|
|
725
|
+
schemas.append({
|
|
726
|
+
"type": "function",
|
|
727
|
+
"name": name,
|
|
728
|
+
"description": "Consulta a hora atual, hora de agora informada pelo sistema.",
|
|
729
|
+
"parameters": {
|
|
730
|
+
"type": "object",
|
|
731
|
+
"properties": {},
|
|
732
|
+
"additionalProperties": False,
|
|
733
|
+
},
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
elif name == "retriever_tool":
|
|
737
|
+
schemas.append({
|
|
738
|
+
"type": "function",
|
|
739
|
+
"name": name,
|
|
740
|
+
"description": "Busca dados relevantes no índice local (RAG). Essa função precisa receber dois argumentos: nome da fonte de dados e 'query'.",
|
|
741
|
+
"parameters": {
|
|
742
|
+
"type": "object",
|
|
743
|
+
"properties": {
|
|
744
|
+
"source_name": {"type": "string", "description": "Nome da fonte de dados"},
|
|
745
|
+
"query": {"type": "string", "description": "Texto para pesquisa"},
|
|
746
|
+
"top_k": {"type": "integer", "minimum": 1, "maximum": 50},
|
|
747
|
+
},
|
|
748
|
+
"required": ["source_name", "query"],
|
|
749
|
+
"additionalProperties": False,
|
|
750
|
+
},
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
elif name == "search_web_tool":
|
|
754
|
+
schemas.append({
|
|
755
|
+
"type": "function",
|
|
756
|
+
"name": name,
|
|
757
|
+
"description": "Faz busca na web. Essa função precisa receber como argumento o texto para pesquisa.",
|
|
758
|
+
"parameters": {
|
|
759
|
+
"type": "object",
|
|
760
|
+
"properties": {
|
|
761
|
+
"query": {"type": "string", "description": "Texto para pesquisa"},
|
|
762
|
+
},
|
|
763
|
+
"required": ["query"],
|
|
764
|
+
"additionalProperties": False,
|
|
765
|
+
},
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
elif name == "execute_python_code_tool":
|
|
769
|
+
schemas.append({
|
|
770
|
+
"type": "function",
|
|
771
|
+
"name": name,
|
|
772
|
+
"description": "Executa código Python assíncrono controlado (retorna variável 'result'). Essa função precisa receber como argumento o script a ser executado.",
|
|
773
|
+
"parameters": {
|
|
774
|
+
"type": "object",
|
|
775
|
+
"properties": {
|
|
776
|
+
"code": {"type": "string", "description": "Código Python a ser executado"},
|
|
777
|
+
},
|
|
778
|
+
"required": ["code"],
|
|
779
|
+
"additionalProperties": False,
|
|
780
|
+
},
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
elif name == "read_file_tool":
|
|
784
|
+
schemas.append({
|
|
785
|
+
"type": "function",
|
|
786
|
+
"name": name,
|
|
787
|
+
"description": "Use essa ferramenta para ler dados de um arquivo. Essa função precisa receber como argumento o nome do arquivo.",
|
|
788
|
+
"parameters": {
|
|
789
|
+
"type": "object",
|
|
790
|
+
"properties": {
|
|
791
|
+
"pathname": {"type": "string", "description": "Pathname do arquivo, incluindo o diretório e o nome do arquivo"},
|
|
792
|
+
"as_base64": {"type": "boolean", "default": False},
|
|
793
|
+
},
|
|
794
|
+
"required": ["pathname"],
|
|
795
|
+
"additionalProperties": False,
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
elif name == "write_file_tool":
|
|
801
|
+
schemas.append({
|
|
802
|
+
"type": "function",
|
|
803
|
+
"name": name,
|
|
804
|
+
"description": "Use essa ferramenta para salvar/gravar dados num arquivo. Essa função precisa receber dois argumentos do tipo string: nome do arquivo e conteúdo.",
|
|
805
|
+
"parameters": {
|
|
806
|
+
"type": "object",
|
|
807
|
+
"properties": {
|
|
808
|
+
"pathname": {"type": "string", "description": "Pathname do arquivo, incluindo o diretório e o nome do arquivo"},
|
|
809
|
+
"content": {"type": "string", "description": "Conteúdo do arquivo"},
|
|
810
|
+
},
|
|
811
|
+
"required": ["pathname", "content"],
|
|
812
|
+
"additionalProperties": False,
|
|
813
|
+
},
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
elif name == "ask_to_agent_tool":
|
|
817
|
+
schemas.append({
|
|
818
|
+
"type": "function",
|
|
819
|
+
"name": name,
|
|
820
|
+
"description": "Use essa ferramenta para fazer perguntas/pedidos para outros agentes. Essa função precisa receber dois argumentos do tipo string: nome do agente e pergunta/pedido.",
|
|
821
|
+
"parameters": {
|
|
822
|
+
"type": "object",
|
|
823
|
+
"properties": {
|
|
824
|
+
"agent_name": {"type": "string", "description": "Nome do agente que será consultado"},
|
|
825
|
+
"question": {"type": "string", "description": "Pergunta ou pedido para o agente"},
|
|
826
|
+
},
|
|
827
|
+
"required": ["agent_id", "question"],
|
|
828
|
+
"additionalProperties": False,
|
|
829
|
+
},
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
return schemas
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def build_custom_tool_schemas(custom_tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
836
|
+
"""Generate minimal JSON schema definitions for API-exposed tools.
|
|
837
|
+
|
|
838
|
+
Exemplo de ferramentas personalizadas:
|
|
839
|
+
[
|
|
840
|
+
{
|
|
841
|
+
'tool': 'GetOpportunitySummary',
|
|
842
|
+
'custom_tool': ask_to_agent_tool,
|
|
843
|
+
'description': 'Prepara um sumário de uma oportunidade. Para usar essa ferramenta submeta um prompt solicitando o sumário, incluindo código da oportunidade.',
|
|
844
|
+
'agent_name': 'opportunity_specialist',
|
|
845
|
+
}
|
|
846
|
+
]
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
custom_tools (list[dict[str, Any]]): parametrização das ferramentas personalizadas.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
list[dict[str, Any]]: Lista de esquemas no formato esperado pela OpenAI Responses API.
|
|
853
|
+
"""
|
|
854
|
+
|
|
855
|
+
schemas: list[dict[str, Any]] = []
|
|
856
|
+
for custom_tool in custom_tools:
|
|
857
|
+
# considera os mesmos parâmetros da ferramenta interna associada
|
|
858
|
+
tool_parameters = build_tool_schemas([custom_tool['tool']])
|
|
859
|
+
|
|
860
|
+
schemas.append({
|
|
861
|
+
"type": "function",
|
|
862
|
+
"name": custom_tool['custom_tool'],
|
|
863
|
+
"description": custom_tool['description'],
|
|
864
|
+
"parameters": tool_parameters[0]['args_obj'] if tool_parameters else {},
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
return schemas
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def exec_tool(tool_name: str, tool_fn, args_obj: dict[str, Any], ctx: ToolContext) -> Any:
|
|
871
|
+
"""Execute a tool by name and arguments."""
|
|
872
|
+
# se for uma ferramenta interna então executa
|
|
873
|
+
match tool_name:
|
|
874
|
+
case "get_current_date_tool":
|
|
875
|
+
return tool_fn(ctx=ctx)
|
|
876
|
+
|
|
877
|
+
case "get_current_time_tool":
|
|
878
|
+
return tool_fn(ctx=ctx)
|
|
879
|
+
|
|
880
|
+
case "calculator_tool":
|
|
881
|
+
expr = args_obj.get("expression") or args_obj.get("expr") or args_obj.get(
|
|
882
|
+
"code") or ""
|
|
883
|
+
return tool_fn(expression=str(expr), ctx=ctx)
|
|
884
|
+
|
|
885
|
+
case "search_web_tool":
|
|
886
|
+
q = args_obj.get("query") or args_obj.get("q") or args_obj.get("text") or ""
|
|
887
|
+
return tool_fn(query=str(q), ctx=ctx)
|
|
888
|
+
|
|
889
|
+
case "retriever_tool":
|
|
890
|
+
source_name = args_obj.get("source_name") or args_obj.get("source") or args_obj.get("name") or ""
|
|
891
|
+
q = args_obj.get("query") or args_obj.get("q") or args_obj.get("text") or ""
|
|
892
|
+
top_k = args_obj.get("top_k") or 4
|
|
893
|
+
return tool_fn(source_name=str(source_name), query=str(q), top_k=int(top_k), ctx=ctx)
|
|
894
|
+
|
|
895
|
+
case "execute_python_code_tool":
|
|
896
|
+
code = args_obj.get("code") or args_obj.get("source") or args_obj.get(
|
|
897
|
+
"python") or ""
|
|
898
|
+
return tool_fn(code=str(code), ctx=ctx)
|
|
899
|
+
|
|
900
|
+
case "read_file_tool":
|
|
901
|
+
pathname = args_obj.get("pathname") or args_obj.get("file") or ""
|
|
902
|
+
as_b64 = bool(args_obj.get("as_base64", False))
|
|
903
|
+
return tool_fn(pathname=str(pathname), ctx=ctx) if not as_b64 else tool_fn(pathname=str(pathname), as_base64=True, ctx=ctx)
|
|
904
|
+
|
|
905
|
+
case "write_file_tool":
|
|
906
|
+
pathname = args_obj.get("pathname") or args_obj.get("file") or ""
|
|
907
|
+
content = args_obj.get("content") or args_obj.get("text") or ""
|
|
908
|
+
return tool_fn(pathname=str(pathname), content=str(content), ctx=ctx)
|
|
909
|
+
|
|
910
|
+
case "ask_to_agent_tool":
|
|
911
|
+
agent_name = args_obj.get("agent_name") or ""
|
|
912
|
+
question = args_obj.get("question") or args_obj.get("prompt") or ""
|
|
913
|
+
return tool_fn(agent_name=str(agent_name), question=str(question), ctx=ctx)
|
|
914
|
+
|
|
915
|
+
# se for uma custom tool então executa
|
|
916
|
+
for custom_tool in getattr(ctx, "custom_tools", []):
|
|
917
|
+
if custom_tool["custom_tool"] == tool_name:
|
|
918
|
+
# executa a ferramenta associada à custom tool
|
|
919
|
+
return exec_tool(custom_tool["tool"], tool_fn, args_obj, ctx)
|
|
920
|
+
|
|
921
|
+
# se for uma MCP tool então executa
|
|
922
|
+
for tool_schema in ctx.mcp_tools_schema or []:
|
|
923
|
+
if tool_schema["name"] == tool_name:
|
|
924
|
+
return exec_mcp_tool(tool_name, args_obj, ctx)
|
|
925
|
+
|
|
926
|
+
# se for uma ferramenta externa definida no código então executa
|
|
927
|
+
for tool_schema in ctx.external_tools_schema or []:
|
|
928
|
+
if tool_schema["name"] == tool_name:
|
|
929
|
+
return tool_fn(tool_name=tool_name, args_obj=args_obj, ctx=ctx)
|
|
930
|
+
|
|
931
|
+
# não identificou a ferramenta
|
|
932
|
+
return f"Não consegui identificar a ferramenta '{tool_name}'!"
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def execute_tool_with_policy(tool_name: str,
|
|
936
|
+
tool_fn: ToolFn,
|
|
937
|
+
args_obj: dict[str, Any],
|
|
938
|
+
ctx: ToolContext,
|
|
939
|
+
runner: Callable[[], Any] | None = None) -> ToolExecutionResult:
|
|
940
|
+
"""Roda uma ferramenta com logs consistentes."""
|
|
941
|
+
start = time.perf_counter()
|
|
942
|
+
msg_id = getattr(ctx, "message_id", "-")
|
|
943
|
+
if getattr(ctx, "is_verbose", False):
|
|
944
|
+
log_msg(f"id={msg_id} tool_start name={tool_name}", func="tool_runner", action="tools", color="MAGENTA")
|
|
945
|
+
|
|
946
|
+
callable_to_run = runner or (lambda: exec_tool(tool_name, tool_fn, args_obj, ctx))
|
|
947
|
+
try:
|
|
948
|
+
value = callable_to_run()
|
|
949
|
+
elapsed = time.perf_counter() - start
|
|
950
|
+
if getattr(ctx, "is_verbose", False):
|
|
951
|
+
log_msg(
|
|
952
|
+
f"id={msg_id} tool_end name={tool_name} elapsed={elapsed:.2f}s",
|
|
953
|
+
func="tool_runner",
|
|
954
|
+
action="tools",
|
|
955
|
+
color="MAGENTA",
|
|
956
|
+
)
|
|
957
|
+
return ToolExecutionResult(value=value, elapsed_seconds=elapsed)
|
|
958
|
+
except Exception as exc: # noqa: BLE001
|
|
959
|
+
elapsed = time.perf_counter() - start
|
|
960
|
+
err = f"Erro ao executar ferramenta: {exc}"
|
|
961
|
+
log_msg(f"id={msg_id} tool_error name={tool_name}: {err}", func="tool_runner", action="tools", color="RED")
|
|
962
|
+
return ToolExecutionResult(value=None, elapsed_seconds=elapsed, error=err)
|