agent-gyrk 0.2.0__tar.gz
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.
- agent_gyrk-0.2.0/PKG-INFO +51 -0
- agent_gyrk-0.2.0/README.md +38 -0
- agent_gyrk-0.2.0/agent_gyrk.egg-info/PKG-INFO +51 -0
- agent_gyrk-0.2.0/agent_gyrk.egg-info/SOURCES.txt +8 -0
- agent_gyrk-0.2.0/agent_gyrk.egg-info/dependency_links.txt +1 -0
- agent_gyrk-0.2.0/agent_gyrk.egg-info/entry_points.txt +2 -0
- agent_gyrk-0.2.0/agent_gyrk.egg-info/top_level.txt +1 -0
- agent_gyrk-0.2.0/agent_gyrk.py +809 -0
- agent_gyrk-0.2.0/pyproject.toml +27 -0
- agent_gyrk-0.2.0/setup.cfg +4 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-gyrk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: agent-gyrk — CLI agentic rodando o modelo GYRK
|
|
5
|
+
Author: Havek
|
|
6
|
+
Project-URL: Homepage, https://gyrk.havek.ai
|
|
7
|
+
Keywords: gyrk,agent,cli,llm,coding-agent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Software Development
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# agent-gyrk
|
|
15
|
+
|
|
16
|
+
Agente de código no terminal rodando o **GYRK** (modelo próprio, na nuvem).
|
|
17
|
+
O harness é dono do loop; o GYRK é só uma política de próxima-ação. Ação por turno =
|
|
18
|
+
um JSON travado por gramática (`response_format json_schema`), com `tool` num enum
|
|
19
|
+
fechado das tools ativas. Só stdlib.
|
|
20
|
+
|
|
21
|
+
## Instalar (comando global, igual claude-code)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pipx install agent-gyrk # cria o comando `gyrk` no PATH
|
|
25
|
+
gyrk # free, sem chave (limitado)
|
|
26
|
+
gyrk init # cola sua chave pra mais (tier Pro)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usar
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
gyrk # REPL interativo
|
|
33
|
+
gyrk "liste os .py" # tarefa única
|
|
34
|
+
gyrk --allow-local "..." # libera run_bash (off por padrão)
|
|
35
|
+
gyrk --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Config (env, sem segredo embutido)
|
|
39
|
+
|
|
40
|
+
| var | default |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `GYRK_BASE_URL` | endpoint da API GYRK (default api.gyrk.havek.ai) |
|
|
43
|
+
| `GYRK_API_KEY` | (vazio) — Bearer pro gateway autenticado (Fase 3) |
|
|
44
|
+
| `GYRK_ALLOW_LOCAL` | `0` |
|
|
45
|
+
| `GYRK_MAX_TOKENS` | `2048` |
|
|
46
|
+
| `GYRK_TEMP` | `0.2` |
|
|
47
|
+
|
|
48
|
+
## Tools (MVP)
|
|
49
|
+
|
|
50
|
+
`list_files`, `read_file`, `grep`, `write_file`, `run_python` — escopadas na pasta
|
|
51
|
+
atual. `run_bash` é gated (`--allow-local`). GYRK puro: zero Claude.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# agent-gyrk
|
|
2
|
+
|
|
3
|
+
Agente de código no terminal rodando o **GYRK** (modelo próprio, na nuvem).
|
|
4
|
+
O harness é dono do loop; o GYRK é só uma política de próxima-ação. Ação por turno =
|
|
5
|
+
um JSON travado por gramática (`response_format json_schema`), com `tool` num enum
|
|
6
|
+
fechado das tools ativas. Só stdlib.
|
|
7
|
+
|
|
8
|
+
## Instalar (comando global, igual claude-code)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pipx install agent-gyrk # cria o comando `gyrk` no PATH
|
|
12
|
+
gyrk # free, sem chave (limitado)
|
|
13
|
+
gyrk init # cola sua chave pra mais (tier Pro)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usar
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gyrk # REPL interativo
|
|
20
|
+
gyrk "liste os .py" # tarefa única
|
|
21
|
+
gyrk --allow-local "..." # libera run_bash (off por padrão)
|
|
22
|
+
gyrk --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Config (env, sem segredo embutido)
|
|
26
|
+
|
|
27
|
+
| var | default |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `GYRK_BASE_URL` | endpoint da API GYRK (default api.gyrk.havek.ai) |
|
|
30
|
+
| `GYRK_API_KEY` | (vazio) — Bearer pro gateway autenticado (Fase 3) |
|
|
31
|
+
| `GYRK_ALLOW_LOCAL` | `0` |
|
|
32
|
+
| `GYRK_MAX_TOKENS` | `2048` |
|
|
33
|
+
| `GYRK_TEMP` | `0.2` |
|
|
34
|
+
|
|
35
|
+
## Tools (MVP)
|
|
36
|
+
|
|
37
|
+
`list_files`, `read_file`, `grep`, `write_file`, `run_python` — escopadas na pasta
|
|
38
|
+
atual. `run_bash` é gated (`--allow-local`). GYRK puro: zero Claude.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-gyrk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: agent-gyrk — CLI agentic rodando o modelo GYRK
|
|
5
|
+
Author: Havek
|
|
6
|
+
Project-URL: Homepage, https://gyrk.havek.ai
|
|
7
|
+
Keywords: gyrk,agent,cli,llm,coding-agent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Software Development
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# agent-gyrk
|
|
15
|
+
|
|
16
|
+
Agente de código no terminal rodando o **GYRK** (modelo próprio, na nuvem).
|
|
17
|
+
O harness é dono do loop; o GYRK é só uma política de próxima-ação. Ação por turno =
|
|
18
|
+
um JSON travado por gramática (`response_format json_schema`), com `tool` num enum
|
|
19
|
+
fechado das tools ativas. Só stdlib.
|
|
20
|
+
|
|
21
|
+
## Instalar (comando global, igual claude-code)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pipx install agent-gyrk # cria o comando `gyrk` no PATH
|
|
25
|
+
gyrk # free, sem chave (limitado)
|
|
26
|
+
gyrk init # cola sua chave pra mais (tier Pro)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usar
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
gyrk # REPL interativo
|
|
33
|
+
gyrk "liste os .py" # tarefa única
|
|
34
|
+
gyrk --allow-local "..." # libera run_bash (off por padrão)
|
|
35
|
+
gyrk --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Config (env, sem segredo embutido)
|
|
39
|
+
|
|
40
|
+
| var | default |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `GYRK_BASE_URL` | endpoint da API GYRK (default api.gyrk.havek.ai) |
|
|
43
|
+
| `GYRK_API_KEY` | (vazio) — Bearer pro gateway autenticado (Fase 3) |
|
|
44
|
+
| `GYRK_ALLOW_LOCAL` | `0` |
|
|
45
|
+
| `GYRK_MAX_TOKENS` | `2048` |
|
|
46
|
+
| `GYRK_TEMP` | `0.2` |
|
|
47
|
+
|
|
48
|
+
## Tools (MVP)
|
|
49
|
+
|
|
50
|
+
`list_files`, `read_file`, `grep`, `write_file`, `run_python` — escopadas na pasta
|
|
51
|
+
atual. `run_bash` é gated (`--allow-local`). GYRK puro: zero Claude.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_gyrk
|
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
agent-gyrk — Fase 1 (MVP): agente de código no terminal rodando o GYRK.
|
|
4
|
+
|
|
5
|
+
O harness e' dono do loop; o GYRK e' so' um callable HTTP (a tese
|
|
6
|
+
"loop e' serving, modelo e' politica de proxima-acao"). A acao por turno e'
|
|
7
|
+
UM JSON travado por GRAMATICA (response_format json_schema -> GBNF), com `tool`
|
|
8
|
+
restrito a um ENUM fechado das tools ativas -> alucinar tool e' impossivel.
|
|
9
|
+
Provado no phase0_spike.py: 100% JSON valido / 100% roteamento / 100% args.
|
|
10
|
+
|
|
11
|
+
- GYRK PURO: zero Claude. Quando os guards travam, para com mensagem honesta.
|
|
12
|
+
- Tools escopadas na cwd. `run_bash` GATED (off por padrao; --allow-local liga).
|
|
13
|
+
- Sem segredo embutido: endpoint/modelo/key vem de env.
|
|
14
|
+
- So' stdlib (urllib) -> facil empacotar (pipx) depois.
|
|
15
|
+
|
|
16
|
+
Uso:
|
|
17
|
+
python3 agent_gyrk.py # REPL interativo
|
|
18
|
+
python3 agent_gyrk.py "liste os .py" # tarefa unica e sai
|
|
19
|
+
python3 agent_gyrk.py --allow-local # libera run_bash
|
|
20
|
+
|
|
21
|
+
Env:
|
|
22
|
+
GYRK_BASE_URL endpoint da API GYRK (default = https://api.gyrk.havek.ai)
|
|
23
|
+
GYRK_API_KEY se setado, vai como Bearer (pronto pro gateway autenticado)
|
|
24
|
+
GYRK_MODEL so' rotulo informativo na UI
|
|
25
|
+
GYRK_ALLOW_LOCAL=1 mesmo que --allow-local
|
|
26
|
+
GYRK_MAX_TOKENS default 2048
|
|
27
|
+
GYRK_TEMP default 0.2
|
|
28
|
+
"""
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import urllib.error
|
|
37
|
+
import urllib.request
|
|
38
|
+
|
|
39
|
+
# ---------- config (env, sem segredo embutido) ----------
|
|
40
|
+
|
|
41
|
+
WORKSPACE = os.getcwd()
|
|
42
|
+
CONFIG_PATH = os.path.expanduser("~/.config/gyrk/config.json")
|
|
43
|
+
|
|
44
|
+
# API pública do GYRK (gateway autenticado). A infra de serving fica ATRÁS dela,
|
|
45
|
+
# nunca exposta no cliente. Pra dev local, aponte com `gyrk init --base-url=...`.
|
|
46
|
+
DEFAULT_BASE_URL = "https://api.gyrk.havek.ai"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_config():
|
|
50
|
+
try:
|
|
51
|
+
if os.path.isfile(CONFIG_PATH):
|
|
52
|
+
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
|
53
|
+
return json.load(f)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_CFG = _load_config()
|
|
60
|
+
# precedência: env > config (~/.config/gyrk) > default
|
|
61
|
+
BASE_URL = (os.environ.get("GYRK_BASE_URL") or _CFG.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
|
|
62
|
+
ENDPOINT = BASE_URL + "/v1/chat/completions"
|
|
63
|
+
API_KEY = (os.environ.get("GYRK_API_KEY") or _CFG.get("api_key") or "").strip()
|
|
64
|
+
MODEL_LABEL = os.environ.get("GYRK_MODEL", "GYRK 1.0")
|
|
65
|
+
MAX_TOKENS = int(os.environ.get("GYRK_MAX_TOKENS", "2048"))
|
|
66
|
+
TEMP = float(os.environ.get("GYRK_TEMP", "0.2"))
|
|
67
|
+
ALLOW_LOCAL = (os.environ.get("GYRK_ALLOW_LOCAL", "") not in ("", "0", "false")) or ("--allow-local" in sys.argv)
|
|
68
|
+
CTX = int(os.environ.get("GYRK_CTX", "8192")) # janela de contexto do serving
|
|
69
|
+
VERBOSE = ("--verbose" in sys.argv) or ("-v" in sys.argv)
|
|
70
|
+
_SESSION_IN = 0 # tokens de entrada acumulados na sessão
|
|
71
|
+
_SESSION_OUT = 0 # tokens de saída acumulados na sessão
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------- cores terminal ----------
|
|
75
|
+
|
|
76
|
+
_USE_COLOR = sys.stdout.isatty()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _c(code, s):
|
|
80
|
+
return s if not _USE_COLOR else f"\033[{code}m{s}\033[0m"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def dim(s): return _c("2", s)
|
|
84
|
+
def bold(s): return _c("1", s)
|
|
85
|
+
def cyan(s): return _c("36", s)
|
|
86
|
+
def green(s): return _c("32", s)
|
|
87
|
+
def yellow(s): return _c("33", s)
|
|
88
|
+
def red(s): return _c("31", s)
|
|
89
|
+
def mag(s): return _c("35", s)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Spinner:
|
|
93
|
+
FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
94
|
+
|
|
95
|
+
def __init__(self, label="pensando"):
|
|
96
|
+
self.label = label
|
|
97
|
+
self._stop = threading.Event()
|
|
98
|
+
self._t = None
|
|
99
|
+
|
|
100
|
+
def _run(self):
|
|
101
|
+
i, t0 = 0, time.time()
|
|
102
|
+
while not self._stop.is_set():
|
|
103
|
+
frame = self.FRAMES[i % len(self.FRAMES)]
|
|
104
|
+
sys.stdout.write("\r " + cyan(frame) + dim(f" {self.label}… {time.time()-t0:.0f}s"))
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
i += 1
|
|
107
|
+
time.sleep(0.08)
|
|
108
|
+
sys.stdout.write("\r" + " " * 48 + "\r")
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
|
|
111
|
+
def __enter__(self):
|
|
112
|
+
if _USE_COLOR:
|
|
113
|
+
self._t = threading.Thread(target=self._run, daemon=True)
|
|
114
|
+
self._t.start()
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc):
|
|
118
|
+
self._stop.set()
|
|
119
|
+
if self._t:
|
|
120
|
+
self._t.join()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------- ferramentas (escopadas na cwd) ----------
|
|
124
|
+
|
|
125
|
+
def _resolve(path: str) -> str:
|
|
126
|
+
if not os.path.isabs(path):
|
|
127
|
+
path = os.path.join(WORKSPACE, path)
|
|
128
|
+
full = os.path.abspath(path)
|
|
129
|
+
if not full.startswith(os.path.abspath(WORKSPACE)):
|
|
130
|
+
raise ValueError("acesso negado fora da pasta atual")
|
|
131
|
+
return full
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def list_files(path: str = "."):
|
|
135
|
+
full = _resolve(path)
|
|
136
|
+
if not os.path.isdir(full):
|
|
137
|
+
return {"error": f"não é diretório: {path}"}
|
|
138
|
+
base = "" if path in (".", "") else path.rstrip("/") + "/"
|
|
139
|
+
items = []
|
|
140
|
+
for name in sorted(os.listdir(full)):
|
|
141
|
+
if name.startswith("."):
|
|
142
|
+
continue
|
|
143
|
+
p = os.path.join(full, name)
|
|
144
|
+
is_dir = os.path.isdir(p)
|
|
145
|
+
items.append({"path": base + name + ("/" if is_dir else ""),
|
|
146
|
+
"is_dir": is_dir,
|
|
147
|
+
"size": os.path.getsize(p) if os.path.isfile(p) else None})
|
|
148
|
+
return {"dir": path, "items": items}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def read_file(path: str, start_line: int = 1, end_line: int = None, max_lines: int = 300):
|
|
152
|
+
full = _resolve(path)
|
|
153
|
+
if not os.path.isfile(full):
|
|
154
|
+
return {"error": f"não é arquivo: {path}"}
|
|
155
|
+
with open(full, "r", encoding="utf-8", errors="replace") as f:
|
|
156
|
+
all_lines = f.read().splitlines()
|
|
157
|
+
total = len(all_lines)
|
|
158
|
+
start = max(1, int(start_line or 1))
|
|
159
|
+
end = min(int(end_line) if end_line else start + max_lines - 1, start + max_lines - 1, total)
|
|
160
|
+
chunk = all_lines[start - 1:end]
|
|
161
|
+
numbered = "\n".join(f"{start + i}\t{ln}" for i, ln in enumerate(chunk))
|
|
162
|
+
tail = "" if end >= total else f"\n... [linhas {end+1}-{total} não mostradas; peça com start_line/end_line]"
|
|
163
|
+
return {"path": path, "total_lines": total, "shown": f"{start}-{end}", "content": numbered + tail}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_GREP_SKIP = {".git", "node_modules", "venv", ".venv", "__pycache__", ".next", "dist", "build", ".mypy_cache"}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def grep(pattern: str, glob: str = None, max_matches: int = 200):
|
|
170
|
+
import fnmatch
|
|
171
|
+
try:
|
|
172
|
+
rx = re.compile(pattern)
|
|
173
|
+
except re.error as e:
|
|
174
|
+
return {"error": f"regex inválida: {e}"}
|
|
175
|
+
matches = []
|
|
176
|
+
for root, dirs, files in os.walk(WORKSPACE):
|
|
177
|
+
dirs[:] = [d for d in dirs if d not in _GREP_SKIP and not d.startswith(".")]
|
|
178
|
+
for fn in files:
|
|
179
|
+
if glob and not fnmatch.fnmatch(fn, glob):
|
|
180
|
+
continue
|
|
181
|
+
fp = os.path.join(root, fn)
|
|
182
|
+
try:
|
|
183
|
+
with open(fp, "r", encoding="utf-8", errors="ignore") as f:
|
|
184
|
+
for i, line in enumerate(f, 1):
|
|
185
|
+
if rx.search(line):
|
|
186
|
+
matches.append({"file": os.path.relpath(fp, WORKSPACE),
|
|
187
|
+
"line": i, "text": line.rstrip()[:200]})
|
|
188
|
+
if len(matches) >= max_matches:
|
|
189
|
+
return {"matches": matches, "truncated": True}
|
|
190
|
+
except Exception:
|
|
191
|
+
continue
|
|
192
|
+
return {"matches": matches, "count": len(matches)}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _validate_python(path: str, content: str):
|
|
196
|
+
issues = []
|
|
197
|
+
if not path.endswith(".py"):
|
|
198
|
+
return issues
|
|
199
|
+
module_name = os.path.splitext(os.path.basename(path))[0]
|
|
200
|
+
pat = rf'^\s*(?:from\s+{re.escape(module_name)}\s+import|import\s+{re.escape(module_name)})\b'
|
|
201
|
+
for ln, line in enumerate(content.splitlines(), 1):
|
|
202
|
+
if re.match(pat, line):
|
|
203
|
+
issues.append(f"linha {ln}: `{line.strip()}` é auto-import — {module_name}.py não pode importar de si mesmo. Defina tudo inline.")
|
|
204
|
+
break
|
|
205
|
+
import ast
|
|
206
|
+
try:
|
|
207
|
+
ast.parse(content)
|
|
208
|
+
except SyntaxError as e:
|
|
209
|
+
issues.append(f"SyntaxError linha {e.lineno}: {e.msg}")
|
|
210
|
+
return issues
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def write_file(path: str, content: str):
|
|
214
|
+
full = _resolve(path)
|
|
215
|
+
os.makedirs(os.path.dirname(full) or ".", exist_ok=True)
|
|
216
|
+
if len(content) > 1_000_000:
|
|
217
|
+
return {"error": "conteúdo > 1MB"}
|
|
218
|
+
stripped = content.strip().lower()
|
|
219
|
+
instructional = ("olha este", "olha esse", "analisa este", "analisa esse",
|
|
220
|
+
"resuma este", "resuma esse", "analyse this", "look at this")
|
|
221
|
+
if len(content) < 80 and any(stripped.startswith(p) for p in instructional):
|
|
222
|
+
return {"error": "conteúdo parece ser a instrução do usuário, não o dado a ser salvo",
|
|
223
|
+
"hint": "não salve a instrução; peça o dado real com {\"final\": \"...\"}."}
|
|
224
|
+
issues = _validate_python(path, content)
|
|
225
|
+
if issues:
|
|
226
|
+
return {"error": "código Python tem problemas — corrija antes de salvar",
|
|
227
|
+
"issues": issues,
|
|
228
|
+
"hint": "reescreva SEM os problemas. NÃO importe o próprio arquivo. Defina tudo inline."}
|
|
229
|
+
with open(full, "w", encoding="utf-8") as f:
|
|
230
|
+
f.write(content)
|
|
231
|
+
return {"path": path, "bytes_written": len(content.encode("utf-8")), "ok": True}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False):
|
|
235
|
+
"""Edição cirúrgica: troca o trecho EXATO `old_string` por `new_string`."""
|
|
236
|
+
full = _resolve(path)
|
|
237
|
+
if not os.path.isfile(full):
|
|
238
|
+
return {"error": f"não é arquivo: {path}"}
|
|
239
|
+
with open(full, "r", encoding="utf-8", errors="replace") as f:
|
|
240
|
+
src = f.read()
|
|
241
|
+
count = src.count(old_string)
|
|
242
|
+
if count == 0:
|
|
243
|
+
return {"error": "old_string não encontrado no arquivo",
|
|
244
|
+
"hint": "copie o trecho EXATO via read_file (sem o número de linha)."}
|
|
245
|
+
if count > 1 and not replace_all:
|
|
246
|
+
return {"error": f"old_string aparece {count}x — ambíguo",
|
|
247
|
+
"hint": "inclua mais contexto pra ficar único, ou passe replace_all=true."}
|
|
248
|
+
new_src = src.replace(old_string, new_string) if replace_all else src.replace(old_string, new_string, 1)
|
|
249
|
+
issues = _validate_python(path, new_src)
|
|
250
|
+
if issues:
|
|
251
|
+
return {"error": "resultado Python tem problemas — revise", "issues": issues}
|
|
252
|
+
with open(full, "w", encoding="utf-8") as f:
|
|
253
|
+
f.write(new_src)
|
|
254
|
+
return {"path": path, "replaced": count if replace_all else 1, "ok": True}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def note(text: str):
|
|
258
|
+
"""Anota um fato na memória persistente (sobrevive à compactação de contexto)."""
|
|
259
|
+
_SCRATCHPAD.append(text.strip())
|
|
260
|
+
return {"ok": True, "notas": len(_SCRATCHPAD)}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def run_python(code: str, timeout: int = 30):
|
|
264
|
+
try:
|
|
265
|
+
proc = subprocess.run(["python3", "-c", code], capture_output=True,
|
|
266
|
+
text=True, timeout=timeout, cwd=WORKSPACE)
|
|
267
|
+
out, err = proc.stdout, proc.stderr
|
|
268
|
+
if len(out) > 5000: out = out[:5000] + "\n...[stdout truncado]"
|
|
269
|
+
if len(err) > 5000: err = err[:5000] + "\n...[stderr truncado]"
|
|
270
|
+
result = {"exit_code": proc.returncode, "stdout": out, "stderr": err}
|
|
271
|
+
if proc.returncode != 0 and "ModuleNotFoundError" in err:
|
|
272
|
+
result["hint"] = "Pacote não instalado. Use só stdlib (math, re, json, os) ou run_bash com pip."
|
|
273
|
+
return result
|
|
274
|
+
except subprocess.TimeoutExpired:
|
|
275
|
+
return {"error": f"timeout após {timeout}s"}
|
|
276
|
+
except Exception as e:
|
|
277
|
+
return {"error": f"{type(e).__name__}: {e}"}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run_bash(command: str, timeout: int = 30):
|
|
281
|
+
try:
|
|
282
|
+
proc = subprocess.run(command, shell=True, capture_output=True,
|
|
283
|
+
text=True, timeout=timeout, cwd=WORKSPACE)
|
|
284
|
+
out, err = proc.stdout, proc.stderr
|
|
285
|
+
if len(out) > 5000: out = out[:5000] + "\n...[stdout truncado]"
|
|
286
|
+
if len(err) > 5000: err = err[:5000] + "\n...[stderr truncado]"
|
|
287
|
+
return {"exit_code": proc.returncode, "stdout": out, "stderr": err}
|
|
288
|
+
except subprocess.TimeoutExpired:
|
|
289
|
+
return {"error": f"timeout após {timeout}s"}
|
|
290
|
+
except Exception as e:
|
|
291
|
+
return {"error": f"{type(e).__name__}: {e}"}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# memória persistente do agente (sobrevive à compactação do contexto 8k)
|
|
295
|
+
_SCRATCHPAD = []
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# Catalogo: (nome, fn, args_desc, quando). run_bash so' entra se ALLOW_LOCAL.
|
|
299
|
+
# A MESMA lista vira o enum da gramatica E a tabela do prompt -> 1 fonte de verdade.
|
|
300
|
+
def build_tools():
|
|
301
|
+
specs = [
|
|
302
|
+
("list_files", list_files, "path", "listar arquivos de uma pasta"),
|
|
303
|
+
("read_file", read_file, "path, start_line, end_line", "ler arquivo (mostra nº de linha)"),
|
|
304
|
+
("grep", grep, "pattern, glob (opcional), max_matches (200)", "buscar texto no código"),
|
|
305
|
+
("write_file", write_file, "path, content", "CRIAR/refazer arquivo inteiro"),
|
|
306
|
+
("edit_file", edit_file, "path, old_string, new_string, replace_all", "editar trecho EXATO de arquivo existente"),
|
|
307
|
+
("run_python", run_python, "code, timeout (30)", "rodar Python (verificar por execução)"),
|
|
308
|
+
("note", note, "text", "guardar um fato na memória (sobrevive à compactação)"),
|
|
309
|
+
]
|
|
310
|
+
if ALLOW_LOCAL:
|
|
311
|
+
specs.append(("run_bash", run_bash, "command, timeout (30)", "rodar comando no shell (cwd)"))
|
|
312
|
+
return specs
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
TOOL_SPECS = build_tools()
|
|
316
|
+
TOOLS = {name: fn for name, fn, _, _ in TOOL_SPECS}
|
|
317
|
+
TOOL_NAMES = list(TOOLS)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------- gramatica da acao (anyOf: tool | final) ----------
|
|
321
|
+
|
|
322
|
+
ACTION_SCHEMA = {
|
|
323
|
+
"anyOf": [
|
|
324
|
+
{"type": "object",
|
|
325
|
+
"properties": {"thought": {"type": "string"},
|
|
326
|
+
"tool": {"type": "string", "enum": TOOL_NAMES},
|
|
327
|
+
"args": {"type": "object", "additionalProperties": True}},
|
|
328
|
+
"required": ["thought", "tool", "args"], "additionalProperties": False},
|
|
329
|
+
{"type": "object",
|
|
330
|
+
"properties": {"thought": {"type": "string"}, "final": {"type": "string"}},
|
|
331
|
+
"required": ["thought", "final"], "additionalProperties": False},
|
|
332
|
+
]
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------- system prompt (tabela de tools dinamica) ----------
|
|
337
|
+
|
|
338
|
+
def build_system_prompt():
|
|
339
|
+
table = "\n".join(f"| {n} | {a} | {q} |" for n, _, a, q in TOOL_SPECS)
|
|
340
|
+
bash_note = ("" if ALLOW_LOCAL else
|
|
341
|
+
"\n(`run_bash` está DESLIGADO nesta sessão — não há shell; use run_python pra cálculos.)")
|
|
342
|
+
return f"""Você é o gyrk — assistente + agente de código rodando no terminal. Pasta atual: `{WORKSPACE}`.
|
|
343
|
+
|
|
344
|
+
## FORMATO (RÍGIDO)
|
|
345
|
+
Responda SEMPRE com UM objeto JSON, sem texto fora, sem markdown.
|
|
346
|
+
A. Tool: {{"thought":"<por que>","tool":"<nome>","args":{{...}}}}
|
|
347
|
+
B. Final: {{"thought":"<por que terminei>","final":"<texto natural ao usuário>"}}
|
|
348
|
+
|
|
349
|
+
## QUANDO USAR CADA UM
|
|
350
|
+
| Entrada | Esquema | Por quê |
|
|
351
|
+
|---|---|---|
|
|
352
|
+
| Saudação ("oi", "obrigado") | B | conversa |
|
|
353
|
+
| Pergunta de fato ("capital do BR?") | B | sei a resposta |
|
|
354
|
+
| Resumir / traduzir / classificar texto colado | B | compreensão, sem código |
|
|
355
|
+
| Listar / ler / buscar arquivos | A | precisa do disco |
|
|
356
|
+
| Criar arquivo / código | A: write_file | é uma criação |
|
|
357
|
+
| Contar exato / regex / cálculo | A: run_python | precisa precisão |
|
|
358
|
+
| Entrada incompleta / ambígua | B perguntando | falta info |
|
|
359
|
+
|
|
360
|
+
REGRA DE OURO: se sua resposta seria prosa, NÃO use tool — vai direto no `final`.
|
|
361
|
+
|
|
362
|
+
## FERRAMENTAS
|
|
363
|
+
| nome | args | quando |
|
|
364
|
+
|---|---|---|
|
|
365
|
+
{table}{bash_note}
|
|
366
|
+
|
|
367
|
+
- **Editar vs criar:** pra MUDAR um arquivo existente use `edit_file` (leia antes com read_file e copie o trecho EXATO — `old_string` é o texto do arquivo SEM o número de linha). `write_file` só pra criar/refazer do zero (apaga o conteúdo).
|
|
368
|
+
- **Verifique por execução:** antes de um `final` de sucesso, RODE (run_python) e confira exit_code 0. Sem ter executado, nunca diga "funcionou".
|
|
369
|
+
- **Memória:** use `note` pra guardar fatos importantes — eles sobrevivem quando o histórico é compactado.
|
|
370
|
+
|
|
371
|
+
## ANTI-PADRÕES (críticos)
|
|
372
|
+
1. **Não anuncie ação sem fazer.** Se o thought diz "vou usar X", o MESMO JSON precisa ter `"tool"` — não pode ter `"final"`. NUNCA declare sucesso de algo que não executou neste step.
|
|
373
|
+
2. **`final` é texto natural, NUNCA outro JSON.**
|
|
374
|
+
3. **Não invente conteúdo que o usuário não passou.** Se a entrada está incompleta, peça.
|
|
375
|
+
4. **Não cole texto longo dentro de run_python.** Salve com write_file primeiro.
|
|
376
|
+
5. **Não escreva `from X import *` dentro de `X.py`.** Auto-import quebra.
|
|
377
|
+
6. **Não declare "executado com sucesso" sem uma tool de execução prévia.**
|
|
378
|
+
|
|
379
|
+
## EXEMPLOS
|
|
380
|
+
> oi
|
|
381
|
+
{{"thought":"saudação","final":"Oi! Em que posso ajudar?"}}
|
|
382
|
+
> qual a capital do brasil?
|
|
383
|
+
{{"thought":"fato — respondo com contexto","final":"A capital é Brasília, inaugurada em 1960, projetada por Lúcio Costa e Oscar Niemeyer."}}
|
|
384
|
+
> <texto longo colado> resuma
|
|
385
|
+
{{"thought":"resumir é compreensão pura, sem tool","final":"<resumo em 2-4 frases>"}}
|
|
386
|
+
> liste os arquivos aqui
|
|
387
|
+
{{"thought":"explorar","tool":"list_files","args":{{"path":"."}}}}
|
|
388
|
+
> ache onde importa requests
|
|
389
|
+
{{"thought":"buscar no código","tool":"grep","args":{{"pattern":"import requests"}}}}
|
|
390
|
+
> crie um hello.py que printa hi
|
|
391
|
+
{{"thought":"código simples","tool":"write_file","args":{{"path":"hello.py","content":"print('hi')\\n"}}}}
|
|
392
|
+
> quantas vezes 'X' aparece no texto colado? (precisão)
|
|
393
|
+
Step 1: {{"thought":"salvo o texto","tool":"write_file","args":{{"path":"input.txt","content":"<texto cru>"}}}}
|
|
394
|
+
Step 2: {{"thought":"conto case-insensitive","tool":"run_python","args":{{"code":"print(open('input.txt').read().lower().count('x'))"}}}}
|
|
395
|
+
|
|
396
|
+
## MULTI-STEP
|
|
397
|
+
Quando o usuário pede VÁRIAS coisas, faça UMA por step e só dê `final` quando TODAS estiverem feitas, descrevendo o que foi de fato feito.
|
|
398
|
+
|
|
399
|
+
## TÉRMINO
|
|
400
|
+
Esquema B com fato observado (não com "vou fazer"). Após 2 erros do mesmo tipo, pare e explique no final.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
SYSTEM_PROMPT = build_system_prompt()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------- parser resiliente (cinto + suspensório, mesmo com gramatica) ----------
|
|
408
|
+
|
|
409
|
+
def _fix_bad_escapes(s: str) -> str:
|
|
410
|
+
return re.sub(r'\\(?!["\\/bfnrtu])', '', s)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def parse_action(text: str):
|
|
414
|
+
text = re.sub(r"```(?:json)?\s*", "", text).replace("```", "").strip()
|
|
415
|
+
decoder = json.JSONDecoder()
|
|
416
|
+
|
|
417
|
+
def looks_clean(obj):
|
|
418
|
+
if not isinstance(obj, dict):
|
|
419
|
+
return False
|
|
420
|
+
if "final" in obj and isinstance(obj["final"], str):
|
|
421
|
+
f = obj["final"].strip()
|
|
422
|
+
if f.startswith("{") and ('"tool"' in f or '"final"' in f):
|
|
423
|
+
return False
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
thought_only = None
|
|
427
|
+
for src in (text, _fix_bad_escapes(text)):
|
|
428
|
+
n, i = len(src), 0
|
|
429
|
+
while i < n:
|
|
430
|
+
if src[i] == "{":
|
|
431
|
+
try:
|
|
432
|
+
obj, end = decoder.raw_decode(src, i)
|
|
433
|
+
except json.JSONDecodeError:
|
|
434
|
+
obj, end = None, None
|
|
435
|
+
if isinstance(obj, dict) and looks_clean(obj):
|
|
436
|
+
if "tool" in obj or "final" in obj:
|
|
437
|
+
return obj
|
|
438
|
+
if thought_only is None and isinstance(obj.get("thought"), str) and obj["thought"].strip():
|
|
439
|
+
thought_only = {"thought": "(promovido)", "final": obj["thought"].strip()}
|
|
440
|
+
if end is not None:
|
|
441
|
+
i = end
|
|
442
|
+
continue
|
|
443
|
+
i += 1
|
|
444
|
+
if _fix_bad_escapes(text) == text:
|
|
445
|
+
break
|
|
446
|
+
return thought_only
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
COMPREHENSION_VERBS = ("resuma", "resumir", "traduza", "traduzir", "classifique",
|
|
450
|
+
"classificar", "qual o tom", "explique este", "explique esse",
|
|
451
|
+
"do que se trata", "qual é a ideia")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def run_tool(action, user_input=""):
|
|
455
|
+
name = action.get("tool")
|
|
456
|
+
args = action.get("args", {}) or {}
|
|
457
|
+
if name not in TOOLS:
|
|
458
|
+
return {"error": f"ferramenta desconhecida: {name}. disponíveis: {TOOL_NAMES}"}
|
|
459
|
+
if name in ("write_file", "run_python", "run_bash") and user_input:
|
|
460
|
+
if any(v in user_input.lower() for v in COMPREHENSION_VERBS):
|
|
461
|
+
return {"error": f"{name} bloqueado: compreensão (resumir/traduzir/classificar) vai DIRETO no final, sem tool",
|
|
462
|
+
"hint": "responda com {\"thought\":\"compreensão direta\",\"final\":\"<resposta em prosa>\"}."}
|
|
463
|
+
try:
|
|
464
|
+
return TOOLS[name](**args)
|
|
465
|
+
except TypeError as e:
|
|
466
|
+
return {"error": f"args inválidos para {name}: {e}"}
|
|
467
|
+
except Exception as e:
|
|
468
|
+
return {"error": f"{type(e).__name__}: {e}"}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------- compactação de contexto (cabe no ctx 8192 do GYRK) ----------
|
|
472
|
+
|
|
473
|
+
CHAR_BUDGET = 16000 # ~4k tokens de input; deixa folga pros 2048 de saída no ctx 8192
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def build_request_messages(messages):
|
|
477
|
+
"""Compacta o histórico pra caber no ctx, SEM mutar a lista canônica.
|
|
478
|
+
Mantém a 1ª msg (system+tarefa), injeta as notas + um resumo dos passos
|
|
479
|
+
antigos descartados, e mantém os turnos recentes que couberem (alternância
|
|
480
|
+
user/assistant preservada — exigência do template do modelo)."""
|
|
481
|
+
head, rest = messages[0], messages[1:]
|
|
482
|
+
kept, total = [], len(head["content"])
|
|
483
|
+
for m in reversed(rest):
|
|
484
|
+
if total + len(m["content"]) > CHAR_BUDGET and kept:
|
|
485
|
+
break
|
|
486
|
+
kept.append(m)
|
|
487
|
+
total += len(m["content"])
|
|
488
|
+
kept.reverse()
|
|
489
|
+
while kept and kept[0]["role"] == "user": # após head(user), o 1º mantido deve ser assistant
|
|
490
|
+
kept.pop(0)
|
|
491
|
+
dropped = rest[:len(rest) - len(kept)]
|
|
492
|
+
extras = []
|
|
493
|
+
if _SCRATCHPAD:
|
|
494
|
+
extras.append("NOTAS (memória persistente):\n- " + "\n- ".join(_SCRATCHPAD))
|
|
495
|
+
if dropped:
|
|
496
|
+
acts = []
|
|
497
|
+
for m in dropped:
|
|
498
|
+
if m["role"] == "assistant":
|
|
499
|
+
try:
|
|
500
|
+
a = json.loads(m["content"])
|
|
501
|
+
if a.get("tool"):
|
|
502
|
+
acts.append(a["tool"])
|
|
503
|
+
except Exception:
|
|
504
|
+
pass
|
|
505
|
+
extras.append(f"[{len(dropped)} mensagens antigas compactadas. Tools já usadas: {', '.join(acts) or 'várias'}]")
|
|
506
|
+
if extras:
|
|
507
|
+
head = {"role": "user", "content": head["content"] + "\n\n" + "\n\n".join(extras)}
|
|
508
|
+
return [head] + kept
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ---------- cliente HTTP do GYRK (a unica peca nova; provada pelo spike) ----------
|
|
512
|
+
|
|
513
|
+
def gyrk_generate(messages):
|
|
514
|
+
body = json.dumps({
|
|
515
|
+
"messages": messages,
|
|
516
|
+
"temperature": TEMP, "top_p": 0.9, "seed": 7, "max_tokens": MAX_TOKENS,
|
|
517
|
+
"response_format": {"type": "json_schema",
|
|
518
|
+
"json_schema": {"name": "action", "schema": ACTION_SCHEMA}},
|
|
519
|
+
}).encode()
|
|
520
|
+
headers = {"Content-Type": "application/json"}
|
|
521
|
+
if API_KEY:
|
|
522
|
+
headers["Authorization"] = f"Bearer {API_KEY}"
|
|
523
|
+
last = ""
|
|
524
|
+
for attempt in range(6):
|
|
525
|
+
try:
|
|
526
|
+
req = urllib.request.Request(ENDPOINT, data=body, headers=headers)
|
|
527
|
+
resp = json.loads(urllib.request.urlopen(req, timeout=180 if attempt == 0 else 90).read())
|
|
528
|
+
return resp["choices"][0]["message"]["content"], resp.get("usage", {})
|
|
529
|
+
except urllib.error.HTTPError as e:
|
|
530
|
+
last = (e.read().decode("utf-8", "ignore") if hasattr(e, "read") else str(e))[:300]
|
|
531
|
+
if "Loading model" in last or e.code in (502, 503):
|
|
532
|
+
time.sleep(min(5 * (attempt + 1), 20))
|
|
533
|
+
continue
|
|
534
|
+
if e.code == 429: # cota/rate-limit do gateway
|
|
535
|
+
try:
|
|
536
|
+
j = json.loads(last)
|
|
537
|
+
msg, up = j.get("error", "limite atingido"), j.get("upgrade")
|
|
538
|
+
except Exception:
|
|
539
|
+
msg, up = "limite atingido", None
|
|
540
|
+
raise RuntimeError(msg + (f" — pegue uma chave em {up}" if up else "") +
|
|
541
|
+
". Já tem chave? rode: gyrk init")
|
|
542
|
+
if e.code in (401, 403):
|
|
543
|
+
raise RuntimeError("chave inválida ou sem acesso. Configure com: gyrk init")
|
|
544
|
+
raise RuntimeError(f"HTTP {e.code}: {last}")
|
|
545
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
546
|
+
last = str(e)
|
|
547
|
+
time.sleep(min(5 * (attempt + 1), 20))
|
|
548
|
+
raise RuntimeError(f"GYRK indisponível após retries: {last}")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ---------- loop do agente (GYRK puro; guards param honestamente) ----------
|
|
552
|
+
|
|
553
|
+
def run_task(task: str, max_steps: int = 12):
|
|
554
|
+
global _SESSION_IN, _SESSION_OUT
|
|
555
|
+
_SCRATCHPAD.clear()
|
|
556
|
+
messages = [{"role": "user", "content": SYSTEM_PROMPT + "\n\nENTRADA DO USUÁRIO: " + task}]
|
|
557
|
+
recent, consecutive_errors, parse_fails, last_ctx = [], 0, 0, 0
|
|
558
|
+
|
|
559
|
+
def add_user(text):
|
|
560
|
+
if messages and messages[-1]["role"] == "user":
|
|
561
|
+
messages[-1]["content"] += "\n\n" + text
|
|
562
|
+
else:
|
|
563
|
+
messages.append({"role": "user", "content": text})
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
for step in range(1, max_steps + 1):
|
|
567
|
+
try:
|
|
568
|
+
with Spinner():
|
|
569
|
+
raw, usage = gyrk_generate(build_request_messages(messages))
|
|
570
|
+
except RuntimeError as e:
|
|
571
|
+
print(" " + red("✗ " + str(e)))
|
|
572
|
+
return
|
|
573
|
+
_SESSION_IN += usage.get("prompt_tokens", 0)
|
|
574
|
+
_SESSION_OUT += usage.get("completion_tokens", 0)
|
|
575
|
+
last_ctx = usage.get("prompt_tokens", 0) or last_ctx
|
|
576
|
+
action = parse_action(raw)
|
|
577
|
+
|
|
578
|
+
if action is None:
|
|
579
|
+
parse_fails += 1
|
|
580
|
+
if parse_fails >= 2:
|
|
581
|
+
print(" " + red("✗ o modelo não retornou um JSON válido."))
|
|
582
|
+
if VERBOSE:
|
|
583
|
+
print(dim(" " + raw[:300].replace("\n", "\n ")))
|
|
584
|
+
return
|
|
585
|
+
messages.append({"role": "assistant", "content": raw[:500]})
|
|
586
|
+
add_user("ERRO: responda com UM JSON: {\"thought\":...,\"tool\":...,\"args\":...} ou {\"thought\":...,\"final\":...}.")
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
if VERBOSE:
|
|
590
|
+
print(" " + dim(f"step {step} · ") + mag(action.get("thought", "")))
|
|
591
|
+
|
|
592
|
+
# ----- FINAL -----
|
|
593
|
+
if "final" in action:
|
|
594
|
+
final_text = action.get("final") or ""
|
|
595
|
+
fake_words = ["foi executado com sucesso", "executei o", "rodei o", "abri o",
|
|
596
|
+
"testado com sucesso", "funcionou perfeitamente", "executou com sucesso"]
|
|
597
|
+
if any(w in final_text.lower() for w in fake_words):
|
|
598
|
+
had_exec = any(m["role"] == "user" and m["content"].startswith("OBSERVATION:")
|
|
599
|
+
and "exit_code" in m["content"] for m in messages)
|
|
600
|
+
if not had_exec:
|
|
601
|
+
if VERBOSE:
|
|
602
|
+
print(dim(" [fake-final — forçando execução real]"))
|
|
603
|
+
messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
|
|
604
|
+
add_user("ERRO: 'final' afirma sucesso mas nenhum run_python/run_bash rodou. Execute agora ou reescreva 'final' só com o que foi feito.")
|
|
605
|
+
continue
|
|
606
|
+
print()
|
|
607
|
+
print(render_final(final_text))
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
# ----- TOOL -----
|
|
611
|
+
if "tool" in action:
|
|
612
|
+
tool = action.get("tool")
|
|
613
|
+
args = action.get("args", {}) or {}
|
|
614
|
+
sig = json.dumps({"tool": tool, "args": args}, sort_keys=True, ensure_ascii=False)
|
|
615
|
+
recent.append(sig)
|
|
616
|
+
if len(recent) >= 3 and recent[-3:] == [sig, sig, sig]:
|
|
617
|
+
print(" " + red("✗ repetindo a mesma ação — parando. Reformule a tarefa."))
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
print(" " + cyan("→ ") + bold(str(tool)) + " " + dim(short_args(args)))
|
|
621
|
+
result = run_tool(action, user_input=task)
|
|
622
|
+
result_str = json.dumps(result, ensure_ascii=False)
|
|
623
|
+
if len(result_str) > 1500:
|
|
624
|
+
result_str = result_str[:1500] + "...[truncado]"
|
|
625
|
+
|
|
626
|
+
is_exec = tool in ("run_python", "run_bash")
|
|
627
|
+
is_error = isinstance(result, dict) and (result.get("error") or (is_exec and result.get("exit_code", 0) != 0))
|
|
628
|
+
print(" " + (red("✗ " + summarize_result(tool, result)) if is_error
|
|
629
|
+
else dim("✓ " + summarize_result(tool, result))))
|
|
630
|
+
|
|
631
|
+
consecutive_errors = consecutive_errors + 1 if is_error else 0
|
|
632
|
+
if consecutive_errors >= 3:
|
|
633
|
+
print(" " + red("✗ 3 erros seguidos — parando."))
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
|
|
637
|
+
obs = "OBSERVATION: " + result_str
|
|
638
|
+
if consecutive_errors == 2:
|
|
639
|
+
obs += ("\n\n[REFLITA] 2 erros seguidos. No próximo `thought` diga: (1) a causa raiz, "
|
|
640
|
+
"(2) por que a abordagem falhou, (3) uma abordagem DIFERENTE. Depois execute a nova "
|
|
641
|
+
"abordagem — ou termine com {\"final\":...} explicando, se não houver saída.")
|
|
642
|
+
obs += "\n\nResponda com UM JSON: próxima ferramenta ou {\"thought\":...,\"final\":...}."
|
|
643
|
+
add_user(obs)
|
|
644
|
+
|
|
645
|
+
print(" " + yellow(f"[parou após {max_steps} passos sem concluir]"))
|
|
646
|
+
finally:
|
|
647
|
+
if last_ctx:
|
|
648
|
+
print_footer(last_ctx)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# ---------- UI (clean & profissional) ----------
|
|
652
|
+
|
|
653
|
+
def fmt_tok(n):
|
|
654
|
+
n = n or 0
|
|
655
|
+
return f"{n/1000:.1f}k" if n >= 1000 else str(int(n))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def ctx_bar(used, total, width=12):
|
|
659
|
+
frac = min(1.0, (used or 0) / total) if total else 0.0
|
|
660
|
+
fill = int(round(frac * width))
|
|
661
|
+
return "▓" * fill + "░" * (width - fill)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def short_args(args):
|
|
665
|
+
for k in ("path", "pattern", "command", "code", "text", "old_string"):
|
|
666
|
+
if k in args and args[k] is not None:
|
|
667
|
+
v = str(args[k]).replace("\n", " ").strip()
|
|
668
|
+
return (v[:54] + "…") if len(v) > 54 else v
|
|
669
|
+
return ""
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def summarize_result(tool, result):
|
|
673
|
+
if not isinstance(result, dict):
|
|
674
|
+
return str(result)[:60]
|
|
675
|
+
if result.get("error"):
|
|
676
|
+
return str(result["error"])[:70]
|
|
677
|
+
if "exit_code" in result:
|
|
678
|
+
out = (result.get("stdout") or "").strip().splitlines()
|
|
679
|
+
return f"exit {result['exit_code']}" + (f" · {out[0][:46]}" if out else "")
|
|
680
|
+
if "items" in result:
|
|
681
|
+
n = len(result["items"])
|
|
682
|
+
return f"{n} item" + ("" if n == 1 else "s")
|
|
683
|
+
if "matches" in result:
|
|
684
|
+
n = result.get("count", len(result.get("matches", [])))
|
|
685
|
+
return f"{n} ocorrência" + ("" if n == 1 else "s")
|
|
686
|
+
if "total_lines" in result:
|
|
687
|
+
return f"{result['total_lines']} linhas (mostrando {result.get('shown', '')})"
|
|
688
|
+
if "bytes_written" in result:
|
|
689
|
+
return f"salvo · {result['bytes_written']} bytes"
|
|
690
|
+
if "replaced" in result:
|
|
691
|
+
n = result["replaced"]
|
|
692
|
+
return f"{n} troca" + ("" if n == 1 else "s")
|
|
693
|
+
if "notas" in result:
|
|
694
|
+
return "anotado"
|
|
695
|
+
return "ok"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def render_final(text):
|
|
699
|
+
lines = (text or "").split("\n")
|
|
700
|
+
out = [" " + green(bold("● ")) + green(lines[0])]
|
|
701
|
+
out += [" " + green(ln) for ln in lines[1:]]
|
|
702
|
+
return "\n".join(out)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def print_footer(ctx_used):
|
|
706
|
+
pct = int(min(100, (ctx_used or 0) / CTX * 100)) if CTX else 0
|
|
707
|
+
print()
|
|
708
|
+
print(" " + dim("─" * 46))
|
|
709
|
+
print(" " + dim("contexto ") + cyan(ctx_bar(ctx_used, CTX)) +
|
|
710
|
+
dim(f" {fmt_tok(ctx_used)}/{fmt_tok(CTX)} ({pct}%) · ") +
|
|
711
|
+
dim(f"sessão {fmt_tok(_SESSION_IN)}↓ {fmt_tok(_SESSION_OUT)}↑ tokens"))
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# ---------- REPL ----------
|
|
715
|
+
|
|
716
|
+
def banner():
|
|
717
|
+
print()
|
|
718
|
+
print(" " + bold(cyan("◆ GYRK")) + dim(" agente de código · v" + VERSION))
|
|
719
|
+
print(" " + dim(WORKSPACE + (" · shell on" if ALLOW_LOCAL else "")))
|
|
720
|
+
print()
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
VERSION = "0.2.0"
|
|
724
|
+
USAGE = """agent-gyrk — agente de código no terminal rodando o modelo GYRK.
|
|
725
|
+
|
|
726
|
+
uso:
|
|
727
|
+
gyrk abre o REPL interativo (digite tarefas)
|
|
728
|
+
gyrk "sua tarefa" roda uma tarefa e sai
|
|
729
|
+
gyrk init configura sua chave de API (ou: gyrk init --key=...)
|
|
730
|
+
gyrk --allow-local ... libera run_bash (off por padrão)
|
|
731
|
+
gyrk -v | --verbose mostra o raciocínio passo a passo (debug)
|
|
732
|
+
gyrk --help | --version
|
|
733
|
+
|
|
734
|
+
env:
|
|
735
|
+
GYRK_BASE_URL endpoint da API GYRK (default https://api.gyrk.havek.ai)
|
|
736
|
+
GYRK_API_KEY Bearer pro gateway autenticado (Fase 3)
|
|
737
|
+
GYRK_ALLOW_LOCAL=1 mesmo que --allow-local
|
|
738
|
+
GYRK_CTX janela de contexto (default 8192)
|
|
739
|
+
"""
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def cmd_init(flags):
|
|
743
|
+
key, base = "", ""
|
|
744
|
+
for a in flags:
|
|
745
|
+
if a.startswith("--key="):
|
|
746
|
+
key = a.split("=", 1)[1].strip()
|
|
747
|
+
if a.startswith("--base-url="):
|
|
748
|
+
base = a.split("=", 1)[1].strip()
|
|
749
|
+
if not key:
|
|
750
|
+
try:
|
|
751
|
+
key = input("Cole sua chave GYRK (gyrk_live_...) [enter = free]: ").strip()
|
|
752
|
+
except (EOFError, KeyboardInterrupt):
|
|
753
|
+
print()
|
|
754
|
+
return
|
|
755
|
+
cfg = _load_config()
|
|
756
|
+
if key:
|
|
757
|
+
cfg["api_key"] = key
|
|
758
|
+
if base:
|
|
759
|
+
cfg["base_url"] = base.rstrip("/")
|
|
760
|
+
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
|
761
|
+
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
|
762
|
+
json.dump(cfg, f, indent=2)
|
|
763
|
+
try:
|
|
764
|
+
os.chmod(CONFIG_PATH, 0o600)
|
|
765
|
+
except Exception:
|
|
766
|
+
pass
|
|
767
|
+
print(" " + green("✓") + dim(f" config salva em {CONFIG_PATH}"))
|
|
768
|
+
print(" " + dim(f"chave: {key[:14]}… (tier definido no servidor)" if key else "sem chave = tier free (limitado)"))
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def main():
|
|
772
|
+
flags = sys.argv[1:]
|
|
773
|
+
if "--version" in flags or "-V" in flags:
|
|
774
|
+
print(f"agent-gyrk {VERSION}")
|
|
775
|
+
return
|
|
776
|
+
if "--help" in flags or "-h" in flags:
|
|
777
|
+
print(USAGE)
|
|
778
|
+
return
|
|
779
|
+
if flags and flags[0] == "init":
|
|
780
|
+
cmd_init(flags[1:])
|
|
781
|
+
return
|
|
782
|
+
argv = [a for a in flags if not a.startswith("-")]
|
|
783
|
+
oneshot = " ".join(argv).strip()
|
|
784
|
+
banner()
|
|
785
|
+
if oneshot:
|
|
786
|
+
run_task(oneshot)
|
|
787
|
+
print()
|
|
788
|
+
return
|
|
789
|
+
print(" " + dim("digite uma tarefa · 'exit' pra sair") + "\n")
|
|
790
|
+
while True:
|
|
791
|
+
try:
|
|
792
|
+
task = input(bold(green("gyrk> "))).strip()
|
|
793
|
+
except (EOFError, KeyboardInterrupt):
|
|
794
|
+
print(dim("\n tchau 👋\n"))
|
|
795
|
+
break
|
|
796
|
+
if not task:
|
|
797
|
+
continue
|
|
798
|
+
if task.lower() in ("exit", "quit", "sair", ":q"):
|
|
799
|
+
print(dim(" tchau 👋\n"))
|
|
800
|
+
break
|
|
801
|
+
try:
|
|
802
|
+
run_task(task)
|
|
803
|
+
except KeyboardInterrupt:
|
|
804
|
+
print(yellow("\n [tarefa interrompida]"))
|
|
805
|
+
print()
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
if __name__ == "__main__":
|
|
809
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-gyrk"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "agent-gyrk — CLI agentic rodando o modelo GYRK"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [] # só stdlib — nada pra compilar, instala instantâneo
|
|
12
|
+
keywords = ["gyrk", "agent", "cli", "llm", "coding-agent"]
|
|
13
|
+
authors = [{ name = "Havek" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Topic :: Software Development",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://gyrk.havek.ai"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
gyrk = "agent_gyrk:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
py-modules = ["agent_gyrk"]
|