agentforge-multi 0.1.3 → 0.1.5

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 +98 -32
  2. package/package.json +1 -1
package/agentforge CHANGED
@@ -76,11 +76,16 @@ WORKER_TOOLS = [
76
76
  {
77
77
  "type": "function",
78
78
  "name": "shell",
79
- "description": "Execute a shell command. Use for running scripts, compiling, testing, installing packages, etc.",
79
+ "description": (
80
+ "Execute a shell command. Default timeout is 120s. "
81
+ "For long-running training/monitoring commands, set timeout_seconds (max 7200). "
82
+ "For fire-and-forget background jobs use '... &' and a short follow-up tail command."
83
+ ),
80
84
  "parameters": {
81
85
  "type": "object",
82
86
  "properties": {
83
87
  "command": {"type": "string", "description": "Shell command to run"},
88
+ "timeout_seconds": {"type": "integer", "description": "Max seconds to wait (default 120, max 7200)"},
84
89
  },
85
90
  "required": ["command"],
86
91
  },
@@ -129,14 +134,12 @@ WORKER_TOOLS = [
129
134
  ]
130
135
 
131
136
 
132
- def _stream_response(payload: dict) -> list:
137
+ def _iter_events(payload: dict):
133
138
  """
134
139
  ChatGPT backend-api/codex/responses 스트리밍 호출.
135
- SSE 이벤트를 파싱하여 (event_type, data_dict) 리스트 반환.
136
- _interrupt_event가 set되면 조기 종료.
140
+ SSE 이벤트를 실시간으로 yield. _interrupt_event가 set되면 조기 종료.
137
141
  """
138
142
  headers = _get_auth_headers()
139
- events = []
140
143
  try:
141
144
  r = _requests.post(
142
145
  CHATGPT_RESPONSES_URL, headers=headers,
@@ -151,23 +154,22 @@ def _stream_response(payload: dict) -> list:
151
154
  decoded = line.decode("utf-8", errors="replace")
152
155
  if decoded.startswith("data: "):
153
156
  try:
154
- ev = json.loads(decoded[6:])
155
- events.append(ev)
157
+ yield json.loads(decoded[6:])
156
158
  except Exception:
157
159
  pass
158
160
  except Exception as e:
159
- events.append({"type": "_error", "message": str(e)})
160
- return events
161
+ yield {"type": "_error", "message": str(e)}
161
162
 
162
163
 
163
164
  def _execute_tool(name: str, args: dict, workdir: str) -> str:
164
165
  """Worker 도구 실행."""
165
166
  try:
166
167
  if name == "shell":
168
+ timeout = min(int(args.get("timeout_seconds", 120)), 7200)
167
169
  result = subprocess.run(
168
170
  args["command"], shell=True,
169
171
  capture_output=True, text=True, errors='replace',
170
- cwd=workdir, timeout=120,
172
+ cwd=workdir, timeout=timeout,
171
173
  )
172
174
  out = (result.stdout + result.stderr).strip()
173
175
  return (out[:6000] if out else "(no output)") + (
@@ -465,28 +467,25 @@ def run_worker(prompt: str, workdir: str, model: str | None,
465
467
  "stream": True,
466
468
  }
467
469
 
468
- events = _stream_response(payload)
470
+ # 실시간 스트리밍 처리
471
+ text_parts: list[str] = []
472
+ fc_items: list[dict] = [] # {call_id, name, arguments}
473
+ got_error = False
469
474
 
470
- # 에러 처리
471
- for ev in events:
472
- if ev["type"] == "_error":
475
+ for ev in _iter_events(payload):
476
+ evtype = ev["type"]
477
+ if evtype == "_error":
473
478
  buf.append(f"[red]API 오류: {ev['message']}[/red]")
474
479
  status_ref[0] = "error"
475
480
  if line_buf.strip():
476
481
  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":
482
+ got_error = True
483
+ break
484
+ elif evtype == "response.output_text.delta":
486
485
  delta = ev.get("delta", "")
487
486
  text_parts.append(delta)
488
487
  _flush_line(delta)
489
- elif t == "response.output_item.done" and ev.get("item", {}).get("type") == "function_call":
488
+ elif evtype == "response.output_item.done" and ev.get("item", {}).get("type") == "function_call":
490
489
  item = ev["item"]
491
490
  fc_items.append({
492
491
  "call_id": item["call_id"],
@@ -494,6 +493,9 @@ def run_worker(prompt: str, workdir: str, model: str | None,
494
493
  "arguments": item["arguments"],
495
494
  })
496
495
 
496
+ if got_error:
497
+ return "\n".join(all_text_parts), 1
498
+
497
499
  round_text = "".join(text_parts)
498
500
  if round_text:
499
501
  all_text_parts.append(round_text)
@@ -542,15 +544,13 @@ def run_evaluator(prompt: str, workdir: str, model: str | None) -> tuple[str, in
542
544
  "store": False,
543
545
  "stream": True,
544
546
  }
545
- events = _stream_response(payload)
546
- for ev in events:
547
+ parts: list[str] = []
548
+ for ev in _iter_events(payload):
547
549
  if ev["type"] == "_error":
548
550
  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
551
+ if ev["type"] == "response.output_text.delta":
552
+ parts.append(ev.get("delta", ""))
553
+ return "".join(parts), 0
554
554
 
555
555
 
556
556
  def parse_decision(text: str) -> tuple[str, str]:
@@ -689,6 +689,56 @@ def render_evaluator_panel(eval_history: list, iteration: int,
689
689
  )
690
690
 
691
691
 
692
+ # ── tmux 자동 래핑 ────────────────────────────────────────────────────────────
693
+
694
+ import shlex as _shlex
695
+ import shutil as _shutil
696
+
697
+ TMUX_SESSION = "agentforge"
698
+
699
+
700
+ def _in_tmux() -> bool:
701
+ return bool(os.environ.get("TMUX"))
702
+
703
+
704
+ def _tmux_session_exists() -> bool:
705
+ r = subprocess.run(["tmux", "has-session", "-t", TMUX_SESSION],
706
+ capture_output=True)
707
+ return r.returncode == 0
708
+
709
+
710
+ def _tmux_launch(extra_args: list):
711
+ """현재 프로세스를 tmux 세션 안의 agentforge로 교체."""
712
+ if _tmux_session_exists():
713
+ console.print(
714
+ f"[yellow]이미 실행 중인 agentforge 세션이 있습니다.[/yellow]\n"
715
+ f" 재접속: [cyan]agentforge -r[/cyan]\n"
716
+ f" 새 세션: [cyan]agentforge --new-session[/cyan]"
717
+ )
718
+ sys.exit(0)
719
+
720
+ exe = _shutil.which("agentforge") or os.path.abspath(sys.argv[0])
721
+ cmd_str = " ".join(_shlex.quote(p) for p in [exe] + extra_args)
722
+ try:
723
+ os.execvp("tmux", ["tmux", "new-session", "-s", TMUX_SESSION, cmd_str])
724
+ except OSError:
725
+ pass # tmux 실행 실패 → 그냥 일반 모드로 계속
726
+
727
+
728
+ def _tmux_reconnect():
729
+ """기존 agentforge tmux 세션에 재접속."""
730
+ if not _shutil.which("tmux"):
731
+ console.print("[red]tmux가 설치되지 않았습니다.[/red]")
732
+ sys.exit(1)
733
+ if not _tmux_session_exists():
734
+ console.print(
735
+ "[yellow]실행 중인 agentforge 세션이 없습니다.[/yellow]\n"
736
+ "새 세션 시작: [cyan]agentforge[/cyan]"
737
+ )
738
+ sys.exit(1)
739
+ os.execvp("tmux", ["tmux", "attach-session", "-t", TMUX_SESSION])
740
+
741
+
692
742
  # ── Agent Loop ────────────────────────────────────────────────────────────────
693
743
 
694
744
  def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
@@ -746,7 +796,7 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
746
796
  break # worker 스레드는 _interrupt_event 확인 후 자체 중단
747
797
  refresh("worker", iteration)
748
798
  time.sleep(0.1)
749
- t.join()
799
+ t.join(timeout=10) # 최대 10초 대기 후 포기
750
800
  refresh("worker", iteration)
751
801
 
752
802
  # ESC로 중단된 경우
@@ -865,6 +915,22 @@ def _handle_interrupt(goal: str, workdir: str, args, max_iter: int) -> str:
865
915
  # ── Main ──────────────────────────────────────────────────────────────────────
866
916
 
867
917
  def main():
918
+ # ── tmux 자동 래핑 ─────────────────────────────────────────────────
919
+ # -r / --reconnect: 기존 세션 재접속
920
+ if len(sys.argv) >= 2 and sys.argv[1] in ("-r", "--reconnect"):
921
+ _tmux_reconnect()
922
+ return
923
+
924
+ # --new-session: 기존 세션 무시하고 강제로 새 세션
925
+ force_new = len(sys.argv) >= 2 and sys.argv[1] == "--new-session"
926
+ extra_args = [a for a in sys.argv[1:] if a != "--new-session"]
927
+
928
+ if not _in_tmux() and _shutil.which("tmux"):
929
+ if force_new and _tmux_session_exists():
930
+ subprocess.run(["tmux", "kill-session", "-t", TMUX_SESSION])
931
+ _tmux_launch(extra_args)
932
+ # execvp 실패 또는 세션 이미 존재 시 여기까지 옴 → 일반 모드로 계속
933
+
868
934
  parser = argparse.ArgumentParser(
869
935
  prog="agentforge",
870
936
  description="Worker + Evaluator 인터랙티브 멀티에이전트 CLI",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentforge-multi",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Multi-agent CLI: Worker + Evaluator agents collaborate in a loop to achieve your goal",
5
5
  "keywords": [
6
6
  "ai",