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.
- package/agentforge +396 -25
- 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 헤더
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
if
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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]
|
|
1183
|
-
console.print(f"[dim]
|
|
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"))
|