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.
- agloop-0.6.0.dist-info/METADATA +43 -0
- agloop-0.6.0.dist-info/RECORD +21 -0
- agloop-0.6.0.dist-info/WHEEL +5 -0
- agloop-0.6.0.dist-info/entry_points.txt +4 -0
- agloop-0.6.0.dist-info/licenses/LICENSE +21 -0
- agloop-0.6.0.dist-info/top_level.txt +3 -0
- core/agent.py +523 -0
- core/git_lock.py +406 -0
- core/init_scaffold.py +59 -0
- core/mcp_server.py +224 -0
- core/privacy_audit.py +87 -0
- core/runner.py +267 -0
- core/scan.py +108 -0
- core/templates/HISTORICO.md +3 -0
- core/templates/gestao/backlog-produto.md +3 -0
- core/templates/gestao/matriz-riscos.md +5 -0
- core/templates/gestao/roadmap.md +5 -0
- core/templates/war_room.md +3 -0
- core/war_room.py +196 -0
- main.py +83 -0
- scripts/vigia.py +72 -0
|
@@ -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,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.
|
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)
|