agentforge-multi 0.1.5 → 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 +216 -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,
@@ -745,10 +865,12 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
745
865
  eval_model: str | None, max_iter: int,
746
866
  layout: Layout, live: Live,
747
867
  initial_history: list | None = None,
748
- initial_eval_history: list | None = None) -> str:
868
+ initial_eval_history: list | None = None,
869
+ mode: str = "code",
870
+ eval_every: int = 1) -> str:
749
871
  """
750
872
  Worker + Evaluator 반복 루프.
751
- 반환: 'done' | 'max'
873
+ 반환: 'done' | 'max' | 'interrupted'
752
874
  """
753
875
  global _last_session
754
876
  history = list(initial_history or [])
@@ -757,6 +879,11 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
757
879
  worker_status = ["idle"]
758
880
  done = False
759
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
+
760
887
  # ESC 리스너 시작
761
888
  _interrupt_event.clear()
762
889
  _esc_stop = threading.Event()
@@ -787,7 +914,8 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
787
914
 
788
915
  def _worker():
789
916
  worker_result[0], worker_result[1] = run_worker(
790
- 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)
791
919
 
792
920
  t = threading.Thread(target=_worker, daemon=True)
793
921
  t.start()
@@ -823,9 +951,13 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
823
951
  "eval_history": eval_history, "workdir": workdir}
824
952
  return _finish('interrupted')
825
953
 
826
- refresh("evaluator", iteration)
827
- eval_prompt = build_evaluator_prompt(goal, last_msg or "", iteration)
828
- 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)"
829
961
 
830
962
  decision, feedback = parse_decision(eval_msg)
831
963
  history[-1]['decision'] = decision
@@ -954,6 +1086,10 @@ def main():
954
1086
  help="Evaluator 모델")
955
1087
  parser.add_argument("-n", "--max-iterations", type=int, default=DEFAULT_MAX_ITER,
956
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, 즉 매번)")
957
1093
  args = parser.parse_args()
958
1094
 
959
1095
  # ── auth 서브커맨드 ────────────────────────────────────────────────
@@ -1004,7 +1140,10 @@ def main():
1004
1140
  "agentforge auth login 으로 나중에 로그인할 수 있습니다.[/dim]"
1005
1141
  )
1006
1142
 
1007
- 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]")
1008
1147
 
1009
1148
  _completer = SlashCompleter()
1010
1149
 
@@ -1054,18 +1193,39 @@ def main():
1054
1193
  max_iter, layout2, live2,
1055
1194
  initial_history=s["history"],
1056
1195
  initial_eval_history=s["eval_history"],
1196
+ mode=current_mode, eval_every=eval_every,
1057
1197
  )
1058
- goal = s["goal"]
1059
1198
  if outcome == 'interrupted':
1060
- goal = _handle_interrupt(goal, workdir, args, max_iter)
1199
+ console.print("\n[yellow]⚠ 중단됨. REPL로 돌아갑니다.[/yellow]")
1061
1200
  elif outcome == 'max':
1062
1201
  console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
1063
1202
  if outcome != 'interrupted':
1064
1203
  console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
1065
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
+
1066
1226
  else:
1067
1227
  console.print(f"[red]알 수 없는 커맨드: /{cmd_name}[/red]")
1068
- console.print("[dim]사용 가능: /resume /exit[/dim]")
1228
+ console.print("[dim]/help 커맨드 목록을 확인하세요.[/dim]")
1069
1229
 
1070
1230
  else:
1071
1231
  # 일반 텍스트 → 바로 Worker에게 목표로 전달
@@ -1078,9 +1238,10 @@ def main():
1078
1238
  outcome = run_agent_loop(
1079
1239
  goal, workdir, args.worker_model, args.eval_model,
1080
1240
  max_iter, layout2, live2,
1241
+ mode=current_mode, eval_every=eval_every,
1081
1242
  )
1082
1243
  if outcome == 'interrupted':
1083
- goal = _handle_interrupt(goal, workdir, args, max_iter)
1244
+ console.print("\n[yellow]⚠ 중단됨. REPL로 돌아갑니다.[/yellow]")
1084
1245
  elif outcome == 'max':
1085
1246
  console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
1086
1247
  if outcome != 'interrupted':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentforge-multi",
3
- "version": "0.1.5",
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",