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.
@@ -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)