agentforge-multi 0.1.0 → 0.1.2
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/README.ko.md +11 -1
- package/README.md +11 -1
- package/agentforge +347 -45
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -81,13 +81,23 @@ DONE 판정이 나거나 최대 반복 횟수에 도달할 때까지 루프가
|
|
|
81
81
|
|
|
82
82
|
## 설치
|
|
83
83
|
|
|
84
|
+
### npm으로 설치 (권장)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm install -g agentforge-multi
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`rich`, `prompt_toolkit` 패키지는 postinstall 스크립트가 자동으로 설치합니다.
|
|
91
|
+
|
|
92
|
+
### git으로 설치
|
|
93
|
+
|
|
84
94
|
```bash
|
|
85
95
|
git clone https://github.com/<your-username>/AgentForge.git
|
|
86
96
|
cd AgentForge
|
|
87
97
|
bash install.sh
|
|
88
98
|
```
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
### 수동 설치
|
|
91
101
|
|
|
92
102
|
```bash
|
|
93
103
|
cp agentforge ~/.local/bin/agentforge
|
package/README.md
CHANGED
|
@@ -81,13 +81,23 @@ The loop continues until the Evaluator decides `DONE` or the iteration limit is
|
|
|
81
81
|
|
|
82
82
|
## Installation
|
|
83
83
|
|
|
84
|
+
### via npm (recommended)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm install -g agentforge-multi
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Dependencies (`rich`, `prompt_toolkit`) are installed automatically via postinstall.
|
|
91
|
+
|
|
92
|
+
### via git
|
|
93
|
+
|
|
84
94
|
```bash
|
|
85
95
|
git clone https://github.com/<your-username>/AgentForge.git
|
|
86
96
|
cd AgentForge
|
|
87
97
|
bash install.sh
|
|
88
98
|
```
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
### manually
|
|
91
101
|
|
|
92
102
|
```bash
|
|
93
103
|
cp agentforge ~/.local/bin/agentforge
|
package/agentforge
CHANGED
|
@@ -12,13 +12,18 @@ Slash commands:
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
15
17
|
import re
|
|
18
|
+
import select
|
|
16
19
|
import subprocess
|
|
17
20
|
import sys
|
|
18
21
|
import tempfile
|
|
22
|
+
import termios
|
|
19
23
|
import textwrap
|
|
20
24
|
import threading
|
|
21
25
|
import time
|
|
26
|
+
import tty
|
|
22
27
|
from collections import deque
|
|
23
28
|
from pathlib import Path
|
|
24
29
|
|
|
@@ -45,12 +50,16 @@ DECISION_RE = re.compile(r'^(DONE|IMPROVE|REDIRECT)(?::\s*(.*))?$', re.I | re.M)
|
|
|
45
50
|
NOISE_RE = re.compile(r'^\s*([\-─═\s]+)?$')
|
|
46
51
|
|
|
47
52
|
console = Console()
|
|
53
|
+
_last_session: dict | None = None # {goal, history, eval_history, workdir}
|
|
54
|
+
_interrupt_event = threading.Event() # ESC 감지 플래그
|
|
55
|
+
_current_proc: subprocess.Popen | None = None # 현재 실행 중인 subprocess
|
|
48
56
|
|
|
49
57
|
# ── Slash command autocomplete ────────────────────────────────────────────────
|
|
50
58
|
|
|
51
59
|
SLASH_COMMANDS = [
|
|
52
|
-
("/plan",
|
|
53
|
-
("/
|
|
60
|
+
("/plan", "계획을 수립한 뒤 Worker + Evaluator 루프 실행"),
|
|
61
|
+
("/resume", "마지막 세션을 이어서 실행"),
|
|
62
|
+
("/exit", "agentforge 종료"),
|
|
54
63
|
]
|
|
55
64
|
|
|
56
65
|
class SlashCompleter(Completer):
|
|
@@ -212,6 +221,98 @@ def build_plan_prompt(goal: str, qa_history: list) -> str:
|
|
|
212
221
|
return "\n".join(lines)
|
|
213
222
|
|
|
214
223
|
|
|
224
|
+
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
def auth_status() -> dict | None:
|
|
227
|
+
auth_file = Path.home() / ".codex" / "auth.json"
|
|
228
|
+
if not auth_file.exists():
|
|
229
|
+
return None
|
|
230
|
+
try:
|
|
231
|
+
data = json.loads(auth_file.read_text())
|
|
232
|
+
except Exception:
|
|
233
|
+
return None
|
|
234
|
+
return {
|
|
235
|
+
"mode": data.get("auth_mode", "unknown"),
|
|
236
|
+
"account_id": data.get("tokens", {}).get("account_id"),
|
|
237
|
+
"last_refresh": data.get("last_refresh"),
|
|
238
|
+
"has_token": bool(data.get("tokens", {}).get("access_token")),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def cmd_auth_login(device: bool = False):
|
|
243
|
+
console.print(Rule("[cyan]agentforge — 로그인[/cyan]"))
|
|
244
|
+
if not device and not sys.stdout.isatty():
|
|
245
|
+
console.print("[yellow]SSH 환경 감지됨. --device 플래그를 권장합니다.[/yellow]\n")
|
|
246
|
+
cmd = [str(CODEX_BIN), "login"]
|
|
247
|
+
if device:
|
|
248
|
+
cmd.append("--device-auth")
|
|
249
|
+
result = subprocess.run(cmd)
|
|
250
|
+
if result.returncode == 0:
|
|
251
|
+
status = auth_status()
|
|
252
|
+
console.print(f"\n[green]✓ 로그인 완료[/green]")
|
|
253
|
+
if status and status["account_id"]:
|
|
254
|
+
console.print(f" 계정: {status['account_id']}")
|
|
255
|
+
else:
|
|
256
|
+
console.print("[red]✗ 로그인 실패[/red]")
|
|
257
|
+
sys.exit(1)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_auth_logout():
|
|
261
|
+
console.print(Rule("[cyan]agentforge — 로그아웃[/cyan]"))
|
|
262
|
+
result = subprocess.run([str(CODEX_BIN), "logout"])
|
|
263
|
+
if result.returncode == 0:
|
|
264
|
+
console.print("[green]✓ 로그아웃 완료[/green]")
|
|
265
|
+
else:
|
|
266
|
+
console.print("[red]✗ 로그아웃 실패[/red]")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_auth_status():
|
|
270
|
+
status = auth_status()
|
|
271
|
+
if not status or not status["has_token"]:
|
|
272
|
+
console.print(Panel(
|
|
273
|
+
"[red]로그인되지 않음[/red]\n\n"
|
|
274
|
+
"로그인: [cyan]agentforge auth login[/cyan]\n"
|
|
275
|
+
"SSH 환경: [cyan]agentforge auth login --device[/cyan]",
|
|
276
|
+
title="인증 상태", border_style="red",
|
|
277
|
+
))
|
|
278
|
+
return
|
|
279
|
+
mode_label = {"chatgpt": "ChatGPT (Plus / Pro)"}.get(
|
|
280
|
+
status["mode"], status["mode"])
|
|
281
|
+
console.print(Panel(
|
|
282
|
+
f"[green]✓ 로그인됨[/green]\n\n"
|
|
283
|
+
f"방식: [cyan]{mode_label}[/cyan]\n"
|
|
284
|
+
f"계정: {status['account_id'] or '(알 수 없음)'}\n"
|
|
285
|
+
f"갱신: {status['last_refresh'] or '(알 수 없음)'}",
|
|
286
|
+
title="인증 상태", border_style="green",
|
|
287
|
+
))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ── ESC Listener ──────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
def _esc_listener(stop: threading.Event):
|
|
293
|
+
"""데몬 스레드: ESC 키 감지 시 _interrupt_event 설정."""
|
|
294
|
+
try:
|
|
295
|
+
fd = sys.stdin.fileno()
|
|
296
|
+
old = termios.tcgetattr(fd)
|
|
297
|
+
except Exception:
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
tty.setcbreak(fd)
|
|
301
|
+
while not stop.is_set():
|
|
302
|
+
if select.select([sys.stdin], [], [], 0.1)[0]:
|
|
303
|
+
ch = os.read(fd, 1)
|
|
304
|
+
if ch == b'\x1b':
|
|
305
|
+
_interrupt_event.set()
|
|
306
|
+
break
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
finally:
|
|
310
|
+
try:
|
|
311
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
215
316
|
# ── Agent Runners ─────────────────────────────────────────────────────────────
|
|
216
317
|
|
|
217
318
|
def _run_codex(cmd: list, workdir: str, buf: deque | None,
|
|
@@ -223,12 +324,14 @@ def _run_codex(cmd: list, workdir: str, buf: deque | None,
|
|
|
223
324
|
full_cmd = cmd + ["-o", out_file]
|
|
224
325
|
try:
|
|
225
326
|
if buf is not None:
|
|
327
|
+
global _current_proc
|
|
226
328
|
if status_ref:
|
|
227
329
|
status_ref[0] = "running"
|
|
228
330
|
proc = subprocess.Popen(
|
|
229
331
|
full_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
230
332
|
text=True, errors='replace',
|
|
231
333
|
)
|
|
334
|
+
_current_proc = proc
|
|
232
335
|
prev_line = ""
|
|
233
336
|
for raw in proc.stdout:
|
|
234
337
|
clean = strip_ansi(raw).rstrip()
|
|
@@ -236,6 +339,7 @@ def _run_codex(cmd: list, workdir: str, buf: deque | None,
|
|
|
236
339
|
buf.append(colorize_line(clean))
|
|
237
340
|
prev_line = clean
|
|
238
341
|
proc.wait()
|
|
342
|
+
_current_proc = None
|
|
239
343
|
rc = proc.returncode
|
|
240
344
|
if status_ref:
|
|
241
345
|
status_ref[0] = "done"
|
|
@@ -316,10 +420,14 @@ def parse_decision(text: str) -> tuple[str, str]:
|
|
|
316
420
|
# ── TUI ───────────────────────────────────────────────────────────────────────
|
|
317
421
|
|
|
318
422
|
def make_layout() -> Layout:
|
|
423
|
+
import shutil
|
|
424
|
+
term_h = shutil.get_terminal_size(fallback=(80, 40)).lines
|
|
425
|
+
body_size = max(10, term_h - 8) # header(3) + 여백(5)
|
|
426
|
+
|
|
319
427
|
layout = Layout()
|
|
320
428
|
layout.split(
|
|
321
429
|
Layout(name="header", size=3),
|
|
322
|
-
Layout(name="body"),
|
|
430
|
+
Layout(name="body", size=body_size),
|
|
323
431
|
)
|
|
324
432
|
layout["body"].split_row(
|
|
325
433
|
Layout(name="worker"),
|
|
@@ -332,37 +440,57 @@ def render_header(goal: str, iteration: int, max_iter: int, phase: str) -> Panel
|
|
|
332
440
|
g = goal[:72] + "..." if len(goal) > 72 else goal
|
|
333
441
|
status = {
|
|
334
442
|
"idle": "[dim]명령 대기 중 /plan <목표> 또는 <목표> 입력[/dim]",
|
|
335
|
-
"worker": "[yellow]
|
|
336
|
-
"evaluator": "[
|
|
337
|
-
"planning": "[cyan]
|
|
338
|
-
"done": "[bold green]✓
|
|
339
|
-
"max": "[bold red]최대 반복 도달[/bold red]",
|
|
443
|
+
"worker": "⚙ [bold yellow on black] WORKER 실행 중 [/bold yellow on black] [dim]Evaluator 대기[/dim]",
|
|
444
|
+
"evaluator": "[dim]Worker 완료[/dim] → ◈ [bold magenta on black] EVALUATOR 평가 중 [/bold magenta on black]",
|
|
445
|
+
"planning": "◑ [bold cyan on black] PLAN AGENT 실행 중 [/bold cyan on black]",
|
|
446
|
+
"done": "[bold green]✓ 완료[/bold green]",
|
|
447
|
+
"max": "[bold red]⚠ 최대 반복 도달[/bold red]",
|
|
340
448
|
}.get(phase, phase)
|
|
341
449
|
iter_str = f" Iter [bold]{iteration}[/bold]/{max_iter}" if iteration > 0 else ""
|
|
342
450
|
content = f"[bold cyan]Goal:[/bold cyan] {g}\n{status}{iter_str}"
|
|
343
451
|
return Panel(content, border_style="cyan", box=box.HEAVY_HEAD)
|
|
344
452
|
|
|
345
453
|
|
|
454
|
+
_DECISION_BADGE = {
|
|
455
|
+
"DONE": "[green]✓[/green]",
|
|
456
|
+
"IMPROVE": "[yellow]~[/yellow]",
|
|
457
|
+
"REDIRECT": "[red]↩[/red]",
|
|
458
|
+
}
|
|
459
|
+
|
|
346
460
|
def render_worker_panel(buf: deque, iteration: int, status: str,
|
|
347
461
|
history: list, done: bool) -> Panel:
|
|
348
462
|
lines = []
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
463
|
+
# 오래된 기록 (직전 제외): summary 한 줄 + 배지
|
|
464
|
+
for h in history[:-1]:
|
|
465
|
+
badge = _DECISION_BADGE.get(h['decision'], '[dim]?[/dim]')
|
|
466
|
+
summary = (h['worker_summary'] or '')[:70]
|
|
467
|
+
lines.append(f"[dim]Iter {h['iter']}[/dim] {badge} [dim]{summary}[/dim]")
|
|
468
|
+
# 직전 기록: worker_lines 최대 8줄 (밝게)
|
|
469
|
+
if history:
|
|
470
|
+
h = history[-1]
|
|
471
|
+
lines.append(f"[bold]── Iter {h['iter']} ──────────────────────────────[/bold]")
|
|
472
|
+
for l in h.get('worker_lines', [])[-8:]:
|
|
473
|
+
lines.append(f"[white]{l}[/white]")
|
|
474
|
+
# 현재 스트리밍
|
|
353
475
|
if buf or status == "running":
|
|
354
476
|
if history:
|
|
355
|
-
lines.append(f"[yellow]── Iter {iteration}
|
|
477
|
+
lines.append(f"[yellow bold]── Iter {iteration} (실행 중) ──────────────[/yellow bold]")
|
|
356
478
|
for line in list(buf):
|
|
357
479
|
lines.append(line)
|
|
358
480
|
if status == "running":
|
|
359
481
|
lines.append("[yellow blink]▌[/yellow blink]")
|
|
360
482
|
border = "green" if done else "yellow"
|
|
361
|
-
|
|
483
|
+
is_active = (status == "running")
|
|
484
|
+
if done:
|
|
485
|
+
title = "[green bold]✓ WORKER AGENT[/green bold]"
|
|
486
|
+
elif is_active:
|
|
487
|
+
title = "[yellow bold]⚙ WORKER AGENT ◀ 실행 중[/yellow bold]"
|
|
488
|
+
else:
|
|
489
|
+
title = "[dim]⚙ WORKER AGENT[/dim]"
|
|
362
490
|
content = "\n".join(lines) if lines else "[dim]명령을 입력하면 작업을 시작합니다.[/dim]"
|
|
363
491
|
return Panel(
|
|
364
492
|
Text.from_markup(content),
|
|
365
|
-
title=
|
|
493
|
+
title=title,
|
|
366
494
|
border_style=border, box=box.ROUNDED, padding=(0, 1),
|
|
367
495
|
)
|
|
368
496
|
|
|
@@ -370,37 +498,50 @@ def render_worker_panel(buf: deque, iteration: int, status: str,
|
|
|
370
498
|
def render_evaluator_panel(eval_history: list, iteration: int,
|
|
371
499
|
phase: str, done: bool) -> Panel:
|
|
372
500
|
lines = []
|
|
373
|
-
|
|
501
|
+
# 오래된 기록 (최신 제외): 배지 + 요약 한 줄
|
|
502
|
+
for e in eval_history[:-1]:
|
|
503
|
+
d, fb = e['decision'], e['feedback']
|
|
504
|
+
badge = {"DONE": "[green]✓ DONE[/green]",
|
|
505
|
+
"IMPROVE": "[yellow]IMPROVE[/yellow]",
|
|
506
|
+
"REDIRECT": "[red]REDIRECT[/red]"}.get(d, d)
|
|
507
|
+
short_fb = fb[:65] + ('…' if len(fb) > 65 else '')
|
|
508
|
+
lines.append(f"[dim]Iter {e['iter']}[/dim] {badge} [dim]{short_fb}[/dim]")
|
|
509
|
+
# 최신 기록: 전체 피드백 표시
|
|
510
|
+
if eval_history:
|
|
511
|
+
e = eval_history[-1]
|
|
374
512
|
d, fb, fm = e['decision'], e['feedback'], e.get('full_msg', '')
|
|
375
|
-
lines.append(f"[
|
|
513
|
+
lines.append(f"[bold]── Iter {e['iter']} ──────────────────────────────[/bold]")
|
|
376
514
|
if d == 'DONE':
|
|
377
515
|
lines.append("[bold green]✓ DONE[/bold green]")
|
|
378
|
-
# 한국어 요약 섹션 추출
|
|
379
516
|
for section in ['판단 이유', '결과물 위치', '결과 요약']:
|
|
380
517
|
m = re.search(rf'{section}:\s*(.+?)(?=\n판단|결과물|결과 요약|$)', fm, re.S)
|
|
381
518
|
if m:
|
|
382
|
-
val = m.group(1).strip()[:
|
|
383
|
-
lines.append(f"[
|
|
519
|
+
val = m.group(1).strip()[:200]
|
|
520
|
+
lines.append(f"[cyan]{section}:[/cyan] [white]{val}[/white]")
|
|
384
521
|
elif d == 'IMPROVE':
|
|
385
|
-
lines.append("[bold yellow]IMPROVE[/bold yellow]")
|
|
386
|
-
|
|
387
|
-
lines.append(f"[yellow]{l}[/yellow]")
|
|
522
|
+
lines.append("[bold yellow]▲ IMPROVE[/bold yellow]")
|
|
523
|
+
lines.append(f"[yellow]{fb}[/yellow]")
|
|
388
524
|
elif d == 'REDIRECT':
|
|
389
|
-
lines.append("[bold red]REDIRECT[/bold red]")
|
|
390
|
-
|
|
391
|
-
lines.append(f"[red]{l}[/red]")
|
|
525
|
+
lines.append("[bold red]↩ REDIRECT[/bold red]")
|
|
526
|
+
lines.append(f"[red]{fb}[/red]")
|
|
392
527
|
lines.append("")
|
|
393
528
|
if phase == "evaluator":
|
|
394
|
-
lines.append(f"[magenta]── Iter {iteration}
|
|
529
|
+
lines.append(f"[magenta bold]── Iter {iteration} (평가 중) ──────────────[/magenta bold]")
|
|
395
530
|
lines.append("[magenta blink]평가 중...[/magenta blink]")
|
|
396
531
|
elif phase == "planning":
|
|
397
532
|
lines.append("[cyan blink]계획 수립 중...[/cyan blink]")
|
|
398
533
|
border = "green" if done else "magenta"
|
|
399
|
-
|
|
534
|
+
is_active = (phase == "evaluator")
|
|
535
|
+
if done:
|
|
536
|
+
title = "[green bold]✓ EVALUATOR AGENT[/green bold]"
|
|
537
|
+
elif is_active:
|
|
538
|
+
title = "[magenta bold]◈ EVALUATOR AGENT ◀ 평가 중[/magenta bold]"
|
|
539
|
+
else:
|
|
540
|
+
title = "[dim]◈ EVALUATOR AGENT[/dim]"
|
|
400
541
|
content = "\n".join(lines) if lines else "[dim]Worker 완료 후 평가를 시작합니다.[/dim]"
|
|
401
542
|
return Panel(
|
|
402
543
|
Text.from_markup(content),
|
|
403
|
-
title=
|
|
544
|
+
title=title,
|
|
404
545
|
border_style=border, box=box.ROUNDED, padding=(0, 1),
|
|
405
546
|
)
|
|
406
547
|
|
|
@@ -485,17 +626,30 @@ def run_plan_mode(raw_goal: str, workdir: str, model: str | None,
|
|
|
485
626
|
|
|
486
627
|
def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
487
628
|
eval_model: str | None, max_iter: int,
|
|
488
|
-
layout: Layout, live: Live
|
|
629
|
+
layout: Layout, live: Live,
|
|
630
|
+
initial_history: list | None = None,
|
|
631
|
+
initial_eval_history: list | None = None) -> str:
|
|
489
632
|
"""
|
|
490
633
|
Worker + Evaluator 반복 루프.
|
|
491
634
|
반환: 'done' | 'max'
|
|
492
635
|
"""
|
|
493
|
-
|
|
494
|
-
|
|
636
|
+
global _last_session
|
|
637
|
+
history = list(initial_history or [])
|
|
638
|
+
eval_history = list(initial_eval_history or [])
|
|
495
639
|
worker_buf = deque(maxlen=WORKER_BUF_LINES)
|
|
496
640
|
worker_status = ["idle"]
|
|
497
641
|
done = False
|
|
498
642
|
|
|
643
|
+
# ESC 리스너 시작
|
|
644
|
+
_interrupt_event.clear()
|
|
645
|
+
_esc_stop = threading.Event()
|
|
646
|
+
esc_t = threading.Thread(target=_esc_listener, args=(_esc_stop,), daemon=True)
|
|
647
|
+
esc_t.start()
|
|
648
|
+
|
|
649
|
+
def _finish(result: str) -> str:
|
|
650
|
+
_esc_stop.set()
|
|
651
|
+
return result
|
|
652
|
+
|
|
499
653
|
def refresh(phase: str, iteration: int):
|
|
500
654
|
layout["header"].update(render_header(goal, iteration, max_iter, phase))
|
|
501
655
|
layout["worker"].update(render_worker_panel(
|
|
@@ -521,11 +675,22 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
521
675
|
t = threading.Thread(target=_worker, daemon=True)
|
|
522
676
|
t.start()
|
|
523
677
|
while t.is_alive():
|
|
678
|
+
if _interrupt_event.is_set():
|
|
679
|
+
if _current_proc:
|
|
680
|
+
_current_proc.kill()
|
|
681
|
+
break
|
|
524
682
|
refresh("worker", iteration)
|
|
525
683
|
time.sleep(0.1)
|
|
526
684
|
t.join()
|
|
527
685
|
refresh("worker", iteration)
|
|
528
686
|
|
|
687
|
+
# ESC로 중단된 경우
|
|
688
|
+
if _interrupt_event.is_set():
|
|
689
|
+
live.stop()
|
|
690
|
+
_last_session = {"goal": goal, "history": history,
|
|
691
|
+
"eval_history": eval_history, "workdir": workdir}
|
|
692
|
+
return _finish('interrupted')
|
|
693
|
+
|
|
529
694
|
last_msg, _ = worker_result
|
|
530
695
|
worker_summary = (last_msg or "").replace('\n', ' ')[:300]
|
|
531
696
|
history.append({
|
|
@@ -537,6 +702,12 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
537
702
|
})
|
|
538
703
|
|
|
539
704
|
# ── Evaluator ────────────────────────────────────────────────────
|
|
705
|
+
if _interrupt_event.is_set():
|
|
706
|
+
live.stop()
|
|
707
|
+
_last_session = {"goal": goal, "history": history,
|
|
708
|
+
"eval_history": eval_history, "workdir": workdir}
|
|
709
|
+
return _finish('interrupted')
|
|
710
|
+
|
|
540
711
|
refresh("evaluator", iteration)
|
|
541
712
|
eval_prompt = build_evaluator_prompt(goal, last_msg or "", iteration)
|
|
542
713
|
eval_msg, _ = run_evaluator(eval_prompt, workdir, eval_model)
|
|
@@ -556,6 +727,10 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
556
727
|
refresh("done", iteration)
|
|
557
728
|
time.sleep(0.8)
|
|
558
729
|
live.stop()
|
|
730
|
+
_last_session = {
|
|
731
|
+
"goal": goal, "history": history,
|
|
732
|
+
"eval_history": eval_history, "workdir": workdir,
|
|
733
|
+
}
|
|
559
734
|
|
|
560
735
|
# 완료 요약 출력
|
|
561
736
|
console.print()
|
|
@@ -569,10 +744,57 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
569
744
|
for line in val.splitlines():
|
|
570
745
|
console.print(f" [{color}]{line}[/{color}]")
|
|
571
746
|
console.print()
|
|
572
|
-
return 'done'
|
|
747
|
+
return _finish('done')
|
|
573
748
|
|
|
574
749
|
live.stop()
|
|
575
|
-
|
|
750
|
+
_last_session = {
|
|
751
|
+
"goal": goal, "history": history,
|
|
752
|
+
"eval_history": eval_history, "workdir": workdir,
|
|
753
|
+
}
|
|
754
|
+
return _finish('max')
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# ── Interrupt Handler ─────────────────────────────────────────────────────────
|
|
758
|
+
|
|
759
|
+
def _handle_interrupt(goal: str, workdir: str, args, max_iter: int) -> str:
|
|
760
|
+
"""
|
|
761
|
+
ESC 중단 후 목표 편집 프롬프트 표시.
|
|
762
|
+
새 목표로 루프를 재실행하고, 최종 목표 문자열 반환.
|
|
763
|
+
"""
|
|
764
|
+
console.print(
|
|
765
|
+
"\n[yellow]⚠ 중단됨.[/yellow] "
|
|
766
|
+
"목표를 수정하거나 Enter로 재실행, Ctrl+C로 REPL 복귀:"
|
|
767
|
+
)
|
|
768
|
+
try:
|
|
769
|
+
new_goal = pt_prompt(
|
|
770
|
+
HTML('<ansiyellow><b>목표 수정</b></ansiyellow> <ansiwhite>></ansiwhite> '),
|
|
771
|
+
default=goal,
|
|
772
|
+
style=PROMPT_STYLE,
|
|
773
|
+
).strip()
|
|
774
|
+
except (EOFError, KeyboardInterrupt):
|
|
775
|
+
console.print("[dim]취소됨. REPL로 돌아갑니다.[/dim]")
|
|
776
|
+
return goal
|
|
777
|
+
|
|
778
|
+
if not new_goal:
|
|
779
|
+
new_goal = goal # Enter만 누르면 동일 목표 재실행
|
|
780
|
+
|
|
781
|
+
layout2 = make_layout()
|
|
782
|
+
layout2["header"].update(render_header(new_goal, 0, max_iter, "idle"))
|
|
783
|
+
layout2["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
|
|
784
|
+
layout2["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
|
|
785
|
+
live2 = Live(layout2, refresh_per_second=8, screen=False)
|
|
786
|
+
|
|
787
|
+
# 목표가 바뀌면 히스토리 없이 fresh 실행
|
|
788
|
+
outcome = run_agent_loop(
|
|
789
|
+
new_goal, workdir, args.worker_model, args.eval_model,
|
|
790
|
+
max_iter, layout2, live2,
|
|
791
|
+
)
|
|
792
|
+
if outcome == 'interrupted':
|
|
793
|
+
return _handle_interrupt(new_goal, workdir, args, max_iter)
|
|
794
|
+
elif outcome == 'max':
|
|
795
|
+
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
796
|
+
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
797
|
+
return new_goal
|
|
576
798
|
|
|
577
799
|
|
|
578
800
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
@@ -582,6 +804,17 @@ def main():
|
|
|
582
804
|
prog="agentforge",
|
|
583
805
|
description="Worker + Evaluator 인터랙티브 멀티에이전트 CLI",
|
|
584
806
|
)
|
|
807
|
+
sub = parser.add_subparsers(dest="subcommand")
|
|
808
|
+
|
|
809
|
+
auth_parser = sub.add_parser("auth", help="인증 관리 (ChatGPT Plus/Pro)")
|
|
810
|
+
auth_sub = auth_parser.add_subparsers(dest="auth_cmd")
|
|
811
|
+
|
|
812
|
+
login_parser = auth_sub.add_parser("login", help="로그인")
|
|
813
|
+
login_parser.add_argument("--device", action="store_true",
|
|
814
|
+
help="SSH/헤드리스 환경용 device auth 사용")
|
|
815
|
+
auth_sub.add_parser("logout", help="로그아웃")
|
|
816
|
+
auth_sub.add_parser("status", help="현재 인증 상태 확인")
|
|
817
|
+
|
|
585
818
|
parser.add_argument("-d", "--dir", default=".", metavar="DIR",
|
|
586
819
|
help="작업 디렉토리 (기본: 현재 디렉토리)")
|
|
587
820
|
parser.add_argument("--worker-model", default=None, metavar="MODEL",
|
|
@@ -592,6 +825,21 @@ def main():
|
|
|
592
825
|
metavar="N", help=f"최대 반복 횟수 (기본: {DEFAULT_MAX_ITER})")
|
|
593
826
|
args = parser.parse_args()
|
|
594
827
|
|
|
828
|
+
# ── auth 서브커맨드 ────────────────────────────────────────────────
|
|
829
|
+
if args.subcommand == "auth":
|
|
830
|
+
if not CODEX_BIN.exists():
|
|
831
|
+
console.print(f"[red]Error: codex not found at {CODEX_BIN}[/red]")
|
|
832
|
+
sys.exit(1)
|
|
833
|
+
if args.auth_cmd == "login":
|
|
834
|
+
cmd_auth_login(device=getattr(args, "device", False))
|
|
835
|
+
elif args.auth_cmd == "logout":
|
|
836
|
+
cmd_auth_logout()
|
|
837
|
+
elif args.auth_cmd == "status":
|
|
838
|
+
cmd_auth_status()
|
|
839
|
+
else:
|
|
840
|
+
auth_parser.print_help()
|
|
841
|
+
return
|
|
842
|
+
|
|
595
843
|
if not CODEX_BIN.exists():
|
|
596
844
|
console.print(f"[bold red]Error:[/bold red] codex not found at {CODEX_BIN}")
|
|
597
845
|
sys.exit(1)
|
|
@@ -604,14 +852,31 @@ def main():
|
|
|
604
852
|
layout["header"].update(render_header("", 0, max_iter, "idle"))
|
|
605
853
|
layout["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
|
|
606
854
|
layout["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
live.start()
|
|
610
|
-
time.sleep(0.3)
|
|
611
|
-
live.stop()
|
|
855
|
+
with Live(layout, refresh_per_second=8, screen=False) as live:
|
|
856
|
+
time.sleep(0.3)
|
|
612
857
|
|
|
613
858
|
# ── REPL 루프 ─────────────────────────────────────────────────────
|
|
614
|
-
|
|
859
|
+
status = auth_status()
|
|
860
|
+
if not status or not status["has_token"]:
|
|
861
|
+
console.print(
|
|
862
|
+
"[yellow]⚠ 로그인되지 않았습니다.[/yellow] "
|
|
863
|
+
"지금 로그인하시겠습니까? ([bold]y[/bold]/n/device) ",
|
|
864
|
+
end="",
|
|
865
|
+
)
|
|
866
|
+
try:
|
|
867
|
+
ans = input().strip().lower()
|
|
868
|
+
except (EOFError, KeyboardInterrupt):
|
|
869
|
+
ans = "n"
|
|
870
|
+
if ans in ("y", ""):
|
|
871
|
+
cmd_auth_login(device=False)
|
|
872
|
+
elif ans == "device":
|
|
873
|
+
cmd_auth_login(device=True)
|
|
874
|
+
else:
|
|
875
|
+
console.print(
|
|
876
|
+
"[dim]로그인을 건너뜁니다. "
|
|
877
|
+
"agentforge auth login 으로 나중에 로그인할 수 있습니다.[/dim]"
|
|
878
|
+
)
|
|
879
|
+
|
|
615
880
|
console.print("[dim]명령을 입력하세요. /plan <목표> | /exit[/dim]")
|
|
616
881
|
|
|
617
882
|
_completer = SlashCompleter()
|
|
@@ -664,13 +929,47 @@ def main():
|
|
|
664
929
|
final_goal, workdir, args.worker_model, args.eval_model,
|
|
665
930
|
max_iter, layout2, live2,
|
|
666
931
|
)
|
|
667
|
-
|
|
932
|
+
goal = final_goal
|
|
933
|
+
if outcome == 'interrupted':
|
|
934
|
+
goal = _handle_interrupt(goal, workdir, args, max_iter)
|
|
935
|
+
elif outcome == 'max':
|
|
668
936
|
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
669
|
-
|
|
937
|
+
if outcome != 'interrupted':
|
|
938
|
+
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
939
|
+
|
|
940
|
+
elif cmd_name == 'resume':
|
|
941
|
+
if not _last_session:
|
|
942
|
+
console.print("[yellow]재개할 세션이 없습니다. 먼저 목표를 실행하세요.[/yellow]")
|
|
943
|
+
continue
|
|
944
|
+
s = _last_session
|
|
945
|
+
prev_iters = len(s["history"])
|
|
946
|
+
console.print(Rule("[cyan]세션 재개[/cyan]"))
|
|
947
|
+
console.print(f"[dim]목표:[/dim] {s['goal'][:80]}")
|
|
948
|
+
console.print(f"[dim]이전 반복:[/dim] {prev_iters}회 → 이어서 실행")
|
|
949
|
+
console.print()
|
|
950
|
+
layout2 = make_layout()
|
|
951
|
+
layout2["header"].update(render_header(s["goal"], prev_iters, max_iter, "idle"))
|
|
952
|
+
layout2["worker"].update(render_worker_panel(deque(), prev_iters, "idle", s["history"], False))
|
|
953
|
+
layout2["evaluator"].update(render_evaluator_panel(s["eval_history"], prev_iters, "idle", False))
|
|
954
|
+
live2 = Live(layout2, refresh_per_second=8, screen=False)
|
|
955
|
+
outcome = run_agent_loop(
|
|
956
|
+
s["goal"], s["workdir"],
|
|
957
|
+
args.worker_model, args.eval_model,
|
|
958
|
+
max_iter, layout2, live2,
|
|
959
|
+
initial_history=s["history"],
|
|
960
|
+
initial_eval_history=s["eval_history"],
|
|
961
|
+
)
|
|
962
|
+
goal = s["goal"]
|
|
963
|
+
if outcome == 'interrupted':
|
|
964
|
+
goal = _handle_interrupt(goal, workdir, args, max_iter)
|
|
965
|
+
elif outcome == 'max':
|
|
966
|
+
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
967
|
+
if outcome != 'interrupted':
|
|
968
|
+
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
670
969
|
|
|
671
970
|
else:
|
|
672
971
|
console.print(f"[red]알 수 없는 커맨드: /{cmd_name}[/red]")
|
|
673
|
-
console.print("[dim]사용 가능: /plan <목표> /exit[/dim]")
|
|
972
|
+
console.print("[dim]사용 가능: /plan <목표> /resume /exit[/dim]")
|
|
674
973
|
|
|
675
974
|
else:
|
|
676
975
|
# 일반 텍스트 → 바로 Worker에게 목표로 전달
|
|
@@ -684,9 +983,12 @@ def main():
|
|
|
684
983
|
goal, workdir, args.worker_model, args.eval_model,
|
|
685
984
|
max_iter, layout2, live2,
|
|
686
985
|
)
|
|
687
|
-
if outcome == '
|
|
986
|
+
if outcome == 'interrupted':
|
|
987
|
+
goal = _handle_interrupt(goal, workdir, args, max_iter)
|
|
988
|
+
elif outcome == 'max':
|
|
688
989
|
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
689
|
-
|
|
990
|
+
if outcome != 'interrupted':
|
|
991
|
+
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
690
992
|
|
|
691
993
|
|
|
692
994
|
if __name__ == "__main__":
|