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/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