agentforge-multi 0.1.4 → 0.1.6
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.
- package/agentforge +282 -55
- package/package.json +1 -1
package/agentforge
CHANGED
|
@@ -27,6 +27,7 @@ from collections import deque
|
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
|
|
29
29
|
import requests as _requests
|
|
30
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
30
31
|
|
|
31
32
|
from rich.console import Console
|
|
32
33
|
from rich.layout import Layout
|
|
@@ -56,6 +57,7 @@ NOISE_RE = re.compile(r'^\s*([\-─═\s]+)?$')
|
|
|
56
57
|
console = Console()
|
|
57
58
|
_last_session: dict | None = None # {goal, history, eval_history, workdir}
|
|
58
59
|
_interrupt_event = threading.Event() # ESC 감지 플래그
|
|
60
|
+
_current_response = None # 현재 스트리밍 HTTP 응답 (즉시 끊기용)
|
|
59
61
|
|
|
60
62
|
# ── ChatGPT backend API ────────────────────────────────────────────────────────
|
|
61
63
|
|
|
@@ -134,30 +136,57 @@ WORKER_TOOLS = [
|
|
|
134
136
|
]
|
|
135
137
|
|
|
136
138
|
|
|
139
|
+
RESEARCH_TOOLS = WORKER_TOOLS + [
|
|
140
|
+
{
|
|
141
|
+
"type": "function", "name": "web_search",
|
|
142
|
+
"description": "Search the web using DuckDuckGo (or Brave if BRAVE_API_KEY set). Returns summaries and links.",
|
|
143
|
+
"parameters": {"type": "object",
|
|
144
|
+
"properties": {"query": {"type": "string"}},
|
|
145
|
+
"required": ["query"]},
|
|
146
|
+
"strict": False,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"type": "function", "name": "fetch_url",
|
|
150
|
+
"description": "Fetch and extract text content from a URL.",
|
|
151
|
+
"parameters": {"type": "object",
|
|
152
|
+
"properties": {"url": {"type": "string"}},
|
|
153
|
+
"required": ["url"]},
|
|
154
|
+
"strict": False,
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
137
159
|
def _iter_events(payload: dict):
|
|
138
160
|
"""
|
|
139
161
|
ChatGPT backend-api/codex/responses 스트리밍 호출.
|
|
140
|
-
SSE 이벤트를 실시간으로 yield. _interrupt_event가 set되면
|
|
162
|
+
SSE 이벤트를 실시간으로 yield. _interrupt_event가 set되면 즉시 연결 끊기.
|
|
141
163
|
"""
|
|
164
|
+
global _current_response
|
|
142
165
|
headers = _get_auth_headers()
|
|
143
166
|
try:
|
|
144
167
|
r = _requests.post(
|
|
145
168
|
CHATGPT_RESPONSES_URL, headers=headers,
|
|
146
169
|
json=payload, stream=True, timeout=300,
|
|
147
170
|
)
|
|
171
|
+
_current_response = r
|
|
148
172
|
r.raise_for_status()
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
try:
|
|
174
|
+
for line in r.iter_lines():
|
|
175
|
+
if _interrupt_event.is_set():
|
|
176
|
+
r.close()
|
|
177
|
+
break
|
|
178
|
+
if not line:
|
|
179
|
+
continue
|
|
180
|
+
decoded = line.decode("utf-8", errors="replace")
|
|
181
|
+
if decoded.startswith("data: "):
|
|
182
|
+
try:
|
|
183
|
+
yield json.loads(decoded[6:])
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
finally:
|
|
187
|
+
_current_response = None
|
|
160
188
|
except Exception as e:
|
|
189
|
+
_current_response = None
|
|
161
190
|
yield {"type": "_error", "message": str(e)}
|
|
162
191
|
|
|
163
192
|
|
|
@@ -195,6 +224,48 @@ def _execute_tool(name: str, args: dict, workdir: str) -> str:
|
|
|
195
224
|
return "\n".join(
|
|
196
225
|
("dir " if p.is_dir() else "file ") + p.name for p in items
|
|
197
226
|
)
|
|
227
|
+
elif name == "web_search":
|
|
228
|
+
query = args["query"]
|
|
229
|
+
brave_key = os.environ.get("BRAVE_API_KEY")
|
|
230
|
+
if brave_key:
|
|
231
|
+
r = _requests.get(
|
|
232
|
+
"https://api.search.brave.com/res/v1/web/search",
|
|
233
|
+
params={"q": query, "count": 10},
|
|
234
|
+
headers={"Accept": "application/json",
|
|
235
|
+
"Accept-Encoding": "gzip",
|
|
236
|
+
"X-Subscription-Token": brave_key},
|
|
237
|
+
timeout=15,
|
|
238
|
+
)
|
|
239
|
+
items = r.json().get("web", {}).get("results", [])
|
|
240
|
+
return "\n".join(
|
|
241
|
+
f"[{i+1}] {it['title']}\n {it['url']}\n {it.get('description','')}"
|
|
242
|
+
for i, it in enumerate(items[:8])
|
|
243
|
+
) or "(결과 없음)"
|
|
244
|
+
else:
|
|
245
|
+
r = _requests.get(
|
|
246
|
+
"https://html.duckduckgo.com/html/",
|
|
247
|
+
params={"q": query},
|
|
248
|
+
headers={"User-Agent": "Mozilla/5.0"},
|
|
249
|
+
timeout=15,
|
|
250
|
+
)
|
|
251
|
+
snippets = re.findall(r'class="result__snippet">(.*?)</a>', r.text, re.S)
|
|
252
|
+
titles = re.findall(r'class="result__a"[^>]*>(.*?)</a>', r.text, re.S)
|
|
253
|
+
urls = re.findall(r'uddg=(https?[^&"]+)', r.text)
|
|
254
|
+
from urllib.parse import unquote
|
|
255
|
+
lines = []
|
|
256
|
+
for i, (t, u, s) in enumerate(zip(titles, urls, snippets)):
|
|
257
|
+
t = re.sub(r'<[^>]+>', '', t).strip()
|
|
258
|
+
s = re.sub(r'<[^>]+>', '', s).strip()
|
|
259
|
+
lines.append(f"[{i+1}] {t}\n {unquote(u)}\n {s}")
|
|
260
|
+
if i >= 7:
|
|
261
|
+
break
|
|
262
|
+
return "\n".join(lines) or "(결과 없음)"
|
|
263
|
+
elif name == "fetch_url":
|
|
264
|
+
url = args["url"]
|
|
265
|
+
r = _requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
|
|
266
|
+
text = re.sub(r'<[^>]+>', ' ', r.text)
|
|
267
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
268
|
+
return text[:8000]
|
|
198
269
|
else:
|
|
199
270
|
return f"Unknown tool: {name}"
|
|
200
271
|
except Exception as e:
|
|
@@ -203,23 +274,30 @@ def _execute_tool(name: str, args: dict, workdir: str) -> str:
|
|
|
203
274
|
# ── Slash command autocomplete ────────────────────────────────────────────────
|
|
204
275
|
|
|
205
276
|
SLASH_COMMANDS = [
|
|
206
|
-
("/resume",
|
|
207
|
-
("/exit",
|
|
277
|
+
("/resume", "마지막 세션 재개"),
|
|
278
|
+
("/exit", "종료"),
|
|
279
|
+
("/mode code", "코딩 모드로 전환"),
|
|
280
|
+
("/mode research", "연구 모드로 전환"),
|
|
281
|
+
("/eval-every <N>", "N번마다 Evaluator 실행"),
|
|
282
|
+
("/status", "현재 설정 확인"),
|
|
283
|
+
("/help", "커맨드 목록 표시"),
|
|
208
284
|
]
|
|
209
285
|
|
|
210
286
|
class SlashCompleter(Completer):
|
|
211
287
|
def get_completions(self, document, complete_event):
|
|
212
288
|
text = document.text_before_cursor
|
|
213
289
|
if text.startswith('/'):
|
|
214
|
-
typed = text
|
|
290
|
+
typed = text[1:] # strip leading /
|
|
215
291
|
for cmd, desc in SLASH_COMMANDS:
|
|
216
|
-
name = cmd.
|
|
217
|
-
|
|
292
|
+
name = cmd[1:].split()[0] # first word without /
|
|
293
|
+
full = cmd[1:] # full command without /
|
|
294
|
+
if full.startswith(typed) or name.startswith(typed.split()[0] if typed else ''):
|
|
295
|
+
import html as _html
|
|
218
296
|
yield Completion(
|
|
219
297
|
cmd,
|
|
220
298
|
start_position=-len(text),
|
|
221
|
-
display=HTML(f'<ansicyan>{cmd}</ansicyan>'),
|
|
222
|
-
display_meta=HTML(f'<ansiwhite>{desc}</ansiwhite>'),
|
|
299
|
+
display=HTML(f'<ansicyan>{_html.escape(cmd)}</ansicyan>'),
|
|
300
|
+
display_meta=HTML(f'<ansiwhite>{_html.escape(desc)}</ansiwhite>'),
|
|
223
301
|
)
|
|
224
302
|
|
|
225
303
|
PROMPT_STYLE = PtStyle.from_dict({
|
|
@@ -298,6 +376,30 @@ EVALUATOR_SYSTEM = textwrap.dedent("""\
|
|
|
298
376
|
Do NOT write anything before the decision keyword.
|
|
299
377
|
""").strip()
|
|
300
378
|
|
|
379
|
+
RESEARCH_WORKER_SYSTEM = textwrap.dedent("""\
|
|
380
|
+
You are an expert researcher. Your goal is to investigate a topic thoroughly.
|
|
381
|
+
- Use web_search to find relevant papers, articles, and data
|
|
382
|
+
- Use fetch_url to read full content of important pages
|
|
383
|
+
- Use read_file/write_file to organize findings into structured notes
|
|
384
|
+
- Synthesize information across multiple sources
|
|
385
|
+
- Stay on topic; do not drift from the research goal
|
|
386
|
+
""").strip()
|
|
387
|
+
|
|
388
|
+
RESEARCH_EVALUATOR_SYSTEM = textwrap.dedent("""\
|
|
389
|
+
You are a rigorous academic reviewer. Evaluate whether the research goal is achieved.
|
|
390
|
+
Respond with EXACTLY ONE first line: DONE, IMPROVE: <feedback>, or REDIRECT: <feedback>
|
|
391
|
+
|
|
392
|
+
DONE only if: sufficient sources found, content analyzed, findings written to file(s).
|
|
393
|
+
IMPROVE if: more sources needed, analysis incomplete, or notes missing.
|
|
394
|
+
REDIRECT if: wrong direction entirely.
|
|
395
|
+
|
|
396
|
+
When DONE, add Korean summary:
|
|
397
|
+
판단 이유: ...
|
|
398
|
+
결과물 위치: ...
|
|
399
|
+
결과 요약: ...
|
|
400
|
+
""").strip()
|
|
401
|
+
|
|
402
|
+
|
|
301
403
|
def build_worker_prompt(goal: str, history: list) -> str:
|
|
302
404
|
lines = [f"GOAL: {goal}", ""]
|
|
303
405
|
if not history:
|
|
@@ -422,6 +524,11 @@ def _esc_listener(stop: threading.Event):
|
|
|
422
524
|
ch = os.read(tty_fd, 1)
|
|
423
525
|
if ch == b'\x1b':
|
|
424
526
|
_interrupt_event.set()
|
|
527
|
+
if _current_response:
|
|
528
|
+
try:
|
|
529
|
+
_current_response.close()
|
|
530
|
+
except Exception:
|
|
531
|
+
pass
|
|
425
532
|
break
|
|
426
533
|
except Exception:
|
|
427
534
|
pass
|
|
@@ -436,9 +543,13 @@ def _esc_listener(stop: threading.Event):
|
|
|
436
543
|
# ── Agent Runners ─────────────────────────────────────────────────────────────
|
|
437
544
|
|
|
438
545
|
def run_worker(prompt: str, workdir: str, model: str | None,
|
|
439
|
-
buf: deque, status_ref: list
|
|
440
|
-
|
|
546
|
+
buf: deque, status_ref: list,
|
|
547
|
+
system_prompt: str | None = None,
|
|
548
|
+
tools: list | None = None) -> tuple[str, int]:
|
|
549
|
+
"""Worker: ChatGPT backend Responses API 직접 호출 + 병렬 도구 실행 루프."""
|
|
441
550
|
model = model or DEFAULT_WORKER_MODEL
|
|
551
|
+
system_prompt = system_prompt or WORKER_SYSTEM
|
|
552
|
+
tools = tools if tools is not None else WORKER_TOOLS
|
|
442
553
|
status_ref[0] = "running"
|
|
443
554
|
|
|
444
555
|
# 입력 히스토리 (user msg + function_call + function_call_output 누적)
|
|
@@ -460,9 +571,9 @@ def run_worker(prompt: str, workdir: str, model: str | None,
|
|
|
460
571
|
|
|
461
572
|
payload = {
|
|
462
573
|
"model": model,
|
|
463
|
-
"instructions":
|
|
574
|
+
"instructions": system_prompt,
|
|
464
575
|
"input": input_history,
|
|
465
|
-
"tools":
|
|
576
|
+
"tools": tools,
|
|
466
577
|
"store": False,
|
|
467
578
|
"stream": True,
|
|
468
579
|
}
|
|
@@ -504,26 +615,33 @@ def run_worker(prompt: str, workdir: str, model: str | None,
|
|
|
504
615
|
if not fc_items:
|
|
505
616
|
break
|
|
506
617
|
|
|
507
|
-
# 도구 실행
|
|
618
|
+
# 도구 병렬 실행
|
|
619
|
+
with ThreadPoolExecutor(max_workers=min(len(fc_items), 8)) as pool:
|
|
620
|
+
futures = {
|
|
621
|
+
pool.submit(
|
|
622
|
+
_execute_tool,
|
|
623
|
+
fc["name"],
|
|
624
|
+
json.loads(fc["arguments"]) if fc["arguments"] else {},
|
|
625
|
+
workdir,
|
|
626
|
+
): fc
|
|
627
|
+
for fc in fc_items
|
|
628
|
+
}
|
|
629
|
+
results: dict[str, str] = {}
|
|
630
|
+
for fut in as_completed(futures):
|
|
631
|
+
fc = futures[fut]
|
|
632
|
+
try:
|
|
633
|
+
results[fc["call_id"]] = fut.result()
|
|
634
|
+
except Exception as e:
|
|
635
|
+
results[fc["call_id"]] = f"Error: {e}"
|
|
636
|
+
|
|
637
|
+
# call_id 순서 보장하며 히스토리 추가
|
|
508
638
|
for fc in fc_items:
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
args = {}
|
|
516
|
-
|
|
517
|
-
arg_preview = raw_args[:80]
|
|
518
|
-
buf.append(f"[cyan]▶ {name}({arg_preview})[/cyan]")
|
|
519
|
-
result = _execute_tool(name, args, workdir)
|
|
520
|
-
short = result[:300].replace('\n', ' ')
|
|
521
|
-
buf.append(f"[dim]{short}[/dim]")
|
|
522
|
-
|
|
523
|
-
# Responses API 형식 히스토리
|
|
524
|
-
input_history.append({"type": "function_call", "call_id": call_id,
|
|
525
|
-
"name": name, "arguments": raw_args})
|
|
526
|
-
input_history.append({"type": "function_call_output", "call_id": call_id,
|
|
639
|
+
result = results[fc["call_id"]]
|
|
640
|
+
buf.append(f"[cyan]▶ {fc['name']}({fc['arguments'][:80]})[/cyan]")
|
|
641
|
+
buf.append(f"[dim]{result[:300].replace(chr(10), ' ')}[/dim]")
|
|
642
|
+
input_history.append({"type": "function_call", "call_id": fc["call_id"],
|
|
643
|
+
"name": fc["name"], "arguments": fc["arguments"]})
|
|
644
|
+
input_history.append({"type": "function_call_output", "call_id": fc["call_id"],
|
|
527
645
|
"output": result})
|
|
528
646
|
|
|
529
647
|
# 잔여 line_buf flush
|
|
@@ -534,12 +652,14 @@ def run_worker(prompt: str, workdir: str, model: str | None,
|
|
|
534
652
|
return "\n".join(all_text_parts), 0
|
|
535
653
|
|
|
536
654
|
|
|
537
|
-
def run_evaluator(prompt: str, workdir: str, model: str | None
|
|
655
|
+
def run_evaluator(prompt: str, workdir: str, model: str | None,
|
|
656
|
+
system_prompt: str | None = None) -> tuple[str, int]:
|
|
538
657
|
"""Evaluator: ChatGPT backend Responses API, 도구 없이 텍스트만."""
|
|
539
658
|
model = model or DEFAULT_EVAL_MODEL
|
|
659
|
+
system_prompt = system_prompt or EVALUATOR_SYSTEM
|
|
540
660
|
payload = {
|
|
541
661
|
"model": model,
|
|
542
|
-
"instructions":
|
|
662
|
+
"instructions": system_prompt,
|
|
543
663
|
"input": [{"role": "user", "content": prompt}],
|
|
544
664
|
"store": False,
|
|
545
665
|
"stream": True,
|
|
@@ -689,16 +809,68 @@ def render_evaluator_panel(eval_history: list, iteration: int,
|
|
|
689
809
|
)
|
|
690
810
|
|
|
691
811
|
|
|
812
|
+
# ── tmux 자동 래핑 ────────────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
import shlex as _shlex
|
|
815
|
+
import shutil as _shutil
|
|
816
|
+
|
|
817
|
+
TMUX_SESSION = "agentforge"
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _in_tmux() -> bool:
|
|
821
|
+
return bool(os.environ.get("TMUX"))
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _tmux_session_exists() -> bool:
|
|
825
|
+
r = subprocess.run(["tmux", "has-session", "-t", TMUX_SESSION],
|
|
826
|
+
capture_output=True)
|
|
827
|
+
return r.returncode == 0
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _tmux_launch(extra_args: list):
|
|
831
|
+
"""현재 프로세스를 tmux 세션 안의 agentforge로 교체."""
|
|
832
|
+
if _tmux_session_exists():
|
|
833
|
+
console.print(
|
|
834
|
+
f"[yellow]이미 실행 중인 agentforge 세션이 있습니다.[/yellow]\n"
|
|
835
|
+
f" 재접속: [cyan]agentforge -r[/cyan]\n"
|
|
836
|
+
f" 새 세션: [cyan]agentforge --new-session[/cyan]"
|
|
837
|
+
)
|
|
838
|
+
sys.exit(0)
|
|
839
|
+
|
|
840
|
+
exe = _shutil.which("agentforge") or os.path.abspath(sys.argv[0])
|
|
841
|
+
cmd_str = " ".join(_shlex.quote(p) for p in [exe] + extra_args)
|
|
842
|
+
try:
|
|
843
|
+
os.execvp("tmux", ["tmux", "new-session", "-s", TMUX_SESSION, cmd_str])
|
|
844
|
+
except OSError:
|
|
845
|
+
pass # tmux 실행 실패 → 그냥 일반 모드로 계속
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _tmux_reconnect():
|
|
849
|
+
"""기존 agentforge tmux 세션에 재접속."""
|
|
850
|
+
if not _shutil.which("tmux"):
|
|
851
|
+
console.print("[red]tmux가 설치되지 않았습니다.[/red]")
|
|
852
|
+
sys.exit(1)
|
|
853
|
+
if not _tmux_session_exists():
|
|
854
|
+
console.print(
|
|
855
|
+
"[yellow]실행 중인 agentforge 세션이 없습니다.[/yellow]\n"
|
|
856
|
+
"새 세션 시작: [cyan]agentforge[/cyan]"
|
|
857
|
+
)
|
|
858
|
+
sys.exit(1)
|
|
859
|
+
os.execvp("tmux", ["tmux", "attach-session", "-t", TMUX_SESSION])
|
|
860
|
+
|
|
861
|
+
|
|
692
862
|
# ── Agent Loop ────────────────────────────────────────────────────────────────
|
|
693
863
|
|
|
694
864
|
def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
695
865
|
eval_model: str | None, max_iter: int,
|
|
696
866
|
layout: Layout, live: Live,
|
|
697
867
|
initial_history: list | None = None,
|
|
698
|
-
initial_eval_history: list | None = None
|
|
868
|
+
initial_eval_history: list | None = None,
|
|
869
|
+
mode: str = "code",
|
|
870
|
+
eval_every: int = 1) -> str:
|
|
699
871
|
"""
|
|
700
872
|
Worker + Evaluator 반복 루프.
|
|
701
|
-
반환: 'done' | 'max'
|
|
873
|
+
반환: 'done' | 'max' | 'interrupted'
|
|
702
874
|
"""
|
|
703
875
|
global _last_session
|
|
704
876
|
history = list(initial_history or [])
|
|
@@ -707,6 +879,11 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
707
879
|
worker_status = ["idle"]
|
|
708
880
|
done = False
|
|
709
881
|
|
|
882
|
+
# 모드별 시스템 프롬프트 및 도구 선택
|
|
883
|
+
worker_sys = RESEARCH_WORKER_SYSTEM if mode == "research" else WORKER_SYSTEM
|
|
884
|
+
eval_sys = RESEARCH_EVALUATOR_SYSTEM if mode == "research" else EVALUATOR_SYSTEM
|
|
885
|
+
active_tools = RESEARCH_TOOLS if mode == "research" else WORKER_TOOLS
|
|
886
|
+
|
|
710
887
|
# ESC 리스너 시작
|
|
711
888
|
_interrupt_event.clear()
|
|
712
889
|
_esc_stop = threading.Event()
|
|
@@ -737,7 +914,8 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
737
914
|
|
|
738
915
|
def _worker():
|
|
739
916
|
worker_result[0], worker_result[1] = run_worker(
|
|
740
|
-
worker_prompt, workdir, worker_model, worker_buf, worker_status
|
|
917
|
+
worker_prompt, workdir, worker_model, worker_buf, worker_status,
|
|
918
|
+
system_prompt=worker_sys, tools=active_tools)
|
|
741
919
|
|
|
742
920
|
t = threading.Thread(target=_worker, daemon=True)
|
|
743
921
|
t.start()
|
|
@@ -773,9 +951,13 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
773
951
|
"eval_history": eval_history, "workdir": workdir}
|
|
774
952
|
return _finish('interrupted')
|
|
775
953
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
954
|
+
if iteration % eval_every == 0 or iteration == max_iter:
|
|
955
|
+
refresh("evaluator", iteration)
|
|
956
|
+
eval_prompt = build_evaluator_prompt(goal, last_msg or "", iteration)
|
|
957
|
+
eval_msg, _ = run_evaluator(eval_prompt, workdir, eval_model,
|
|
958
|
+
system_prompt=eval_sys)
|
|
959
|
+
else:
|
|
960
|
+
eval_msg = "IMPROVE: (evaluation skipped)"
|
|
779
961
|
|
|
780
962
|
decision, feedback = parse_decision(eval_msg)
|
|
781
963
|
history[-1]['decision'] = decision
|
|
@@ -865,6 +1047,22 @@ def _handle_interrupt(goal: str, workdir: str, args, max_iter: int) -> str:
|
|
|
865
1047
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
866
1048
|
|
|
867
1049
|
def main():
|
|
1050
|
+
# ── tmux 자동 래핑 ─────────────────────────────────────────────────
|
|
1051
|
+
# -r / --reconnect: 기존 세션 재접속
|
|
1052
|
+
if len(sys.argv) >= 2 and sys.argv[1] in ("-r", "--reconnect"):
|
|
1053
|
+
_tmux_reconnect()
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
# --new-session: 기존 세션 무시하고 강제로 새 세션
|
|
1057
|
+
force_new = len(sys.argv) >= 2 and sys.argv[1] == "--new-session"
|
|
1058
|
+
extra_args = [a for a in sys.argv[1:] if a != "--new-session"]
|
|
1059
|
+
|
|
1060
|
+
if not _in_tmux() and _shutil.which("tmux"):
|
|
1061
|
+
if force_new and _tmux_session_exists():
|
|
1062
|
+
subprocess.run(["tmux", "kill-session", "-t", TMUX_SESSION])
|
|
1063
|
+
_tmux_launch(extra_args)
|
|
1064
|
+
# execvp 실패 또는 세션 이미 존재 시 여기까지 옴 → 일반 모드로 계속
|
|
1065
|
+
|
|
868
1066
|
parser = argparse.ArgumentParser(
|
|
869
1067
|
prog="agentforge",
|
|
870
1068
|
description="Worker + Evaluator 인터랙티브 멀티에이전트 CLI",
|
|
@@ -888,6 +1086,10 @@ def main():
|
|
|
888
1086
|
help="Evaluator 모델")
|
|
889
1087
|
parser.add_argument("-n", "--max-iterations", type=int, default=DEFAULT_MAX_ITER,
|
|
890
1088
|
metavar="N", help=f"최대 반복 횟수 (기본: {DEFAULT_MAX_ITER})")
|
|
1089
|
+
parser.add_argument("--mode", choices=["code", "research"], default="code",
|
|
1090
|
+
help="실행 모드: code(기본) / research(웹 검색·분석)")
|
|
1091
|
+
parser.add_argument("--eval-every", type=int, default=1, metavar="N",
|
|
1092
|
+
help="N번 반복마다 Evaluator 실행 (기본: 1, 즉 매번)")
|
|
891
1093
|
args = parser.parse_args()
|
|
892
1094
|
|
|
893
1095
|
# ── auth 서브커맨드 ────────────────────────────────────────────────
|
|
@@ -938,7 +1140,10 @@ def main():
|
|
|
938
1140
|
"agentforge auth login 으로 나중에 로그인할 수 있습니다.[/dim]"
|
|
939
1141
|
)
|
|
940
1142
|
|
|
941
|
-
|
|
1143
|
+
current_mode = args.mode
|
|
1144
|
+
eval_every = args.eval_every
|
|
1145
|
+
|
|
1146
|
+
console.print(f"[dim]명령을 입력하세요. /help 로 커맨드 목록 확인. 모드: {current_mode}[/dim]")
|
|
942
1147
|
|
|
943
1148
|
_completer = SlashCompleter()
|
|
944
1149
|
|
|
@@ -988,18 +1193,39 @@ def main():
|
|
|
988
1193
|
max_iter, layout2, live2,
|
|
989
1194
|
initial_history=s["history"],
|
|
990
1195
|
initial_eval_history=s["eval_history"],
|
|
1196
|
+
mode=current_mode, eval_every=eval_every,
|
|
991
1197
|
)
|
|
992
|
-
goal = s["goal"]
|
|
993
1198
|
if outcome == 'interrupted':
|
|
994
|
-
|
|
1199
|
+
console.print("\n[yellow]⚠ 중단됨. REPL로 돌아갑니다.[/yellow]")
|
|
995
1200
|
elif outcome == 'max':
|
|
996
1201
|
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
997
1202
|
if outcome != 'interrupted':
|
|
998
1203
|
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
999
1204
|
|
|
1205
|
+
elif cmd_name == 'mode':
|
|
1206
|
+
if cmd_arg in ('code', 'research'):
|
|
1207
|
+
current_mode = cmd_arg
|
|
1208
|
+
console.print(f"[cyan]모드 변경: {current_mode}[/cyan]")
|
|
1209
|
+
else:
|
|
1210
|
+
console.print("[red]사용법: /mode code 또는 /mode research[/red]")
|
|
1211
|
+
|
|
1212
|
+
elif cmd_name == 'eval-every':
|
|
1213
|
+
try:
|
|
1214
|
+
eval_every = int(cmd_arg)
|
|
1215
|
+
console.print(f"[cyan]Evaluator: {eval_every}번마다 실행[/cyan]")
|
|
1216
|
+
except ValueError:
|
|
1217
|
+
console.print("[red]숫자를 입력하세요. 예: /eval-every 3[/red]")
|
|
1218
|
+
|
|
1219
|
+
elif cmd_name == 'status':
|
|
1220
|
+
console.print(f"모드: [cyan]{current_mode}[/cyan] | eval-every: [cyan]{eval_every}[/cyan] | dir: [cyan]{workdir}[/cyan]")
|
|
1221
|
+
|
|
1222
|
+
elif cmd_name == 'help':
|
|
1223
|
+
for cmd, desc in SLASH_COMMANDS:
|
|
1224
|
+
console.print(f" [cyan]{cmd:<25}[/cyan] {desc}")
|
|
1225
|
+
|
|
1000
1226
|
else:
|
|
1001
1227
|
console.print(f"[red]알 수 없는 커맨드: /{cmd_name}[/red]")
|
|
1002
|
-
console.print("[dim]
|
|
1228
|
+
console.print("[dim]/help 로 커맨드 목록을 확인하세요.[/dim]")
|
|
1003
1229
|
|
|
1004
1230
|
else:
|
|
1005
1231
|
# 일반 텍스트 → 바로 Worker에게 목표로 전달
|
|
@@ -1012,9 +1238,10 @@ def main():
|
|
|
1012
1238
|
outcome = run_agent_loop(
|
|
1013
1239
|
goal, workdir, args.worker_model, args.eval_model,
|
|
1014
1240
|
max_iter, layout2, live2,
|
|
1241
|
+
mode=current_mode, eval_every=eval_every,
|
|
1015
1242
|
)
|
|
1016
1243
|
if outcome == 'interrupted':
|
|
1017
|
-
|
|
1244
|
+
console.print("\n[yellow]⚠ 중단됨. REPL로 돌아갑니다.[/yellow]")
|
|
1018
1245
|
elif outcome == 'max':
|
|
1019
1246
|
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
1020
1247
|
if outcome != 'interrupted':
|