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.
Files changed (2) hide show
  1. package/agentforge +282 -55
  2. 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
- for line in r.iter_lines():
150
- if _interrupt_event.is_set():
151
- break
152
- if not line:
153
- continue
154
- decoded = line.decode("utf-8", errors="replace")
155
- if decoded.startswith("data: "):
156
- try:
157
- yield json.loads(decoded[6:])
158
- except Exception:
159
- pass
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", "agentforge 종료"),
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.lstrip('/')
290
+ typed = text[1:] # strip leading /
215
291
  for cmd, desc in SLASH_COMMANDS:
216
- name = cmd.lstrip('/')
217
- if name.startswith(typed):
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) -> tuple[str, int]:
440
- """Worker: ChatGPT backend Responses API 직접 호출 + 도구 실행 루프."""
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": WORKER_SYSTEM,
574
+ "instructions": system_prompt,
464
575
  "input": input_history,
465
- "tools": WORKER_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
- call_id = fc["call_id"]
510
- name = fc["name"]
511
- raw_args = fc["arguments"]
512
- try:
513
- args = json.loads(raw_args)
514
- except Exception:
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) -> tuple[str, int]:
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": EVALUATOR_SYSTEM,
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) -> str:
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
- refresh("evaluator", iteration)
777
- eval_prompt = build_evaluator_prompt(goal, last_msg or "", iteration)
778
- eval_msg, _ = run_evaluator(eval_prompt, workdir, eval_model)
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
- console.print("[dim]명령을 입력하세요. /resume | /exit[/dim]")
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
- goal = _handle_interrupt(goal, workdir, args, max_iter)
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]사용 가능: /resume /exit[/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
- goal = _handle_interrupt(goal, workdir, args, max_iter)
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':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentforge-multi",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Multi-agent CLI: Worker + Evaluator agents collaborate in a loop to achieve your goal",
5
5
  "keywords": [
6
6
  "ai",