agentforge-multi 0.1.1 → 0.1.3
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 +278 -254
- package/package.json +1 -1
package/agentforge
CHANGED
|
@@ -7,7 +7,7 @@ Usage:
|
|
|
7
7
|
|
|
8
8
|
Slash commands:
|
|
9
9
|
/exit 종료
|
|
10
|
-
/
|
|
10
|
+
/resume 마지막 세션 재개
|
|
11
11
|
<일반 텍스트> Worker에게 즉시 전달 (목표 설정)
|
|
12
12
|
"""
|
|
13
13
|
|
|
@@ -18,7 +18,6 @@ import re
|
|
|
18
18
|
import select
|
|
19
19
|
import subprocess
|
|
20
20
|
import sys
|
|
21
|
-
import tempfile
|
|
22
21
|
import termios
|
|
23
22
|
import textwrap
|
|
24
23
|
import threading
|
|
@@ -27,6 +26,8 @@ import tty
|
|
|
27
26
|
from collections import deque
|
|
28
27
|
from pathlib import Path
|
|
29
28
|
|
|
29
|
+
import requests as _requests
|
|
30
|
+
|
|
30
31
|
from rich.console import Console
|
|
31
32
|
from rich.layout import Layout
|
|
32
33
|
from rich.live import Live
|
|
@@ -45,6 +46,9 @@ from prompt_toolkit.formatted_text import HTML
|
|
|
45
46
|
CODEX_BIN = Path.home() / ".npm-global" / "bin" / "codex"
|
|
46
47
|
DEFAULT_MAX_ITER = 5000
|
|
47
48
|
WORKER_BUF_LINES = 60
|
|
49
|
+
DEFAULT_WORKER_MODEL = "gpt-5.4"
|
|
50
|
+
DEFAULT_EVAL_MODEL = "gpt-5.1-codex-mini"
|
|
51
|
+
CHATGPT_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
|
48
52
|
ANSI_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07')
|
|
49
53
|
DECISION_RE = re.compile(r'^(DONE|IMPROVE|REDIRECT)(?::\s*(.*))?$', re.I | re.M)
|
|
50
54
|
NOISE_RE = re.compile(r'^\s*([\-─═\s]+)?$')
|
|
@@ -52,12 +56,151 @@ NOISE_RE = re.compile(r'^\s*([\-─═\s]+)?$')
|
|
|
52
56
|
console = Console()
|
|
53
57
|
_last_session: dict | None = None # {goal, history, eval_history, workdir}
|
|
54
58
|
_interrupt_event = threading.Event() # ESC 감지 플래그
|
|
55
|
-
|
|
59
|
+
|
|
60
|
+
# ── ChatGPT backend API ────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def _get_auth_headers() -> dict:
|
|
63
|
+
"""~/.codex/auth.json 에서 Bearer 헤더 + ChatGPT-Account-Id 반환."""
|
|
64
|
+
auth_file = Path.home() / ".codex" / "auth.json"
|
|
65
|
+
data = json.loads(auth_file.read_text())
|
|
66
|
+
token = data["tokens"]["access_token"]
|
|
67
|
+
account_id = data["tokens"].get("account_id", "")
|
|
68
|
+
return {
|
|
69
|
+
"Authorization": f"Bearer {token}",
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"ChatGPT-Account-Id": account_id,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
WORKER_TOOLS = [
|
|
76
|
+
{
|
|
77
|
+
"type": "function",
|
|
78
|
+
"name": "shell",
|
|
79
|
+
"description": "Execute a shell command. Use for running scripts, compiling, testing, installing packages, etc.",
|
|
80
|
+
"parameters": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"properties": {
|
|
83
|
+
"command": {"type": "string", "description": "Shell command to run"},
|
|
84
|
+
},
|
|
85
|
+
"required": ["command"],
|
|
86
|
+
},
|
|
87
|
+
"strict": False,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"type": "function",
|
|
91
|
+
"name": "read_file",
|
|
92
|
+
"description": "Read the full contents of a file",
|
|
93
|
+
"parameters": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"properties": {
|
|
96
|
+
"path": {"type": "string"},
|
|
97
|
+
},
|
|
98
|
+
"required": ["path"],
|
|
99
|
+
},
|
|
100
|
+
"strict": False,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"type": "function",
|
|
104
|
+
"name": "write_file",
|
|
105
|
+
"description": "Write content to a file (creates or overwrites)",
|
|
106
|
+
"parameters": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"properties": {
|
|
109
|
+
"path": {"type": "string"},
|
|
110
|
+
"content": {"type": "string"},
|
|
111
|
+
},
|
|
112
|
+
"required": ["path", "content"],
|
|
113
|
+
},
|
|
114
|
+
"strict": False,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"type": "function",
|
|
118
|
+
"name": "list_files",
|
|
119
|
+
"description": "List files and directories at a path",
|
|
120
|
+
"parameters": {
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"path": {"type": "string"},
|
|
124
|
+
},
|
|
125
|
+
"required": ["path"],
|
|
126
|
+
},
|
|
127
|
+
"strict": False,
|
|
128
|
+
},
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _stream_response(payload: dict) -> list:
|
|
133
|
+
"""
|
|
134
|
+
ChatGPT backend-api/codex/responses 스트리밍 호출.
|
|
135
|
+
SSE 이벤트를 파싱하여 (event_type, data_dict) 리스트 반환.
|
|
136
|
+
_interrupt_event가 set되면 조기 종료.
|
|
137
|
+
"""
|
|
138
|
+
headers = _get_auth_headers()
|
|
139
|
+
events = []
|
|
140
|
+
try:
|
|
141
|
+
r = _requests.post(
|
|
142
|
+
CHATGPT_RESPONSES_URL, headers=headers,
|
|
143
|
+
json=payload, stream=True, timeout=300,
|
|
144
|
+
)
|
|
145
|
+
r.raise_for_status()
|
|
146
|
+
for line in r.iter_lines():
|
|
147
|
+
if _interrupt_event.is_set():
|
|
148
|
+
break
|
|
149
|
+
if not line:
|
|
150
|
+
continue
|
|
151
|
+
decoded = line.decode("utf-8", errors="replace")
|
|
152
|
+
if decoded.startswith("data: "):
|
|
153
|
+
try:
|
|
154
|
+
ev = json.loads(decoded[6:])
|
|
155
|
+
events.append(ev)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
except Exception as e:
|
|
159
|
+
events.append({"type": "_error", "message": str(e)})
|
|
160
|
+
return events
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _execute_tool(name: str, args: dict, workdir: str) -> str:
|
|
164
|
+
"""Worker 도구 실행."""
|
|
165
|
+
try:
|
|
166
|
+
if name == "shell":
|
|
167
|
+
result = subprocess.run(
|
|
168
|
+
args["command"], shell=True,
|
|
169
|
+
capture_output=True, text=True, errors='replace',
|
|
170
|
+
cwd=workdir, timeout=120,
|
|
171
|
+
)
|
|
172
|
+
out = (result.stdout + result.stderr).strip()
|
|
173
|
+
return (out[:6000] if out else "(no output)") + (
|
|
174
|
+
f"\n[exit code: {result.returncode}]" if result.returncode != 0 else ""
|
|
175
|
+
)
|
|
176
|
+
elif name == "read_file":
|
|
177
|
+
path = Path(args["path"])
|
|
178
|
+
if not path.is_absolute():
|
|
179
|
+
path = Path(workdir) / path
|
|
180
|
+
return path.read_text(errors='replace')[:10000]
|
|
181
|
+
elif name == "write_file":
|
|
182
|
+
path = Path(args["path"])
|
|
183
|
+
if not path.is_absolute():
|
|
184
|
+
path = Path(workdir) / path
|
|
185
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
path.write_text(args["content"])
|
|
187
|
+
return f"Written: {path}"
|
|
188
|
+
elif name == "list_files":
|
|
189
|
+
path = Path(args["path"])
|
|
190
|
+
if not path.is_absolute():
|
|
191
|
+
path = Path(workdir) / path
|
|
192
|
+
items = sorted(path.iterdir())
|
|
193
|
+
return "\n".join(
|
|
194
|
+
("dir " if p.is_dir() else "file ") + p.name for p in items
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
return f"Unknown tool: {name}"
|
|
198
|
+
except Exception as e:
|
|
199
|
+
return f"Error: {e}"
|
|
56
200
|
|
|
57
201
|
# ── Slash command autocomplete ────────────────────────────────────────────────
|
|
58
202
|
|
|
59
203
|
SLASH_COMMANDS = [
|
|
60
|
-
("/plan", "계획을 수립한 뒤 Worker + Evaluator 루프 실행"),
|
|
61
204
|
("/resume", "마지막 세션을 이어서 실행"),
|
|
62
205
|
("/exit", "agentforge 종료"),
|
|
63
206
|
]
|
|
@@ -153,24 +296,6 @@ EVALUATOR_SYSTEM = textwrap.dedent("""\
|
|
|
153
296
|
Do NOT write anything before the decision keyword.
|
|
154
297
|
""").strip()
|
|
155
298
|
|
|
156
|
-
PLAN_SYSTEM = textwrap.dedent("""\
|
|
157
|
-
You are a planning agent. DO NOT write any code or modify any files.
|
|
158
|
-
Your job is to create a detailed implementation plan in Korean.
|
|
159
|
-
|
|
160
|
-
If anything is unclear, list your questions first in this format:
|
|
161
|
-
질문:
|
|
162
|
-
1. ...
|
|
163
|
-
2. ...
|
|
164
|
-
|
|
165
|
-
If everything is clear, output the plan directly:
|
|
166
|
-
계획:
|
|
167
|
-
- ...
|
|
168
|
-
- ...
|
|
169
|
-
|
|
170
|
-
Be specific and actionable. No code, only plan.
|
|
171
|
-
""").strip()
|
|
172
|
-
|
|
173
|
-
|
|
174
299
|
def build_worker_prompt(goal: str, history: list) -> str:
|
|
175
300
|
lines = [f"GOAL: {goal}", ""]
|
|
176
301
|
if not history:
|
|
@@ -208,18 +333,6 @@ def build_evaluator_prompt(goal: str, worker_output: str, iteration: int) -> str
|
|
|
208
333
|
])
|
|
209
334
|
|
|
210
335
|
|
|
211
|
-
def build_plan_prompt(goal: str, qa_history: list) -> str:
|
|
212
|
-
lines = [PLAN_SYSTEM, "", f"목표: {goal}"]
|
|
213
|
-
if qa_history:
|
|
214
|
-
lines.append("")
|
|
215
|
-
lines.append("이전 질의응답:")
|
|
216
|
-
for qa in qa_history:
|
|
217
|
-
lines.append(f" Q: {qa['q']}")
|
|
218
|
-
lines.append(f" A: {qa['a']}")
|
|
219
|
-
lines.append("")
|
|
220
|
-
lines.append("위 정보를 바탕으로 계획을 수립하거나 질문을 출력하라.")
|
|
221
|
-
return "\n".join(lines)
|
|
222
|
-
|
|
223
336
|
|
|
224
337
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
225
338
|
|
|
@@ -290,17 +403,21 @@ def cmd_auth_status():
|
|
|
290
403
|
# ── ESC Listener ──────────────────────────────────────────────────────────────
|
|
291
404
|
|
|
292
405
|
def _esc_listener(stop: threading.Event):
|
|
293
|
-
"""데몬 스레드:
|
|
406
|
+
"""데몬 스레드: /dev/tty 직접 열어 ESC 키 감지."""
|
|
294
407
|
try:
|
|
295
|
-
|
|
296
|
-
old = termios.tcgetattr(fd)
|
|
408
|
+
tty_fd = os.open('/dev/tty', os.O_RDONLY)
|
|
297
409
|
except Exception:
|
|
298
410
|
return
|
|
299
411
|
try:
|
|
300
|
-
|
|
412
|
+
old = termios.tcgetattr(tty_fd)
|
|
413
|
+
new = list(old)
|
|
414
|
+
new[3] &= ~(termios.ICANON | termios.ECHO)
|
|
415
|
+
new[6][termios.VMIN] = 1
|
|
416
|
+
new[6][termios.VTIME] = 0
|
|
417
|
+
termios.tcsetattr(tty_fd, termios.TCSANOW, new)
|
|
301
418
|
while not stop.is_set():
|
|
302
|
-
if select.select([
|
|
303
|
-
ch = os.read(
|
|
419
|
+
if select.select([tty_fd], [], [], 0.1)[0]:
|
|
420
|
+
ch = os.read(tty_fd, 1)
|
|
304
421
|
if ch == b'\x1b':
|
|
305
422
|
_interrupt_event.set()
|
|
306
423
|
break
|
|
@@ -308,103 +425,132 @@ def _esc_listener(stop: threading.Event):
|
|
|
308
425
|
pass
|
|
309
426
|
finally:
|
|
310
427
|
try:
|
|
311
|
-
termios.tcsetattr(
|
|
428
|
+
termios.tcsetattr(tty_fd, termios.TCSADRAIN, old)
|
|
429
|
+
os.close(tty_fd)
|
|
312
430
|
except Exception:
|
|
313
431
|
pass
|
|
314
432
|
|
|
315
433
|
|
|
316
434
|
# ── Agent Runners ─────────────────────────────────────────────────────────────
|
|
317
435
|
|
|
318
|
-
def
|
|
319
|
-
status_ref: list
|
|
320
|
-
"""
|
|
321
|
-
|
|
322
|
-
|
|
436
|
+
def run_worker(prompt: str, workdir: str, model: str | None,
|
|
437
|
+
buf: deque, status_ref: list) -> tuple[str, int]:
|
|
438
|
+
"""Worker: ChatGPT backend Responses API 직접 호출 + 도구 실행 루프."""
|
|
439
|
+
model = model or DEFAULT_WORKER_MODEL
|
|
440
|
+
status_ref[0] = "running"
|
|
441
|
+
|
|
442
|
+
# 입력 히스토리 (user msg + function_call + function_call_output 누적)
|
|
443
|
+
input_history: list[dict] = [{"role": "user", "content": prompt}]
|
|
444
|
+
all_text_parts: list[str] = []
|
|
445
|
+
line_buf = ""
|
|
446
|
+
|
|
447
|
+
def _flush_line(text: str):
|
|
448
|
+
nonlocal line_buf
|
|
449
|
+
line_buf += text
|
|
450
|
+
while '\n' in line_buf:
|
|
451
|
+
ln, line_buf = line_buf.split('\n', 1)
|
|
452
|
+
if ln.strip() and not NOISE_RE.match(ln):
|
|
453
|
+
buf.append(colorize_line(ln))
|
|
454
|
+
|
|
455
|
+
for _round in range(30):
|
|
456
|
+
if _interrupt_event.is_set():
|
|
457
|
+
break
|
|
323
458
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
459
|
+
payload = {
|
|
460
|
+
"model": model,
|
|
461
|
+
"instructions": WORKER_SYSTEM,
|
|
462
|
+
"input": input_history,
|
|
463
|
+
"tools": WORKER_TOOLS,
|
|
464
|
+
"store": False,
|
|
465
|
+
"stream": True,
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
events = _stream_response(payload)
|
|
469
|
+
|
|
470
|
+
# 에러 처리
|
|
471
|
+
for ev in events:
|
|
472
|
+
if ev["type"] == "_error":
|
|
473
|
+
buf.append(f"[red]API 오류: {ev['message']}[/red]")
|
|
474
|
+
status_ref[0] = "error"
|
|
475
|
+
if line_buf.strip():
|
|
476
|
+
buf.append(colorize_line(line_buf))
|
|
477
|
+
return "\n".join(all_text_parts), 1
|
|
478
|
+
|
|
479
|
+
# 텍스트 + 도구 호출 수집
|
|
480
|
+
text_parts: list[str] = []
|
|
481
|
+
fc_items: list[dict] = [] # {call_id, name, arguments}
|
|
482
|
+
|
|
483
|
+
for ev in events:
|
|
484
|
+
t = ev["type"]
|
|
485
|
+
if t == "response.output_text.delta":
|
|
486
|
+
delta = ev.get("delta", "")
|
|
487
|
+
text_parts.append(delta)
|
|
488
|
+
_flush_line(delta)
|
|
489
|
+
elif t == "response.output_item.done" and ev.get("item", {}).get("type") == "function_call":
|
|
490
|
+
item = ev["item"]
|
|
491
|
+
fc_items.append({
|
|
492
|
+
"call_id": item["call_id"],
|
|
493
|
+
"name": item["name"],
|
|
494
|
+
"arguments": item["arguments"],
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
round_text = "".join(text_parts)
|
|
498
|
+
if round_text:
|
|
499
|
+
all_text_parts.append(round_text)
|
|
500
|
+
|
|
501
|
+
# 도구 호출 없으면 종료
|
|
502
|
+
if not fc_items:
|
|
503
|
+
break
|
|
357
504
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
except Exception:
|
|
368
|
-
pass
|
|
505
|
+
# 도구 실행 및 히스토리에 추가
|
|
506
|
+
for fc in fc_items:
|
|
507
|
+
call_id = fc["call_id"]
|
|
508
|
+
name = fc["name"]
|
|
509
|
+
raw_args = fc["arguments"]
|
|
510
|
+
try:
|
|
511
|
+
args = json.loads(raw_args)
|
|
512
|
+
except Exception:
|
|
513
|
+
args = {}
|
|
369
514
|
|
|
370
|
-
|
|
515
|
+
arg_preview = raw_args[:80]
|
|
516
|
+
buf.append(f"[cyan]▶ {name}({arg_preview})[/cyan]")
|
|
517
|
+
result = _execute_tool(name, args, workdir)
|
|
518
|
+
short = result[:300].replace('\n', ' ')
|
|
519
|
+
buf.append(f"[dim]{short}[/dim]")
|
|
371
520
|
|
|
521
|
+
# Responses API 형식 히스토리
|
|
522
|
+
input_history.append({"type": "function_call", "call_id": call_id,
|
|
523
|
+
"name": name, "arguments": raw_args})
|
|
524
|
+
input_history.append({"type": "function_call_output", "call_id": call_id,
|
|
525
|
+
"output": result})
|
|
372
526
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
]
|
|
380
|
-
if model:
|
|
381
|
-
cmd += ["-m", model]
|
|
382
|
-
cmd.append(prompt)
|
|
383
|
-
return _run_codex(cmd, workdir, buf, status_ref)
|
|
527
|
+
# 잔여 line_buf flush
|
|
528
|
+
if line_buf.strip():
|
|
529
|
+
buf.append(colorize_line(line_buf))
|
|
530
|
+
|
|
531
|
+
status_ref[0] = "done"
|
|
532
|
+
return "\n".join(all_text_parts), 0
|
|
384
533
|
|
|
385
534
|
|
|
386
535
|
def run_evaluator(prompt: str, workdir: str, model: str | None) -> tuple[str, int]:
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
"
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
"
|
|
402
|
-
"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
cmd += ["-m", model]
|
|
406
|
-
cmd.append(prompt)
|
|
407
|
-
return _run_codex(cmd, workdir, None, None)
|
|
536
|
+
"""Evaluator: ChatGPT backend Responses API, 도구 없이 텍스트만."""
|
|
537
|
+
model = model or DEFAULT_EVAL_MODEL
|
|
538
|
+
payload = {
|
|
539
|
+
"model": model,
|
|
540
|
+
"instructions": EVALUATOR_SYSTEM,
|
|
541
|
+
"input": [{"role": "user", "content": prompt}],
|
|
542
|
+
"store": False,
|
|
543
|
+
"stream": True,
|
|
544
|
+
}
|
|
545
|
+
events = _stream_response(payload)
|
|
546
|
+
for ev in events:
|
|
547
|
+
if ev["type"] == "_error":
|
|
548
|
+
return f"[Evaluator error] {ev['message']}", 1
|
|
549
|
+
text = "".join(
|
|
550
|
+
ev.get("delta", "") for ev in events
|
|
551
|
+
if ev["type"] == "response.output_text.delta"
|
|
552
|
+
)
|
|
553
|
+
return text, 0
|
|
408
554
|
|
|
409
555
|
|
|
410
556
|
def parse_decision(text: str) -> tuple[str, str]:
|
|
@@ -420,11 +566,9 @@ def parse_decision(text: str) -> tuple[str, str]:
|
|
|
420
566
|
# ── TUI ───────────────────────────────────────────────────────────────────────
|
|
421
567
|
|
|
422
568
|
def make_layout() -> Layout:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
term_h = 40
|
|
427
|
-
body_size = max(10, term_h - 5) # header(3) + 여백(2)
|
|
569
|
+
import shutil
|
|
570
|
+
term_h = shutil.get_terminal_size(fallback=(80, 40)).lines
|
|
571
|
+
body_size = max(10, term_h - 8) # header(3) + 여백(5)
|
|
428
572
|
|
|
429
573
|
layout = Layout()
|
|
430
574
|
layout.split(
|
|
@@ -441,10 +585,9 @@ def make_layout() -> Layout:
|
|
|
441
585
|
def render_header(goal: str, iteration: int, max_iter: int, phase: str) -> Panel:
|
|
442
586
|
g = goal[:72] + "..." if len(goal) > 72 else goal
|
|
443
587
|
status = {
|
|
444
|
-
"idle": "[dim]명령 대기 중
|
|
588
|
+
"idle": "[dim]명령 대기 중 목표를 입력하거나 /resume /exit[/dim]",
|
|
445
589
|
"worker": "⚙ [bold yellow on black] WORKER 실행 중 [/bold yellow on black] [dim]Evaluator 대기[/dim]",
|
|
446
590
|
"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
591
|
"done": "[bold green]✓ 완료[/bold green]",
|
|
449
592
|
"max": "[bold red]⚠ 최대 반복 도달[/bold red]",
|
|
450
593
|
}.get(phase, phase)
|
|
@@ -530,8 +673,6 @@ def render_evaluator_panel(eval_history: list, iteration: int,
|
|
|
530
673
|
if phase == "evaluator":
|
|
531
674
|
lines.append(f"[magenta bold]── Iter {iteration} (평가 중) ──────────────[/magenta bold]")
|
|
532
675
|
lines.append("[magenta blink]평가 중...[/magenta blink]")
|
|
533
|
-
elif phase == "planning":
|
|
534
|
-
lines.append("[cyan blink]계획 수립 중...[/cyan blink]")
|
|
535
676
|
border = "green" if done else "magenta"
|
|
536
677
|
is_active = (phase == "evaluator")
|
|
537
678
|
if done:
|
|
@@ -548,82 +689,6 @@ def render_evaluator_panel(eval_history: list, iteration: int,
|
|
|
548
689
|
)
|
|
549
690
|
|
|
550
691
|
|
|
551
|
-
# ── Plan Mode ─────────────────────────────────────────────────────────────────
|
|
552
|
-
|
|
553
|
-
def run_plan_mode(raw_goal: str, workdir: str, model: str | None,
|
|
554
|
-
layout: Layout, live: Live) -> str | None:
|
|
555
|
-
"""
|
|
556
|
-
/plan 모드: Plan Agent와 대화 후 최종 계획(목표 문자열)을 반환.
|
|
557
|
-
취소 시 None 반환.
|
|
558
|
-
"""
|
|
559
|
-
goal = raw_goal.strip()
|
|
560
|
-
if not goal:
|
|
561
|
-
live.stop()
|
|
562
|
-
try:
|
|
563
|
-
goal = input("계획할 목표를 입력하세요: ").strip()
|
|
564
|
-
except (EOFError, KeyboardInterrupt):
|
|
565
|
-
return None
|
|
566
|
-
if not goal:
|
|
567
|
-
return None
|
|
568
|
-
|
|
569
|
-
qa_history = []
|
|
570
|
-
|
|
571
|
-
while True:
|
|
572
|
-
# Plan Agent 실행
|
|
573
|
-
layout["header"].update(render_header(goal, 0, 0, "planning"))
|
|
574
|
-
layout["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
|
|
575
|
-
layout["evaluator"].update(render_evaluator_panel([], 0, "planning", False))
|
|
576
|
-
live.start()
|
|
577
|
-
time.sleep(0.2)
|
|
578
|
-
|
|
579
|
-
plan_prompt = build_plan_prompt(goal, qa_history)
|
|
580
|
-
plan_output, _ = run_plan_agent(plan_prompt, workdir, model)
|
|
581
|
-
|
|
582
|
-
live.stop()
|
|
583
|
-
console.print()
|
|
584
|
-
|
|
585
|
-
# 질문이 있는지 확인
|
|
586
|
-
has_questions = '질문:' in plan_output or re.search(r'^\d+\.\s', plan_output, re.M)
|
|
587
|
-
has_plan = '계획:' in plan_output or re.search(r'^[-•]\s', plan_output, re.M)
|
|
588
|
-
|
|
589
|
-
console.print(Rule("[cyan]Plan Agent[/cyan]"))
|
|
590
|
-
console.print(plan_output)
|
|
591
|
-
console.print()
|
|
592
|
-
|
|
593
|
-
if has_questions and not has_plan:
|
|
594
|
-
# 질의응답
|
|
595
|
-
console.print("[dim]질문에 답변하세요 (취소: /cancel):[/dim]")
|
|
596
|
-
try:
|
|
597
|
-
answer = input("> ").strip()
|
|
598
|
-
except (EOFError, KeyboardInterrupt):
|
|
599
|
-
return None
|
|
600
|
-
if answer.lower() in ('/cancel', '/exit'):
|
|
601
|
-
return None
|
|
602
|
-
qa_history.append({'q': plan_output, 'a': answer})
|
|
603
|
-
continue # 다시 Plan Agent 실행
|
|
604
|
-
|
|
605
|
-
# 계획 수립 완료 → accept
|
|
606
|
-
console.print("[dim]이 계획으로 진행하시겠습니까? (y: 실행 / n: 다시 작성 / /cancel: 취소)[/dim]")
|
|
607
|
-
try:
|
|
608
|
-
ans = input("accept (y/n) > ").strip().lower()
|
|
609
|
-
except (EOFError, KeyboardInterrupt):
|
|
610
|
-
return None
|
|
611
|
-
if ans == 'y':
|
|
612
|
-
# 계획 내용을 목표로 삼아 반환
|
|
613
|
-
final_goal = f"{goal}\n\n[계획]\n{plan_output}"
|
|
614
|
-
return final_goal
|
|
615
|
-
elif ans in ('/cancel', '/exit'):
|
|
616
|
-
return None
|
|
617
|
-
# n → 다시 처음부터
|
|
618
|
-
qa_history.clear()
|
|
619
|
-
try:
|
|
620
|
-
new_goal = input("새 목표를 입력하거나 Enter로 기존 목표 유지: ").strip()
|
|
621
|
-
except (EOFError, KeyboardInterrupt):
|
|
622
|
-
return None
|
|
623
|
-
if new_goal:
|
|
624
|
-
goal = new_goal
|
|
625
|
-
|
|
626
|
-
|
|
627
692
|
# ── Agent Loop ────────────────────────────────────────────────────────────────
|
|
628
693
|
|
|
629
694
|
def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
@@ -678,9 +743,7 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
|
|
|
678
743
|
t.start()
|
|
679
744
|
while t.is_alive():
|
|
680
745
|
if _interrupt_event.is_set():
|
|
681
|
-
|
|
682
|
-
_current_proc.kill()
|
|
683
|
-
break
|
|
746
|
+
break # worker 스레드는 _interrupt_event 확인 후 자체 중단
|
|
684
747
|
refresh("worker", iteration)
|
|
685
748
|
time.sleep(0.1)
|
|
686
749
|
t.join()
|
|
@@ -842,10 +905,6 @@ def main():
|
|
|
842
905
|
auth_parser.print_help()
|
|
843
906
|
return
|
|
844
907
|
|
|
845
|
-
if not CODEX_BIN.exists():
|
|
846
|
-
console.print(f"[bold red]Error:[/bold red] codex not found at {CODEX_BIN}")
|
|
847
|
-
sys.exit(1)
|
|
848
|
-
|
|
849
908
|
workdir = str(Path(args.dir).resolve())
|
|
850
909
|
max_iter = args.max_iterations
|
|
851
910
|
|
|
@@ -854,11 +913,8 @@ def main():
|
|
|
854
913
|
layout["header"].update(render_header("", 0, max_iter, "idle"))
|
|
855
914
|
layout["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
|
|
856
915
|
layout["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
live.start()
|
|
860
|
-
time.sleep(0.3)
|
|
861
|
-
live.stop()
|
|
916
|
+
with Live(layout, refresh_per_second=8, screen=False) as live:
|
|
917
|
+
time.sleep(0.3)
|
|
862
918
|
|
|
863
919
|
# ── REPL 루프 ─────────────────────────────────────────────────────
|
|
864
920
|
status = auth_status()
|
|
@@ -882,8 +938,7 @@ def main():
|
|
|
882
938
|
"agentforge auth login 으로 나중에 로그인할 수 있습니다.[/dim]"
|
|
883
939
|
)
|
|
884
940
|
|
|
885
|
-
console.print()
|
|
886
|
-
console.print("[dim]명령을 입력하세요. /plan <목표> | /exit[/dim]")
|
|
941
|
+
console.print("[dim]명령을 입력하세요. /resume | /exit[/dim]")
|
|
887
942
|
|
|
888
943
|
_completer = SlashCompleter()
|
|
889
944
|
|
|
@@ -912,37 +967,6 @@ def main():
|
|
|
912
967
|
console.print("[dim]종료합니다.[/dim]")
|
|
913
968
|
break
|
|
914
969
|
|
|
915
|
-
elif cmd_name == 'plan':
|
|
916
|
-
# Plan 모드
|
|
917
|
-
layout["header"].update(render_header(cmd_arg or "", 0, max_iter, "idle"))
|
|
918
|
-
layout["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
|
|
919
|
-
layout["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
|
|
920
|
-
|
|
921
|
-
final_goal = run_plan_mode(cmd_arg, workdir, args.worker_model, layout, live)
|
|
922
|
-
if final_goal is None:
|
|
923
|
-
console.print("[dim]계획 취소됨.[/dim]")
|
|
924
|
-
continue
|
|
925
|
-
console.print()
|
|
926
|
-
console.print("[cyan]계획 확정. Worker + Evaluator 루프를 시작합니다...[/cyan]")
|
|
927
|
-
time.sleep(0.5)
|
|
928
|
-
# 새 layout/live 인스턴스로 루프 실행
|
|
929
|
-
layout2 = make_layout()
|
|
930
|
-
layout2["header"].update(render_header(final_goal, 0, max_iter, "idle"))
|
|
931
|
-
layout2["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
|
|
932
|
-
layout2["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
|
|
933
|
-
live2 = Live(layout2, refresh_per_second=8, screen=False)
|
|
934
|
-
outcome = run_agent_loop(
|
|
935
|
-
final_goal, workdir, args.worker_model, args.eval_model,
|
|
936
|
-
max_iter, layout2, live2,
|
|
937
|
-
)
|
|
938
|
-
goal = final_goal
|
|
939
|
-
if outcome == 'interrupted':
|
|
940
|
-
goal = _handle_interrupt(goal, workdir, args, max_iter)
|
|
941
|
-
elif outcome == 'max':
|
|
942
|
-
console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
|
|
943
|
-
if outcome != 'interrupted':
|
|
944
|
-
console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
|
|
945
|
-
|
|
946
970
|
elif cmd_name == 'resume':
|
|
947
971
|
if not _last_session:
|
|
948
972
|
console.print("[yellow]재개할 세션이 없습니다. 먼저 목표를 실행하세요.[/yellow]")
|
|
@@ -975,7 +999,7 @@ def main():
|
|
|
975
999
|
|
|
976
1000
|
else:
|
|
977
1001
|
console.print(f"[red]알 수 없는 커맨드: /{cmd_name}[/red]")
|
|
978
|
-
console.print("[dim]사용 가능: /
|
|
1002
|
+
console.print("[dim]사용 가능: /resume /exit[/dim]")
|
|
979
1003
|
|
|
980
1004
|
else:
|
|
981
1005
|
# 일반 텍스트 → 바로 Worker에게 목표로 전달
|