agentforge-multi 0.1.0 → 0.1.1
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 -39
- 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,16 @@ def parse_decision(text: str) -> tuple[str, str]:
|
|
|
316
420
|
# ── TUI ───────────────────────────────────────────────────────────────────────
|
|
317
421
|
|
|
318
422
|
def make_layout() -> Layout:
|
|
423
|
+
try:
|
|
424
|
+
term_h = os.get_terminal_size().lines
|
|
425
|
+
except OSError:
|
|
426
|
+
term_h = 40
|
|
427
|
+
body_size = max(10, term_h - 5) # header(3) + 여백(2)
|
|
428
|
+
|
|
319
429
|
layout = Layout()
|
|
320
430
|
layout.split(
|
|
321
431
|
Layout(name="header", size=3),
|
|
322
|
-
Layout(name="body"),
|
|
432
|
+
Layout(name="body", size=body_size),
|
|
323
433
|
)
|
|
324
434
|
layout["body"].split_row(
|
|
325
435
|
Layout(name="worker"),
|
|
@@ -332,37 +442,57 @@ def render_header(goal: str, iteration: int, max_iter: int, phase: str) -> Panel
|
|
|
332
442
|
g = goal[:72] + "..." if len(goal) > 72 else goal
|
|
333
443
|
status = {
|
|
334
444
|
"idle": "[dim]명령 대기 중 /plan <목표> 또는 <목표> 입력[/dim]",
|
|
335
|
-
"worker": "[yellow]
|
|
336
|
-
"evaluator": "[
|
|
337
|
-
"planning": "[cyan]
|
|
338
|
-
"done": "[bold green]✓
|
|
339
|
-
"max": "[bold red]최대 반복 도달[/bold red]",
|
|
445
|
+
"worker": "⚙ [bold yellow on black] WORKER 실행 중 [/bold yellow on black] [dim]Evaluator 대기[/dim]",
|
|
446
|
+
"evaluator": "[dim]Worker 완료[/dim] → ◈ [bold magenta on black] EVALUATOR 평가 중 [/bold magenta on black]",
|
|
447
|
+
"planning": "◑ [bold cyan on black] PLAN AGENT 실행 중 [/bold cyan on black]",
|
|
448
|
+
"done": "[bold green]✓ 완료[/bold green]",
|
|
449
|
+
"max": "[bold red]⚠ 최대 반복 도달[/bold red]",
|
|
340
450
|
}.get(phase, phase)
|
|
341
451
|
iter_str = f" Iter [bold]{iteration}[/bold]/{max_iter}" if iteration > 0 else ""
|
|
342
452
|
content = f"[bold cyan]Goal:[/bold cyan] {g}\n{status}{iter_str}"
|
|
343
453
|
return Panel(content, border_style="cyan", box=box.HEAVY_HEAD)
|
|
344
454
|
|
|
345
455
|
|
|
456
|
+
_DECISION_BADGE = {
|
|
457
|
+
"DONE": "[green]✓[/green]",
|
|
458
|
+
"IMPROVE": "[yellow]~[/yellow]",
|
|
459
|
+
"REDIRECT": "[red]↩[/red]",
|
|
460
|
+
}
|
|
461
|
+
|
|
346
462
|
def render_worker_panel(buf: deque, iteration: int, status: str,
|
|
347
463
|
history: list, done: bool) -> Panel:
|
|
348
464
|
lines = []
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
465
|
+
# 오래된 기록 (직전 제외): summary 한 줄 + 배지
|
|
466
|
+
for h in history[:-1]:
|
|
467
|
+
badge = _DECISION_BADGE.get(h['decision'], '[dim]?[/dim]')
|
|
468
|
+
summary = (h['worker_summary'] or '')[:70]
|
|
469
|
+
lines.append(f"[dim]Iter {h['iter']}[/dim] {badge} [dim]{summary}[/dim]")
|
|
470
|
+
# 직전 기록: worker_lines 최대 8줄 (밝게)
|
|
471
|
+
if history:
|
|
472
|
+
h = history[-1]
|
|
473
|
+
lines.append(f"[bold]── Iter {h['iter']} ──────────────────────────────[/bold]")
|
|
474
|
+
for l in h.get('worker_lines', [])[-8:]:
|
|
475
|
+
lines.append(f"[white]{l}[/white]")
|
|
476
|
+
# 현재 스트리밍
|
|
353
477
|
if buf or status == "running":
|
|
354
478
|
if history:
|
|
355
|
-
lines.append(f"[yellow]── Iter {iteration}
|
|
479
|
+
lines.append(f"[yellow bold]── Iter {iteration} (실행 중) ──────────────[/yellow bold]")
|
|
356
480
|
for line in list(buf):
|
|
357
481
|
lines.append(line)
|
|
358
482
|
if status == "running":
|
|
359
483
|
lines.append("[yellow blink]▌[/yellow blink]")
|
|
360
484
|
border = "green" if done else "yellow"
|
|
361
|
-
|
|
485
|
+
is_active = (status == "running")
|
|
486
|
+
if done:
|
|
487
|
+
title = "[green bold]✓ WORKER AGENT[/green bold]"
|
|
488
|
+
elif is_active:
|
|
489
|
+
title = "[yellow bold]⚙ WORKER AGENT ◀ 실행 중[/yellow bold]"
|
|
490
|
+
else:
|
|
491
|
+
title = "[dim]⚙ WORKER AGENT[/dim]"
|
|
362
492
|
content = "\n".join(lines) if lines else "[dim]명령을 입력하면 작업을 시작합니다.[/dim]"
|
|
363
493
|
return Panel(
|
|
364
494
|
Text.from_markup(content),
|
|
365
|
-
title=
|
|
495
|
+
title=title,
|
|
366
496
|
border_style=border, box=box.ROUNDED, padding=(0, 1),
|
|
367
497
|
)
|
|
368
498
|
|
|
@@ -370,37 +500,50 @@ def render_worker_panel(buf: deque, iteration: int, status: str,
|
|
|
370
500
|
def render_evaluator_panel(eval_history: list, iteration: int,
|
|
371
501
|
phase: str, done: bool) -> Panel:
|
|
372
502
|
lines = []
|
|
373
|
-
|
|
503
|
+
# 오래된 기록 (최신 제외): 배지 + 요약 한 줄
|
|
504
|
+
for e in eval_history[:-1]:
|
|
505
|
+
d, fb = e['decision'], e['feedback']
|
|
506
|
+
badge = {"DONE": "[green]✓ DONE[/green]",
|
|
507
|
+
"IMPROVE": "[yellow]IMPROVE[/yellow]",
|
|
508
|
+
"REDIRECT": "[red]REDIRECT[/red]"}.get(d, d)
|
|
509
|
+
short_fb = fb[:65] + ('…' if len(fb) > 65 else '')
|
|
510
|
+
lines.append(f"[dim]Iter {e['iter']}[/dim] {badge} [dim]{short_fb}[/dim]")
|
|
511
|
+
# 최신 기록: 전체 피드백 표시
|
|
512
|
+
if eval_history:
|
|
513
|
+
e = eval_history[-1]
|
|
374
514
|
d, fb, fm = e['decision'], e['feedback'], e.get('full_msg', '')
|
|
375
|
-
lines.append(f"[
|
|
515
|
+
lines.append(f"[bold]── Iter {e['iter']} ──────────────────────────────[/bold]")
|
|
376
516
|
if d == 'DONE':
|
|
377
517
|
lines.append("[bold green]✓ DONE[/bold green]")
|
|
378
|
-
# 한국어 요약 섹션 추출
|
|
379
518
|
for section in ['판단 이유', '결과물 위치', '결과 요약']:
|
|
380
519
|
m = re.search(rf'{section}:\s*(.+?)(?=\n판단|결과물|결과 요약|$)', fm, re.S)
|
|
381
520
|
if m:
|
|
382
|
-
val = m.group(1).strip()[:
|
|
383
|
-
lines.append(f"[
|
|
521
|
+
val = m.group(1).strip()[:200]
|
|
522
|
+
lines.append(f"[cyan]{section}:[/cyan] [white]{val}[/white]")
|
|
384
523
|
elif d == 'IMPROVE':
|
|
385
|
-
lines.append("[bold yellow]IMPROVE[/bold yellow]")
|
|
386
|
-
|
|
387
|
-
lines.append(f"[yellow]{l}[/yellow]")
|
|
524
|
+
lines.append("[bold yellow]▲ IMPROVE[/bold yellow]")
|
|
525
|
+
lines.append(f"[yellow]{fb}[/yellow]")
|
|
388
526
|
elif d == 'REDIRECT':
|
|
389
|
-
lines.append("[bold red]REDIRECT[/bold red]")
|
|
390
|
-
|
|
391
|
-
lines.append(f"[red]{l}[/red]")
|
|
527
|
+
lines.append("[bold red]↩ REDIRECT[/bold red]")
|
|
528
|
+
lines.append(f"[red]{fb}[/red]")
|
|
392
529
|
lines.append("")
|
|
393
530
|
if phase == "evaluator":
|
|
394
|
-
lines.append(f"[magenta]── Iter {iteration}
|
|
531
|
+
lines.append(f"[magenta bold]── Iter {iteration} (평가 중) ──────────────[/magenta bold]")
|
|
395
532
|
lines.append("[magenta blink]평가 중...[/magenta blink]")
|
|
396
533
|
elif phase == "planning":
|
|
397
534
|
lines.append("[cyan blink]계획 수립 중...[/cyan blink]")
|
|
398
535
|
border = "green" if done else "magenta"
|
|
399
|
-
|
|
536
|
+
is_active = (phase == "evaluator")
|
|
537
|
+
if done:
|
|
538
|
+
title = "[green bold]✓ EVALUATOR AGENT[/green bold]"
|
|
539
|
+
elif is_active:
|
|
540
|
+
title = "[magenta bold]◈ EVALUATOR AGENT ◀ 평가 중[/magenta bold]"
|
|
541
|
+
else:
|
|
542
|
+
title = "[dim]◈ EVALUATOR AGENT[/dim]"
|
|
400
543
|
content = "\n".join(lines) if lines else "[dim]Worker 완료 후 평가를 시작합니다.[/dim]"
|
|
401
544
|
return Panel(
|
|
402
545
|
Text.from_markup(content),
|
|
403
|
-
title=
|
|
546
|
+
title=title,
|
|
404
547
|
border_style=border, box=box.ROUNDED, padding=(0, 1),
|
|
405
548
|
)
|
|
406
549
|
|
|
@@ -485,17 +628,30 @@ def run_plan_mode(raw_goal: str, workdir: str, model: str | None,
|
|
|
485
628
|
|
|
486
629
|
def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
487
630
|
eval_model: str | None, max_iter: int,
|
|
488
|
-
layout: Layout, live: Live
|
|
631
|
+
layout: Layout, live: Live,
|
|
632
|
+
initial_history: list | None = None,
|
|
633
|
+
initial_eval_history: list | None = None) -> str:
|
|
489
634
|
"""
|
|
490
635
|
Worker + Evaluator 반복 루프.
|
|
491
636
|
반환: 'done' | 'max'
|
|
492
637
|
"""
|
|
493
|
-
|
|
494
|
-
|
|
638
|
+
global _last_session
|
|
639
|
+
history = list(initial_history or [])
|
|
640
|
+
eval_history = list(initial_eval_history or [])
|
|
495
641
|
worker_buf = deque(maxlen=WORKER_BUF_LINES)
|
|
496
642
|
worker_status = ["idle"]
|
|
497
643
|
done = False
|
|
498
644
|
|
|
645
|
+
# ESC 리스너 시작
|
|
646
|
+
_interrupt_event.clear()
|
|
647
|
+
_esc_stop = threading.Event()
|
|
648
|
+
esc_t = threading.Thread(target=_esc_listener, args=(_esc_stop,), daemon=True)
|
|
649
|
+
esc_t.start()
|
|
650
|
+
|
|
651
|
+
def _finish(result: str) -> str:
|
|
652
|
+
_esc_stop.set()
|
|
653
|
+
return result
|
|
654
|
+
|
|
499
655
|
def refresh(phase: str, iteration: int):
|
|
500
656
|
layout["header"].update(render_header(goal, iteration, max_iter, phase))
|
|
501
657
|
layout["worker"].update(render_worker_panel(
|
|
@@ -521,11 +677,22 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
521
677
|
t = threading.Thread(target=_worker, daemon=True)
|
|
522
678
|
t.start()
|
|
523
679
|
while t.is_alive():
|
|
680
|
+
if _interrupt_event.is_set():
|
|
681
|
+
if _current_proc:
|
|
682
|
+
_current_proc.kill()
|
|
683
|
+
break
|
|
524
684
|
refresh("worker", iteration)
|
|
525
685
|
time.sleep(0.1)
|
|
526
686
|
t.join()
|
|
527
687
|
refresh("worker", iteration)
|
|
528
688
|
|
|
689
|
+
# ESC로 중단된 경우
|
|
690
|
+
if _interrupt_event.is_set():
|
|
691
|
+
live.stop()
|
|
692
|
+
_last_session = {"goal": goal, "history": history,
|
|
693
|
+
"eval_history": eval_history, "workdir": workdir}
|
|
694
|
+
return _finish('interrupted')
|
|
695
|
+
|
|
529
696
|
last_msg, _ = worker_result
|
|
530
697
|
worker_summary = (last_msg or "").replace('\n', ' ')[:300]
|
|
531
698
|
history.append({
|
|
@@ -537,6 +704,12 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
537
704
|
})
|
|
538
705
|
|
|
539
706
|
# ── Evaluator ────────────────────────────────────────────────────
|
|
707
|
+
if _interrupt_event.is_set():
|
|
708
|
+
live.stop()
|
|
709
|
+
_last_session = {"goal": goal, "history": history,
|
|
710
|
+
"eval_history": eval_history, "workdir": workdir}
|
|
711
|
+
return _finish('interrupted')
|
|
712
|
+
|
|
540
713
|
refresh("evaluator", iteration)
|
|
541
714
|
eval_prompt = build_evaluator_prompt(goal, last_msg or "", iteration)
|
|
542
715
|
eval_msg, _ = run_evaluator(eval_prompt, workdir, eval_model)
|
|
@@ -556,6 +729,10 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
556
729
|
refresh("done", iteration)
|
|
557
730
|
time.sleep(0.8)
|
|
558
731
|
live.stop()
|
|
732
|
+
_last_session = {
|
|
733
|
+
"goal": goal, "history": history,
|
|
734
|
+
"eval_history": eval_history, "workdir": workdir,
|
|
735
|
+
}
|
|
559
736
|
|
|
560
737
|
# 완료 요약 출력
|
|
561
738
|
console.print()
|
|
@@ -569,10 +746,57 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
569
746
|
for line in val.splitlines():
|
|
570
747
|
console.print(f" [{color}]{line}[/{color}]")
|
|
571
748
|
console.print()
|
|
572
|
-
return 'done'
|
|
749
|
+
return _finish('done')
|
|
573
750
|
|
|
574
751
|
live.stop()
|
|
575
|
-
|
|
752
|
+
_last_session = {
|
|
753
|
+
"goal": goal, "history": history,
|
|
754
|
+
"eval_history": eval_history, "workdir": workdir,
|
|
755
|
+
}
|
|
756
|
+
return _finish('max')
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# ── Interrupt Handler ─────────────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
def _handle_interrupt(goal: str, workdir: str, args, max_iter: int) -> str:
|
|
762
|
+
"""
|
|
763
|
+
ESC 중단 후 목표 편집 프롬프트 표시.
|
|
764
|
+
새 목표로 루프를 재실행하고, 최종 목표 문자열 반환.
|
|
765
|
+
"""
|
|
766
|
+
console.print(
|
|
767
|
+
"\n[yellow]⚠ 중단됨.[/yellow] "
|
|
768
|
+
"목표를 수정하거나 Enter로 재실행, Ctrl+C로 REPL 복귀:"
|
|
769
|
+
)
|
|
770
|
+
try:
|
|
771
|
+
new_goal = pt_prompt(
|
|
772
|
+
HTML('<ansiyellow><b>목표 수정</b></ansiyellow> <ansiwhite>></ansiwhite> '),
|
|
773
|
+
default=goal,
|
|
774
|
+
style=PROMPT_STYLE,
|
|
775
|
+
).strip()
|
|
776
|
+
except (EOFError, KeyboardInterrupt):
|
|
777
|
+
console.print("[dim]취소됨. REPL로 돌아갑니다.[/dim]")
|
|
778
|
+
return goal
|
|
779
|
+
|
|
780
|
+
if not new_goal:
|
|
781
|
+
new_goal = goal # Enter만 누르면 동일 목표 재실행
|
|
782
|
+
|
|
783
|
+
layout2 = make_layout()
|
|
784
|
+
layout2["header"].update(render_header(new_goal, 0, max_iter, "idle"))
|
|
785
|
+
layout2["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
|
|
786
|
+
layout2["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
|
|
787
|
+
live2 = Live(layout2, refresh_per_second=8, screen=False)
|
|
788
|
+
|
|
789
|
+
# 목표가 바뀌면 히스토리 없이 fresh 실행
|
|
790
|
+
outcome = run_agent_loop(
|
|
791
|
+
new_goal, workdir, args.worker_model, args.eval_model,
|
|
792
|
+
max_iter, layout2, live2,
|
|
793
|
+
)
|
|
794
|
+
if outcome == 'interrupted':
|
|
795
|
+
return _handle_interrupt(new_goal, workdir, args, max_iter)
|
|
796
|
+
elif outcome == 'max':
|
|
797
|
+
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
798
|
+
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
799
|
+
return new_goal
|
|
576
800
|
|
|
577
801
|
|
|
578
802
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
@@ -582,6 +806,17 @@ def main():
|
|
|
582
806
|
prog="agentforge",
|
|
583
807
|
description="Worker + Evaluator 인터랙티브 멀티에이전트 CLI",
|
|
584
808
|
)
|
|
809
|
+
sub = parser.add_subparsers(dest="subcommand")
|
|
810
|
+
|
|
811
|
+
auth_parser = sub.add_parser("auth", help="인증 관리 (ChatGPT Plus/Pro)")
|
|
812
|
+
auth_sub = auth_parser.add_subparsers(dest="auth_cmd")
|
|
813
|
+
|
|
814
|
+
login_parser = auth_sub.add_parser("login", help="로그인")
|
|
815
|
+
login_parser.add_argument("--device", action="store_true",
|
|
816
|
+
help="SSH/헤드리스 환경용 device auth 사용")
|
|
817
|
+
auth_sub.add_parser("logout", help="로그아웃")
|
|
818
|
+
auth_sub.add_parser("status", help="현재 인증 상태 확인")
|
|
819
|
+
|
|
585
820
|
parser.add_argument("-d", "--dir", default=".", metavar="DIR",
|
|
586
821
|
help="작업 디렉토리 (기본: 현재 디렉토리)")
|
|
587
822
|
parser.add_argument("--worker-model", default=None, metavar="MODEL",
|
|
@@ -592,6 +827,21 @@ def main():
|
|
|
592
827
|
metavar="N", help=f"최대 반복 횟수 (기본: {DEFAULT_MAX_ITER})")
|
|
593
828
|
args = parser.parse_args()
|
|
594
829
|
|
|
830
|
+
# ── auth 서브커맨드 ────────────────────────────────────────────────
|
|
831
|
+
if args.subcommand == "auth":
|
|
832
|
+
if not CODEX_BIN.exists():
|
|
833
|
+
console.print(f"[red]Error: codex not found at {CODEX_BIN}[/red]")
|
|
834
|
+
sys.exit(1)
|
|
835
|
+
if args.auth_cmd == "login":
|
|
836
|
+
cmd_auth_login(device=getattr(args, "device", False))
|
|
837
|
+
elif args.auth_cmd == "logout":
|
|
838
|
+
cmd_auth_logout()
|
|
839
|
+
elif args.auth_cmd == "status":
|
|
840
|
+
cmd_auth_status()
|
|
841
|
+
else:
|
|
842
|
+
auth_parser.print_help()
|
|
843
|
+
return
|
|
844
|
+
|
|
595
845
|
if not CODEX_BIN.exists():
|
|
596
846
|
console.print(f"[bold red]Error:[/bold red] codex not found at {CODEX_BIN}")
|
|
597
847
|
sys.exit(1)
|
|
@@ -611,6 +861,27 @@ def main():
|
|
|
611
861
|
live.stop()
|
|
612
862
|
|
|
613
863
|
# ── REPL 루프 ─────────────────────────────────────────────────────
|
|
864
|
+
status = auth_status()
|
|
865
|
+
if not status or not status["has_token"]:
|
|
866
|
+
console.print(
|
|
867
|
+
"[yellow]⚠ 로그인되지 않았습니다.[/yellow] "
|
|
868
|
+
"지금 로그인하시겠습니까? ([bold]y[/bold]/n/device) ",
|
|
869
|
+
end="",
|
|
870
|
+
)
|
|
871
|
+
try:
|
|
872
|
+
ans = input().strip().lower()
|
|
873
|
+
except (EOFError, KeyboardInterrupt):
|
|
874
|
+
ans = "n"
|
|
875
|
+
if ans in ("y", ""):
|
|
876
|
+
cmd_auth_login(device=False)
|
|
877
|
+
elif ans == "device":
|
|
878
|
+
cmd_auth_login(device=True)
|
|
879
|
+
else:
|
|
880
|
+
console.print(
|
|
881
|
+
"[dim]로그인을 건너뜁니다. "
|
|
882
|
+
"agentforge auth login 으로 나중에 로그인할 수 있습니다.[/dim]"
|
|
883
|
+
)
|
|
884
|
+
|
|
614
885
|
console.print()
|
|
615
886
|
console.print("[dim]명령을 입력하세요. /plan <목표> | /exit[/dim]")
|
|
616
887
|
|
|
@@ -664,13 +935,47 @@ def main():
|
|
|
664
935
|
final_goal, workdir, args.worker_model, args.eval_model,
|
|
665
936
|
max_iter, layout2, live2,
|
|
666
937
|
)
|
|
667
|
-
|
|
938
|
+
goal = final_goal
|
|
939
|
+
if outcome == 'interrupted':
|
|
940
|
+
goal = _handle_interrupt(goal, workdir, args, max_iter)
|
|
941
|
+
elif outcome == 'max':
|
|
668
942
|
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
669
|
-
|
|
943
|
+
if outcome != 'interrupted':
|
|
944
|
+
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
945
|
+
|
|
946
|
+
elif cmd_name == 'resume':
|
|
947
|
+
if not _last_session:
|
|
948
|
+
console.print("[yellow]재개할 세션이 없습니다. 먼저 목표를 실행하세요.[/yellow]")
|
|
949
|
+
continue
|
|
950
|
+
s = _last_session
|
|
951
|
+
prev_iters = len(s["history"])
|
|
952
|
+
console.print(Rule("[cyan]세션 재개[/cyan]"))
|
|
953
|
+
console.print(f"[dim]목표:[/dim] {s['goal'][:80]}")
|
|
954
|
+
console.print(f"[dim]이전 반복:[/dim] {prev_iters}회 → 이어서 실행")
|
|
955
|
+
console.print()
|
|
956
|
+
layout2 = make_layout()
|
|
957
|
+
layout2["header"].update(render_header(s["goal"], prev_iters, max_iter, "idle"))
|
|
958
|
+
layout2["worker"].update(render_worker_panel(deque(), prev_iters, "idle", s["history"], False))
|
|
959
|
+
layout2["evaluator"].update(render_evaluator_panel(s["eval_history"], prev_iters, "idle", False))
|
|
960
|
+
live2 = Live(layout2, refresh_per_second=8, screen=False)
|
|
961
|
+
outcome = run_agent_loop(
|
|
962
|
+
s["goal"], s["workdir"],
|
|
963
|
+
args.worker_model, args.eval_model,
|
|
964
|
+
max_iter, layout2, live2,
|
|
965
|
+
initial_history=s["history"],
|
|
966
|
+
initial_eval_history=s["eval_history"],
|
|
967
|
+
)
|
|
968
|
+
goal = s["goal"]
|
|
969
|
+
if outcome == 'interrupted':
|
|
970
|
+
goal = _handle_interrupt(goal, workdir, args, max_iter)
|
|
971
|
+
elif outcome == 'max':
|
|
972
|
+
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
973
|
+
if outcome != 'interrupted':
|
|
974
|
+
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
670
975
|
|
|
671
976
|
else:
|
|
672
977
|
console.print(f"[red]알 수 없는 커맨드: /{cmd_name}[/red]")
|
|
673
|
-
console.print("[dim]사용 가능: /plan <목표> /exit[/dim]")
|
|
978
|
+
console.print("[dim]사용 가능: /plan <목표> /resume /exit[/dim]")
|
|
674
979
|
|
|
675
980
|
else:
|
|
676
981
|
# 일반 텍스트 → 바로 Worker에게 목표로 전달
|
|
@@ -684,9 +989,12 @@ def main():
|
|
|
684
989
|
goal, workdir, args.worker_model, args.eval_model,
|
|
685
990
|
max_iter, layout2, live2,
|
|
686
991
|
)
|
|
687
|
-
if outcome == '
|
|
992
|
+
if outcome == 'interrupted':
|
|
993
|
+
goal = _handle_interrupt(goal, workdir, args, max_iter)
|
|
994
|
+
elif outcome == 'max':
|
|
688
995
|
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
689
|
-
|
|
996
|
+
if outcome != 'interrupted':
|
|
997
|
+
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
690
998
|
|
|
691
999
|
|
|
692
1000
|
if __name__ == "__main__":
|