agloop 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: agloop
3
+ Version: 0.6.0
4
+ Summary: Serverless Git & Markdown Multi-Agent Orchestration Framework for heterogeneous LLM agents (e.g. Claude <-> Gemini War Room)
5
+ Author: Yure Mark
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yuremarketing/AgLoop
8
+ Project-URL: Repository, https://github.com/yuremarketing/AgLoop
9
+ Project-URL: Issues, https://github.com/yuremarketing/AgLoop/issues
10
+ Keywords: agents,multi-agent,llm,orchestration,git,ai
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Classifier: Topic :: Software Development :: Version Control :: Git
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: GitPython>=3.1.43
25
+ Requires-Dist: python-dotenv>=1.0.1
26
+ Requires-Dist: litellm>=1.44.0
27
+ Provides-Extra: adk
28
+ Requires-Dist: google-adk>=0.1.0; extra == "adk"
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # AgLoop
34
+
35
+ **AgLoop** is a lightweight, Git-based orchestration framework for autonomous AI agents (like Claude and Gemini). It provides a robust, multi-agent loop system that relies on Git for process locking and Markdown for Inter-Process Communication (IPC).
36
+
37
+ ## Core Concepts
38
+
39
+ - **Git-based Locking:** Uses standard `git push` and `git pull` as a reliable lock to manage agent concurrency across different machines or sessions.
40
+ - **Turn-based Execution:** Agents take "turns" executing work in an orchestrated manner, avoiding merge conflicts.
41
+ - **Markdown IPC:** The "War Room" (Sala de Guerra) approach where agents propose, debate, and agree on implementations via a single `.md` file.
42
+
43
+ *Originated from the Sysoft project.*
@@ -0,0 +1,21 @@
1
+ main.py,sha256=jztLTvO1zzfDWZ9gITG8bSRWKMR1GzeWdSDz34g_oXg,3147
2
+ agloop-0.6.0.dist-info/licenses/LICENSE,sha256=OA0MwEOB-6kVASZK-U3az6vi2cn-k__MLtj8zE-DXRU,1066
3
+ core/agent.py,sha256=TxCF7bPfWKJv8HtWGqJ8JpXnLoeBqANH1odwbvzH6vs,20886
4
+ core/git_lock.py,sha256=PMgm9ykZr6Dw3QlzHNyDyfsx6OQv_Lfz42y5zabY41w,16322
5
+ core/init_scaffold.py,sha256=KPwZ1siDYR0oBi3fK5zh1ECdWoIpwY9nrNsatQQIuCc,2046
6
+ core/mcp_server.py,sha256=hGMF_r-iVoys0W1zCO3crtfRkYTfIKvPTycXYvtVpS0,8476
7
+ core/privacy_audit.py,sha256=J5e2ynnEH4gWaHSrcNSYFCAXk8iPLU-gzN9lr1jPVPE,3121
8
+ core/runner.py,sha256=ASGI2fJuTy1mpilSyT-VPP8u77W2Pab_82cZFi6aQMg,10892
9
+ core/scan.py,sha256=yWgULxL92ncFEHSjcCy2z8OsHiXuy_Mq6WyimJ1wRZA,3772
10
+ core/war_room.py,sha256=yP5Q98HFYowwj9-80g_yOubcFhSTsVAnRxCg_x9kTtg,7880
11
+ core/templates/HISTORICO.md,sha256=4zY-Xoj_t1VO8vWvuFHj42-LZvdo6Qwd-FIDQ-JNjZA,115
12
+ core/templates/war_room.md,sha256=yOfX4QU6NOkLVRxoACNuhRFFORs7XBnH4Wfleco8Isw,62
13
+ core/templates/gestao/backlog-produto.md,sha256=pzht0mW9YLV3sSVLXiUyrsTZWhNt18SjrXRstkKVlwA,124
14
+ core/templates/gestao/matriz-riscos.md,sha256=NYotzFrVWCMhV1gYmbZEj7YQEEX3Yy28XRB_hVn5xuE,124
15
+ core/templates/gestao/roadmap.md,sha256=n6wzo_7KV93jWY5US6hLeNW96W8oYjVctpkKBQ7luXc,145
16
+ scripts/vigia.py,sha256=f_K0bY74vsc4mdp_Wr34EIdQaF0rKyzsukU3T8vwahs,2531
17
+ agloop-0.6.0.dist-info/METADATA,sha256=ykMk6t0ESn4bCzVVLRtlmCSxlXPF0dRDJ9bpg3ZBbq0,2066
18
+ agloop-0.6.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
19
+ agloop-0.6.0.dist-info/entry_points.txt,sha256=R1n-nq7Pmuw7mIvLBEw49TbAzg-zT6Fovmq_wK_i9Ts,105
20
+ agloop-0.6.0.dist-info/top_level.txt,sha256=BxBy12Vc9JX_yPI_afIbG-kQXnzLR2rHbV1idEX4R9E,18
21
+ agloop-0.6.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ agloop = main:main
3
+ agloop-mcp = core.mcp_server:main
4
+ agloop-vigia = scripts.vigia:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yure Mark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ core
2
+ main
3
+ scripts
core/agent.py ADDED
@@ -0,0 +1,523 @@
1
+ import asyncio
2
+ import fnmatch
3
+ import inspect
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from typing import Any, Callable, Dict, List, Optional, Sequence
11
+
12
+ import litellm
13
+
14
+ try:
15
+ from google.adk.agents import LlmAgent # type: ignore
16
+ from google.adk.models.lite_llm import LiteLlm # type: ignore
17
+ from google.adk.runners import InMemoryRunner # type: ignore
18
+
19
+ ADK_AVAILABLE = True
20
+ except ImportError:
21
+ LlmAgent = None # type: ignore
22
+ LiteLlm = None # type: ignore
23
+ InMemoryRunner = None # type: ignore
24
+ ADK_AVAILABLE = False
25
+
26
+ _logger = logging.getLogger("agloop.agent")
27
+
28
+ _STRUCTURED_LOG_FIELDS = ("event", "agent", "model", "tool_calls", "commit_sha", "usage")
29
+
30
+
31
+ class _JsonLogFormatter(logging.Formatter):
32
+ """Renders one JSON object per line, carrying only the AgLoop structured fields present."""
33
+
34
+ def format(self, record: logging.LogRecord) -> str:
35
+ payload = {
36
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
37
+ "level": record.levelname,
38
+ "logger": record.name,
39
+ "message": record.getMessage(),
40
+ }
41
+ for field in _STRUCTURED_LOG_FIELDS:
42
+ value = getattr(record, field, None)
43
+ if value is not None:
44
+ payload[field] = value
45
+ return json.dumps(payload, ensure_ascii=False)
46
+
47
+
48
+ def configure_json_logging(level: int = logging.INFO) -> None:
49
+ """
50
+ Configures the `agloop` logger tree to emit one JSON object per line on stderr
51
+ (never stdout, so it never corrupts the MCP server's JSON-RPC stdout stream).
52
+ """
53
+ handler = logging.StreamHandler()
54
+ handler.setFormatter(_JsonLogFormatter())
55
+ root = logging.getLogger("agloop")
56
+ root.handlers = [handler]
57
+ root.setLevel(level)
58
+ root.propagate = False
59
+
60
+
61
+ def _accepts_kwarg(fn: Callable, name: str) -> bool:
62
+ """True if `fn` declares `name` or accepts arbitrary `**kwargs`."""
63
+ try:
64
+ sig = inspect.signature(fn)
65
+ except (TypeError, ValueError):
66
+ return True
67
+ return name in sig.parameters or any(
68
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
69
+ )
70
+
71
+
72
+ SENSITIVE_PATTERNS = (
73
+ ".env",
74
+ ".env.*",
75
+ "*.pem",
76
+ "*.key",
77
+ "id_rsa*",
78
+ "*credentials*",
79
+ "*secret*",
80
+ )
81
+
82
+ _CPF_RE = re.compile(r"\b\d{3}\.\d{3}\.\d{3}-\d{2}\b")
83
+ _SECRET_TOKEN_RE = re.compile(
84
+ r"\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{20,})\b"
85
+ )
86
+ _EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b")
87
+
88
+
89
+ def sanitize_prompt_pii(text: str) -> str:
90
+ """
91
+ Masks personal/sensitive identifiers before sending context to an LLM API (LGPD-02 / Risco R09):
92
+ * Brazilian CPF numbers (`NNN.NNN.NNN-NN`) -> `[CPF_REDACTED]`
93
+ * Common API tokens (`sk-...`, `ghp_...`, `AIza...`) -> `[SECRET_REDACTED]`
94
+ * All e-mail addresses except synthetic `@agloop.local` identities -> `[EMAIL_REDACTED]`
95
+ """
96
+ if not text:
97
+ return text
98
+
99
+ sanitized = _CPF_RE.sub("[CPF_REDACTED]", text)
100
+ sanitized = _SECRET_TOKEN_RE.sub("[SECRET_REDACTED]", sanitized)
101
+
102
+ def _mask_email(match: re.Match) -> str:
103
+ domain = match.group(1).lower()
104
+ if domain == "agloop.local":
105
+ return match.group(0)
106
+ return "[EMAIL_REDACTED]"
107
+
108
+ return _EMAIL_RE.sub(_mask_email, sanitized)
109
+
110
+
111
+ def _is_within_workspace(repo_root: str, target_path: str) -> bool:
112
+ """Checks strict workspace confinement using `os.path.commonpath` (prevents sibling prefix bypass)."""
113
+ root_real = os.path.realpath(repo_root)
114
+ target_real = os.path.realpath(target_path)
115
+ try:
116
+ return os.path.commonpath([root_real, target_real]) == root_real
117
+ except ValueError:
118
+ return False
119
+
120
+
121
+ def _is_sensitive_path(repo_root: str, target_path: str) -> bool:
122
+ """Returns True if `target_path` matches LGPD-01 / Risco R06 sensitive file patterns."""
123
+ root_real = os.path.realpath(repo_root)
124
+ target_real = os.path.realpath(target_path)
125
+ rel_path = os.path.relpath(target_real, root_real).replace("\\", "/")
126
+ parts = [p for p in rel_path.split("/") if p and p != "."]
127
+
128
+ if ".git" in parts:
129
+ return True
130
+
131
+ basename = os.path.basename(target_real).lower()
132
+ for pattern in SENSITIVE_PATTERNS:
133
+ if fnmatch.fnmatch(basename, pattern.lower()):
134
+ return True
135
+ return False
136
+
137
+
138
+ def read_file(filepath: str, max_lines: int = 200, repo_root: Optional[str] = None) -> str:
139
+ """
140
+ Safe read-only tool confined to the repository workspace with LGPD-01 / R06 blacklist.
141
+ """
142
+ root_real = os.path.realpath(repo_root or os.getcwd())
143
+ target_real = os.path.realpath(os.path.join(root_real, filepath))
144
+
145
+ if not _is_within_workspace(root_real, target_real):
146
+ return "Access denied: path is outside the repository workspace."
147
+ if _is_sensitive_path(root_real, target_real):
148
+ return "Access denied: reading sensitive or credential files is blocked (LGPD-01)."
149
+ if not os.path.isfile(target_real):
150
+ return f"Error: file not found: {filepath}"
151
+
152
+ with open(target_real, "r", encoding="utf-8", errors="replace") as f:
153
+ lines = []
154
+ for idx, line in enumerate(f):
155
+ if idx >= max_lines:
156
+ lines.append(f"... [truncated after {max_lines} lines]")
157
+ break
158
+ lines.append(line.rstrip("\n"))
159
+ return sanitize_prompt_pii("\n".join(lines))
160
+
161
+
162
+ def list_dir(dirpath: str = ".", repo_root: Optional[str] = None) -> str:
163
+ """
164
+ Safe directory listing tool confined to the repository workspace (hides sensitive files).
165
+ """
166
+ root_real = os.path.realpath(repo_root or os.getcwd())
167
+ target_real = os.path.realpath(os.path.join(root_real, dirpath))
168
+
169
+ if not _is_within_workspace(root_real, target_real):
170
+ return "Access denied: directory is outside the repository workspace."
171
+ if _is_sensitive_path(root_real, target_real):
172
+ return "Access denied: directory is restricted (LGPD-01)."
173
+ if not os.path.isdir(target_real):
174
+ return f"Error: directory not found: {dirpath}"
175
+
176
+ entries: List[str] = []
177
+ for entry in sorted(os.listdir(target_real)):
178
+ full = os.path.join(target_real, entry)
179
+ if _is_sensitive_path(root_real, full):
180
+ continue
181
+ suffix = "/" if os.path.isdir(full) else ""
182
+ entries.append(f"{entry}{suffix}")
183
+ return "\n".join(entries)
184
+
185
+
186
+ def git_diff(target: str = "HEAD", max_chars: int = 4000, repo_root: Optional[str] = None) -> str:
187
+ """
188
+ Safe read-only `git diff` inspection tool confined to the repository workspace.
189
+ """
190
+ if target.startswith("-"):
191
+ return "Access denied: invalid git diff target."
192
+ root_real = os.path.realpath(repo_root or os.getcwd())
193
+ try:
194
+ res = subprocess.run(
195
+ ["git", "diff", target],
196
+ cwd=root_real,
197
+ capture_output=True,
198
+ text=True,
199
+ timeout=15,
200
+ check=False,
201
+ )
202
+ except Exception as e:
203
+ return f"Error running git diff: {e}"
204
+
205
+ output = (res.stdout or "") + (res.stderr or "")
206
+ if len(output) > max_chars:
207
+ output = output[:max_chars] + f"\n... [truncated at {max_chars} chars]"
208
+ return sanitize_prompt_pii(output.strip())
209
+
210
+
211
+ def run_pytest(target: str = "tests", timeout: int = 30, repo_root: Optional[str] = None) -> str:
212
+ """
213
+ Safe read-only test runner confined to the `tests/` directory.
214
+ """
215
+ if target.startswith("-"):
216
+ return "Access denied: flags are not allowed in target."
217
+ root_real = os.path.realpath(repo_root or os.getcwd())
218
+ tests_root = os.path.realpath(os.path.join(root_real, "tests"))
219
+ target_real = os.path.realpath(os.path.join(root_real, target))
220
+
221
+ if not _is_within_workspace(tests_root, target_real):
222
+ return "Access denied: pytest target must be inside the tests/ directory."
223
+
224
+ try:
225
+ res = subprocess.run(
226
+ [sys.executable, "-m", "pytest", "-q", target_real],
227
+ cwd=root_real,
228
+ capture_output=True,
229
+ text=True,
230
+ timeout=timeout,
231
+ check=False,
232
+ )
233
+ output = (res.stdout or "") + (res.stderr or "")
234
+ return sanitize_prompt_pii(output.strip())
235
+ except Exception as e:
236
+ return f"Error running pytest: {e}"
237
+
238
+
239
+ DEFAULT_SAFE_TOOLS: Sequence[Callable] = (read_file, list_dir, git_diff, run_pytest)
240
+
241
+ _TOOL_SCHEMAS: Dict[str, Dict[str, Any]] = {
242
+ "read_file": {
243
+ "type": "function",
244
+ "function": {
245
+ "name": "read_file",
246
+ "description": "Read a file inside the repository workspace (sensitive files blocked by LGPD-01).",
247
+ "parameters": {
248
+ "type": "object",
249
+ "properties": {
250
+ "filepath": {"type": "string", "description": "Relative path to the file inside the repository."},
251
+ "max_lines": {"type": "integer", "description": "Maximum number of lines to read.", "default": 200},
252
+ },
253
+ "required": ["filepath"],
254
+ },
255
+ },
256
+ },
257
+ "list_dir": {
258
+ "type": "function",
259
+ "function": {
260
+ "name": "list_dir",
261
+ "description": "List files and directories inside the repository workspace.",
262
+ "parameters": {
263
+ "type": "object",
264
+ "properties": {
265
+ "dirpath": {"type": "string", "description": "Relative directory path.", "default": "."},
266
+ },
267
+ },
268
+ },
269
+ },
270
+ "git_diff": {
271
+ "type": "function",
272
+ "function": {
273
+ "name": "git_diff",
274
+ "description": "Inspect git diff for a commit or ref inside the repository.",
275
+ "parameters": {
276
+ "type": "object",
277
+ "properties": {
278
+ "target": {"type": "string", "description": "Git ref or commit to diff against.", "default": "HEAD"},
279
+ },
280
+ },
281
+ },
282
+ },
283
+ "run_pytest": {
284
+ "type": "function",
285
+ "function": {
286
+ "name": "run_pytest",
287
+ "description": "Run pytest on the repository test suite (confined to tests/).",
288
+ "parameters": {
289
+ "type": "object",
290
+ "properties": {
291
+ "target": {"type": "string", "description": "Path inside tests/ to execute.", "default": "tests"},
292
+ },
293
+ },
294
+ },
295
+ },
296
+ }
297
+
298
+
299
+ class Agent:
300
+ def __init__(
301
+ self,
302
+ name: str,
303
+ role_prompt: str,
304
+ model: str = "gemini/gemini-1.5-pro-latest",
305
+ tools: Optional[Sequence[Callable]] = None,
306
+ use_adk: bool = True,
307
+ max_tool_rounds: int = 4,
308
+ timeout: Optional[float] = None,
309
+ max_retries: Optional[int] = None,
310
+ fallbacks: Optional[Sequence[str]] = None,
311
+ ):
312
+ self.name = name
313
+ self.role_prompt = role_prompt
314
+ self.model = model
315
+ self.tools: List[Callable] = list(DEFAULT_SAFE_TOOLS if tools is None else tools)
316
+ self._tool_map: Dict[str, Callable] = {getattr(fn, "__name__", str(fn)): fn for fn in self.tools}
317
+ self.max_tool_rounds = max_tool_rounds
318
+ self.use_adk = bool(use_adk and ADK_AVAILABLE)
319
+ self.adk_agent = self._build_adk_agent() if self.use_adk else None
320
+
321
+ # Resilience defaults (Issue #11): read at construction time, not import
322
+ # time, so `load_dotenv()` called by the CLI before building the Agent
323
+ # is honored even though `core.agent` is imported earlier in main.py.
324
+ self.timeout = float(timeout if timeout is not None else os.getenv("LLM_TIMEOUT", "60"))
325
+ self.max_retries = int(max_retries if max_retries is not None else os.getenv("LLM_MAX_RETRIES", "3"))
326
+ self.fallbacks: Optional[List[str]] = list(fallbacks) if fallbacks else None
327
+
328
+ def _system_instruction(self) -> str:
329
+ return (
330
+ f"You are {self.name}.\n"
331
+ f"{self.role_prompt}\n"
332
+ "You are participating in a War Room debate via Markdown.\n"
333
+ "Below is the current content of the War Room. Add your thoughts or actions.\n"
334
+ "Do not output markdown headers like '## AgentName', just output your response text directly."
335
+ )
336
+
337
+ def _build_adk_agent(self):
338
+ """Builds a Google ADK `LlmAgent` backed by `LiteLlm` and safe read-only tools."""
339
+ if not ADK_AVAILABLE or LlmAgent is None or LiteLlm is None:
340
+ return None
341
+ safe_identifier = re.sub(r"\W+", "_", self.name).strip("_") or "agloop_agent"
342
+ return LlmAgent(
343
+ name=safe_identifier,
344
+ model=LiteLlm(model=self.model),
345
+ instruction=self._system_instruction(),
346
+ tools=list(self.tools),
347
+ )
348
+
349
+ def _run_adk(self, user_prompt: str) -> str:
350
+ """Executes `self.adk_agent` via Google ADK `InMemoryRunner` and returns the final text."""
351
+ if InMemoryRunner is None or self.adk_agent is None:
352
+ raise RuntimeError("Google ADK InMemoryRunner is not available.")
353
+
354
+ runner = InMemoryRunner(agent=self.adk_agent, app_name="agloop")
355
+ if hasattr(runner, "run_text"):
356
+ return str(runner.run_text(user_prompt)).strip()
357
+
358
+ async def _consume() -> str:
359
+ session_service = getattr(runner, "session_service", None)
360
+ user_id = "agloop_user"
361
+ slug_name = re.sub(r"\W+", "_", self.name)
362
+ session_id = f"session_{slug_name}"
363
+ if session_service is not None and hasattr(session_service, "create_session"):
364
+ maybe_coro = session_service.create_session(
365
+ app_name="agloop", user_id=user_id, session_id=session_id
366
+ )
367
+ if inspect.isawaitable(maybe_coro):
368
+ await maybe_coro
369
+
370
+ from google.genai import types # type: ignore
371
+
372
+ new_msg = types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)])
373
+ chunks: List[str] = []
374
+ async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=new_msg):
375
+ content = getattr(event, "content", None)
376
+ parts = getattr(content, "parts", None) if content else None
377
+ if parts:
378
+ for part in parts:
379
+ txt = getattr(part, "text", None)
380
+ if txt:
381
+ chunks.append(txt)
382
+ return "\n".join(chunks).strip()
383
+
384
+ return asyncio.run(_consume())
385
+
386
+ def _litellm_tool_schemas(self) -> List[Dict[str, Any]]:
387
+ schemas: List[Dict[str, Any]] = []
388
+ for fn in self.tools:
389
+ name = getattr(fn, "__name__", "")
390
+ if name in _TOOL_SCHEMAS:
391
+ schemas.append(_TOOL_SCHEMAS[name])
392
+ return schemas
393
+
394
+ def _resilience_kwargs(self) -> Dict[str, Any]:
395
+ """Timeout/retry/fallback kwargs for `litellm.completion`, added only if the
396
+ installed `litellm` version (or a test fake) actually accepts them."""
397
+ kwargs: Dict[str, Any] = {}
398
+ if _accepts_kwarg(litellm.completion, "timeout"):
399
+ kwargs["timeout"] = self.timeout
400
+ if _accepts_kwarg(litellm.completion, "num_retries"):
401
+ kwargs["num_retries"] = self.max_retries
402
+ if self.fallbacks and _accepts_kwarg(litellm.completion, "fallbacks"):
403
+ kwargs["fallbacks"] = self.fallbacks
404
+ return kwargs
405
+
406
+ def _log_usage(self, response: Any) -> None:
407
+ usage = getattr(response, "usage", None)
408
+ if usage is not None:
409
+ _logger.info(
410
+ "completion usage",
411
+ extra={"event": "llm_usage", "agent": self.name, "model": self.model, "usage": str(usage)},
412
+ )
413
+
414
+ def _call_completion(self, messages: List[Dict[str, Any]]):
415
+ schemas = self._litellm_tool_schemas()
416
+ kwargs = self._resilience_kwargs()
417
+ if schemas and _accepts_kwarg(litellm.completion, "tools"):
418
+ kwargs["tools"] = schemas
419
+ response = litellm.completion(model=self.model, messages=messages, **kwargs)
420
+ self._log_usage(response)
421
+ return response
422
+
423
+ def _execute_tool_call(self, tool_name: str, raw_args: Any) -> str:
424
+ _logger.info(
425
+ "tool call",
426
+ extra={"event": "tool_call", "agent": self.name, "model": self.model, "tool_calls": tool_name},
427
+ )
428
+ fn = self._tool_map.get(tool_name)
429
+ if fn is None:
430
+ return f"Error: unknown or unauthorized tool '{tool_name}'."
431
+ try:
432
+ if isinstance(raw_args, str):
433
+ kwargs = json.loads(raw_args) if raw_args.strip() else {}
434
+ elif isinstance(raw_args, dict):
435
+ kwargs = raw_args
436
+ else:
437
+ kwargs = {}
438
+ return sanitize_prompt_pii(str(fn(**kwargs)))
439
+ except Exception as e:
440
+ return f"Error executing tool '{tool_name}': {e}"
441
+
442
+ def _run_litellm_with_tools(self, messages: List[Dict[str, Any]]) -> str:
443
+ """Invokes `litellm.completion` and resolves any requested tool calls up to `max_tool_rounds`."""
444
+ conversation = list(messages)
445
+ for _ in range(max(1, self.max_tool_rounds)):
446
+ response = self._call_completion(conversation)
447
+ msg = response.choices[0].message
448
+ tool_calls = getattr(msg, "tool_calls", None)
449
+ if not tool_calls:
450
+ return (msg.content or "").strip()
451
+
452
+ assistant_entry: Dict[str, Any] = {
453
+ "role": "assistant",
454
+ "content": getattr(msg, "content", None) or "",
455
+ "tool_calls": [],
456
+ }
457
+ for idx, tc in enumerate(tool_calls):
458
+ tc_id = getattr(tc, "id", None) or f"call_{idx}"
459
+ fn_obj = getattr(tc, "function", tc)
460
+ fn_name = getattr(fn_obj, "name", "")
461
+ fn_args = getattr(fn_obj, "arguments", "{}")
462
+ assistant_entry["tool_calls"].append(
463
+ {
464
+ "id": tc_id,
465
+ "type": "function",
466
+ "function": {"name": fn_name, "arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args)},
467
+ }
468
+ )
469
+ conversation.append(assistant_entry)
470
+
471
+ for tc_item in assistant_entry["tool_calls"]:
472
+ fn_name = tc_item["function"]["name"]
473
+ fn_args = tc_item["function"]["arguments"]
474
+ tool_output = self._execute_tool_call(fn_name, fn_args)
475
+ conversation.append(
476
+ {
477
+ "role": "tool",
478
+ "tool_call_id": tc_item["id"],
479
+ "name": fn_name,
480
+ "content": tool_output,
481
+ }
482
+ )
483
+
484
+ final_resp = litellm.completion(model=self.model, messages=conversation, **self._resilience_kwargs())
485
+ self._log_usage(final_resp)
486
+ return (final_resp.choices[0].message.content or "").strip()
487
+
488
+ def think_and_act(self, war_room_context: str) -> Optional[str]:
489
+ """
490
+ Sanitizes the War Room context (LGPD-02) and invokes the cognitive engine:
491
+ 1. Google ADK (`self.adk_agent` via `InMemoryRunner`) when enabled and installed;
492
+ 2. Otherwise `litellm.completion` with multi-round safe tool execution (`read_file`,
493
+ `list_dir`, `git_diff`, `run_pytest`).
494
+
495
+ Returns `None` on inference failure (timeout, provider outage, etc.) instead of a
496
+ placeholder string — the caller (`core.runner`) must not treat `None` as a real
497
+ message to commit into `war_room.md` (Issue #11: a transient LLM error must not
498
+ become a permanent entry in the governance history).
499
+ """
500
+ sanitized_context = sanitize_prompt_pii(war_room_context)
501
+ user_prompt = f"WAR ROOM CONTEXT:\n{sanitized_context}\n\nWhat is your next message?"
502
+
503
+ _logger.info("thinking", extra={"event": "thinking", "agent": self.name, "model": self.model})
504
+ try:
505
+ if self.use_adk and self.adk_agent is not None:
506
+ return self._run_adk(user_prompt)
507
+
508
+ messages = [
509
+ {"role": "system", "content": self._system_instruction()},
510
+ {"role": "user", "content": user_prompt},
511
+ ]
512
+ return self._run_litellm_with_tools(messages)
513
+ except Exception as e:
514
+ _logger.error(
515
+ "inference failed",
516
+ extra={"event": "inference_error", "agent": self.name, "model": self.model},
517
+ exc_info=e,
518
+ )
519
+ return None
520
+
521
+ def generate_response(self, war_room_context: str) -> Optional[str]:
522
+ """Alias for `think_and_act` (ADR 0005 compatibility)."""
523
+ return self.think_and_act(war_room_context)