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
oskaragent/agent.py
ADDED
|
@@ -0,0 +1,1911 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from random import Random
|
|
15
|
+
from textwrap import dedent
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from colorama import Fore
|
|
19
|
+
|
|
20
|
+
from .agent_config import AgentConfig
|
|
21
|
+
from .agent_mcp_tools import exec_mcp_tool, build_mcp_tools_schemas
|
|
22
|
+
from .agent_tools import (DEFAULT_TOOL_NAMES, ToolExecutionResult, build_tool_schemas,
|
|
23
|
+
execute_tool_with_policy, exec_tool, get_builtin_tools, build_custom_tool_schemas)
|
|
24
|
+
from .helpers import log_msg, ToolContext, render_file_as_content_blocks, create_working_folder
|
|
25
|
+
from .rag.sql_repository import SqlRepository
|
|
26
|
+
from .converters import convert_markdown_to_html_block, convert_json_to_csv
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _approx_token_count(text: str) -> int:
|
|
30
|
+
"""Estimate token usage based on word count heuristics.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
text (str): Conteúdo textual cujo total aproximado de tokens será calculado.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
int: Estimativa de tokens, assumindo média de 0,75 palavra por token.
|
|
37
|
+
"""
|
|
38
|
+
words = len(re.findall(r"\S+", text))
|
|
39
|
+
return max(1, int(words / 0.75))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_base_url() -> str | None:
|
|
43
|
+
"""Read the OpenAI base URL from the repository-level `keys.yaml`.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
str | None: URL configurada ou `None` quando não encontrada.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
root = Path(__file__).resolve().parent.parent # project root
|
|
50
|
+
keys_path = root / "keys.yaml"
|
|
51
|
+
if not keys_path.exists():
|
|
52
|
+
return None
|
|
53
|
+
for raw in keys_path.read_text(encoding="utf-8").splitlines():
|
|
54
|
+
line = raw.strip()
|
|
55
|
+
if not line or not line.startswith("OPENAI_BASE_URL"):
|
|
56
|
+
continue
|
|
57
|
+
parts = line.split(":", 1)
|
|
58
|
+
if len(parts) != 2:
|
|
59
|
+
continue
|
|
60
|
+
val = parts[1].strip().strip("'\"")
|
|
61
|
+
return val or None
|
|
62
|
+
return None
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class ResiliencePolicy:
|
|
69
|
+
"""Políticas de resiliência aplicadas nas chamadas ao LLM e ferramentas (ver docs/ADR-setup-agent-refactor.md)."""
|
|
70
|
+
|
|
71
|
+
max_llm_attempts: int = 3
|
|
72
|
+
llm_timeout_seconds: float = 120.0
|
|
73
|
+
backoff_base_seconds: float = 2.0
|
|
74
|
+
backoff_jitter_seconds: float = 1.0
|
|
75
|
+
max_tool_iterations: int = 3
|
|
76
|
+
tool_timeout_seconds: float = 60.0
|
|
77
|
+
tool_timeouts: dict[str, float] | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Oskar:
|
|
81
|
+
def __init__(self,
|
|
82
|
+
agent_config: AgentConfig | None = None,
|
|
83
|
+
input_data: dict | None = None,
|
|
84
|
+
session_id: str | None = None,
|
|
85
|
+
session_name: str | None = None,
|
|
86
|
+
session_created_at: datetime | None = None,
|
|
87
|
+
session_updated_at: datetime | None = None,
|
|
88
|
+
working_folder: str | None = None,
|
|
89
|
+
is_verbose: bool = False,
|
|
90
|
+
response_callback: Callable[[dict[str, Any]], None] | None = None,
|
|
91
|
+
mcp_tools: list[dict] | None = None,
|
|
92
|
+
external_tools_schema: list[dict] | None = None,
|
|
93
|
+
external_tools_function_handler: Callable[
|
|
94
|
+
[str, dict | None, ToolContext], dict[str, Any]] | None = None,
|
|
95
|
+
openai_client: Any | None = None,
|
|
96
|
+
_mcp_tools_schemas: list[dict] | None = None,
|
|
97
|
+
):
|
|
98
|
+
"""Instantiate the oskaragent agent with configuration, session, and tooling data.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
agent_config (AgentConfig | None, optional): Configuração específica do agente. Defaults to None.
|
|
102
|
+
input_data (dict | None, optional): Dados auxiliares repassados aos prompts. Defaults to None.
|
|
103
|
+
session_id (str | None, optional): Identificador único da sessão corrente. Defaults to None.
|
|
104
|
+
session_name (str | None, optional): Nome amigável associado à sessão. Defaults to None.
|
|
105
|
+
session_created_at (datetime | None, optional): Data/hora de criação da sessão. Defaults to now().
|
|
106
|
+
session_updated_at (datetime | None, optional): Última atualização registrada para a sessão. Defaults to now().
|
|
107
|
+
working_folder (str | None, optional): Diretório base para arquivos temporários/output. Defaults to None.
|
|
108
|
+
is_verbose (bool, optional): Indica se logs detalhados devem ser emitidos. Defaults to False.
|
|
109
|
+
response_callback (Callable[[dict[str, Any]], None] | None, optional): Callback executado após gerar respostas. Defaults to None.
|
|
110
|
+
mcp_tools: list[dict[str, Any]: Esquema das ferramentas MCP externas. Defaults to None.
|
|
111
|
+
external_tools_schema: list[dict[str, Any]: Esquema das ferramentas externas definidas no código. Defaults to None.
|
|
112
|
+
external_tools_function_handler: Callable[[str, dict | None], dict[str, Any]] | None, optional): Função para executar ferramentas externas definidas no código. Defaults to None.
|
|
113
|
+
openai_client (Any | None, optional): Cliente OpenAI injetado para facilitar testes. Defaults to None.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
None: O construtor apenas inicializa o estado interno.
|
|
117
|
+
"""
|
|
118
|
+
# Config & session
|
|
119
|
+
self.agent_config: AgentConfig = agent_config or AgentConfig()
|
|
120
|
+
self.input_data: dict[str, Any] = input_data or {}
|
|
121
|
+
self.session_id: str = session_id or str(uuid.uuid4())
|
|
122
|
+
self.session_name: str | None = session_name
|
|
123
|
+
self.session_created_at: datetime = session_created_at
|
|
124
|
+
self.session_updated_at: datetime = session_updated_at
|
|
125
|
+
self.working_folder: str | None = working_folder
|
|
126
|
+
self.is_verbose: bool = is_verbose
|
|
127
|
+
self._reasoning_effort: str | None = None
|
|
128
|
+
self._resilience: ResiliencePolicy = self._build_resilience_policy()
|
|
129
|
+
|
|
130
|
+
# Tools
|
|
131
|
+
self.tools: dict[str, Callable[..., Any]] = {}
|
|
132
|
+
self._mcp_tools_schemas: list[dict] | None = None
|
|
133
|
+
|
|
134
|
+
if _mcp_tools_schemas:
|
|
135
|
+
# se os esquemas das ferramentas MCP já estiverem montados então utiliza
|
|
136
|
+
self._mcp_tools_schemas = _mcp_tools_schemas
|
|
137
|
+
elif mcp_tools:
|
|
138
|
+
# senão consulta os servidores e gera os esquemas
|
|
139
|
+
self._mcp_tools_schemas = asyncio.run(build_mcp_tools_schemas(mcp_tools))
|
|
140
|
+
|
|
141
|
+
self.external_tools_schema = external_tools_schema
|
|
142
|
+
self.external_tools_function_handler = external_tools_function_handler
|
|
143
|
+
|
|
144
|
+
# History (structured records)
|
|
145
|
+
self.message_history: list[dict[str, Any]] = []
|
|
146
|
+
self.history_window_size: int = int((self.agent_config.model_settings or {}).get("history_window_size", 5))
|
|
147
|
+
|
|
148
|
+
# RAG / retrieval
|
|
149
|
+
self.retrievers: list[dict[str, Any]] = []
|
|
150
|
+
|
|
151
|
+
# Client
|
|
152
|
+
self._openai_client: Any | None = openai_client
|
|
153
|
+
self._init_error_reason: str | None = None
|
|
154
|
+
|
|
155
|
+
# Subordinate agents (for multi-agent)
|
|
156
|
+
self.subordinated_agents = []
|
|
157
|
+
|
|
158
|
+
# Callback and others functions
|
|
159
|
+
self.response_callback = response_callback
|
|
160
|
+
self._custom_tools_names: set[
|
|
161
|
+
str] = set() # functions built on other tools. Example: tool 'GetOpportunityInfo' for custom tool 'ask_to_agent_tool|GetOpportunityInfo'
|
|
162
|
+
|
|
163
|
+
# system prompt prepared to be sent to LLM
|
|
164
|
+
self._my_system_prompt = None
|
|
165
|
+
|
|
166
|
+
# attached files
|
|
167
|
+
self._attached_files = []
|
|
168
|
+
|
|
169
|
+
# setup de agent
|
|
170
|
+
self._setup_agent()
|
|
171
|
+
|
|
172
|
+
def _build_resilience_policy(self) -> ResiliencePolicy:
|
|
173
|
+
"""Monta política de resiliência a partir das configurações do agente.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
ResiliencePolicy: Política consolidada para LLM e ferramentas, mantendo paridade com docs/ADR-setup-agent-refactor.md e os defaults seguros.
|
|
177
|
+
"""
|
|
178
|
+
cfg = (self.agent_config.model_settings or {}).get("resilience", {})
|
|
179
|
+
|
|
180
|
+
def _coerce_timeout_map(value: Any) -> dict[str, float]:
|
|
181
|
+
"""Normaliza os timeouts específicos por ferramenta.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
value (Any): Estrutura de timeouts recebida via configuração do agente.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
dict[str, float]: Mapa normalizado de `nome_da_ferramenta -> timeout_em_segundos`.
|
|
188
|
+
"""
|
|
189
|
+
if not isinstance(value, dict):
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
timeouts: dict[str, float] = {}
|
|
193
|
+
for name, raw_timeout in value.items():
|
|
194
|
+
try:
|
|
195
|
+
timeouts[str(name)] = float(raw_timeout)
|
|
196
|
+
except Exception:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
return timeouts
|
|
200
|
+
|
|
201
|
+
def _coerce_int(value: Any, default: int) -> int:
|
|
202
|
+
try:
|
|
203
|
+
return int(value)
|
|
204
|
+
except Exception:
|
|
205
|
+
return default
|
|
206
|
+
|
|
207
|
+
def _coerce_float(value: Any, default: float) -> float:
|
|
208
|
+
try:
|
|
209
|
+
return float(value)
|
|
210
|
+
except Exception:
|
|
211
|
+
return default
|
|
212
|
+
|
|
213
|
+
# Permite que consultas mais pesadas do Salesforce concluam sem falso positivo de timeout.
|
|
214
|
+
default_tool_timeouts = {"get_salesforce_opportunity_info_tool": 45.0}
|
|
215
|
+
configured_timeouts = _coerce_timeout_map(cfg.get("tool_timeouts"))
|
|
216
|
+
merged_tool_timeouts = {**default_tool_timeouts, **configured_timeouts}
|
|
217
|
+
|
|
218
|
+
return ResiliencePolicy(
|
|
219
|
+
max_llm_attempts=_coerce_int(cfg.get("max_llm_attempts"), 3),
|
|
220
|
+
llm_timeout_seconds=_coerce_float(cfg.get("llm_timeout_seconds"), 180.0),
|
|
221
|
+
backoff_base_seconds=_coerce_float(cfg.get("backoff_base_seconds"), 2.0),
|
|
222
|
+
backoff_jitter_seconds=_coerce_float(cfg.get("backoff_jitter_seconds"), 1.0),
|
|
223
|
+
max_tool_iterations=_coerce_int(cfg.get("max_tool_iterations"), 3),
|
|
224
|
+
tool_timeout_seconds=_coerce_float(cfg.get("tool_timeout_seconds"), 180.0),
|
|
225
|
+
tool_timeouts=merged_tool_timeouts,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _now(self) -> datetime:
|
|
229
|
+
"""Retorna o tempo atual usando a fonte de tempo injetada.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
datetime: Momento capturado com a função de relógio configurada (permite testes determinísticos).
|
|
233
|
+
"""
|
|
234
|
+
return datetime.now()
|
|
235
|
+
|
|
236
|
+
def _ensure_session_working_folder(self) -> Path | None:
|
|
237
|
+
"""
|
|
238
|
+
Garante que o diretório de trabalho da sessão exista e retorna seu caminho.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
|
|
242
|
+
Path: path to the session working folder
|
|
243
|
+
"""
|
|
244
|
+
if not self.working_folder:
|
|
245
|
+
self.working_folder = "_oskar_working_folder"
|
|
246
|
+
self.working_folder = create_working_folder(Path(self.working_folder), self.session_id)
|
|
247
|
+
return Path(self.working_folder)
|
|
248
|
+
|
|
249
|
+
def _remove_empty_session_working_folder(self):
|
|
250
|
+
"""
|
|
251
|
+
Removes empty files and directories within the session working folder.
|
|
252
|
+
|
|
253
|
+
This method ensures that the session working folder is cleaned up by
|
|
254
|
+
removing any empty files and directories present within it. If the session
|
|
255
|
+
directory itself becomes empty after the cleanup, it is also removed.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
FileNotFoundError: If the session working folder does not exist.
|
|
259
|
+
|
|
260
|
+
"""
|
|
261
|
+
if not self.working_folder:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
session_dir = Path(self.working_folder)
|
|
265
|
+
for entry in session_dir.iterdir():
|
|
266
|
+
if entry.is_file() and entry.stat().st_size == 0:
|
|
267
|
+
entry.unlink()
|
|
268
|
+
if not any(session_dir.iterdir()):
|
|
269
|
+
session_dir.rmdir()
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def id(self) -> str:
|
|
273
|
+
"""str: Identificador atual do agente (alias para `agent_config.agent_name`)."""
|
|
274
|
+
return self.agent_config.agent_id
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def name(self) -> str:
|
|
278
|
+
"""str: Nome exposto do agente."""
|
|
279
|
+
return self.agent_config.agent_name
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def description(self) -> str:
|
|
283
|
+
"""str: Descrição textual do agente."""
|
|
284
|
+
return self.agent_config.description
|
|
285
|
+
|
|
286
|
+
@description.setter
|
|
287
|
+
def description(self, value: str):
|
|
288
|
+
"""Atualiza a descrição pública do agente."""
|
|
289
|
+
self.agent_config.description = value
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def model(self) -> str:
|
|
293
|
+
"""str: Nome do modelo configurado para o agente."""
|
|
294
|
+
return self.agent_config.model
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def reasoning_effort(self):
|
|
298
|
+
"""str: Modo de raciocínio atual do agente."""
|
|
299
|
+
return self._reasoning_effort
|
|
300
|
+
|
|
301
|
+
@reasoning_effort.setter
|
|
302
|
+
def reasoning_effort(self, value: str):
|
|
303
|
+
"""Define o modo de raciocínio do agente."""
|
|
304
|
+
if value is not None:
|
|
305
|
+
if value not in ["none", "low", "medium", "high"]:
|
|
306
|
+
raise ValueError(f"Invalid mode: {value}. Valid modes are: none, low, medium, high")
|
|
307
|
+
if value == "none":
|
|
308
|
+
value = None
|
|
309
|
+
self._reasoning_effort = value
|
|
310
|
+
|
|
311
|
+
def to_json(self) -> dict[str, Any]:
|
|
312
|
+
"""Export the agent state to a JSON-serializable structure.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
dict[str, Any]: Dicionário contendo configuração, sessão, estado e histórico do agente.
|
|
316
|
+
"""
|
|
317
|
+
if self.session_created_at is None:
|
|
318
|
+
self.session_created_at = self.session_updated_at = self._now()
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"agent_config": self.agent_config.to_json(),
|
|
322
|
+
"session": {
|
|
323
|
+
"id": self.session_id,
|
|
324
|
+
"name": self.session_name,
|
|
325
|
+
"created_at": self.session_created_at.isoformat(),
|
|
326
|
+
"updated_at": self.session_updated_at.isoformat(),
|
|
327
|
+
"working_folder": self.working_folder,
|
|
328
|
+
"is_verbose": self.is_verbose,
|
|
329
|
+
"history_window_size": self.history_window_size,
|
|
330
|
+
"reasoning_effort": self.reasoning_effort,
|
|
331
|
+
"input_data": self.input_data,
|
|
332
|
+
"mcp_tools_schemas": self._mcp_tools_schemas,
|
|
333
|
+
"external_tools_schema": self.external_tools_schema,
|
|
334
|
+
"message_history": self.message_history,
|
|
335
|
+
"attached_files": self._attached_files,
|
|
336
|
+
},
|
|
337
|
+
"subordinated_agents": [a.to_json() for a in self.subordinated_agents or []],
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def from_json(cls, data: dict[str, Any],
|
|
342
|
+
working_folder: str,
|
|
343
|
+
is_verbose: bool = False,
|
|
344
|
+
response_callback: Callable[[dict[str, Any]], None] | None = None,
|
|
345
|
+
mcp_tools: list[dict] | None = None,
|
|
346
|
+
external_tools_schema: list[dict] | None = None,
|
|
347
|
+
external_tools_function_handler: Callable[
|
|
348
|
+
[str, dict | None, ToolContext], dict[str, Any]] | None = None,
|
|
349
|
+
) -> "oskaragent":
|
|
350
|
+
"""Rehydrate an agent instance from serialized state.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
data (dict[str, Any]): Estrutura previamente gerada por `to_json`.
|
|
354
|
+
working_folder: pasta de trabalho.
|
|
355
|
+
is_verbose (bool, optional): Indica se logs detalhados devem ser emitidos. Defaults to False.
|
|
356
|
+
response_callback (Callable[[dict[str, Any]], None] | None, optional): Callback executado após gerar respostas. Defaults to None.
|
|
357
|
+
mcp_tools: list[dict[str, Any]: Esquema das ferramentas MCP externas. Defaults to None.
|
|
358
|
+
external_tools_schema: list[dict[str, Any]: Esquema das ferramentas externas definidas no código. Defaults to None.
|
|
359
|
+
external_tools_function_handler: Callable[[str, dict | None], dict[str, Any]] | None, optional): Função para executar ferramentas externas definidas no código. Defaults to None.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Oskar: Nova instância com configuração, sessão e histórico restaurados.
|
|
363
|
+
"""
|
|
364
|
+
cfg_json = (data or {}).get("agent_config") or {}
|
|
365
|
+
agent_cfg = AgentConfig(json_config=cfg_json)
|
|
366
|
+
|
|
367
|
+
sess = (data or {}).get("session") or {}
|
|
368
|
+
|
|
369
|
+
def _parse_dt(val: Any, default: datetime) -> datetime:
|
|
370
|
+
"""Parse ISO-8601 strings into datetime objects, falling back to a default.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
val (Any): Valor potencialmente representando uma data/hora.
|
|
374
|
+
default (datetime): Data padrão utilizada em caso de falha.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
datetime: Data convertida ou o valor padrão.
|
|
378
|
+
"""
|
|
379
|
+
if isinstance(val, datetime):
|
|
380
|
+
return val
|
|
381
|
+
if isinstance(val, str) and val:
|
|
382
|
+
try:
|
|
383
|
+
# Accept 'Z' suffix as UTC
|
|
384
|
+
v = val.replace("Z", "+00:00") if val.endswith("Z") else val
|
|
385
|
+
return datetime.fromisoformat(v)
|
|
386
|
+
except Exception:
|
|
387
|
+
return default
|
|
388
|
+
return default
|
|
389
|
+
|
|
390
|
+
created_at = _parse_dt(sess.get("created_at"), datetime.now())
|
|
391
|
+
updated_at = _parse_dt(sess.get("updated_at"), datetime.now())
|
|
392
|
+
|
|
393
|
+
inst = cls(
|
|
394
|
+
agent_config=agent_cfg,
|
|
395
|
+
input_data=sess.get("input_data", {}),
|
|
396
|
+
session_id=sess.get("id"),
|
|
397
|
+
session_name=sess.get("name"),
|
|
398
|
+
session_created_at=created_at,
|
|
399
|
+
session_updated_at=updated_at,
|
|
400
|
+
working_folder=working_folder,
|
|
401
|
+
is_verbose=is_verbose,
|
|
402
|
+
response_callback=response_callback,
|
|
403
|
+
mcp_tools=mcp_tools,
|
|
404
|
+
external_tools_schema=external_tools_schema,
|
|
405
|
+
external_tools_function_handler=external_tools_function_handler,
|
|
406
|
+
_mcp_tools_schemas=sess.get("mcp_tools_schemas", [])
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Keep the original window size if present
|
|
410
|
+
try:
|
|
411
|
+
inst.history_window_size = int(sess.get("history_window_size", inst.history_window_size))
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
# restore reasoning_effort
|
|
416
|
+
inst.reasoning_effort = sess.get("reasoning_effort", None)
|
|
417
|
+
|
|
418
|
+
# restore attached files
|
|
419
|
+
inst._attached_files = sess.get("attached_files", [])
|
|
420
|
+
|
|
421
|
+
# restore MCP tools
|
|
422
|
+
inst._mcp_tools_schemas = sess.get("mcp_tools_schemas", [])
|
|
423
|
+
|
|
424
|
+
# Restore message history as-is
|
|
425
|
+
mh = data.get("message_history") or sess.get("message_history")
|
|
426
|
+
if isinstance(mh, list):
|
|
427
|
+
inst.message_history = mh
|
|
428
|
+
|
|
429
|
+
# Restore subordinated agents
|
|
430
|
+
subordinated_agents = []
|
|
431
|
+
for a in (data or {}).get("subordinated_agents", []):
|
|
432
|
+
if isinstance(a, dict):
|
|
433
|
+
# Recover from JSON
|
|
434
|
+
subordinated_agents.append(cls.from_json(a, working_folder))
|
|
435
|
+
else:
|
|
436
|
+
# Recover from dict
|
|
437
|
+
subordinated_agents.append(cls(
|
|
438
|
+
agent_config=AgentConfig(json_config=a.get("agent_config", {})), working_folder=working_folder),
|
|
439
|
+
)
|
|
440
|
+
inst.subordinated_agents = subordinated_agents
|
|
441
|
+
|
|
442
|
+
return inst
|
|
443
|
+
|
|
444
|
+
def add_subordinated_agent(self, subordinate_agent: Oskar, role: str = None) -> None:
|
|
445
|
+
"""Associate another oskaragent agent as a subordinate collaborator.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
subordinate_agent (Oskar): Instância do agente subordinado a ser anexado.
|
|
449
|
+
role (str, optional): Papel ou descrição do subordinado. Defaults to None.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
None: Atualiza a lista interna de agentes subordinados.
|
|
453
|
+
"""
|
|
454
|
+
found_agent = next(
|
|
455
|
+
(agent for agent in self.subordinated_agents if agent.name == subordinate_agent.name),
|
|
456
|
+
None)
|
|
457
|
+
|
|
458
|
+
if found_agent:
|
|
459
|
+
# o agente já está associado
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
if role:
|
|
463
|
+
subordinate_agent.description = role
|
|
464
|
+
|
|
465
|
+
subordinate_agent.working_folder = self.working_folder
|
|
466
|
+
subordinate_agent.is_verbose = self.is_verbose
|
|
467
|
+
|
|
468
|
+
# adiciona o agente na lista de agentes subordinados
|
|
469
|
+
self.subordinated_agents.append(subordinate_agent)
|
|
470
|
+
|
|
471
|
+
# atualiza as ferramentas o system prompt para fazer referência ao novo agente
|
|
472
|
+
self._build_system_prompt()
|
|
473
|
+
self._build_tools_registry()
|
|
474
|
+
|
|
475
|
+
def _setup_agent(self):
|
|
476
|
+
"""Prepare OpenAI client, retrieval stack, and tool registry.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
None: Todos os efeitos são aplicados diretamente no estado interno do agente.
|
|
480
|
+
"""
|
|
481
|
+
self._configure_openai_client()
|
|
482
|
+
self.retrievers = self._load_retrievers()
|
|
483
|
+
self._prepare_working_databases()
|
|
484
|
+
|
|
485
|
+
self._build_tools_registry()
|
|
486
|
+
self._build_system_prompt()
|
|
487
|
+
|
|
488
|
+
def _configure_openai_client(self) -> None:
|
|
489
|
+
"""Inicializa o cliente OpenAI respeitando dependências injetadas.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
None: Configura `_openai_client` ou registra o motivo de falha para modo offline.
|
|
493
|
+
"""
|
|
494
|
+
if self._openai_client is not None:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
from openai import OpenAI # type: ignore
|
|
499
|
+
except Exception as exc: # noqa: BLE001
|
|
500
|
+
self._openai_client = None
|
|
501
|
+
self._init_error_reason = f"Falha ao importar SDK OpenAI: {exc}"
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
505
|
+
base_url = os.getenv("OPENAI_BASE_URL") or get_base_url()
|
|
506
|
+
if not api_key:
|
|
507
|
+
self._openai_client = None
|
|
508
|
+
self._init_error_reason = "Variável de ambiente OPENAI_API_KEY não definida."
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
self._openai_client = OpenAI(api_key=api_key, base_url=base_url) if base_url else OpenAI(api_key=api_key)
|
|
513
|
+
except Exception as exc: # noqa: BLE001
|
|
514
|
+
self._openai_client = None
|
|
515
|
+
self._init_error_reason = f"Falha ao inicializar cliente OpenAI: {exc}"
|
|
516
|
+
|
|
517
|
+
def _load_retrievers(self) -> list[dict[str, Any]]:
|
|
518
|
+
"""Carrega metadados de fontes RAG configuradas.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
list[dict[str, Any]]: Coleção de retrievers prontos para habilitar o RAG conforme agent_config.
|
|
522
|
+
"""
|
|
523
|
+
retrievers: list[dict[str, Any]] = []
|
|
524
|
+
for knowledge_base in self.agent_config.knowledge_base or []:
|
|
525
|
+
kb_path = knowledge_base["folder"]
|
|
526
|
+
kb_name = knowledge_base["name"]
|
|
527
|
+
kb_collection = knowledge_base.get("collection", kb_name)
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
retrievers.append(
|
|
531
|
+
{
|
|
532
|
+
"name": kb_name,
|
|
533
|
+
"details": {
|
|
534
|
+
"kb_path": kb_path,
|
|
535
|
+
"kb_collection": kb_collection
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
except Exception as exc: # noqa: BLE001
|
|
539
|
+
log_msg(
|
|
540
|
+
f"Falha ao inicializar o RAG: '{kb_name}': {exc}",
|
|
541
|
+
func="_load_retrievers",
|
|
542
|
+
action="vectorstore",
|
|
543
|
+
color="YELLOW",
|
|
544
|
+
)
|
|
545
|
+
return retrievers
|
|
546
|
+
|
|
547
|
+
def _prepare_working_databases(self) -> None:
|
|
548
|
+
"""Gera arquivos de apoio para bases relacionais declaradas no agente.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
None: Converte consultas declarativas em CSVs prontos para as ferramentas de análise em pandas.
|
|
552
|
+
"""
|
|
553
|
+
working_dbs = self.agent_config.working_databases or []
|
|
554
|
+
if not working_dbs:
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
folder = self._ensure_session_working_folder()
|
|
558
|
+
for working_database in working_dbs:
|
|
559
|
+
filename = (folder / f"{working_database['name']}.csv").resolve()
|
|
560
|
+
|
|
561
|
+
if not filename.exists():
|
|
562
|
+
log_msg(
|
|
563
|
+
f"Criando CSV a partir do banco de dados: {working_database['name']}",
|
|
564
|
+
func="_prepare_working_databases",
|
|
565
|
+
action="",
|
|
566
|
+
color="CYAN",
|
|
567
|
+
)
|
|
568
|
+
try:
|
|
569
|
+
rep = SqlRepository(working_database["connection_string"])
|
|
570
|
+
doc_data = rep.read(working_database["query"])
|
|
571
|
+
convert_json_to_csv(doc_data, str(filename))
|
|
572
|
+
except Exception as exc: # noqa: BLE001
|
|
573
|
+
log_msg(
|
|
574
|
+
f"Falha ao gerar CSV da base '{working_database['name']}': {exc}",
|
|
575
|
+
func="_prepare_working_databases",
|
|
576
|
+
action="",
|
|
577
|
+
color="RED",
|
|
578
|
+
)
|
|
579
|
+
continue
|
|
580
|
+
|
|
581
|
+
if self.agent_config.working_files is None:
|
|
582
|
+
self.agent_config.working_files = []
|
|
583
|
+
|
|
584
|
+
self.agent_config.working_files.append({
|
|
585
|
+
"name": working_database["name"],
|
|
586
|
+
"description": working_database["description"],
|
|
587
|
+
"pathname": str(filename)
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
python_repl_tool = 'execute_python_code_tool'
|
|
591
|
+
if python_repl_tool not in self.agent_config.tools_names:
|
|
592
|
+
self.agent_config.tools_names.append(python_repl_tool)
|
|
593
|
+
|
|
594
|
+
def _build_tools_registry(self):
|
|
595
|
+
"""Combina ferramentas built-in, customizadas e filtradas por allowlist.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
dict[str, Callable[..., Any]]: Registro final de ferramentas habilitadas, respeitando allowlist e RAG (docs/ADR-setup-agent-refactor.md).
|
|
599
|
+
"""
|
|
600
|
+
tools_names = (self.agent_config.tools_names or []) + list(DEFAULT_TOOL_NAMES)
|
|
601
|
+
|
|
602
|
+
# tool para se comunicar com outros agentes
|
|
603
|
+
if len(self.subordinated_agents) > 0 and "ask_to_agent_tool" not in tools_names:
|
|
604
|
+
tools_names.append("ask_to_agent_tool")
|
|
605
|
+
|
|
606
|
+
# ferramentas built-in (nativas)
|
|
607
|
+
builtin = get_builtin_tools()
|
|
608
|
+
tools = {**builtin}
|
|
609
|
+
allow = set(tools_names)
|
|
610
|
+
if allow:
|
|
611
|
+
if len(self.retrievers) > 0:
|
|
612
|
+
allow.add("retriever_tool")
|
|
613
|
+
tools = {k: v for k, v in tools.items() if k in allow}
|
|
614
|
+
|
|
615
|
+
if len(self.retrievers) > 0 and "retriever_tool" not in tools and "retriever_tool" in builtin:
|
|
616
|
+
tools["retriever_tool"] = builtin["retriever_tool"]
|
|
617
|
+
log_msg("Ferramenta 'retriever_tool' habilitada (RAG ativo)", func="_build_tools_registry",
|
|
618
|
+
action="tools",
|
|
619
|
+
color="MAGENTA")
|
|
620
|
+
|
|
621
|
+
# ferramentas customizadas, como: tool 'GetOpportunityInfo' que chama 'ask_to_agent_tool'
|
|
622
|
+
for custom_tool in (self.agent_config.custom_tools or {}).values() or []:
|
|
623
|
+
tools_names.append(custom_tool["custom_tool"])
|
|
624
|
+
tools[custom_tool["custom_tool"]] = builtin[custom_tool["tool"]]
|
|
625
|
+
|
|
626
|
+
# ferramentas externas definidas no código do chamador
|
|
627
|
+
for tool_schema in self.external_tools_schema or []:
|
|
628
|
+
tools[tool_schema["name"]] = self.external_tools_function_handler
|
|
629
|
+
|
|
630
|
+
# ferramentas MCP
|
|
631
|
+
for tool_schema in self._mcp_tools_schemas or []:
|
|
632
|
+
tools[tool_schema["name"]] = exec_mcp_tool
|
|
633
|
+
|
|
634
|
+
self.tools = tools
|
|
635
|
+
|
|
636
|
+
def _make_tool_context(self, message_id: str) -> ToolContext:
|
|
637
|
+
"""Build the context object passed to tool executions.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
message_id (str): Identificador da mensagem corrente.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
ToolContext: Estrutura com metadados de sessão, arquivos e agentes subordinados.
|
|
644
|
+
"""
|
|
645
|
+
return ToolContext(
|
|
646
|
+
agent_id=self.id,
|
|
647
|
+
session_id=self.session_id,
|
|
648
|
+
message_id=message_id,
|
|
649
|
+
is_verbose=self.is_verbose,
|
|
650
|
+
input_data=self.input_data or {},
|
|
651
|
+
subordinate_agents=self.subordinated_agents,
|
|
652
|
+
mcp_tools_schema=self._mcp_tools_schemas,
|
|
653
|
+
external_tools_schema=self.external_tools_schema,
|
|
654
|
+
retrievers=self.retrievers,
|
|
655
|
+
working_folder=self.working_folder,
|
|
656
|
+
knowledge_base=self.agent_config.knowledge_base
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
def update_system_prompt(self, additional_instructions: str):
|
|
660
|
+
"""Atualiza o system prompt com instruções adicionais, se fornecidas."""
|
|
661
|
+
self._build_system_prompt(additional_instructions=additional_instructions)
|
|
662
|
+
|
|
663
|
+
def _build_system_prompt(self, additional_instructions: str = None):
|
|
664
|
+
"""Compose the system prompt enriched with contextual instructions.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
additional_instructions (str): instruções adicionais a serem consideradas no system prompt.
|
|
668
|
+
"""
|
|
669
|
+
system_prompt = ""
|
|
670
|
+
|
|
671
|
+
# adiciona ao system prompt as instruções para interagir com outros agentes
|
|
672
|
+
if len(self.subordinated_agents) > 0:
|
|
673
|
+
subordinate_agents_in_system_prompt = (
|
|
674
|
+
"Você faz parte de uma equipe de agentes, cada um especialista num assunto. Perguntas sobre esses assuntos devem ser encaminhadas para esses agentes, usando a ferramenta 'ask_to_agent_tool'.\n"
|
|
675
|
+
"Segue a lista de agentes disponíveis:\n")
|
|
676
|
+
|
|
677
|
+
for subordinate_agent in self.subordinated_agents:
|
|
678
|
+
subordinate_agents_in_system_prompt += f"- {subordinate_agent.name}: {subordinate_agent.description}\n"
|
|
679
|
+
|
|
680
|
+
system_prompt += subordinate_agents_in_system_prompt + '\n\n---\n\n'
|
|
681
|
+
# endif --
|
|
682
|
+
|
|
683
|
+
# adiciona ao system prompt as instruções para fazer o agente de comportar como analista de BI
|
|
684
|
+
if len(self.agent_config.working_files or []) > 0:
|
|
685
|
+
system_prompt += "Vamos analisar algumas fontes de dados CSV, cada um com um nome e um conjunto de colunas. Seguem as fontes de dados disponíveis:\n\n"
|
|
686
|
+
|
|
687
|
+
for working_file in self.agent_config.working_files:
|
|
688
|
+
system_prompt += ("**Fonte de dados**\n"
|
|
689
|
+
f"Nome: {working_file['name']}\n"
|
|
690
|
+
f"Descrição: {working_file['description']}\n"
|
|
691
|
+
f"Pathname: {working_file['pathname']}\n"
|
|
692
|
+
f"Colunas:\n")
|
|
693
|
+
|
|
694
|
+
# monta uma lista com os nomes das colunas do CSV cujo nome está em self._dataframe
|
|
695
|
+
df = pd.read_csv(working_file['pathname'], nrows=0) # Read just the first line to get column names
|
|
696
|
+
dataframe_fields = '\n- '.join(df.columns.tolist())
|
|
697
|
+
|
|
698
|
+
system_prompt += f"- {dataframe_fields}\n\n"
|
|
699
|
+
|
|
700
|
+
system_prompt += dedent("""
|
|
701
|
+
Para responder perguntas e pedidos sobre os dados de um CSV gere e execute código Python para extrair as informações seguindo esses passos:
|
|
702
|
+
|
|
703
|
+
1. Carregar o CSV em um `pandas dataframe`.
|
|
704
|
+
2. Converter os campos que contém valores do tipo data (em inglês 'date') para dados do tipo 'datetime'.
|
|
705
|
+
3. Criar os comandos para extrair as informações.
|
|
706
|
+
4. Executar o script Python.
|
|
707
|
+
5. Analisar a resposta e sugerir algum insight.
|
|
708
|
+
6. Quando for solicitado um gráfico então grave a imagem do gráfico no arquivo informado.
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
Ao criar scripts Python para gerar gráficos ou tabelas, siga essas regras:
|
|
713
|
+
|
|
714
|
+
1. Ao usar pandas siga estas regras:
|
|
715
|
+
- Configure o pandas para suprimir avisos sobre *chained assignments* e para exibir todas as linhas e colunas do *dataframe*.
|
|
716
|
+
- Ao agrupar e filtrar dados em um *dataframe* do pandas, armazene resultados intermediários em variáveis e use essas variáveis nas operações subsequentes.
|
|
717
|
+
|
|
718
|
+
2. Ao carregar arquivos CSV em pandas dataframes, siga estas regras:
|
|
719
|
+
- Use o comando `pd.read_csv()` para carregar arquivos CSV em dataframes pandas.
|
|
720
|
+
- Use o parâmetro `encoding='utf-8'` para garantir a correta leitura de caracteres especiais.
|
|
721
|
+
- Use o parâmetro `low_memory=False` para evitar problemas de memória com arquivos grandes.
|
|
722
|
+
- Use o parâmetro `dtype=str` para garantir que todas as colunas sejam tratadas como strings.
|
|
723
|
+
- Converta as colunas do tipo data (em inglês) para o formato datetime usando o comando `pd.to_datetime()`, usando o seguinte código:
|
|
724
|
+
|
|
725
|
+
```python
|
|
726
|
+
# Consider columns whose names contain 'data' or 'date' (case-insensitive)
|
|
727
|
+
date_like_cols = [c for c in df.columns if ('data' in c.lower()) or ('date' in c.lower())]
|
|
728
|
+
for c in date_like_cols:
|
|
729
|
+
# Clean common invisible characters and surrounding spaces before parsing
|
|
730
|
+
series_clean = (
|
|
731
|
+
df[c]
|
|
732
|
+
.astype(str)
|
|
733
|
+
.str.replace('\u00A0', '', regex=False) # non-breaking space
|
|
734
|
+
.str.strip()
|
|
735
|
+
)
|
|
736
|
+
# Convert to datetime; format may vary (e.g., 'YYYY-MM-DD HH:MM:SS'), so let pandas infer
|
|
737
|
+
df[c] = pd.to_datetime(series_clean, errors='coerce')
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
3. Ao criar gráficos siga estas regras:
|
|
741
|
+
- Crie o gráfico usando `matplotlib.pyplot`.
|
|
742
|
+
- Defina o tamanho do gráfico como `figsize=(14,5)`.
|
|
743
|
+
- Configure o gráfico para incluir um título, legenda e linhas de grade. As linhas de grade devem ser cinza claro.
|
|
744
|
+
- Posicione a legenda com base na quantidade de séries de dados:
|
|
745
|
+
* Se a contagem ≤ 15, use: `plt.legend(bbox_to_anchor=(1.44,-0.10), loc='lower right')` e `plt.tight_layout()`.
|
|
746
|
+
* Se 15 < contagem ≤ 30, use: `plt.legend(bbox_to_anchor=(1.44,-0.10), loc='lower right', ncol=2)` e `plt.tight_layout()`.
|
|
747
|
+
* Se 30 < contagem ≤ 45, use: `plt.legend(bbox_to_anchor=(1.44,-0.10), loc='lower right', ncol=3)` e `plt.tight_layout()`.
|
|
748
|
+
* Se contagem > 45, use: `plt.legend(bbox_to_anchor=(1.44,-0.10), loc='lower right', ncol=4)` e `plt.tight_layout()`.
|
|
749
|
+
- NÃO INCLUA O COMANDO `plt.show()`, pois o ambiente de execução não oferece suporte à exibição gráfica.
|
|
750
|
+
- Inclua um comando para salvar o gráfico como um arquivo PNG na pasta `{working_folder}`, com o nome do arquivo especificado no prompt do usuário.
|
|
751
|
+
|
|
752
|
+
4. Ao exibir tabelas, siga estas regras:
|
|
753
|
+
- Coloque o resultado em um *dataframe*.
|
|
754
|
+
- SEMPRE limpe os valores de string usando o seguinte comando:
|
|
755
|
+
```python
|
|
756
|
+
result = result.transform(lambda value: '...' if isinstance(value, str) and (chr(92) + chr(110)) in value else value)
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
- SEMPRE mostre o dataframe usando o seguinte comando:
|
|
760
|
+
```python
|
|
761
|
+
print(result.head(50).to_csv(index=False, quoting=1, quotechar='"'))
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
5. Se o resultado for um conjunto de dados em formato CSV:
|
|
765
|
+
- Se tiver mais de 3 linhas, converta os dados em uma tabela markdown com todas as linhas.
|
|
766
|
+
- Se tiver 0 linhas, responda com o texto "No records".
|
|
767
|
+
- Caso contrário, apresente o resultado como uma lista.
|
|
768
|
+
|
|
769
|
+
6. Ao gerar a resposta siga essas regras:
|
|
770
|
+
- **Não** mencione o nome do arquivo CSV na resposta.
|
|
771
|
+
- **Não** mencione o local do arquivo CSV na resposta.
|
|
772
|
+
- **Não** retorne o código-fonte do script Python na resposta.
|
|
773
|
+
|
|
774
|
+
---\n\n""")
|
|
775
|
+
|
|
776
|
+
# endif --
|
|
777
|
+
|
|
778
|
+
# adiciona ao system prompt as instruções para fazer o agente trabalhar com documentos vetorizados
|
|
779
|
+
if len(self.retrievers or []) > 0:
|
|
780
|
+
system_prompt += "RAG está habilitado, você deve usar a ferramenta retriever_tool para obter o contexto relevante e gerar a resposta com base no contexto recuperado.\n"
|
|
781
|
+
system_prompt += "As seguintes fontes de dados estão disponíveis:\n"
|
|
782
|
+
|
|
783
|
+
for retriever in self.retrievers:
|
|
784
|
+
system_prompt += f"- {retriever['name']}\n"
|
|
785
|
+
|
|
786
|
+
system_prompt += "\n---\n\n"
|
|
787
|
+
# endif --
|
|
788
|
+
|
|
789
|
+
# adiciona ao system prompt a lista das ferramentas
|
|
790
|
+
tools_names = list(self.tools.keys()) if self.tools else []
|
|
791
|
+
tools_names.extend(list((self.agent_config.custom_tools or {}).keys()))
|
|
792
|
+
if self.subordinated_agents:
|
|
793
|
+
tools_names.append("ask_to_agent_tool")
|
|
794
|
+
if len(tools_names) > 0:
|
|
795
|
+
tool_hint = "Ferramentas (funções) disponíveis para uso: \n" + ", ".join(sorted(tools_names))
|
|
796
|
+
system_prompt += tool_hint + "\n\n---\n\n"
|
|
797
|
+
|
|
798
|
+
# adiciona ao system prompt as instruções adicionais
|
|
799
|
+
if additional_instructions:
|
|
800
|
+
system_prompt += f"{additional_instructions}\n\n---\n\n"
|
|
801
|
+
|
|
802
|
+
# process vars in the prompt, like agent input data
|
|
803
|
+
system_prompt = self._prepare_prompt(prompt=system_prompt + dedent(self.agent_config.system_prompt))
|
|
804
|
+
|
|
805
|
+
self._my_system_prompt = system_prompt
|
|
806
|
+
|
|
807
|
+
def _prepare_prompt(self, prompt: str, message_id: str | None = None) -> str:
|
|
808
|
+
"""Resolve template variables and inject contextual hints.
|
|
809
|
+
|
|
810
|
+
Args:
|
|
811
|
+
prompt (str): Texto original que será enriquecido.
|
|
812
|
+
message_id (str | None, optional): Identificador usado em instruções dependentes da mensagem. Defaults to None.
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
str: Prompt final com substituições e orientações adicionais.
|
|
816
|
+
"""
|
|
817
|
+
if message_id:
|
|
818
|
+
temp_prompt = prompt.lower()
|
|
819
|
+
if (
|
|
820
|
+
(
|
|
821
|
+
"gerar" in temp_prompt or "gere" in temp_prompt or
|
|
822
|
+
"criar" in temp_prompt or "crie" in temp_prompt or
|
|
823
|
+
"mostr" in temp_prompt or # mostrar, mostre
|
|
824
|
+
"elabo" in temp_prompt or # elaborar, elabore
|
|
825
|
+
"visuali" in temp_prompt
|
|
826
|
+
) and
|
|
827
|
+
("diagrama" in temp_prompt or "plantuml" in temp_prompt)
|
|
828
|
+
):
|
|
829
|
+
# orientações para fazer o agente gerar diagramas Plantuml
|
|
830
|
+
hint = dedent("""
|
|
831
|
+
Quando criar um diagrama ou fluxograma, siga essas instruções:
|
|
832
|
+
- Crie o diagrama em formato 'PlantUML'.
|
|
833
|
+
- Ao dar nomes aos objetos do diagrama use o padrão CamelCase.
|
|
834
|
+
- Activity Diagram: loops do tipo 'repeat' devem terminar com 'repeat while'.
|
|
835
|
+
- Activity Diagram: todos laços 'if' devem ter um 'endif' correspondente.
|
|
836
|
+
- Gantt Chart: use o diagrama para representar linha do tempo ou sequência de eventos.
|
|
837
|
+
|
|
838
|
+
Exemplo de diagrama 'Gantt Chart':
|
|
839
|
+
```plantuml
|
|
840
|
+
@startgantt
|
|
841
|
+
printscale weekly
|
|
842
|
+
|
|
843
|
+
Project starts 2020-07-01
|
|
844
|
+
[Atividade 1] starts 2020-07-01
|
|
845
|
+
[Atividade 2] starts 2020-07-16
|
|
846
|
+
[Atividade 3] starts 2020-07-20
|
|
847
|
+
@endgantt
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
---\n\n"""
|
|
851
|
+
)
|
|
852
|
+
prompt = hint + prompt
|
|
853
|
+
# endif --
|
|
854
|
+
|
|
855
|
+
# prompt of a question
|
|
856
|
+
if len(self.agent_config.working_files or []) > 0 or "execute_python_code_tool" in self.tools:
|
|
857
|
+
# prompt para fazer o agente trabalhar com arquivos CSV/BI
|
|
858
|
+
folder = self._ensure_session_working_folder()
|
|
859
|
+
hint = (
|
|
860
|
+
f"Quando precisar salvar algum arquivo cujo pathname não foi completamente fornecido siga essa regra:\n"
|
|
861
|
+
f"- nome da pasta: '{folder.as_posix()}'\n"
|
|
862
|
+
f"- nome do arquivo: '{message_id}-' seguido de um número aleatório entre 1 e 10000.\n\n---\n\n")
|
|
863
|
+
prompt = hint + prompt
|
|
864
|
+
# endif --
|
|
865
|
+
|
|
866
|
+
# process variables inside the prompt
|
|
867
|
+
mapping: dict[str, str] = {
|
|
868
|
+
"session_id": self.session_id,
|
|
869
|
+
"session_name": self.session_name or "",
|
|
870
|
+
"message_id": message_id or "",
|
|
871
|
+
"now": self._now().isoformat(timespec="seconds"),
|
|
872
|
+
'working_folder': self.working_folder or ""
|
|
873
|
+
}
|
|
874
|
+
for k, v in (self.input_data or {}).items():
|
|
875
|
+
if isinstance(v, (str, int, float)):
|
|
876
|
+
mapping[str(k)] = str(v)
|
|
877
|
+
|
|
878
|
+
def repl(m: re.Match[str]) -> str:
|
|
879
|
+
"""Replace template variables found within curly braces.
|
|
880
|
+
|
|
881
|
+
Args:
|
|
882
|
+
m (re.Match[str]): Resultado da expressão regular contendo o nome da variável.
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
str: Valor substituto presente em `mapping` ou o token original quando ausente.
|
|
886
|
+
"""
|
|
887
|
+
key = m.group(1)
|
|
888
|
+
return mapping.get(key, m.group(0))
|
|
889
|
+
|
|
890
|
+
# Replace {var}
|
|
891
|
+
prompt = dedent(re.sub(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", repl, prompt))
|
|
892
|
+
return prompt
|
|
893
|
+
|
|
894
|
+
@staticmethod
|
|
895
|
+
def _summarize_history(messages: list[dict[str, Any]], limit_chars: int = 800) -> str:
|
|
896
|
+
"""Condense prior message history into a short textual summary.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
messages (list[dict[str, Any]]): Histórico de mensagens estruturadas.
|
|
900
|
+
limit_chars (int, optional): Quantidade máxima aproximada de caracteres. Defaults to 800.
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
str: Resumo textual das interações anteriores.
|
|
904
|
+
"""
|
|
905
|
+
if not messages:
|
|
906
|
+
return ""
|
|
907
|
+
# Concise heuristic summarization: keep early context and last line
|
|
908
|
+
text_parts: list[str] = []
|
|
909
|
+
for m in messages:
|
|
910
|
+
role = m.get("role", "")
|
|
911
|
+
content = m.get("content", "")
|
|
912
|
+
if isinstance(content, list):
|
|
913
|
+
content = " ".join([c.get("text", "") if isinstance(c, dict) else str(c) for c in content])
|
|
914
|
+
text_parts.append(f"[{role}] {str(content)[:200]}")
|
|
915
|
+
|
|
916
|
+
summary = " \n".join(text_parts)
|
|
917
|
+
if len(summary) > limit_chars:
|
|
918
|
+
summary = summary[: limit_chars - 3] + "..."
|
|
919
|
+
return f"Resumo das interações anteriores (condensado):\n{summary}"
|
|
920
|
+
|
|
921
|
+
def _build_input_messages(self, question: str, *,
|
|
922
|
+
attached_files: list[str] | None = None,
|
|
923
|
+
include_history: bool = True, ) -> list[dict[str, Any]]:
|
|
924
|
+
"""Compose the payload sent to the OpenAI Responses API.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
question (str): Prompt principal fornecido pelo usuário.
|
|
928
|
+
attached_files (list[str] | None, optional): Caminhos de arquivos anexados. Defaults to None.
|
|
929
|
+
include_history (bool): Indica se deve enviar o histórico de conversas com o prompt.
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
list[dict[str, Any]]: Lista de mensagens formatadas conforme o protocolo da API.
|
|
933
|
+
"""
|
|
934
|
+
# System message
|
|
935
|
+
system_msg = {"role": "system", "content": self._my_system_prompt}
|
|
936
|
+
|
|
937
|
+
messages: list[dict[str, Any]] = [system_msg]
|
|
938
|
+
|
|
939
|
+
if include_history:
|
|
940
|
+
# Build history: last K pairs + summary of older
|
|
941
|
+
k = max(0, int(self.history_window_size))
|
|
942
|
+
prior_struct = self.message_history[-(2 * k):] if k > 0 else []
|
|
943
|
+
older_struct = self.message_history[: max(0, len(self.message_history) - len(prior_struct))]
|
|
944
|
+
|
|
945
|
+
# Map structured history into API messages
|
|
946
|
+
if older_struct:
|
|
947
|
+
messages.append({"role": "system", "content": self._summarize_history(older_struct)})
|
|
948
|
+
|
|
949
|
+
for m in prior_struct:
|
|
950
|
+
role = m.get("role")
|
|
951
|
+
content = m.get("content", "")
|
|
952
|
+
if role == "agent":
|
|
953
|
+
api_role = "assistant"
|
|
954
|
+
elif role == "user":
|
|
955
|
+
api_role = "user"
|
|
956
|
+
else:
|
|
957
|
+
# Skip stored system messages; we already inject system content above
|
|
958
|
+
continue
|
|
959
|
+
messages.append({"role": api_role, "content": content})
|
|
960
|
+
|
|
961
|
+
# Build multimodal user content. If there are image files, use structured blocks
|
|
962
|
+
content_blocks: list[dict[str, Any]] = [{"type": "input_text", "text": question}] # prompt
|
|
963
|
+
for file_path in attached_files or []:
|
|
964
|
+
try:
|
|
965
|
+
if file_path.lower().endswith((".png", ".jpg", ".jpeg", ".pdf")):
|
|
966
|
+
# arquivos suportados pela API da OpenAI
|
|
967
|
+
uploaded_file = self._openai_client.files.create(
|
|
968
|
+
file=open(file_path, "rb"),
|
|
969
|
+
purpose="assistants"
|
|
970
|
+
)
|
|
971
|
+
if file_path.lower().endswith((".png", ".jpg", ".jpeg")):
|
|
972
|
+
file_block = {"type": "input_image", "file_id": uploaded_file.id}
|
|
973
|
+
self._attached_files.append(file_block)
|
|
974
|
+
|
|
975
|
+
elif file_path.lower().endswith((".wav", ".mp3", ".m4a", ".ogg", ".webm")):
|
|
976
|
+
file_block = {"type": "input_audio", "audio_file_id": uploaded_file.id}
|
|
977
|
+
self._attached_files.append(file_block)
|
|
978
|
+
|
|
979
|
+
else:
|
|
980
|
+
file_block = {"type": "input_file", "file_id": uploaded_file.id}
|
|
981
|
+
self._attached_files.append(file_block)
|
|
982
|
+
|
|
983
|
+
elif file_path.lower().endswith(
|
|
984
|
+
(".md", ".markdown", ".txt", ".text", ".puml", ".json", ".yaml", ".xml")):
|
|
985
|
+
# arquivos texto enviados com o prompt
|
|
986
|
+
blocks = render_file_as_content_blocks(file_path)
|
|
987
|
+
content_blocks.extend(blocks)
|
|
988
|
+
else:
|
|
989
|
+
# outros tipos de arquivo
|
|
990
|
+
raise ValueError(f"Unsupported file type: {file_path}")
|
|
991
|
+
|
|
992
|
+
except ValueError as exc:
|
|
993
|
+
name = os.path.basename(file_path)
|
|
994
|
+
content_blocks.append({
|
|
995
|
+
"type": "input_text",
|
|
996
|
+
"text": f"[erro ao processar arquivo: {name}] {exc}",
|
|
997
|
+
})
|
|
998
|
+
except Exception as exc:
|
|
999
|
+
name = os.path.basename(file_path)
|
|
1000
|
+
content_blocks.append({
|
|
1001
|
+
"type": "input_text",
|
|
1002
|
+
"text": f"[falha inesperada ao processar arquivo: {name}] {exc}",
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
# Remove the processed file if it is a temporary file stored in the working folder
|
|
1006
|
+
if self.session_id in file_path:
|
|
1007
|
+
try:
|
|
1008
|
+
os.remove(file_path)
|
|
1009
|
+
except OSError as e:
|
|
1010
|
+
log_msg(f"Error removing file {file_path}: {e}", func="render_file_as_content_blocks", color="RED")
|
|
1011
|
+
|
|
1012
|
+
# If we only had text (no files), keep content as a simple string for minimal payload
|
|
1013
|
+
content_blocks.extend(self._attached_files)
|
|
1014
|
+
|
|
1015
|
+
if len(content_blocks) > 1:
|
|
1016
|
+
# When files are present (esp. images), send structured content blocks
|
|
1017
|
+
messages.append({"role": "user", "content": content_blocks})
|
|
1018
|
+
else:
|
|
1019
|
+
messages.append({"role": "user", "content": question})
|
|
1020
|
+
|
|
1021
|
+
return messages
|
|
1022
|
+
|
|
1023
|
+
def _compose_tool_schemas(self, tools_names: list[str]) -> list[dict[str, Any]]:
|
|
1024
|
+
"""Constrói schemas de ferramentas padrão e customizadas.
|
|
1025
|
+
|
|
1026
|
+
Args:
|
|
1027
|
+
tools_names (list[str]): Lista de ferramentas solicitadas para a chamada.
|
|
1028
|
+
|
|
1029
|
+
Returns:
|
|
1030
|
+
list[dict[str, Any]]: Schemas OpenAI unificados, incluindo overrides corporativos quando declarados.
|
|
1031
|
+
"""
|
|
1032
|
+
schemas = build_tool_schemas(tools_names)
|
|
1033
|
+
|
|
1034
|
+
if self.agent_config.custom_tools:
|
|
1035
|
+
schemas.extend(build_custom_tool_schemas(self.agent_config.custom_tools))
|
|
1036
|
+
|
|
1037
|
+
if self._mcp_tools_schemas:
|
|
1038
|
+
schemas.extend(self._mcp_tools_schemas)
|
|
1039
|
+
|
|
1040
|
+
if self.external_tools_schema:
|
|
1041
|
+
schemas.extend(self.external_tools_schema)
|
|
1042
|
+
|
|
1043
|
+
selected_tools = [
|
|
1044
|
+
schema
|
|
1045
|
+
for schema in schemas
|
|
1046
|
+
if schema["name"] in tools_names
|
|
1047
|
+
]
|
|
1048
|
+
|
|
1049
|
+
return selected_tools
|
|
1050
|
+
|
|
1051
|
+
@staticmethod
|
|
1052
|
+
def _parse_response_outputs(outputs: list[dict[str, Any]] | None) -> tuple[str, list[dict[str, Any]]]:
|
|
1053
|
+
"""Separa texto final e chamadas de ferramentas a partir da resposta da API.
|
|
1054
|
+
|
|
1055
|
+
Args:
|
|
1056
|
+
outputs (list[dict[str, Any]] | None): Blocos retornados pela API de respostas.
|
|
1057
|
+
|
|
1058
|
+
Returns:
|
|
1059
|
+
tuple[str, list[dict[str, Any]]]: Texto consolidado do assistente e a lista de tool-calls para execução.
|
|
1060
|
+
"""
|
|
1061
|
+
if not outputs:
|
|
1062
|
+
return "", []
|
|
1063
|
+
text_parts: list[str] = []
|
|
1064
|
+
tool_calls: list[dict[str, Any]] = []
|
|
1065
|
+
for out in outputs:
|
|
1066
|
+
out_type = out.get("type")
|
|
1067
|
+
if out_type == "message" and out.get("role") == "assistant":
|
|
1068
|
+
for msg_content in out.get("content", []) or []:
|
|
1069
|
+
if msg_content.get("type") == "output_text":
|
|
1070
|
+
text_parts.append(msg_content.get("text", ""))
|
|
1071
|
+
elif out_type == "function_call":
|
|
1072
|
+
tool_calls.append(out)
|
|
1073
|
+
return "".join(text_parts), tool_calls
|
|
1074
|
+
|
|
1075
|
+
@staticmethod
|
|
1076
|
+
def _is_retryable_error(exc: Exception) -> bool:
|
|
1077
|
+
"""Determina se o erro permite retentativa.
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
exc (Exception): Erro capturado durante a chamada ao LLM.
|
|
1081
|
+
|
|
1082
|
+
Returns:
|
|
1083
|
+
bool: True quando há sinais de falha transitória que justificam nova tentativa.
|
|
1084
|
+
"""
|
|
1085
|
+
transient_markers = ("timeout", "temporarily", "rate limit", "overload", "connection", "unavailable")
|
|
1086
|
+
text = str(exc).lower()
|
|
1087
|
+
return any(marker in text for marker in transient_markers)
|
|
1088
|
+
|
|
1089
|
+
@staticmethod
|
|
1090
|
+
def _normalize_tool_args(call: dict[str, Any]) -> dict[str, Any]:
|
|
1091
|
+
"""Normaliza argumentos recebidos em chamadas de ferramentas.
|
|
1092
|
+
|
|
1093
|
+
Args:
|
|
1094
|
+
call (dict[str, Any]): Estrutura da chamada de função vinda do modelo.
|
|
1095
|
+
|
|
1096
|
+
Returns:
|
|
1097
|
+
dict[str, Any]: Argumentos convertidos em dicionário, preservando chaves originais sempre que possível.
|
|
1098
|
+
"""
|
|
1099
|
+
args_obj: Any = call.get("arguments") or call.get("args_obj") or call.get("function", {}).get("arguments")
|
|
1100
|
+
if isinstance(args_obj, str):
|
|
1101
|
+
try:
|
|
1102
|
+
args_obj = json.loads(args_obj)
|
|
1103
|
+
except Exception:
|
|
1104
|
+
args_obj = {"code": args_obj, "expression": args_obj, "query": args_obj}
|
|
1105
|
+
if not isinstance(args_obj, dict):
|
|
1106
|
+
return {}
|
|
1107
|
+
return args_obj
|
|
1108
|
+
|
|
1109
|
+
def _call_llm_with_resilience(self,
|
|
1110
|
+
model_name: str,
|
|
1111
|
+
verbosity: str,
|
|
1112
|
+
messages_for_model: list[dict[str, Any]],
|
|
1113
|
+
tools_schema: list[dict[str, Any]]) -> Any:
|
|
1114
|
+
"""Executa chamadas ao LLM com retentativas, backoff e timeout.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
model_name (str): Identificador do modelo configurado.
|
|
1118
|
+
messages_for_model (list[dict[str, Any]]): Payload serializado no formato esperado pela API.
|
|
1119
|
+
tools_schema (list[dict[str, Any]]): Schemas de ferramentas liberadas para a execução.
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
Any: Resposta bruta do cliente OpenAI.
|
|
1123
|
+
"""
|
|
1124
|
+
attempts = 0
|
|
1125
|
+
last_error: Exception | None = None
|
|
1126
|
+
while attempts < max(1, self._resilience.max_llm_attempts):
|
|
1127
|
+
attempts += 1
|
|
1128
|
+
try:
|
|
1129
|
+
return self._openai_client.responses.create(
|
|
1130
|
+
model=model_name,
|
|
1131
|
+
input=messages_for_model,
|
|
1132
|
+
reasoning={"effort": self.reasoning_effort},
|
|
1133
|
+
tools=tools_schema or None,
|
|
1134
|
+
timeout=self._resilience.llm_timeout_seconds,
|
|
1135
|
+
text={
|
|
1136
|
+
"verbosity": verbosity
|
|
1137
|
+
}
|
|
1138
|
+
)
|
|
1139
|
+
except Exception as exc:
|
|
1140
|
+
last_error = exc
|
|
1141
|
+
if not self._is_retryable_error(exc):
|
|
1142
|
+
break
|
|
1143
|
+
if attempts >= self._resilience.max_llm_attempts:
|
|
1144
|
+
break
|
|
1145
|
+
backoff = self._resilience.backoff_base_seconds * (2 ** (attempts - 1))
|
|
1146
|
+
jitter = Random().uniform(0, self._resilience.backoff_jitter_seconds)
|
|
1147
|
+
time.sleep(backoff + jitter)
|
|
1148
|
+
|
|
1149
|
+
raise last_error or RuntimeError("Falha ao contactar o modelo LLM.")
|
|
1150
|
+
|
|
1151
|
+
def get_pretty_messages_history(self, message_format: str = 'raw',
|
|
1152
|
+
list_subordinated_agents_history: bool = False) -> list:
|
|
1153
|
+
"""Return the conversation history formatted for presentation.
|
|
1154
|
+
|
|
1155
|
+
Args:
|
|
1156
|
+
message_format (str, optional): Formato desejado para o conteúdo (`raw` ou `html`). Defaults to 'raw'.
|
|
1157
|
+
list_subordinated_agents_history (bool, optional): Indica se deve incluir respostas de agentes subordinados. Defaults to False.
|
|
1158
|
+
|
|
1159
|
+
Returns:
|
|
1160
|
+
list[dict[str, Any]]: Lista de mensagens formatadas, incluindo agrupamentos de pergunta e resposta.
|
|
1161
|
+
"""
|
|
1162
|
+
if self.is_verbose:
|
|
1163
|
+
log_msg(f"list_all={list_subordinated_agents_history}", func="get_messages_history", color="MAGENTA")
|
|
1164
|
+
|
|
1165
|
+
def format_message(message_content: str) -> tuple[str, list[str]]:
|
|
1166
|
+
"""Formata o texto da mensagem conforme o formato solicitado.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
message_content (str): Conteúdo bruto registrado no histórico.
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
tuple[str, list[str]]: Texto transformado e lista de idiomas identificados.
|
|
1173
|
+
"""
|
|
1174
|
+
if message_format == 'html':
|
|
1175
|
+
return convert_markdown_to_html_block(message_content, flag_insert_copy_to_clipboard_command=False)
|
|
1176
|
+
|
|
1177
|
+
return message_content, []
|
|
1178
|
+
|
|
1179
|
+
def create_message(message_id, message_kind, sent_at, content, languages=None, model_name=None, usage=None):
|
|
1180
|
+
"""Cria um registro de mensagem normalizado para camadas de visualização.
|
|
1181
|
+
|
|
1182
|
+
Args:
|
|
1183
|
+
message_id (str): Identificador único da mensagem.
|
|
1184
|
+
message_kind (str): Tipo categórico (`UserPrompt`, `Answer`, etc.).
|
|
1185
|
+
sent_at (str): Timestamp no formato ISO apontando quando a mensagem foi enviada.
|
|
1186
|
+
content (str | tuple[str, list[str]]): Conteúdo já formatado conforme a visualização.
|
|
1187
|
+
languages (list[str] | None, optional): Idiomas detectados no conteúdo. Defaults to None.
|
|
1188
|
+
model_name (str | None, optional): Nome do modelo utilizado na resposta. Defaults to None.
|
|
1189
|
+
usage (dict | None, optional): Estatísticas de uso associadas à mensagem. Defaults to None.
|
|
1190
|
+
|
|
1191
|
+
Returns:
|
|
1192
|
+
dict[str, Any]: Estrutura padronizada representando a mensagem e metadados associados.
|
|
1193
|
+
"""
|
|
1194
|
+
return {
|
|
1195
|
+
"kind": message_kind,
|
|
1196
|
+
"message_id": message_id,
|
|
1197
|
+
"agent_name": self.name if message_kind == 'Answer' else None,
|
|
1198
|
+
"sent_at": sent_at,
|
|
1199
|
+
"content": content,
|
|
1200
|
+
"files": message["files"],
|
|
1201
|
+
"format": message_format,
|
|
1202
|
+
"languages": languages,
|
|
1203
|
+
"model_name": model_name,
|
|
1204
|
+
"usage": usage,
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
messages_list = []
|
|
1208
|
+
for message in self.message_history:
|
|
1209
|
+
if message["role"] == 'user':
|
|
1210
|
+
kind = "UserPrompt"
|
|
1211
|
+
formated_content, content_languages = format_message(message["content"])
|
|
1212
|
+
|
|
1213
|
+
elif message["role"] == 'agent':
|
|
1214
|
+
kind = "Answer"
|
|
1215
|
+
formated_content, content_languages = format_message(
|
|
1216
|
+
message["content"].replace(self.session_id, "").replace(message["message_id"], ""))
|
|
1217
|
+
|
|
1218
|
+
else:
|
|
1219
|
+
if self.is_verbose:
|
|
1220
|
+
log_msg(f"Discarded message.role='{message['role']}'", func="get_messages_history", color="MAGENTA")
|
|
1221
|
+
|
|
1222
|
+
kind = formated_content = content_languages = None
|
|
1223
|
+
# endif --
|
|
1224
|
+
|
|
1225
|
+
if kind:
|
|
1226
|
+
message_usage = {
|
|
1227
|
+
"request_tokens": message["usage"]["input_tokens"],
|
|
1228
|
+
"response_tokens": message["usage"]["output_tokens"],
|
|
1229
|
+
"total_tokens": message["usage"]["total_tokens"],
|
|
1230
|
+
"elapsed_time_in_seconds": message["elapsed_time_in_seconds"],
|
|
1231
|
+
}
|
|
1232
|
+
messages_list.append(create_message(message_id=message["message_id"],
|
|
1233
|
+
message_kind=kind,
|
|
1234
|
+
sent_at=message["timestamp"],
|
|
1235
|
+
content=formated_content,
|
|
1236
|
+
languages=content_languages,
|
|
1237
|
+
model_name=message.get("model_name"),
|
|
1238
|
+
usage=message_usage))
|
|
1239
|
+
# endif --
|
|
1240
|
+
# endfor --
|
|
1241
|
+
|
|
1242
|
+
# agrupa perguntas e respostas
|
|
1243
|
+
grouped_message_list = []
|
|
1244
|
+
for message in messages_list:
|
|
1245
|
+
if message['kind'] == "SystemPrompt":
|
|
1246
|
+
grouped_message_list.append(message)
|
|
1247
|
+
|
|
1248
|
+
elif message['kind'] == "UserPrompt":
|
|
1249
|
+
message["answers"] = []
|
|
1250
|
+
grouped_message_list.append(message)
|
|
1251
|
+
|
|
1252
|
+
else:
|
|
1253
|
+
grouped_message_list[-1]["answers"].append(message)
|
|
1254
|
+
|
|
1255
|
+
# junta as mensagens dos agentes subordinados
|
|
1256
|
+
if list_subordinated_agents_history:
|
|
1257
|
+
for agent in self.subordinated_agents:
|
|
1258
|
+
grouped_message_list += agent.get_messages_history(message_format=message_format,
|
|
1259
|
+
list_all=list_subordinated_agents_history)
|
|
1260
|
+
|
|
1261
|
+
return grouped_message_list
|
|
1262
|
+
|
|
1263
|
+
def _exec_tool(self, tool_name: str, message_id: str, args_obj: dict) -> ToolExecutionResult:
|
|
1264
|
+
"""Executa uma ferramenta registrada aplicando política de resiliência.
|
|
1265
|
+
|
|
1266
|
+
Args:
|
|
1267
|
+
tool_name (str): Nome da ferramenta solicitada.
|
|
1268
|
+
message_id (str): Identificador da mensagem corrente (para isolamento de artefatos).
|
|
1269
|
+
args_obj (dict): Argumentos serializados para a chamada.
|
|
1270
|
+
|
|
1271
|
+
Returns:
|
|
1272
|
+
ToolExecutionResult: Resultado padronizado, já incluindo métricas de execução.
|
|
1273
|
+
"""
|
|
1274
|
+
tool_fn = self.tools.get(tool_name)
|
|
1275
|
+
if tool_fn is None:
|
|
1276
|
+
err = f"Tool '{tool_name}' não está disponível."
|
|
1277
|
+
return ToolExecutionResult(value={"error": err}, elapsed_seconds=0, error=err)
|
|
1278
|
+
|
|
1279
|
+
ctx = self._make_tool_context(message_id)
|
|
1280
|
+
|
|
1281
|
+
def _runner() -> Any:
|
|
1282
|
+
if tool_fn:
|
|
1283
|
+
# função interna e externa (definida no código)
|
|
1284
|
+
tool_result = exec_tool(tool_name, tool_fn, args_obj, ctx)
|
|
1285
|
+
return tool_result
|
|
1286
|
+
return None
|
|
1287
|
+
|
|
1288
|
+
return execute_tool_with_policy(tool_name, tool_fn, args_obj, ctx, runner=_runner)
|
|
1289
|
+
|
|
1290
|
+
def _run_tool_calls(self, tool_calls: list[dict[str, Any]], message_id: str) -> list[dict[str, Any]]:
|
|
1291
|
+
"""Executa chamadas de ferramentas e devolve outputs no formato da API.
|
|
1292
|
+
|
|
1293
|
+
Args:
|
|
1294
|
+
tool_calls (list[dict[str, Any]]): Chamadas solicitadas pelo modelo.
|
|
1295
|
+
message_id (str): Identificador usado para rastrear artefatos da execução.
|
|
1296
|
+
|
|
1297
|
+
Returns:
|
|
1298
|
+
list[dict[str, Any]]: Lista de blocos `function_call_output` prontos para reenvio ao LLM.
|
|
1299
|
+
"""
|
|
1300
|
+
next_inputs: list[dict[str, Any]] = []
|
|
1301
|
+
for call in tool_calls:
|
|
1302
|
+
call_id = call.get("call_id")
|
|
1303
|
+
tool_name = call.get("name") or ""
|
|
1304
|
+
args_obj = self._normalize_tool_args(call)
|
|
1305
|
+
|
|
1306
|
+
tool_result = self._exec_tool(tool_name, message_id, args_obj)
|
|
1307
|
+
|
|
1308
|
+
if self.is_verbose:
|
|
1309
|
+
preview = str(tool_result.value).replace("\n", " ") if tool_result else ""
|
|
1310
|
+
if len(preview) > 120:
|
|
1311
|
+
preview = preview[:117] + "..."
|
|
1312
|
+
log_msg(
|
|
1313
|
+
f"id={message_id} tool_end name={tool_name} result='{preview}'",
|
|
1314
|
+
func="_run_tool_calls",
|
|
1315
|
+
action="tool_call",
|
|
1316
|
+
color="MAGENTA",
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
try:
|
|
1320
|
+
value = tool_result.to_model_payload() if tool_result else {}
|
|
1321
|
+
result_text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
1322
|
+
except Exception:
|
|
1323
|
+
result_text = str(tool_result.value if tool_result else "")
|
|
1324
|
+
|
|
1325
|
+
next_inputs.append({
|
|
1326
|
+
"type": "function_call_output",
|
|
1327
|
+
"call_id": call_id,
|
|
1328
|
+
"output": result_text,
|
|
1329
|
+
})
|
|
1330
|
+
return next_inputs
|
|
1331
|
+
|
|
1332
|
+
@staticmethod
|
|
1333
|
+
def _compute_usage(input_msgs: list[dict[str, Any]],
|
|
1334
|
+
output_text: str,
|
|
1335
|
+
usage: dict[str, Any] | None) -> tuple[int, int, int]:
|
|
1336
|
+
"""Calcula tokens de entrada/saída usando dados oficiais ou heurística."""
|
|
1337
|
+
if usage and all(k in usage for k in ("input_tokens", "output_tokens")):
|
|
1338
|
+
input_tokens = int(usage.get("input_tokens", 0))
|
|
1339
|
+
output_tokens = int(usage.get("output_tokens", 0))
|
|
1340
|
+
total_tokens = input_tokens + output_tokens
|
|
1341
|
+
return input_tokens, output_tokens, total_tokens
|
|
1342
|
+
|
|
1343
|
+
input_text = "\n".join([str(m.get("content", "")) for m in input_msgs])
|
|
1344
|
+
input_tokens = _approx_token_count(input_text)
|
|
1345
|
+
output_tokens = _approx_token_count(output_text)
|
|
1346
|
+
total_tokens = input_tokens + output_tokens
|
|
1347
|
+
return input_tokens, output_tokens, total_tokens
|
|
1348
|
+
|
|
1349
|
+
@staticmethod
|
|
1350
|
+
def _sanitize_artifact_name(name: str, message_id: str, session_id: str | None = None) -> str:
|
|
1351
|
+
"""Remove prefixes de sessão e mensagem para evitar exposição de paths.
|
|
1352
|
+
|
|
1353
|
+
Args:
|
|
1354
|
+
name (str): Nome bruto do arquivo gerado.
|
|
1355
|
+
message_id (str): Identificador da mensagem que originou o artefato.
|
|
1356
|
+
session_id (str | None, optional): Identificador de sessão para higienização adicional. Defaults to None.
|
|
1357
|
+
|
|
1358
|
+
Returns:
|
|
1359
|
+
str: Nome seguro, sem IDs sensíveis.
|
|
1360
|
+
"""
|
|
1361
|
+
safe_name = name
|
|
1362
|
+
if session_id:
|
|
1363
|
+
for sep in ("_", "-"):
|
|
1364
|
+
pref = f"{session_id}{sep}"
|
|
1365
|
+
if safe_name.startswith(pref):
|
|
1366
|
+
safe_name = safe_name[len(pref):]
|
|
1367
|
+
break
|
|
1368
|
+
for sep in ("_", "-"):
|
|
1369
|
+
pref = f"{message_id}{sep}"
|
|
1370
|
+
if safe_name.startswith(pref):
|
|
1371
|
+
safe_name = safe_name[len(pref):]
|
|
1372
|
+
break
|
|
1373
|
+
return safe_name
|
|
1374
|
+
|
|
1375
|
+
def _collect_session_artifacts(self, message_id: str) -> list[dict[str, str]]:
|
|
1376
|
+
"""Coleta arquivos gerados na pasta segura da sessão.
|
|
1377
|
+
|
|
1378
|
+
Args:
|
|
1379
|
+
message_id (str): Identificador usado como prefixo dos artefatos criados.
|
|
1380
|
+
|
|
1381
|
+
Returns:
|
|
1382
|
+
list[dict[str, str]]: Arquivos codificados em base64, prontos para anexar no histórico.
|
|
1383
|
+
"""
|
|
1384
|
+
artifacts: list[dict[str, str]] = []
|
|
1385
|
+
try:
|
|
1386
|
+
session_dir = self._ensure_session_working_folder().resolve()
|
|
1387
|
+
for entry in session_dir.iterdir():
|
|
1388
|
+
if not entry.is_file() or not str(entry.name).startswith(message_id):
|
|
1389
|
+
continue
|
|
1390
|
+
try:
|
|
1391
|
+
data = entry.read_bytes()
|
|
1392
|
+
except Exception:
|
|
1393
|
+
data = b""
|
|
1394
|
+
# try:
|
|
1395
|
+
# entry.unlink()
|
|
1396
|
+
# except Exception:
|
|
1397
|
+
# pass
|
|
1398
|
+
|
|
1399
|
+
safe_name = self._sanitize_artifact_name(entry.name, message_id, str(self.session_id))
|
|
1400
|
+
content = base64.b64encode(data).decode("ascii") if data else ""
|
|
1401
|
+
artifacts.append({"filename": safe_name, "content": content})
|
|
1402
|
+
except Exception:
|
|
1403
|
+
pass
|
|
1404
|
+
return artifacts
|
|
1405
|
+
|
|
1406
|
+
def _execute_conversation_loop(self,
|
|
1407
|
+
model_name: str,
|
|
1408
|
+
verbosity: str,
|
|
1409
|
+
input_msgs: list[dict[str, Any]],
|
|
1410
|
+
tools_schema: list[dict[str, Any]],
|
|
1411
|
+
message_id: str) -> tuple[str, dict[str, Any] | None]:
|
|
1412
|
+
"""Executa o ciclo LLM + ferramentas respeitando limites de iteração.
|
|
1413
|
+
|
|
1414
|
+
Args:
|
|
1415
|
+
model_name (str): Nome do modelo a ser chamado.
|
|
1416
|
+
verbosity (str): Nível de verbosidade da resposta do LLM.
|
|
1417
|
+
input_msgs (list[dict[str, Any]]): Mensagens de entrada já preparadas.
|
|
1418
|
+
tools_schema (list[dict[str, Any]]): Schemas de ferramentas liberadas.
|
|
1419
|
+
message_id (str): Identificador da interação (para rastreamento e artefatos).
|
|
1420
|
+
|
|
1421
|
+
Returns:
|
|
1422
|
+
tuple[str, dict[str, Any] | None]: Texto final do assistente e metadados de uso retornados pela API.
|
|
1423
|
+
"""
|
|
1424
|
+
output_text = ""
|
|
1425
|
+
usage: dict[str, Any] | None = None
|
|
1426
|
+
messages_for_model = input_msgs
|
|
1427
|
+
|
|
1428
|
+
for _ in range(max(1, self._resilience.max_tool_iterations)):
|
|
1429
|
+
resp = self._call_llm_with_resilience(model_name=model_name,
|
|
1430
|
+
verbosity=verbosity,
|
|
1431
|
+
messages_for_model=messages_for_model,
|
|
1432
|
+
tools_schema=tools_schema)
|
|
1433
|
+
raw = resp.to_dict() if hasattr(resp, "to_dict") else json.loads(
|
|
1434
|
+
json.dumps(resp, default=lambda o: getattr(o, "__dict__", str(o))))
|
|
1435
|
+
|
|
1436
|
+
usage = raw.get("usage") or usage
|
|
1437
|
+
output_blocks = raw.get("output") or []
|
|
1438
|
+
assistant_text, tool_calls = self._parse_response_outputs(output_blocks)
|
|
1439
|
+
|
|
1440
|
+
if tool_calls:
|
|
1441
|
+
# executa as ferramentas solicitadas pelo modelo e atualiza a lista de mensagens
|
|
1442
|
+
base_outputs = getattr(resp, "output", None) or output_blocks
|
|
1443
|
+
next_inputs = list(base_outputs) if isinstance(base_outputs, list) else []
|
|
1444
|
+
tool_outputs = self._run_tool_calls(tool_calls, message_id)
|
|
1445
|
+
messages_for_model = messages_for_model + next_inputs + tool_outputs
|
|
1446
|
+
continue
|
|
1447
|
+
|
|
1448
|
+
output_text = assistant_text
|
|
1449
|
+
break
|
|
1450
|
+
|
|
1451
|
+
return output_text, usage
|
|
1452
|
+
|
|
1453
|
+
@staticmethod
|
|
1454
|
+
def _encode_user_files(files: list[str] | None) -> list[dict[str, str]]:
|
|
1455
|
+
"""Codifica anexos do usuário em base64 para persistência no histórico.
|
|
1456
|
+
|
|
1457
|
+
Args:
|
|
1458
|
+
files (list[str] | None): Caminhos dos arquivos anexados pelo usuário.
|
|
1459
|
+
|
|
1460
|
+
Returns:
|
|
1461
|
+
list[dict[str, str]]: Estruturas contendo nome seguro e conteúdo codificado (ou vazio em caso de erro).
|
|
1462
|
+
"""
|
|
1463
|
+
user_files_struct: list[dict[str, str]] = []
|
|
1464
|
+
for file_path in files or []:
|
|
1465
|
+
try:
|
|
1466
|
+
name = os.path.basename(file_path)
|
|
1467
|
+
data = Path(file_path).read_bytes()
|
|
1468
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
1469
|
+
user_files_struct.append({"name": name, "content": b64})
|
|
1470
|
+
except Exception:
|
|
1471
|
+
user_files_struct.append({"name": file_path, "content": ""})
|
|
1472
|
+
return user_files_struct
|
|
1473
|
+
|
|
1474
|
+
def consult(self,
|
|
1475
|
+
system_prompt: str,
|
|
1476
|
+
user_prompt: str,
|
|
1477
|
+
model: str | None = None,
|
|
1478
|
+
verbosity: str = "low",
|
|
1479
|
+
reasoning_effort: str | None = None) -> str | None:
|
|
1480
|
+
"""Executa uma consulta rápida ao modelo sem system prompt, histórico ou ferramentas.
|
|
1481
|
+
|
|
1482
|
+
Args:
|
|
1483
|
+
system_prompt (str): Prompt inicial para orientar o modelo.
|
|
1484
|
+
user_prompt (str): Pergunta ou instrução a ser enviada diretamente ao modelo.
|
|
1485
|
+
model (str | None, optional): Modelo alvo; usa o configurado no agente quando omitido. Defaults to None.
|
|
1486
|
+
verbosity (str, optional): Nível de verbosidade da resposta (`low`, `medium` ou `high`). Defaults to "low".
|
|
1487
|
+
reasoning_effort (str | None, optional): Esforço de raciocínio do modelo. Defaults to None.
|
|
1488
|
+
|
|
1489
|
+
Returns:
|
|
1490
|
+
str: Texto retornado pelo modelo ou fallback seguro em caso de falha.
|
|
1491
|
+
"""
|
|
1492
|
+
# Intenção: fornecer um atalho minimalista que ignore contexto persistente e ferramentas.
|
|
1493
|
+
if not isinstance(user_prompt, str) or not user_prompt.strip():
|
|
1494
|
+
return None
|
|
1495
|
+
|
|
1496
|
+
# Logica: envia apenas o bloco de usuário e extrai o texto de resposta do modelo.
|
|
1497
|
+
self.reasoning_effort = reasoning_effort
|
|
1498
|
+
model_name = self.agent_config.model if model is None else model
|
|
1499
|
+
messages_for_model = [
|
|
1500
|
+
{"role": "system", "content": system_prompt},
|
|
1501
|
+
{"role": "user", "content": user_prompt}
|
|
1502
|
+
]
|
|
1503
|
+
|
|
1504
|
+
try:
|
|
1505
|
+
response = self._call_llm_with_resilience(model_name=model_name,
|
|
1506
|
+
verbosity=verbosity,
|
|
1507
|
+
messages_for_model=messages_for_model,
|
|
1508
|
+
tools_schema=[])
|
|
1509
|
+
except Exception as exc: # noqa: BLE001
|
|
1510
|
+
return f"Falha ao submeter o prompt: {exc}"
|
|
1511
|
+
|
|
1512
|
+
raw = response.to_dict() if hasattr(response, "to_dict") else json.loads(
|
|
1513
|
+
json.dumps(response, default=lambda o: getattr(o, "__dict__", str(o))))
|
|
1514
|
+
output_blocks = raw.get("output") or []
|
|
1515
|
+
assistant_text, _ = self._parse_response_outputs(output_blocks)
|
|
1516
|
+
|
|
1517
|
+
if assistant_text:
|
|
1518
|
+
return assistant_text
|
|
1519
|
+
|
|
1520
|
+
# fallback seguro quando a API nao retorna texto.
|
|
1521
|
+
return None
|
|
1522
|
+
|
|
1523
|
+
def answer(self,
|
|
1524
|
+
question: str,
|
|
1525
|
+
message_format: str = 'raw',
|
|
1526
|
+
attached_files: str | list[str] | None = None,
|
|
1527
|
+
model: str | None = None,
|
|
1528
|
+
verbosity: str = 'medium',
|
|
1529
|
+
reasoning_effort: str = None,
|
|
1530
|
+
action: str = 'chat',
|
|
1531
|
+
include_history: bool = True,
|
|
1532
|
+
is_consult_prompt: bool = False) -> dict:
|
|
1533
|
+
"""Gera uma resposta usando o modelo configurado, incluindo ferramentas e anexos (ver docs/ADR-setup-agent-refactor.md — Atualização 2025-02-19).
|
|
1534
|
+
|
|
1535
|
+
Args:
|
|
1536
|
+
question (str): Pergunta ou instrução original do usuário.
|
|
1537
|
+
message_format (str, optional): Formato da resposta (`raw` ou `html`). Defaults to 'raw'.
|
|
1538
|
+
attached_files (str | list[str] | None, optional): Caminho ou lista de caminhos para anexos enviados ao prompt. Defaults to None.
|
|
1539
|
+
model (str | None, optional): Nome do modelo a ser utilizado; se ausente, usa o modelo configurado no agente. Defaults to None.
|
|
1540
|
+
verbosity (str): Nível de verbosidade da resposta do LLM (`low`, `medium` ou `high`).
|
|
1541
|
+
reasoning_effort (str | None, optional): Esforço de raciocínio ("none", "low", "medium", "high"); padrão "none" prioriza latência/custo e pode ser sobrescrito por chamadas que exijam mais contexto. Defaults to None.
|
|
1542
|
+
action (str, optional): Rótulo funcional usado para logs e relatórios de telemetria. Defaults to 'chat'.
|
|
1543
|
+
include_history (bool, optional): Define se o histórico recente deve ser enviado com o prompt. Defaults to True.
|
|
1544
|
+
is_consult_prompt (bool, optional): Indica se a mensagem é uma consulta para outro agente, ajustando logs e roteamento. Defaults to False.
|
|
1545
|
+
|
|
1546
|
+
Returns:
|
|
1547
|
+
dict[str, Any]: Estrutura contendo resposta, metadados, anexos retornados e estatísticas de uso.
|
|
1548
|
+
"""
|
|
1549
|
+
if self.session_created_at is None:
|
|
1550
|
+
self.session_created_at = datetime.now()
|
|
1551
|
+
|
|
1552
|
+
if self.session_updated_at is None:
|
|
1553
|
+
self.session_updated_at = datetime.now()
|
|
1554
|
+
|
|
1555
|
+
self.reasoning_effort = reasoning_effort
|
|
1556
|
+
|
|
1557
|
+
msg_timestamp = self._now()
|
|
1558
|
+
self._ensure_session_working_folder()
|
|
1559
|
+
message_id = str(uuid.uuid4())
|
|
1560
|
+
|
|
1561
|
+
# se necessário, prepara o planejamento para elaboração da resposta
|
|
1562
|
+
planning = self.task_plan(prompt=question)
|
|
1563
|
+
if planning:
|
|
1564
|
+
print(f"{Fore.LIGHTBLUE_EX}Tarefa planejada")
|
|
1565
|
+
planning += "\n\n---\n\n"
|
|
1566
|
+
else:
|
|
1567
|
+
planning = ""
|
|
1568
|
+
|
|
1569
|
+
# se necessário, prepara as instruções de self-reflection
|
|
1570
|
+
self_reflection = self.task_reflection(prompt=question, planning=planning)
|
|
1571
|
+
if self_reflection:
|
|
1572
|
+
self_reflection += "\n\n---\n\n"
|
|
1573
|
+
else:
|
|
1574
|
+
self_reflection = ""
|
|
1575
|
+
|
|
1576
|
+
question = planning + self_reflection + question
|
|
1577
|
+
|
|
1578
|
+
# prepara o prompt para ser submetido para LLM
|
|
1579
|
+
prepared_q = self._prepare_prompt(question, message_id=message_id)
|
|
1580
|
+
|
|
1581
|
+
# mostra mensagem de log
|
|
1582
|
+
if self.is_verbose:
|
|
1583
|
+
_q = prepared_q.replace("\n", " ")
|
|
1584
|
+
if len(_q) > 120:
|
|
1585
|
+
_q = _q[:117] + "..."
|
|
1586
|
+
log_msg(f"id={message_id} consult={is_consult_prompt} q='{_q}'", func="answer", action=str(action),
|
|
1587
|
+
color="MAGENTA")
|
|
1588
|
+
|
|
1589
|
+
# monta o histórico de mensagens para a LLM
|
|
1590
|
+
files: list[str] | None = [attached_files] if isinstance(attached_files, str) else attached_files
|
|
1591
|
+
input_msgs = self._build_input_messages(prepared_q,
|
|
1592
|
+
attached_files=files,
|
|
1593
|
+
include_history=include_history)
|
|
1594
|
+
|
|
1595
|
+
user_files_struct = self._encode_user_files(files)
|
|
1596
|
+
approx_tokens = _approx_token_count(prepared_q)
|
|
1597
|
+
now_ts = self._now().isoformat(timespec="seconds")
|
|
1598
|
+
|
|
1599
|
+
user_message_struct = {
|
|
1600
|
+
"role": "user",
|
|
1601
|
+
"message_id": message_id,
|
|
1602
|
+
"agent_name": "user",
|
|
1603
|
+
"timestamp": now_ts,
|
|
1604
|
+
"sent_at": now_ts,
|
|
1605
|
+
"elapsed_time_in_seconds": 0,
|
|
1606
|
+
"content": prepared_q,
|
|
1607
|
+
"files": user_files_struct,
|
|
1608
|
+
"format": message_format or "raw",
|
|
1609
|
+
"languages": [],
|
|
1610
|
+
"model_name": "",
|
|
1611
|
+
"usage": {
|
|
1612
|
+
"input_tokens": approx_tokens,
|
|
1613
|
+
"output_tokens": 0,
|
|
1614
|
+
"total_tokens": approx_tokens,
|
|
1615
|
+
},
|
|
1616
|
+
}
|
|
1617
|
+
self.message_history.append(user_message_struct)
|
|
1618
|
+
|
|
1619
|
+
# prepara as informações das ferramentas
|
|
1620
|
+
tools_names = list(self.tools.keys())
|
|
1621
|
+
if self.subordinated_agents:
|
|
1622
|
+
# atualiza a lista de ferramentas, incluindo a ferramenta para interagir com outros agentes
|
|
1623
|
+
tool_name = "ask_to_agent_tool"
|
|
1624
|
+
if tool_name not in tools_names:
|
|
1625
|
+
tools_names.append(tool_name)
|
|
1626
|
+
self._build_tools_registry()
|
|
1627
|
+
tools_schema = self._compose_tool_schemas(tools_names)
|
|
1628
|
+
model_name = self.agent_config.model if model is None else model
|
|
1629
|
+
|
|
1630
|
+
usage: dict[str, Any] | None = None
|
|
1631
|
+
|
|
1632
|
+
# submete o prompt para LLM e coleta a resposta
|
|
1633
|
+
if self._openai_client is None:
|
|
1634
|
+
reason = self._init_error_reason or "motivo não identificado"
|
|
1635
|
+
output_text = f"[modo offline] Não foi possível contactar a API. Motivo: {reason}"
|
|
1636
|
+
else:
|
|
1637
|
+
try:
|
|
1638
|
+
output_text, usage = self._execute_conversation_loop(model_name=model_name,
|
|
1639
|
+
verbosity=verbosity,
|
|
1640
|
+
input_msgs=input_msgs,
|
|
1641
|
+
tools_schema=tools_schema,
|
|
1642
|
+
message_id=message_id)
|
|
1643
|
+
except Exception as exc: # noqa: BLE001
|
|
1644
|
+
output_text = f"Falha ao submeter o prompt: {exc}"
|
|
1645
|
+
|
|
1646
|
+
input_tokens, output_tokens, total_tokens = self._compute_usage(input_msgs, output_text, usage)
|
|
1647
|
+
assistant_files_struct2 = self._collect_session_artifacts(message_id)
|
|
1648
|
+
|
|
1649
|
+
assistant_message_struct = {
|
|
1650
|
+
"role": "agent",
|
|
1651
|
+
"message_id": message_id,
|
|
1652
|
+
"agent_name": self.agent_config.agent_name,
|
|
1653
|
+
"timestamp": msg_timestamp.replace(microsecond=0).isoformat(timespec="seconds"),
|
|
1654
|
+
"sent_at": self._now().replace(microsecond=0).isoformat(timespec="seconds"),
|
|
1655
|
+
"elapsed_time_in_seconds": int((self._now() - msg_timestamp).total_seconds()),
|
|
1656
|
+
"content": output_text,
|
|
1657
|
+
"files": assistant_files_struct2,
|
|
1658
|
+
"format": message_format or "raw",
|
|
1659
|
+
"languages": [],
|
|
1660
|
+
"model_name": model_name,
|
|
1661
|
+
"usage": {
|
|
1662
|
+
"input_tokens": input_tokens,
|
|
1663
|
+
"output_tokens": output_tokens,
|
|
1664
|
+
"total_tokens": total_tokens,
|
|
1665
|
+
},
|
|
1666
|
+
}
|
|
1667
|
+
self.message_history.append(assistant_message_struct)
|
|
1668
|
+
self.session_updated_at = self._now()
|
|
1669
|
+
|
|
1670
|
+
if self.response_callback:
|
|
1671
|
+
try:
|
|
1672
|
+
self.response_callback(assistant_message_struct)
|
|
1673
|
+
except Exception:
|
|
1674
|
+
pass
|
|
1675
|
+
|
|
1676
|
+
if self.is_verbose:
|
|
1677
|
+
usage2n = assistant_message_struct.get("usage", {}) or {}
|
|
1678
|
+
log_msg(
|
|
1679
|
+
(
|
|
1680
|
+
f"id={message_id} finalized tokens"
|
|
1681
|
+
f"{{'input': {usage2n.get('input_tokens')}, 'output': {usage2n.get('output_tokens')}, 'total': {usage2n.get('total_tokens')}}}"
|
|
1682
|
+
),
|
|
1683
|
+
func="answer",
|
|
1684
|
+
action=str(action),
|
|
1685
|
+
color="MAGENTA",
|
|
1686
|
+
)
|
|
1687
|
+
|
|
1688
|
+
# elimina a pasta de trabalho se estiver vazia
|
|
1689
|
+
self._remove_empty_session_working_folder()
|
|
1690
|
+
|
|
1691
|
+
return assistant_message_struct
|
|
1692
|
+
|
|
1693
|
+
def delete_old_files(self, max_age_days: int = 30) -> list:
|
|
1694
|
+
"""Remove arquivos antigos do storage remoto do OpenAI.
|
|
1695
|
+
|
|
1696
|
+
Intent:
|
|
1697
|
+
Garantir limpeza periódica de anexos e saídas alinhada ao ciclo de retenção descrito em docs/ADR-setup-agent-refactor.md.
|
|
1698
|
+
|
|
1699
|
+
Args:
|
|
1700
|
+
max_age_days (int, optional): Janela de retenção em dias; arquivos anteriores ao limite serão apagados. Defaults to 30.
|
|
1701
|
+
|
|
1702
|
+
Returns:
|
|
1703
|
+
list: Tuplas contendo ID, nome do arquivo e data de criação para cada item removido.
|
|
1704
|
+
"""
|
|
1705
|
+
# Quantidade de dias para expiração
|
|
1706
|
+
cutoff_date = datetime.now(timezone.utc) - timedelta(days=max_age_days)
|
|
1707
|
+
|
|
1708
|
+
# 1. Listar todos os arquivos
|
|
1709
|
+
files = self._openai_client.files.list()
|
|
1710
|
+
|
|
1711
|
+
deleted_files = []
|
|
1712
|
+
|
|
1713
|
+
# 2. Filtrar arquivos antigos
|
|
1714
|
+
for f in files.data:
|
|
1715
|
+
created_at = datetime.fromtimestamp(f.created_at, tz=timezone.utc)
|
|
1716
|
+
|
|
1717
|
+
if created_at < cutoff_date:
|
|
1718
|
+
# 3. Excluir
|
|
1719
|
+
self._openai_client.files.delete(f.id)
|
|
1720
|
+
deleted_files.append((f.id, f.filename, created_at))
|
|
1721
|
+
|
|
1722
|
+
# 4. retorna os nomes dos arquivos removidos
|
|
1723
|
+
return deleted_files
|
|
1724
|
+
|
|
1725
|
+
def task_plan(self, prompt: str) -> str | None:
|
|
1726
|
+
"""Avalia se um prompt requer planejamento para resposta"""
|
|
1727
|
+
# adiciona ao system prompt a lista das ferramentas
|
|
1728
|
+
tools_names = list(self.tools.keys()) if self.tools else []
|
|
1729
|
+
tools_names.extend(list((self.agent_config.custom_tools or {}).keys()))
|
|
1730
|
+
if self.subordinated_agents:
|
|
1731
|
+
tools_names.append("ask_to_agent_tool")
|
|
1732
|
+
if len(tools_names) > 0:
|
|
1733
|
+
tools_schema = self._compose_tool_schemas(tools_names)
|
|
1734
|
+
tool_hint = "Considere que o agente poderá usar as seguintes ferramentas: \n"
|
|
1735
|
+
for tool in tools_schema:
|
|
1736
|
+
tool_hint += f"-{tool['name']} - {tool['description']}\n"
|
|
1737
|
+
|
|
1738
|
+
# avalia se precisa de um planejamento e o tipo de planejamento
|
|
1739
|
+
try:
|
|
1740
|
+
system_prompt = f"Você é um classificador de roteamento para um agente de IA autônomo. Retorne APENAS JSON válido. Sem markdown, sem texto extra."
|
|
1741
|
+
user_prompt = dedent(f"""
|
|
1742
|
+
Classifique se o prompt do usuário precisa de planejamento.
|
|
1743
|
+
|
|
1744
|
+
Escala:
|
|
1745
|
+
- score 0-4: direct (direto)
|
|
1746
|
+
- score 5-7: micro_plan (micro-planejamento)
|
|
1747
|
+
- score 8-10: plan_then_execute (planejar e executar)
|
|
1748
|
+
|
|
1749
|
+
Defina ask_clarifying=true apenas se a falta de informações impedir a execução. Defina needs_tools=true se arquivos/APIs/dados externos forem necessários.
|
|
1750
|
+
|
|
1751
|
+
Retorne um JSON com:
|
|
1752
|
+
- score (int 0-10),
|
|
1753
|
+
- approach (direct|micro_plan|plan_then_execute|ask_clarifying),
|
|
1754
|
+
- risk (low|med|high),
|
|
1755
|
+
- needs_tools (bool),
|
|
1756
|
+
- ask_clarifying (bool),
|
|
1757
|
+
- signals (array de strings curtas).
|
|
1758
|
+
|
|
1759
|
+
**Segue o prompt do usuário**:
|
|
1760
|
+
|
|
1761
|
+
{prompt}""")
|
|
1762
|
+
|
|
1763
|
+
answer = self.consult(system_prompt=system_prompt, user_prompt=user_prompt)
|
|
1764
|
+
evaluation = json.loads(answer)
|
|
1765
|
+
|
|
1766
|
+
if evaluation.get("score", 0) < 5:
|
|
1767
|
+
return None
|
|
1768
|
+
|
|
1769
|
+
except Exception:
|
|
1770
|
+
return None
|
|
1771
|
+
|
|
1772
|
+
# cria o planejamento
|
|
1773
|
+
if evaluation["score"] < 8:
|
|
1774
|
+
# prepara um plano minimalista
|
|
1775
|
+
plan = dedent(f"""
|
|
1776
|
+
Antes de executar a tarefa abaixo, prepare um plano interno minimalista.
|
|
1777
|
+
|
|
1778
|
+
Regras:
|
|
1779
|
+
- O plano é interno e NÃO deve ser exibido.
|
|
1780
|
+
- Use no máximo 3 tópicos (bullet points) curtos.
|
|
1781
|
+
- Foque apenas na ordem de execução e nas restrições.
|
|
1782
|
+
- NÃO explique o raciocínio ou as decisões.
|
|
1783
|
+
- Após o planejamento, execute a tarefa normalmente.""")
|
|
1784
|
+
else:
|
|
1785
|
+
# prepara um plano compexo
|
|
1786
|
+
plan = dedent(f"""
|
|
1787
|
+
# PLANEJAMENTO
|
|
1788
|
+
|
|
1789
|
+
Esta é uma tarefa complexa. Siga o processo rigorosamente e não revele o chain-of-thought ou seu raciocínio interno.
|
|
1790
|
+
|
|
1791
|
+
## FASE 1 — PLANEJAMENTO
|
|
1792
|
+
|
|
1793
|
+
Prepare um plano de execução detalhado antes de resolver a tarefa.
|
|
1794
|
+
|
|
1795
|
+
Regras de planejamento:
|
|
1796
|
+
- NÃO resolva a tarefa nesta fase.
|
|
1797
|
+
- Produza um plano estruturado, passo a passo.
|
|
1798
|
+
- Identifique as entradas necessárias, restrições, suposições e riscos.
|
|
1799
|
+
- Defina critérios de validação para o sucesso.
|
|
1800
|
+
- Se informações críticas estiverem faltando, liste as perguntas e pare.
|
|
1801
|
+
|
|
1802
|
+
Formato de saída para a FASE 1 (use exatamente estas seções):
|
|
1803
|
+
|
|
1804
|
+
```markdown
|
|
1805
|
+
PLAN:
|
|
1806
|
+
- Passo 1:
|
|
1807
|
+
- Passo 2:
|
|
1808
|
+
- ...
|
|
1809
|
+
|
|
1810
|
+
INPUTS:
|
|
1811
|
+
- ...
|
|
1812
|
+
|
|
1813
|
+
CONSTRAINTS:
|
|
1814
|
+
- ...
|
|
1815
|
+
|
|
1816
|
+
ASSUMPTIONS:
|
|
1817
|
+
- ...
|
|
1818
|
+
|
|
1819
|
+
RISKS:
|
|
1820
|
+
- ...
|
|
1821
|
+
|
|
1822
|
+
VALIDATION:
|
|
1823
|
+
- ...
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
## FASE 2 — EXECUÇÃO
|
|
1827
|
+
|
|
1828
|
+
Após o plano estar pronto, execute a tarefa seguindo o plano rigorosamente.
|
|
1829
|
+
|
|
1830
|
+
Regras de execução:
|
|
1831
|
+
- Não revele o plano ou o raciocínio interno.
|
|
1832
|
+
- Produza apenas a entrega final.
|
|
1833
|
+
- Declare explicitamente quaisquer suposições utilizadas.
|
|
1834
|
+
- Se a validação falhar, relate a falha claramente.
|
|
1835
|
+
""")
|
|
1836
|
+
|
|
1837
|
+
return plan
|
|
1838
|
+
|
|
1839
|
+
def task_reflection(self, prompt: str, planning: str | None = None) -> str | None:
|
|
1840
|
+
"""
|
|
1841
|
+
Avalia se um prompt requer avaliação da resposta.
|
|
1842
|
+
|
|
1843
|
+
Use self-reflection somente quando pelo menos um dos critérios abaixo for verdadeiro:
|
|
1844
|
+
|
|
1845
|
+
A. Alto risco de erro conceitual
|
|
1846
|
+
- Ambiguidade semântica relevante
|
|
1847
|
+
- Conceitos mal definidos ou conflitantes
|
|
1848
|
+
- Dependência de pressupostos implícitos
|
|
1849
|
+
|
|
1850
|
+
Ex.: estratégia, arquitetura, filosofia, direito, diagnóstico, decisões abertas.
|
|
1851
|
+
|
|
1852
|
+
B. Custo de erro elevado
|
|
1853
|
+
- Decisão irreversível
|
|
1854
|
+
- Output será usado como base para código, contrato, política, arquitetura
|
|
1855
|
+
= Resposta errada gera retrabalho caro
|
|
1856
|
+
|
|
1857
|
+
C. Tarefa exige reasoning multi-step
|
|
1858
|
+
- Planejamento
|
|
1859
|
+
- Análise causal
|
|
1860
|
+
- Avaliação de trade-offs
|
|
1861
|
+
- Criação de frameworks ou heurísticas
|
|
1862
|
+
|
|
1863
|
+
D. Prompt “parece simples”, mas não é
|
|
1864
|
+
- Perguntas curtas com profundidade oculta
|
|
1865
|
+
- Exemplos clássicos:
|
|
1866
|
+
“Qual é a melhor arquitetura…”,
|
|
1867
|
+
“Qual abordagem devo usar…”,
|
|
1868
|
+
“Explique X” (quando X é abstrato)
|
|
1869
|
+
"""
|
|
1870
|
+
if planning is None:
|
|
1871
|
+
# avalia se precisa de alto-avaliação (quando há um plano então precisa)
|
|
1872
|
+
try:
|
|
1873
|
+
system_prompt = dedent(f"""
|
|
1874
|
+
Você é um classificador de tarefas cognitivas.
|
|
1875
|
+
|
|
1876
|
+
Avalie o prompt abaixo e responda APENAS com:
|
|
1877
|
+
- YES → se a tarefa se beneficia significativamente de self-reflection
|
|
1878
|
+
- NO → caso contrário
|
|
1879
|
+
|
|
1880
|
+
Critérios:
|
|
1881
|
+
- Ambiguidade conceitual relevante
|
|
1882
|
+
- Alto custo de erro
|
|
1883
|
+
- Necessidade de reasoning multi-step
|
|
1884
|
+
- Dependência de pressupostos implícitos""")
|
|
1885
|
+
user_prompt = dedent(f"""
|
|
1886
|
+
**Prompt a avaliar**:
|
|
1887
|
+
|
|
1888
|
+
{prompt}""")
|
|
1889
|
+
|
|
1890
|
+
answer = self.consult(system_prompt=system_prompt, user_prompt=user_prompt)
|
|
1891
|
+
evaluation = json.loads(answer)
|
|
1892
|
+
|
|
1893
|
+
if evaluation.get("content", "").lower() != "yes":
|
|
1894
|
+
return None
|
|
1895
|
+
|
|
1896
|
+
except Exception:
|
|
1897
|
+
return None
|
|
1898
|
+
|
|
1899
|
+
# cria o prompt de self-reflection
|
|
1900
|
+
prompt_self_reflection = dedent(f"""
|
|
1901
|
+
Antes de responder, siga este processo interno:
|
|
1902
|
+
1. Identifique possíveis ambiguidades ou pressupostos ocultos.
|
|
1903
|
+
2. Avalie riscos de erro conceitual.
|
|
1904
|
+
3. Escolha a abordagem mais robusta.
|
|
1905
|
+
4. Só então produza a resposta final.
|
|
1906
|
+
|
|
1907
|
+
Não exponha o processo intermediário.
|
|
1908
|
+
Forneça apenas a resposta final estruturada.
|
|
1909
|
+
""")
|
|
1910
|
+
|
|
1911
|
+
return prompt_self_reflection
|