agentforge-multi 0.1.6 → 0.1.7

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 +396 -25
  2. package/package.json +1 -1
package/agentforge CHANGED
@@ -45,6 +45,8 @@ from prompt_toolkit.formatted_text import HTML
45
45
  # ── Constants ─────────────────────────────────────────────────────────────────
46
46
 
47
47
  CODEX_BIN = Path.home() / ".npm-global" / "bin" / "codex"
48
+ KNOWLEDGE_DIR = Path.home() / ".agentforge" / "knowledge" # 영구 지식 저장소 루트
49
+ SESSION_FILE = Path.home() / ".agentforge" / "last_session.json" # 세션 영속화
48
50
  DEFAULT_MAX_ITER = 5000
49
51
  WORKER_BUF_LINES = 60
50
52
  DEFAULT_WORKER_MODEL = "gpt-5.4"
@@ -57,21 +59,71 @@ NOISE_RE = re.compile(r'^\s*([\-─═\s]+)?$')
57
59
  console = Console()
58
60
  _last_session: dict | None = None # {goal, history, eval_history, workdir}
59
61
  _interrupt_event = threading.Event() # ESC 감지 플래그
62
+
63
+
64
+ def _persist_session(session: dict) -> None:
65
+ """세션을 디스크에 저장. worker_lines는 TUI 표시용이라 제외."""
66
+ try:
67
+ SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
68
+ # worker_lines는 디스플레이 전용 → 저장 제외
69
+ history_slim = [
70
+ {k: v for k, v in h.items() if k != 'worker_lines'}
71
+ for h in session.get('history', [])
72
+ ]
73
+ data = {
74
+ **session,
75
+ 'history': history_slim,
76
+ 'saved_at': time.strftime("%Y-%m-%dT%H:%M:%S"),
77
+ }
78
+ SESSION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ def _load_persisted_session() -> dict | None:
84
+ """디스크에서 세션 로드. 실패 시 None."""
85
+ try:
86
+ if not SESSION_FILE.exists():
87
+ return None
88
+ data = json.loads(SESSION_FILE.read_text())
89
+ # worker_lines 누락된 항목 복원
90
+ for h in data.get('history', []):
91
+ h.setdefault('worker_lines', [])
92
+ return data
93
+ except Exception:
94
+ return None
60
95
  _current_response = None # 현재 스트리밍 HTTP 응답 (즉시 끊기용)
61
96
 
97
+ # HTTP 세션 — TCP+TLS 연결 재사용으로 매 호출 수십~수백 ms 절감
98
+ _session = _requests.Session()
99
+ _session.headers.update({"Content-Type": "application/json"})
100
+
101
+ # 인증 헤더 캐시 — auth.json mtime이 바뀔 때만 재읽기
102
+ _auth_cache: dict | None = None
103
+ _auth_mtime: float = 0.0
104
+
62
105
  # ── ChatGPT backend API ────────────────────────────────────────────────────────
63
106
 
64
107
  def _get_auth_headers() -> dict:
65
- """~/.codex/auth.json 에서 Bearer 헤더 + ChatGPT-Account-Id 반환."""
108
+ """~/.codex/auth.json 에서 Bearer 헤더 반환. mtime 기반 캐싱."""
109
+ global _auth_cache, _auth_mtime
66
110
  auth_file = Path.home() / ".codex" / "auth.json"
111
+ try:
112
+ mtime = auth_file.stat().st_mtime
113
+ except OSError:
114
+ return {}
115
+ if _auth_cache is not None and mtime == _auth_mtime:
116
+ return _auth_cache
67
117
  data = json.loads(auth_file.read_text())
68
118
  token = data["tokens"]["access_token"]
69
119
  account_id = data["tokens"].get("account_id", "")
70
- return {
120
+ _auth_cache = {
71
121
  "Authorization": f"Bearer {token}",
72
122
  "Content-Type": "application/json",
73
123
  "ChatGPT-Account-Id": account_id,
74
124
  }
125
+ _auth_mtime = mtime
126
+ return _auth_cache
75
127
 
76
128
 
77
129
  WORKER_TOOLS = [
@@ -164,7 +216,7 @@ def _iter_events(payload: dict):
164
216
  global _current_response
165
217
  headers = _get_auth_headers()
166
218
  try:
167
- r = _requests.post(
219
+ r = _session.post(
168
220
  CHATGPT_RESPONSES_URL, headers=headers,
169
221
  json=payload, stream=True, timeout=300,
170
222
  )
@@ -184,6 +236,7 @@ def _iter_events(payload: dict):
184
236
  except Exception:
185
237
  pass
186
238
  finally:
239
+ r.close() # GeneratorExit(gen.close()) 시에도 HTTP 연결 즉시 반환
187
240
  _current_response = None
188
241
  except Exception as e:
189
242
  _current_response = None
@@ -228,7 +281,7 @@ def _execute_tool(name: str, args: dict, workdir: str) -> str:
228
281
  query = args["query"]
229
282
  brave_key = os.environ.get("BRAVE_API_KEY")
230
283
  if brave_key:
231
- r = _requests.get(
284
+ r = _session.get(
232
285
  "https://api.search.brave.com/res/v1/web/search",
233
286
  params={"q": query, "count": 10},
234
287
  headers={"Accept": "application/json",
@@ -242,7 +295,7 @@ def _execute_tool(name: str, args: dict, workdir: str) -> str:
242
295
  for i, it in enumerate(items[:8])
243
296
  ) or "(결과 없음)"
244
297
  else:
245
- r = _requests.get(
298
+ r = _session.get(
246
299
  "https://html.duckduckgo.com/html/",
247
300
  params={"q": query},
248
301
  headers={"User-Agent": "Mozilla/5.0"},
@@ -262,7 +315,7 @@ def _execute_tool(name: str, args: dict, workdir: str) -> str:
262
315
  return "\n".join(lines) or "(결과 없음)"
263
316
  elif name == "fetch_url":
264
317
  url = args["url"]
265
- r = _requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
318
+ r = _session.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
266
319
  text = re.sub(r'<[^>]+>', ' ', r.text)
267
320
  text = re.sub(r'\s+', ' ', text).strip()
268
321
  return text[:8000]
@@ -400,23 +453,252 @@ RESEARCH_EVALUATOR_SYSTEM = textwrap.dedent("""\
400
453
  """).strip()
401
454
 
402
455
 
403
- def build_worker_prompt(goal: str, history: list) -> str:
456
+ TOKEN_COMPRESS_THRESHOLD = 100_000 # 프롬프트 추정 토큰이 이 수를 넘으면 압축
457
+ HISTORY_KEEP_RECENT = 4 # 압축 후 보존할 최근 항목 수 (상세 내용 유지)
458
+
459
+
460
+ def _estimate_tokens(text: str) -> int:
461
+ """토큰 수 추정. tiktoken 있으면 정확하게, 없으면 글자 수 기반 근사."""
462
+ try:
463
+ import tiktoken
464
+ enc = tiktoken.get_encoding("cl100k_base")
465
+ return len(enc.encode(text))
466
+ except Exception:
467
+ # 영문 ~4자/토큰, 한글 ~1.5자/토큰 혼합 근사 → 3자/토큰
468
+ return len(text) // 3
469
+
470
+ # ── Knowledge Store (RAG) ────────────────────────────────────────────────────
471
+
472
+ def _goal_to_slug(goal: str) -> str:
473
+ """목표 문자열 → 파일시스템 안전 폴더명 (한글 보존)."""
474
+ slug = re.sub(r'[^\w가-힣\s]', '', goal) # 특수문자 제거, 한글·영문·숫자 보존
475
+ slug = re.sub(r'\s+', '_', slug.strip()) # 공백 → 언더스코어
476
+ return slug[:72] or "unnamed"
477
+
478
+
479
+ def _knowledge_path(goal: str) -> Path:
480
+ """목표별 지식 저장소 디렉토리 경로 반환 (없으면 생성)."""
481
+ p = KNOWLEDGE_DIR / _goal_to_slug(goal)
482
+ p.mkdir(parents=True, exist_ok=True)
483
+ return p
484
+
485
+
486
+ def _save_attempt(goal: str, record: dict) -> None:
487
+ """시도 기록을 JSONL에 append (스레드 안전: Linux append는 원자적)."""
488
+ try:
489
+ path = _knowledge_path(goal) / "attempts.jsonl"
490
+ line = json.dumps(record, ensure_ascii=False) + "\n"
491
+ with open(path, "a", encoding="utf-8") as f:
492
+ f.write(line)
493
+ except Exception:
494
+ pass # 저장 실패해도 에이전트 동작에 영향 없도록
495
+
496
+
497
+ def _load_past_attempts(goal: str) -> list[dict]:
498
+ """과거 시도 기록 전체 로드."""
499
+ try:
500
+ path = _knowledge_path(goal) / "attempts.jsonl"
501
+ if not path.exists():
502
+ return []
503
+ records = []
504
+ for line in path.read_text(encoding="utf-8").splitlines():
505
+ line = line.strip()
506
+ if line:
507
+ try:
508
+ records.append(json.loads(line))
509
+ except Exception:
510
+ pass
511
+ return records
512
+ except Exception:
513
+ return []
514
+
515
+
516
+ def _retrieve_relevant(query: str, attempts: list[dict], top_k: int = 6) -> list[dict]:
517
+ """
518
+ BM25로 query와 가장 관련 있는 과거 시도 검색.
519
+ rank_bm25 없으면 단어 overlap 기반 폴백.
520
+ """
521
+ if not attempts:
522
+ return []
523
+
524
+ def _text(a: dict) -> str:
525
+ return f"{a.get('feedback','')} {a.get('worker_summary','')} {a.get('decision','')}"
526
+
527
+ try:
528
+ from rank_bm25 import BM25Okapi
529
+ corpus = [_text(a).lower().split() for a in attempts]
530
+ bm25 = BM25Okapi(corpus)
531
+ scores = bm25.get_scores(query.lower().split())
532
+ top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
533
+ return [attempts[i] for i in top_idx]
534
+ except Exception:
535
+ # 폴백: 단어 overlap 점수
536
+ q_words = set(query.lower().split())
537
+ scored = []
538
+ for a in attempts:
539
+ words = set(_text(a).lower().split())
540
+ scored.append((len(q_words & words), a))
541
+ scored.sort(key=lambda x: x[0], reverse=True)
542
+ return [a for _, a in scored[:top_k]]
543
+
544
+
545
+ def _build_rag_section(past_attempts: list[dict], current_context: str) -> str:
546
+ """
547
+ 현재 상황과 관련된 과거 시도를 RAG로 검색하여
548
+ Worker 프롬프트에 삽입할 경고 섹션 생성.
549
+ """
550
+ if not past_attempts:
551
+ return ""
552
+
553
+ relevant = _retrieve_relevant(current_context, past_attempts, top_k=6)
554
+ if not relevant:
555
+ return ""
556
+
557
+ lines = [
558
+ "━" * 60,
559
+ "PAST ATTEMPTS FROM PREVIOUS SESSIONS — DO NOT REPEAT THESE:",
560
+ ]
561
+ for a in relevant:
562
+ decision = a.get("decision", "?")
563
+ feedback = a.get("feedback", "")[:200]
564
+ summary = a.get("worker_summary", "")[:150]
565
+ ts = a.get("timestamp", "")[:10]
566
+ badge = {"DONE": "✓", "IMPROVE": "▲", "REDIRECT": "↩"}.get(decision, "?")
567
+ lines.append(f" [{badge} {decision}] ({ts}) {feedback}")
568
+ if summary:
569
+ lines.append(f" tried: {summary}")
570
+ lines.append("━" * 60)
571
+ return "\n".join(lines)
572
+
573
+
574
+ # ── History Compression ───────────────────────────────────────────────────────
575
+
576
+ COMPRESSOR_SYSTEM = textwrap.dedent("""\
577
+ You are a concise summarizer for an AI agent's work log.
578
+ Given a list of past iterations (what the agent tried and what the evaluator said),
579
+ produce a compact summary that preserves:
580
+ - Every approach that was tried (even failed ones)
581
+ - Why each approach was rejected or approved
582
+ - The current state of the work (what exists, what doesn't)
583
+ - Any important file paths or technical details
584
+
585
+ Write in plain English. Be dense but complete. Max 400 words.
586
+ Do NOT omit failed approaches — the agent must not repeat them.
587
+ """).strip()
588
+
589
+
590
+ def _call_compressor(entries: list[dict], goal: str, model: str | None) -> str:
591
+ """과거 history entries를 LLM으로 요약. 실패 시 원본 한 줄 요약 반환."""
592
+ lines = [f"GOAL: {goal}", "", "ITERATIONS TO SUMMARIZE:"]
593
+ for h in entries:
594
+ lines.append(f" Iter {h['iter']}: [{h['decision']}] {h.get('feedback','')[:200]}")
595
+ ws = h.get('worker_summary', '')
596
+ if ws:
597
+ lines.append(f" Worker did: {ws[:200]}")
598
+ prompt_text = "\n".join(lines)
599
+
600
+ model = model or DEFAULT_EVAL_MODEL
601
+ payload = {
602
+ "model": model,
603
+ "instructions": COMPRESSOR_SYSTEM,
604
+ "input": [{"role": "user", "content": prompt_text}],
605
+ "store": False,
606
+ "stream": True,
607
+ }
608
+ parts: list[str] = []
609
+ gen = _iter_events(payload)
610
+ try:
611
+ for ev in gen:
612
+ if ev["type"] == "response.output_text.delta":
613
+ parts.append(ev.get("delta", ""))
614
+ finally:
615
+ gen.close()
616
+ result = "".join(parts).strip()
617
+ if not result:
618
+ # 폴백: 원본 한 줄 목록
619
+ result = "\n".join(
620
+ f"Iter {h['iter']} [{h['decision']}]: {h.get('feedback','')[:100]}"
621
+ for h in entries
622
+ )
623
+ return result
624
+
625
+
626
+ def compress_history(history: list, goal: str, model: str | None) -> list:
627
+ """
628
+ history가 COMPRESS_THRESHOLD를 넘으면 오래된 항목을 LLM 요약으로 교체.
629
+ 최근 KEEP_RECENT 항목은 보존.
630
+ 반환: 새 history 리스트 (압축 entry + 최근 항목)
631
+ """
632
+ # 이미 압축된 summary entry가 있으면 그것도 포함해 재압축 대상 선정
633
+ to_compress = history[:-HISTORY_KEEP_RECENT]
634
+ keep = history[-HISTORY_KEEP_RECENT:]
635
+
636
+ # summary entry는 그대로, 일반 entry만 압축
637
+ prev_summary_text = ""
638
+ normal_entries = []
639
+ for h in to_compress:
640
+ if h.get('type') == 'compressed_summary':
641
+ prev_summary_text = h['content']
642
+ else:
643
+ normal_entries.append(h)
644
+
645
+ if not normal_entries:
646
+ return history # 압축할 게 없음
647
+
648
+ new_text = _call_compressor(normal_entries, goal, model)
649
+ if prev_summary_text:
650
+ combined = prev_summary_text + "\n\n--- Additional history ---\n" + new_text
651
+ else:
652
+ combined = new_text
653
+
654
+ first_iter = normal_entries[0]['iter']
655
+ last_iter = normal_entries[-1]['iter']
656
+ summary_entry = {
657
+ 'type': 'compressed_summary',
658
+ 'covers': f"{first_iter}–{last_iter}",
659
+ 'content': combined,
660
+ }
661
+ return [summary_entry] + keep
662
+
663
+
664
+ def build_worker_prompt(goal: str, history: list,
665
+ past_attempts: list[dict] | None = None) -> str:
404
666
  lines = [f"GOAL: {goal}", ""]
667
+
668
+ # RAG 섹션: 현재 상황을 쿼리로 과거 시도 검색·삽입
669
+ if past_attempts:
670
+ current_ctx = " ".join(
671
+ f"{h.get('feedback','')} {h.get('worker_summary','')}"
672
+ for h in history[-3:] if h.get('type') != 'compressed_summary'
673
+ ) or goal
674
+ rag = _build_rag_section(past_attempts, current_ctx)
675
+ if rag:
676
+ lines.append(rag)
677
+ lines.append("")
678
+
405
679
  if not history:
406
680
  lines += [WORKER_SYSTEM, "", "Begin working on the goal above. Make concrete changes now."]
407
681
  else:
408
682
  lines.append("ITERATION HISTORY:")
409
683
  for h in history:
410
- lines.append(f" Iteration {h['iter']}:")
411
- lines.append(f" Your work: {h['worker_summary']}")
412
- lines.append(f" Evaluator: {h['decision']}" +
413
- (f"{h['feedback']}" if h['feedback'] else ""))
684
+ if h.get('type') == 'compressed_summary':
685
+ lines.append(f" [COMPRESSED SUMMARY — Iter {h['covers']}]")
686
+ for ln in h['content'].splitlines():
687
+ lines.append(f" {ln}")
688
+ lines.append(" ──────────────────────────────────────")
689
+ else:
690
+ lines.append(f" Iteration {h['iter']}:")
691
+ lines.append(f" Your work: {h['worker_summary']}")
692
+ lines.append(f" Evaluator: {h['decision']}" +
693
+ (f" — {h['feedback']}" if h['feedback'] else ""))
414
694
  lines.append("")
415
- last = history[-1]
416
- if last['decision'].upper() == 'IMPROVE':
417
- lines.append(f"INSTRUCTION: Refine your previous work. Feedback: {last['feedback']}")
418
- else:
419
- lines.append(f"INSTRUCTION: Abandon previous approach. Try differently: {last['feedback']}")
695
+ # 마지막 실제 iteration entry 찾기
696
+ last = next((h for h in reversed(history) if h.get('type') != 'compressed_summary'), None)
697
+ if last:
698
+ if last['decision'].upper() == 'IMPROVE':
699
+ lines.append(f"INSTRUCTION: Refine your previous work. Feedback: {last['feedback']}")
700
+ else:
701
+ lines.append(f"INSTRUCTION: Abandon previous approach. Try differently: {last['feedback']}")
420
702
  lines.append("Make the changes now.")
421
703
  return "\n".join(lines)
422
704
 
@@ -654,7 +936,10 @@ def run_worker(prompt: str, workdir: str, model: str | None,
654
936
 
655
937
  def run_evaluator(prompt: str, workdir: str, model: str | None,
656
938
  system_prompt: str | None = None) -> tuple[str, int]:
657
- """Evaluator: ChatGPT backend Responses API, 도구 없이 텍스트만."""
939
+ """Evaluator: ChatGPT backend Responses API, 도구 없이 텍스트만.
940
+ IMPROVE/REDIRECT는 첫 번째 완성 라인에서 결정 → 스트림 조기 종료.
941
+ DONE은 한국어 요약까지 필요하므로 전체 수신.
942
+ """
658
943
  model = model or DEFAULT_EVAL_MODEL
659
944
  system_prompt = system_prompt or EVALUATOR_SYSTEM
660
945
  payload = {
@@ -665,11 +950,27 @@ def run_evaluator(prompt: str, workdir: str, model: str | None,
665
950
  "stream": True,
666
951
  }
667
952
  parts: list[str] = []
668
- for ev in _iter_events(payload):
669
- if ev["type"] == "_error":
670
- return f"[Evaluator error] {ev['message']}", 1
671
- if ev["type"] == "response.output_text.delta":
672
- parts.append(ev.get("delta", ""))
953
+ accumulated = ""
954
+ early_decided = False
955
+ gen = _iter_events(payload)
956
+ try:
957
+ for ev in gen:
958
+ if ev["type"] == "_error":
959
+ return f"[Evaluator error] {ev['message']}", 1
960
+ if ev["type"] == "response.output_text.delta":
961
+ delta = ev.get("delta", "")
962
+ parts.append(delta)
963
+ if not early_decided:
964
+ accumulated += delta
965
+ # 첫 줄이 완성되면 결정 파싱 시도
966
+ if '\n' in accumulated:
967
+ first_line = accumulated.split('\n')[0].strip()
968
+ m = DECISION_RE.match(first_line)
969
+ if m and m.group(1).upper() in ('IMPROVE', 'REDIRECT'):
970
+ early_decided = True
971
+ break # 한국어 요약 불필요 → 스트림 종료
972
+ finally:
973
+ gen.close()
673
974
  return "".join(parts), 0
674
975
 
675
976
 
@@ -884,6 +1185,19 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
884
1185
  eval_sys = RESEARCH_EVALUATOR_SYSTEM if mode == "research" else EVALUATOR_SYSTEM
885
1186
  active_tools = RESEARCH_TOOLS if mode == "research" else WORKER_TOOLS
886
1187
 
1188
+ # 압축 상태 — 백그라운드 압축 스레드 결과 수신용
1189
+ _compress_result: list[list] = [] # [new_history] 채워지면 완료
1190
+ _compress_thread: threading.Thread | None = None
1191
+
1192
+ # ── 지식 저장소 로드 ─────────────────────────────────────────────────
1193
+ past_attempts = _load_past_attempts(goal)
1194
+ knowledge_dir = _knowledge_path(goal)
1195
+ if past_attempts:
1196
+ console.print(
1197
+ f"[dim]📚 지식 저장소 로드: {knowledge_dir.name}/ "
1198
+ f"({len(past_attempts)}개 과거 시도)[/dim]"
1199
+ )
1200
+
887
1201
  # ESC 리스너 시작
888
1202
  _interrupt_event.clear()
889
1203
  _esc_stop = threading.Event()
@@ -909,7 +1223,7 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
909
1223
  live.start()
910
1224
  refresh("worker", iteration)
911
1225
 
912
- worker_prompt = build_worker_prompt(goal, history)
1226
+ worker_prompt = build_worker_prompt(goal, history, past_attempts=past_attempts)
913
1227
  worker_result = [None, None]
914
1228
 
915
1229
  def _worker():
@@ -932,6 +1246,7 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
932
1246
  live.stop()
933
1247
  _last_session = {"goal": goal, "history": history,
934
1248
  "eval_history": eval_history, "workdir": workdir}
1249
+ _persist_session(_last_session)
935
1250
  return _finish('interrupted')
936
1251
 
937
1252
  last_msg, _ = worker_result
@@ -949,6 +1264,7 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
949
1264
  live.stop()
950
1265
  _last_session = {"goal": goal, "history": history,
951
1266
  "eval_history": eval_history, "workdir": workdir}
1267
+ _persist_session(_last_session)
952
1268
  return _finish('interrupted')
953
1269
 
954
1270
  if iteration % eval_every == 0 or iteration == max_iter:
@@ -969,6 +1285,40 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
969
1285
  'full_msg': eval_msg,
970
1286
  })
971
1287
 
1288
+ # ── 지식 저장소에 attempt 기록 ───────────────────────────────────
1289
+ attempt_record = {
1290
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
1291
+ "iter": iteration,
1292
+ "decision": decision,
1293
+ "feedback": feedback,
1294
+ "worker_summary": worker_summary,
1295
+ "goal": goal,
1296
+ }
1297
+ _save_attempt(goal, attempt_record)
1298
+ past_attempts.append(attempt_record) # 현재 세션 내 즉시 반영
1299
+
1300
+ # ── 이전 압축 결과 반영 ──────────────────────────────────────────
1301
+ if _compress_thread and not _compress_thread.is_alive() and _compress_result:
1302
+ history = _compress_result[0]
1303
+ _compress_result.clear()
1304
+ _compress_thread = None
1305
+
1306
+ # ── 프롬프트 토큰이 100k 초과 시 백그라운드 압축 시작 ────────────
1307
+ prompt_tokens = _estimate_tokens(build_worker_prompt(goal, history, past_attempts=past_attempts))
1308
+ if (prompt_tokens > TOKEN_COMPRESS_THRESHOLD
1309
+ and (_compress_thread is None or not _compress_thread.is_alive())
1310
+ and decision != 'DONE'):
1311
+ _snap = list(history) # 스냅샷 캡처
1312
+ _res = _compress_result
1313
+
1314
+ def _do_compress():
1315
+ new_h = compress_history(_snap, goal, eval_model)
1316
+ _res.clear()
1317
+ _res.append(new_h)
1318
+
1319
+ _compress_thread = threading.Thread(target=_do_compress, daemon=True)
1320
+ _compress_thread.start()
1321
+
972
1322
  if decision == 'DONE':
973
1323
  done = True
974
1324
  refresh("done", iteration)
@@ -978,6 +1328,7 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
978
1328
  "goal": goal, "history": history,
979
1329
  "eval_history": eval_history, "workdir": workdir,
980
1330
  }
1331
+ _persist_session(_last_session)
981
1332
 
982
1333
  # 완료 요약 출력
983
1334
  console.print()
@@ -998,6 +1349,7 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
998
1349
  "goal": goal, "history": history,
999
1350
  "eval_history": eval_history, "workdir": workdir,
1000
1351
  }
1352
+ _persist_session(_last_session)
1001
1353
  return _finish('max')
1002
1354
 
1003
1355
 
@@ -1143,6 +1495,17 @@ def main():
1143
1495
  current_mode = args.mode
1144
1496
  eval_every = args.eval_every
1145
1497
 
1498
+ # 프로세스 시작 시 디스크에서 마지막 세션 복원
1499
+ global _last_session
1500
+ if _last_session is None:
1501
+ _last_session = _load_persisted_session()
1502
+ if _last_session:
1503
+ saved_at = _last_session.get('saved_at', '알 수 없음')
1504
+ console.print(
1505
+ f"[dim]💾 이전 세션 복원됨: [cyan]{_last_session['goal'][:60]}[/cyan] "
1506
+ f"({len(_last_session['history'])}회, {saved_at})[/dim]"
1507
+ )
1508
+
1146
1509
  console.print(f"[dim]명령을 입력하세요. /help 로 커맨드 목록 확인. 모드: {current_mode}[/dim]")
1147
1510
 
1148
1511
  _completer = SlashCompleter()
@@ -1178,9 +1541,17 @@ def main():
1178
1541
  continue
1179
1542
  s = _last_session
1180
1543
  prev_iters = len(s["history"])
1544
+ saved_at = s.get('saved_at', '알 수 없음')
1181
1545
  console.print(Rule("[cyan]세션 재개[/cyan]"))
1182
- console.print(f"[dim]목표:[/dim] {s['goal'][:80]}")
1183
- console.print(f"[dim]이전 반복:[/dim] {prev_iters}회 → 이어서 실행")
1546
+ console.print(f"[dim]목표:[/dim] [white]{s['goal'][:80]}[/white]")
1547
+ console.print(f"[dim]반복:[/dim] {prev_iters}회 완료")
1548
+ console.print(f"[dim]저장:[/dim] {saved_at}")
1549
+ last_decision = next(
1550
+ (h['decision'] for h in reversed(s['history'])
1551
+ if h.get('decision') and h.get('type') != 'compressed_summary'),
1552
+ '없음'
1553
+ )
1554
+ console.print(f"[dim]마지막 판정:[/dim] {last_decision}")
1184
1555
  console.print()
1185
1556
  layout2 = make_layout()
1186
1557
  layout2["header"].update(render_header(s["goal"], prev_iters, max_iter, "idle"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentforge-multi",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Multi-agent CLI: Worker + Evaluator agents collaborate in a loop to achieve your goal",
5
5
  "keywords": [
6
6
  "ai",