team.run_stream() 한 번이 AutoGen 이벤트가 되고, 그 이벤트가 mapping·logger·scorer를 지나
events / runs 테이블의 어느 컬럼에 어떤 값으로 떨어지는지 — 실제 스트림 한 건을 이벤트별로 끝까지 따라간다.
v5-multitool 팀으로 실행했을 때 나오는 이벤트 시퀀스를 실제 코드 경로 그대로 재구성한 것이다.
토큰·latency 수치는 예시이되, 어떤 컬럼에 어떻게 들어가는지의 규칙은 코드 그대로다. 파일 참조는
src/agent_eval/… 기준, 라인은 현재 main 기준.평가의 본체는 runner._execute_run() 안의 단 두 줄: 팀을 빌드하고, team.run_stream()을 TraceLogger.log_stream()에 먹인다.
run→
runner.run_batch→
schedule_one Semaphore→
_prepare_run_row runs INSERT (running)→
_execute_run
runner.py:237–299)# 모델 클라이언트는 run마다 새로 — 동시성에서 공유 상태를 피한다 client = model_client_factory() team, _ = build_registered_team(team_id, settings, client) # registry: team_id → 빌더, AC9 가드 run_message = build_run_message(team_id, task, settings) # v2/v3/v4/v5만 첨부경로 주입, 그 외 task.question async with pool.acquire() as conn: trace_result = await TraceLogger(conn).log_stream( # ← 스트림 소비 + events 적재 run_id, team.run_stream(task=run_message), # ← AutoGen 라이브러리 진입점 ) is_open_ended = task.benchmark in _OPEN_ENDED # gaia=False → 한 줄 답 answer_text = (trace_result.final_answer_full if is_open_ended else trace_result.final_answer) score_fn = score # benchmark별 분기, gaia는 gaia.scorer.score score_result = score_fn(answer_text, task.gold_answer) await conn.execute("UPDATE runs SET status='completed', …") # ← 결과 확정
team.run_stream(task=run_message) 가 전부다. 이것이 AutoGen
SelectorGroupChat.run_stream() 를 호출 → 내부적으로 selector LLM이 다음 발화자를 고르고,
각 AssistantAgent 가 model_client로 추론하며, 그 과정의 모든 메시지/도구호출/선택 결정을
async generator 로 하나씩 yield 한다. eval 코드는 이 제너레이터를 소비만 한다 —
AutoGen 내부는 건드리지 않는다.v1_baseline.build_team, v2~v6 공유 골격)SelectorGroupChat(
[planner, executor, critic], # 3 AssistantAgent
model_client=model_client,
termination_condition = TextMentionTermination("APPROVE", sources=["critic"])
| MaxMessageTermination(settings.max_messages),
selector_prompt = SELECTOR_PROMPT, # selector가 JSON {next,reason_code,summary} 출력하도록 강제
allow_repeated_speaker = False, # 같은 에이전트 연속 발화 금지 → 자연스러운 P→E→P→C 교대
max_selector_attempts = 3,
emit_team_events = True, # ← SelectorEvent를 스트림에 흘려보냄(이게 없으면 라우팅 근거 미기록)
model_client_streaming = True, # ← ModelClientStreamingChunkEvent 발생 → logger가 조용히 스킵
)
termination: critic이 APPROVE를 말하면 종료. 못 하면 max_messages(예: 30)에서 강제 종료 → terminated_by='max_messages'.allow_repeated_speaker=False 때문에 executor가 파일읽기→집계를 한 턴에 몰아 못 한다. 그래서 trace가 P→E→P→E→P→C 처럼 교대한다(§E의 핵심 구조).emit_team_events=True 가 만드는 SelectorEvent 가 selector_decision 행의 원천 — “왜 이 에이전트가 다음인가”를 JSONB로 남긴다.GAIA L1, 첨부 xlsx 1개. 이 한 건이 아래 §C~§H를 관통한다.
# tasks 테이블 행 task_id = "c61d22de-…-5e9707bd3466" benchmark = "gaia" level = 1 requires_web = false file_name = "cafe_sales.xlsx" question = "첨부 엑셀은 한 카페의 지난주 판매 내역이다. 음료(drink)를 제외한 음식(food) 매출 합계는 USD로 얼마인가? 소수점 2자리로 답하라." gold_answer = "89706.00" # build_run_message("v5-multitool", task, settings) → registry._RUN_MESSAGE_BUILDERS[v5] run_message = question + "\n\n[첨부파일 경로: /srv/data/gaia/validation/cafe_sales.xlsx] " + "이 파일이 필요하면 read_attached_file 도구에 이 경로를 그대로 전달하라."
build_run_message에 등록이 없어
run_message = task.question 그대로다(경로 없음). v2~v5만 절대경로를 주입한다. → §J.| # | AutoGen 객체 (type) | .source | 핵심 페이로드 | .models_usage(prompt / completion) |
|---|---|---|---|---|
| E1 | TextMessage | user | run_message 전문 | — / — |
| E2 | SelectorEvent | selector | {"next":"planner","reason_code":"NEEDS_PLANNING",…} | (미기록) |
| E3 | TextMessage | planner | “executor가 파일을 읽고 food행을 합산하게 하라” 계획 | 412 / 96 |
| E4 | SelectorEvent | selector | {"next":"executor","reason_code":"NEEDS_EXECUTION",…} | (미기록) |
| E5 | ToolCallRequestEvent | executor | [FunctionCall(read_attached_file, {"path":"…cafe_sales.xlsx"})] | 520 / 40 |
| E6 | ToolCallExecutionEvent | executor | [FunctionExecutionResult(is_error=False, content="# Sheet: Sales\\n…")] | — / — |
| E7 | ToolCallSummaryMessage | executor | 도구 결과 요약(=추출 텍스트) | — / — |
| E8 | SelectorEvent | selector | {"next":"planner","reason_code":"NEEDS_PLANNING",…} | (미기록) |
| E9 | TextMessage | planner | “food만 필터해 합산하는 python_exec 코드를 돌려라” | 720 / 120 |
| E10 | SelectorEvent | selector | {"next":"executor","reason_code":"NEEDS_EXECUTION",…} | (미기록) |
| E11 | ToolCallRequestEvent | executor | [FunctionCall(python_exec, {"code":"import openpyxl …"})] | 980 / 210 |
| E12 | ToolCallExecutionEvent | executor | [FunctionExecutionResult(is_error=False, content="89706.0\\n")] | — / — |
| E13 | ToolCallSummaryMessage | executor | “89706.0” | — / — |
| E14 | SelectorEvent | selector | {"next":"planner","reason_code":"READY_TO_FINAL",…} | (미기록) |
| E15 | TextMessage | planner | FINAL ANSWER: 89706.00 | 1180 / 64 |
| E16 | SelectorEvent | selector | {"next":"critic","reason_code":"NEEDS_VERIFICATION",…} | (미기록) |
| E17 | TextMessage | critic | APPROVE | 1320 / 8 |
| E18 | …TaskResult | — | stop_reason="Text 'APPROVE' mentioned"; messages=[E1…E17] | — / — |
ModelClientStreamingChunkEvent 수십~수백 개가
흐른다(model_client_streaming=True). mapping.get_message_type()이 이들에 None을 돌려주므로
logger의 어느 분기에도 안 걸리고 조용히 버려진다 — DB에 단 한 행도 안 남는다. ThoughtEvent,
MemoryQueryEvent 도 동일.tracing/mapping.py는 IO가 없는 순수 변환표다. AutoGen .type 리터럴(또는 클래스명)을 받아 DB message_types.code를 돌려준다.
AutoGen type | get_message_type() 반환 | logger 처리 | events 행 수 |
|---|---|---|---|
(클래스명 …TaskResult) | final_answer | messages 역순에서 FINAL ANSWER 메시지 추출 | 1 |
TextMessage | agent_message | content 절단 + 토큰합산 + turn++ | 1 |
ToolCallSummaryMessage | agent_message | 위와 동일(도구결과 요약을 발화로 취급) | 1 |
ToolCallRequestEvent | tool_call | FunctionCall 1개당 1행으로 분해 + 토큰합산 | N |
ToolCallExecutionEvent | None ⚠ | logger가 content를 순회해 항목별로 tool_result/tool_error 로 쪼갬 | N |
SelectorEvent | selector_decision | JSON 파싱 → reason_code/next/summary | 1 |
SelectSpeakerEvent | selector_decision | content=List[str] 첫 항목이 선택 에이전트(reason 없음) | 1 |
HandoffMessage | handoff | content 절단 저장 | 1 |
EvaluatorScoreEvent † | evaluator_score | verdict → eval_meta JSONB(009 컬럼) | 1 |
| 그 외(StreamingChunk/Thought/MemoryQuery) | None | 스킵 — DB에 안 남음 | 0 |
ToolCallExecutionEvent의 None은 “기록하지 마라”가 아니라 “이벤트 레벨이 아니라 항목 레벨에서 분기하라”는 신호 — logger가 별도 분기(is_tool_execution_event)로 직접 잡는다. 진짜 “버려라”는 마지막 줄 None이다.EvaluatorScoreEvent는 AutoGen 표준이 아니라 FabricMAS 교차검증 워커가 yield하는 duck-typed 이벤트다. mapping/logger 모두 클래스를 import하지 않고 getattr로만 읽는다.parse_selector_reason(): ```json … ``` 래핑 해제 → json.loads → 실패 시 본문에서 첫 {~마지막 } 재시도 → 그래도 실패면 ("PARSE_FAILED", {"summary": text[:500]}). 절대 예외를 올리지 않는다.async for event in stream: 루프가 이벤트마다 위에서부터 아래 사다리를 타고 첫 매칭에서 continue. 그 사이 seq와 누산기들이 갱신된다.
logger.py:549–669)1. type 이름이 ...TaskResult → final_answer 1행, 토큰합산 안 함 2. is_tool_execution_event(event) → tool_result/tool_error N행 (항목별) 3. type == "ToolCallRequestEvent" → tool_call N행 + 토큰합산 4. type == "SelectorEvent" → selector_decision 1행 5. type == "SelectSpeakerEvent" → selector_decision 1행 6. type == "EvaluatorScoreEvent" → evaluator_score 1행 7. get_message_type==agent_message → agent_message 1행 + 토큰합산 + turns++ + FINAL ANSWER 캡처 8. get_message_type==handoff → handoff 1행 9. (그 외) → continue (스킵)
| 변수 | 갱신 시점 | 최종 → runs 컬럼 |
|---|---|---|
seq | 행을 INSERT할 때마다 +1 (이벤트 아님, 행 단위) | events.seq (PK 일부) |
total_input_tokens / output | agent_message·tool_call 분기에서만 models_usage 합산 | input_tokens / output_tokens / total_tokens |
total_turns | agent_message 1행마다 +1 | total_turns |
final_answer_str | planner의 메시지에서만 FINAL ANSWER: 한 줄 추출 | (gaia) runs.final_answer + 채점 입력 |
final_answer_full_str | critic 외 에이전트의 FINAL ANSWER 포함 메시지 전문(마지막 것이 이김) | (개방형) 채점 입력 |
stop_reason | TaskResult에서 | _terminated_by() → terminated_by |
first_ts / last_ts | 각 이벤트 도착 시각(_now_utc()) | (last−first) → latency_ms |
prev_ts | 이벤트마다 갱신(행이 아니라 이벤트 단위) | 행별 latency_ms 계산용 |
missed_inserts | INSERT 실패 1건당 +1 (예외는 삼킴) | 경고 로그만 — 배치 안 죽음 |
_safe_insert()로 감싸 있다 —
실패하면 missed_inserts += 1 하고 경고만 남기고 다음 이벤트로 간다. run이 crash로 떨어지는 건
로깅 실패가 아니라 run_stream 자체가 예외를 던질 때뿐이다(→ _mark_crashed).각 카드: 원시 객체 → 탄 분기 → 빌더가 만든 record dict → events INSERT 한 행. seq는 0부터, 누산기 변화도 함께.
_extract_tokens=(0,0). user는 planner가 아니라 FINAL ANSWER 캡처 대상 아님.parse_selector_reason() → reason_code="NEEDS_PLANNING", summary="…". 별도로 본문 {…}에서 next 추출.FINAL ANSWER: 없음 → final_answer_str 갱신 안 됨. 토큰 in=412/out=96 합산._parse_tool_args: arguments 문자열 → json.loads → _mask_secrets(민감키 없음, 통과). tool_args를 다시 json.dumps(ensure_ascii=False).json.dumps(raw)로 JSON 문자열 스칼라화. 3,914>2048 → 2048자 절단 + sha256.get_message_type이 agent_message로 매핑 → executor가 “발화”한 것으로 기록. usage None → 토큰 0. executor≠planner & executor≠critic이고 content에 FINAL ANSWER 없음.json.loads("89706.0\\n")는 성공해 float 89706.0이 된다 → tool_result에 JSON 숫자로 저장(문자열 아님). 2048 이하 → hash 없음. is_error=False라 tool_error는 NULL._extract_final_answer_from_content → "89706.00". 플레이스홀더(<…>,`,* 시작) 아님 → final_answer_str = "89706.00"._build_final_answer_record: messages 역순 탐색 → "FINAL ANSWER" 포함 메시지(E15)를 content로. final_answer_str는 이미 planner에서 잡혔으므로 덮어쓰지 않음(TaskResult 폴백은 한 번도 안 잡혔을 때만). stop_reason 저장.final_answer="89706.00", final_answer_full="음식 매출만…89706.00",
stop_reason="Text 'APPROVE' mentioned", total_turns=7,
total_tokens=5670 (in 5132 / out 538), latency_ms=26900, missed_inserts=0.위 워크스루에서 스쳐 지난 가공들. 전부 logger.py 모듈 함수 + 상수.
_mask_secrets (절대규칙 5, 단일 지점)dict/list 재귀 순회. 키가 정규식 (password|token|secret|api_key|authorization)(대소문자 무관)에 매칭되면 값을 ***로. tool_args 저장 전 반드시 통과.
# 가령 모델이 이런 인자를 넘겼다면(예시) arguments = '{"url":"https://x","api_key":"sk-live-9f...","headers":{"Authorization":"Bearer abc"}}' # → 저장되는 tool_args (JSONB) {"url":"https://x","api_key":"***","headers":{"Authorization":"***"}} # 중첩도 마스킹
우리 예제(read_attached_file/python_exec)엔 민감키가 없어 원본 그대로 통과했다. DSN/토큰이 예외·로그·커밋에 새지 않게 하는 게 목적.
| 대상 | 임계 | 초과 시 | 흔적 |
|---|---|---|---|
agent_message/final_answer/handoff content | 8192자 | text[:8192] | content_truncated=true |
| tool_result | 2048자 | json[:2048] | tool_result_hash=sha256(전체) |
E6이 이 케이스: 3,914자 → 2048 저장 + 해시. 절단된 JSON이 invalid가 될 수 있어, 절단본은 문자열로 저장(다시 파싱 실패 시).
_parse_tool_argsFunctionCall.arguments(JSON 문자열) → json.loads → 실패하면 {"_raw": 원본문자열}로 감쌈(절대 예외 안 냄) → 마스킹 → 다시 json.dumps(ensure_ascii=False).
_extract_tokensevent.models_usage.(prompt_tokens, completion_tokens). 없으면 (0,0). 행에는 x or None으로 0을 NULL화. 합산은 agent_message·tool_call 분기에서만 → tool_result·selector·TaskResult의 LLM 비용은 합계에 안 들어간다.
SelectorEvent(selector LLM)의 토큰은 어디에도 합산되지 않는다. selector_decision 행의 tokens도 NULL. 따라서 runs.total_tokens는 “selector 제외” 합계다 — 팀 간 비용 비교 시 이 편향을 감안해야 한다.events.latency_ms = 이 이벤트 ts − prev_ts(직전 이벤트). 멀티행 이벤트는 i==0 행만 값, 나머지는 NULL. prev_ts는 행이 아니라 이벤트 단위로 갱신.runs.latency_ms = last_ts − first_ts. 벽시계(이벤트 도착 시각) 기준이라 모델 추론·도구 실행·DB INSERT가 모두 포함된 end-to-end다.| 추출기 | 위치 | 입력 | 언제 쓰나 |
|---|---|---|---|
logger._extract_final_answer_from_content | 스트리밍 중 | planner 메시지만 | runs.final_answer + (gaia) 채점 입력 |
gaia.scorer.extract_final_answer | 채점기 내부(독립) | 임의 전체 텍스트(마지막 매치) | 전체 텍스트를 받았을 때(테스트/개방형 일부) |
runner 경로에선 logger가 이미 추출해 한 줄 답을 넘기므로, gaia.scorer는 재추출 없이 normalize+비교만 한다(→ §H). critic 제외·planner 한정·플레이스홀더 가드는 critic의 검수 CoT가 정답을 덮어쓰는 오염을 막기 위함.
한 run_id 아래 seq 0~17. (event_id BIGSERIAL·ts·run_id 생략. 빈 칸=NULL.)
| seq | agent | message_type | tool_name | tool_call_id | content / tool_result / selector_reason | tok | in | out | lat |
|---|---|---|---|---|---|---|---|---|---|
| 0 | user | agent_message | 첨부 엑셀은…그대로 전달하라. | ||||||
| 1 | selector | selector_decision | →planner · {NEEDS_PLANNING,"문제를 분해…"} | 1200 | |||||
| 2 | planner | agent_message | 1) executor가 read_attached_file로… | 508 | 412 | 96 | 3100 | ||
| 3 | selector | selector_decision | →executor · {NEEDS_EXECUTION,"파일을 읽어야…"} | 900 | |||||
| 4 | executor | tool_call | read_attached_file | call_Rkz9 | {"path":"/srv/…/cafe_sales.xlsx"} | 560 | 520 | 40 | 2600 |
| 5 | executor | tool_result | read_attached_file | call_Rkz9 | "# Sheet: Sales\t…"(2048자, hash 9f2c…a17b) | 180 | |||
| 6 | executor | agent_message | # Sheet: Sales …(도구결과 요약) | 50 | |||||
| 7 | selector | selector_decision | →planner · {NEEDS_PLANNING,"집계 방법…"} | 850 | |||||
| 8 | planner | agent_message | food 카테고리만 합산하는 코드를… | 840 | 720 | 120 | 4200 | ||
| 9 | selector | selector_decision | →executor · {NEEDS_EXECUTION,"코드 실행…"} | 780 | |||||
| 10 | executor | tool_call | python_exec | call_Vh2m | {"code":"import openpyxl\n…print(total)"} | 1190 | 980 | 210 | 5100 |
| 11 | executor | tool_result | python_exec | call_Vh2m | 89706.0 JSON number | 1300 | |||
| 12 | executor | agent_message | 89706.0 | 40 | |||||
| 13 | selector | selector_decision | →planner · {READY_TO_FINAL,"최종 작성…"} | 820 | |||||
| 14 | planner | agent_message | 음식 매출만…FINAL ANSWER: 89706.00 | 1244 | 1180 | 64 | 3600 | ||
| 15 | selector | selector_decision | →critic · {NEEDS_VERIFICATION,"검증…"} | 760 | |||||
| 16 | critic | agent_message | APPROVE | 1328 | 1320 | 8 | 1400 | ||
| 17 | final_answer | 음식 매출만…FINAL ANSWER: 89706.00 | 20 | ||||||
| 합계 (agent_message·tool_call만 합산) | 5670 | 5132 | 538 | 26900* | |||||
* 26900은 행별 latency 합이 아니라 last−first 벽시계다(seq0의 NULL 때문에 행 합과는 다름). tool_call/result/selector/final_answer 행은 토큰 합산에 안 들어간다.
logger가 RunResult를 돌려주면, runner가 채점기를 골라 돌리고 runs를 completed로 확정한다.
runner.py:258–261)is_open_ended = task.benchmark in _OPEN_ENDED # {analysis,cppx,extract,sql,meeting,ocrproxy,coding,codetest} # gaia는 _OPEN_ENDED 밖 → 한 줄 답 사용 answer_text = trace_result.final_answer # = "89706.00" # (개방형이면 final_answer_full 전문을 써서 출처/본문까지 채점)
format_valid = predicted is not None # "89706.00" → True norm_pred = normalize("89706.00") # 통화·콤마·% 제거 → "89706.00" → float → "89706" norm_gold = normalize("89706.00") # → "89706" success = _compare_normalized("89706","89706") # 둘 다 float 파싱 → abs(a−b)<1e-6 # ScoreResult(success=True, format_valid=True)
| 컬럼 | 값 | 출처 |
|---|---|---|
| status | completed | 고정 |
| terminated_by | approve | _terminated_by("Text 'APPROVE'…") → "approve" |
| final_answer | 89706.00 | answer_text(=trace.final_answer) |
| success | true | score_result.success |
| format_valid | true | score_result.format_valid |
| total_turns | 7 | trace.total_turns |
| total_tokens / input / output | 5670 / 5132 / 538 | trace 합산 |
| latency_ms | 26900 | trace(last−first) |
| ended_at | now() | UPDATE 시각 |
| (INSERT 시 이미 박힌 값) team_id=v5-multitool · task_id=c61d… · attempt=1 · batch_id=… · model_name=glm-4.7 · temperature=0.0 · seed=42 · max_messages=30 | ||
_execute_run의 except가 _mark_crashed 호출 →
status='crashed', terminated_by='error', ended_at=now(). success는 NULL로 남는다 — 그래서
CHECK(status!='completed' OR success IS NOT NULL)을 위반하지 않는다(crashed는 success NULL 허용).배치가 끝나면 run_batch 말미에 db.refresh_views(conn). report/Grafana는 원본 테이블이 아니라 이 뷰를 읽는다(상세는 코드맵 §6·§9).
| 뷰 | 읽는 컬럼 | 산출 |
|---|---|---|
mv_batch_summary | runs.success / total_tokens / latency_ms / status | 배치×팀 pass@1·효율·p95 |
mv_passk | runs.success × attempt | pass@k |
mv_tool_summary | events(message_type∈{tool_call,tool_error}, tool_name) | 도구별 호출/에러 수 — idx_events_tool 부분 인덱스 활용 |
| grounding 뷰들 | events + 도메인 grounding 테이블 | 인용 grounded% 등 |
우리 run 1건은 mv_tool_summary에 read_attached_file ×1, python_exec ×1(에러 0)로, mv_batch_summary에 v5-multitool의 성공 1건/토큰 5670/latency 26900으로 집계된다.
위 §A~§H의 골격(SelectorGroupChat P-E-C, logger, scorer)은 v1~v6 전부 동일하다. 각 버전은 단 하나의 변수만 바꿔, 그 효과를 격리 측정한다(experiments 원장). 먼저 요약표, 그 다음 모든 프롬프트·코드·레지스트리 변경의 실제 unified diff(git diff 그대로)를 전수 수록한다.
| 버전 | 건드리는 단계 | 실제 변경(파일) | 왜 (직전 실패) | prompt_bundle_hash | trace에서 보이는 차이 |
|---|---|---|---|---|---|
| v1-baseline | —(기준) | P-E-C + read_attached_file(UTF-8) + python_calc(AST 산술). run_message=task.question |
— | H₁ | 첨부 태스크: 질문에 경로 없음 → planner가 파일명 환각 → tool_error FileNotFound. success=false |
| v2-attach-path | 입력층(build_run_message) |
v2_attach_path.py: run 메시지에 첨부 절대경로 주입. 프롬프트·도구·토폴로지 완전 동일 |
v1의 파일명 환각으로 read 전수 실패 | =H₁ v1과 동일 | seq0 user content에 [첨부파일 경로:…] 등장 · tool_call.args.path가 올바른 경로. 단 UTF-8 리더라 바이너리(xlsx)는 여전히 UnicodeDecodeError → tool_error |
| v3-prompt-fix | 에이전트 프롬프트(planner·critic) | FINAL ANSWER 예시를 실제 값으로 교체 + <answer> 플레이스홀더 금지/반려 명시. python_calc 용도 명시. 경로주입 유지 |
모델이 FINAL ANSWER: <answer> 그대로 출력 → logger 플레이스홀더 가드가 None → format_valid=false |
H₃ 새 hash | planner content가 실제 값. logger의 _TEMPLATE_PLACEHOLDER_RE에 더는 안 걸림 → final_answer 채워짐 |
| v4-prompt-fix | 에이전트 프롬프트(+규칙 1줄) | “답이 한 단어·한 글자여도 반드시 FINAL ANSWER: 형식” + critic은 그 행 없으면 무조건 반려 |
v3에서 Guava 단독 출력 → FINAL ANSWER 행 없음 → format_valid=false |
H₄ 새 hash | 짧은 답도 planner content에 FINAL ANSWER: 행 보장 → format_valid 안정 |
| v5-multitool 예제 run |
도구 교체(executor) + planner/executor 프롬프트 | tools_v5.py: read_attached_file→멀티포맷(xlsx/docx/pptx/pdf, 이미지·오디오는 안내문) · python_calc→python_exec(격리 subprocess, 20s 타임아웃, 8KB 출력). 형식규칙은 v4 계승 |
v1~v4 UTF-8 리더가 바이너리 첨부에서 전수 실패 · python_calc는 파싱코드 SyntaxError | H₅ 새 hash | tool_name=python_exec(키 code), 바이너리 xlsx가 tool_result(추출 텍스트)로 성공. 이게 §G의 덤프 |
| v6-verify-gate | 라우팅(selector_prompt만) |
v6_verify_gate.py: selector에 “검증 의무 경유” 블록 추가 — planner가 FINAL ANSWER: 내면 반드시 critic 선택, critic APPROVE 없이는 종료 금지. planner/executor/critic·도구·종료조건은 v1과 동일 |
실패 run의 critic 미경유(MAST FM-3.2): critic 게으름이 아니라 selector가 검증을 건너뛰는 라우팅 결정 | H₆ 새 hash (selector만 달라도) |
selector_decision 시퀀스가 강제됨: planner FINAL ANSWER 직후 행이 항상 →critic, NEEDS_VERIFICATION. critic 미경유 종료가 사라짐 |
prompt_bundle_hash 체인 (sha256 of "\n---\n".join([PLANNER,EXECUTOR,CRITIC,SELECTOR])) —
표의 H₁~H₆에 해당하는 진짜 값:
+++ b/teams/v2_attach_path.py +def build_team(settings, model_client): + """v1-baseline 구성을 team_id만 바꿔 그대로 빌드한다.""" + return v1_baseline.build_team(settings, model_client, team_id="v2-attach-path")
+++ b/teams/v2_attach_path.py +def build_run_message(task, settings): + file_name = getattr(task, "file_name", None) + if not file_name: + return task.question # 첨부 없으면 v1과 동일 + abs_path = Path(settings.gaia_dir).expanduser().resolve() / file_name + return (f"{task.question}\n\n" + f"[첨부파일 경로: {abs_path}] " + f"이 파일이 필요하면 read_attached_file 도구에 이 경로를 그대로 전달하라.")
task.question)--- a/teams/registry.py +++ b/teams/registry.py _RUN_MESSAGE_BUILDERS: dict[str, Callable] = { + _V2_TEAM_ID: _v2_build_run_message, }
from v1_baseline import … 그대로 ·
hash H₁→H₃
--- a/PLANNER_PROMPT (v1=v2) +++ b/PLANNER_PROMPT (v3) @@ -2,5 +2,11 @@ - 태스크를 단계로 분해하고 executor에게 필요한 계산/파일 작업을 지시한다. - 직접 도구를 실행하지 않는다. -- 모든 필요 정보가 모이면 critic 검증을 받을 최종 답변 초안을 작성한다. -- 최종 답변은 반드시 `FINAL ANSWER: <answer>` 한 줄이어야 한다. -- 숫자는 콤마/단위 없이, 문자열은 관사 없이, 리스트는 콤마 구분으로 작성한다. +- 모든 필요 정보가 모이면 critic 검증을 받을 최종 답변을 아래 형식으로 한 줄만 출력한다. + + 숫자: FINAL ANSWER: 42 + 문자열: FINAL ANSWER: Paris + 리스트: FINAL ANSWER: cat, dog, fish + +- <answer> 같은 플레이스홀더는 절대 출력하지 않는다. 반드시 실제 값을 적는다. +- 숫자는 콤마·단위 없이, 문자열은 관사 없이, 리스트는 쉼표 구분으로 작성한다. +- executor의 python_calc는 산술식 전용(예: 3*4+2)이다. import/print 코드는 전달하지 않는다.
--- a/CRITIC_PROMPT (v1=v2) +++ b/CRITIC_PROMPT (v3) @@ -1,4 +1,5 @@ 당신은 GAIA 평가용 critic이다. - 최종 답변이 질문에 정확히 답하는지 검토한다. -- `FINAL ANSWER: <answer>` 형식을 지키는지 확인한다. +- `FINAL ANSWER: 42` 또는 `FINAL ANSWER: Paris` 처럼 실제 값이 들어간 형식인지 확인한다. +- `FINAL ANSWER: <answer>` 처럼 꺾쇠 플레이스홀더가 남아 있으면 반드시 반려한다. - 통과하면 APPROVE 만 단독 출력하고, 아니면 구체적 반려 사유를 출력한다.
<answer>를 그대로 따라 써 FINAL ANSWER: <answer>를 출력 → logger의 _TEMPLATE_PLACEHOLDER_RE(^[`<*])가 None 반환 → final_answer 미캡처. 예시를 실제 값으로 바꾸고 플레이스홀더를 명시 금지해 차단.--- a/PLANNER_PROMPT (v3) +++ b/PLANNER_PROMPT (v4) @@ -8,4 +8,5 @@ 리스트: FINAL ANSWER: cat, dog, fish +- 답이 한 단어 또는 한 글자여도 반드시 `FINAL ANSWER:` 형식을 사용한다. 절대 생략하지 않는다. - <answer> 같은 플레이스홀더는 절대 출력하지 않는다. 반드시 실제 값을 적는다. - 숫자는 콤마·단위 없이, 문자열은 관사 없이, 리스트는 쉼표 구분으로 작성한다.
--- a/CRITIC_PROMPT (v3) +++ b/CRITIC_PROMPT (v4) @@ -2,4 +2,5 @@ - 최종 답변이 질문에 정확히 답하는지 검토한다. - `FINAL ANSWER: 42` 또는 `FINAL ANSWER: Paris` 처럼 실제 값이 들어간 형식인지 확인한다. +- `FINAL ANSWER:` 행이 없으면 답변 길이에 관계없이 반드시 반려한다. - `FINAL ANSWER: <answer>` 처럼 꺾쇠 플레이스홀더가 남아 있으면 반드시 반려한다. - 통과하면 APPROVE 만 단독 출력하고, 아니면 구체적 반려 사유를 출력한다.
Guava 한 단어면 모델이 FINAL ANSWER: 행을 생략 → format_valid=false. “한 글자라도 형식 필수” 한 줄 + critic 반려 규칙 한 줄로 격리 수정.--- a/teams/v5_multitool.py +++ b/teams/v5_multitool.py -from agent_eval.teams.v1_baseline import (… python_calc, read_attached_file) +from agent_eval.teams.tools_v5 import python_exec, read_attached_file ... - tools=[read_attached_file, python_calc], + tools=[read_attached_file, python_exec],
- def read_attached_file(path): # v1~v4 (teams/v1_baseline.py) - p = Path(path).expanduser().resolve() - if not p.is_file(): raise FileNotFoundError(str(p)) - return p.read_text(encoding="utf-8") # ← 바이너리에서 UnicodeDecodeError로 전수 실패 + def read_attached_file(path): # v5 (teams/tools_v5.py) + ext = p.suffix.lower() + if ext in _TEXT_EXTS: content = _read_text(p) # errors="replace" + elif ext in (".xlsx",".xls"): content = _read_xlsx(p) # openpyxl 시트→탭 텍스트 + elif ext == ".docx": content = _read_docx(p) # python-docx + elif ext == ".pptx": content = _read_pptx(p) # python-pptx + elif ext == ".pdf": content = _read_pdf(p) # pdfplumber + elif ext in _IMAGE_EXTS: return "[이미지 …텍스트 추출 불가…]" # 크래시 대신 안내문 + elif ext in _AUDIO_EXTS: return "[오디오 …전사 불가…]" + return _truncate(content, 20_000)
- def python_calc(expression): # v1~v4 — AST allowlist, 산술만 - parsed = ast.parse(expression, mode="eval") # 파일 파싱 코드 넘기면 SyntaxError - return str(_eval_numeric(parsed)) + def python_exec(code): # v5 — 모델이 쓴 파이썬을 실행 + proc = subprocess.run([sys.executable, "-c", code], + capture_output=True, text=True, + timeout=20, cwd=tempdir) # 20s 타임아웃 · 8KB 출력 가드 · 폐쇄망 + return _truncate(proc.stdout, 8_000)
--- a/PLANNER_PROMPT (v4) +++ b/PLANNER_PROMPT (v5) @@ -1,4 +1,4 @@ 당신은 GAIA 평가용 planner다. -- 태스크를 단계로 분해하고 executor에게 필요한 계산/파일 작업을 지시한다. +- 태스크를 단계로 분해하고 executor에게 필요한 파일 작업/계산을 지시한다. - 직접 도구를 실행하지 않는다. - 모든 필요 정보가 모이면 critic 검증을 받을 최종 답변을 아래 형식으로 한 줄만 출력한다. @@ -11,3 +11,4 @@ - <answer> 같은 플레이스홀더는 절대 출력하지 않는다. 반드시 실제 값을 적는다. - 숫자는 콤마·단위 없이, 문자열은 관사 없이, 리스트는 쉼표 구분으로 작성한다. -- executor의 python_calc는 산술식 전용(예: 3*4+2)이다. import/print 코드는 전달하지 않는다. +- 첨부파일이 주어지면 executor에게 read_attached_file로 내용을 먼저 추출하게 한다. +- 표 합계·필터·정렬 등 산술 이상의 계산이 필요하면 executor에게 python_exec 코드를 지시한다.
--- a/EXECUTOR_PROMPT (v1~v4) +++ b/EXECUTOR_PROMPT (v5) @@ -1,4 +1,10 @@ -당신은 GAIA 평가용 executor다. -- planner가 요청한 로컬 첨부파일 읽기와 안전한 산술 계산만 수행한다. -- 폐쇄망 전제이므로 웹/API 호출을 시도하지 않는다. -- 도구 결과를 간결히 보고한다. +당신은 GAIA 평가용 executor다. 두 가지 로컬 도구만 사용한다. +1. read_attached_file(path): 첨부파일을 텍스트로 추출한다. 텍스트뿐 아니라 + xlsx/docx/pptx/pdf도 지원한다. 이미지/오디오는 추출 불가 안내를 반환한다. + 질문에 [첨부파일 경로: ...]가 있으면 그 절대경로를 그대로 전달한다. +2. python_exec(code): 격리된 Python으로 코드를 실행하고 print 출력을 받는다. + 파일 파싱·표 집계·정렬·날짜 계산 등 산술 이상의 작업에 사용한다. + 첨부 파일은 절대경로로 open 한다. 결과는 반드시 print 한다. + pandas, openpyxl 등 표준 데이터 라이브러리를 import 할 수 있다. +- 폐쇄망 전제이므로 웹/API 호출은 시도하지 않는다. +- 도구 결과(추출 텍스트·실행 출력)를 간결히 보고한다.
python_calc→python_exec, args 키가 expression→code.
바이너리 xlsx가 더는 UnicodeDecodeError(tool_error)가 아니라 추출 텍스트(tool_result)로 떨어진다 — §E·§G의 그 run이 바로 이 변경의 산물.from v1_baseline import … 무변경 · hash H₁→H₆
--- a/SELECTOR_PROMPT (v1) +++ b/SELECTOR_PROMPT (v6) @@ -4,4 +4,11 @@ Conversation: {history} + +Mandatory verification gate (do not violate): +- A run may end ONLY after critic outputs APPROVE. +- The moment planner produces a line starting with `FINAL ANSWER:`, you MUST select + critic next with reason_code NEEDS_VERIFICATION. Never route to planner or executor + to "finalize" a draft — only critic can clear a final answer. +- If critic returned a rejection, route back to planner with reason_code NEEDS_REVISION. Return exactly one JSON object with this schema:
v1_baseline.build_team(team_id="v2-attach-path")를 그대로 호출(프롬프트 재사용) → 그래서 hash가 v1과 같다. v6은 SELECTOR_PROMPT만 바꿔 hash가 달라진다. v3/v4/v5는 planner/critic/executor·도구를 건드려 hash가 달라진다.registry.ensure_team_record): 같은 team_id인데 DB의 prompt_bundle_hash와 다르면 TeamConfigMismatchError로 즉시 중단. “프롬프트 한 글자라도 바꿨으면 새 team_id를 써라”를 DB가 집행한다.registry._RUN_MESSAGE_BUILDERS에 v2~v5만 등록 — v1의 입력 재현성을 깨지 않으려고 “기본은 task.question 그대로”를 유지한다.스키마(001_init.sql)의 CHECK는 “정합성을 코드 검증만으로 끝내지 마라”(절대규칙 4)의 집행자. 위 18행+runs 1행이 실제로 통과하는지 대조.
| 제약 | 이 run에서 |
|---|---|
events PK (run_id, seq) | seq 0~17 유일 ✔ |
tool_call → tool_call_id·tool_name NOT NULL | seq4·10: call_Rkz9/Vh2m + 이름 둘 다 있음 ✔ |
tool_result → tool_call_id NOT NULL | seq5·11: call_id 있음 ✔ |
NOT(tool_result AND tool_error 동시 NOT NULL) | 둘 다 tool_result만(is_error=False) ✔ |
latency_ms ≥ 0 OR NULL | seq0=NULL, 나머지 ≥0 ✔ |
runs: status='completed' → success NOT NULL | completed + success=true ✔ |
terminated_by ∈ {approve,max_messages,error} | approve ✔ |
temperature ≥ 0 · tokens ≥ 0 | 0.0 · 5670/5132/538 ✔ |
UNIQUE(batch_id, task_id, attempt) | 재실행 시 _prepare_run_row가 기존 행 DELETE 후 재INSERT(retry) ✔ |
진입점은 python cli.py <command> (typer). 모든 명령은 load_settings()로 .env의 EVAL_*를 읽는다. 비밀(DSN/키)은 로그·예외에 출력하지 않는다.
EVAL_*) — src/agent_eval/config.py| 변수 | 기본값 | 의미 / 비고 |
|---|---|---|
EVAL_PG_DSN | 필수 | PostgreSQL DSN. 누락 시 모든 명령 즉시 실패(값은 미출력). 스키마 eval_ph1로 격리 |
EVAL_LLM_BASE_URL | None | 내부 vLLM OpenAI-compatible 엔드포인트. 폐쇄망: 이 값만 사용(외부 API 금지) |
EVAL_LLM_MODEL | None (.env: glm-4.7) | runs.model_name 으로 기록. 항상 당시 최고 가용 모델 권장 |
EVAL_LLM_API_KEY | None (.env: dummy) | vLLM은 임의값 허용. 외부키면 커밋 금지 |
EVAL_LLM_REASONING_EFFORT | None | reasoning 모델 추론강도(예: xhigh). 있을 때만 create arg로 전달 |
EVAL_GAIA_DIR | data/gaia/validation | GAIA metadata.jsonl+첨부 위치. v2~v5 첨부 절대경로 주입의 기준 |
EVAL_RESEARCH_DIR / EVAL_CPP_DIR | data/research · expert-solver/problems | research / cppx 태스크 소스 |
EVAL_DEFAULT_TEMPERATURE | 0.2 | runs.temperature(NOT NULL). 재현성 비교의 고정 변수 |
EVAL_DEFAULT_SEED | None (.env: 42) | runs.seed. 비교하려면 반드시 설정(미설정 run은 비교 불능) |
EVAL_MAX_MESSAGES | 20 | MaxMessageTermination 한계 → 초과 시 terminated_by=max_messages |
EVAL_CONCURRENCY | 1 (.env: 2) | --concurrency 미지정 시 기본 동시 실행 수 |
EVAL_FIRECRAWL_API_KEY / _BASE_URL | None · api.firecrawl.dev | web_search 백엔드. 키 있으면 Firecrawl, 없으면 DDG 폴백 |
EVAL_CLOSED_NET | true | 폐쇄망 토글(기본 ON=안전). web 검색 모드는 OFF에서만 |
run — 평가 배치 실행 (핵심)python cli.py run --batch <ID> [--team …] [--benchmark …] [--level N] [--exclude-web]
[--task-ids a,b] [--limit N] [--attempts N] [--concurrency N] [--retry-crashed]
| 옵션 | 타입 | 기본 | 의미 (cli.py:212) |
|---|---|---|---|
--batch | str | 필수 | 배치 ID = runs.batch_id. UNIQUE(batch_id,task_id,attempt)의 재개 키 — 같은 batch 재실행 시 완료분은 건너뜀 |
--team | str | v1-baseline | registry에 등록된 team_id (미등록이면 등록목록과 함께 KeyError) |
--benchmark | str | gaia | tasks.benchmark 필터. gaia / research / cppx / analysis / extract / sql / meeting / ocrproxy / coding / codetest … |
--level | int | None | GAIA level(1·2·3) 또는 research hops 필터 |
--exclude-web | flag | off | requires_web=false인 task만 — 폐쇄망/오프라인 스모크 |
--task-ids | csv | None | 특정 task_id만(콤마구분). 크래시 수렴·디버깅에 핵심 |
--limit | int | None | 선택 task 수 상한(ORDER BY task_id LIMIT). 스모크용 |
--attempts | int≥1 | 1 | task별 반복 횟수 → pass@k. 각 attempt가 독립 run 행 |
--concurrency | int≥1 | None→EVAL_CONCURRENCY | 동시 실행 수(asyncio.Semaphore). 풀 크기도 자동 +1 |
--retry-crashed | flag | off | 기존 crashed run을 삭제 후 재실행. completed는 무조건 skip |
runner._prepare_run_row): 같은 (batch,task,attempt)가 completed면 skip · crashed면 --retry-crashed일 때만 삭제 후 재시도 · 그 외 신규 INSERT(status=running). → 같은 --batch로 다시 돌리면 멈춘 지점부터 이어진다.REFRESH(db.refresh_views) → report/Grafana가 즉시 최신.# ① 오프라인 스모크 — 2문제만, 웹 불요, 끝까지 도는지 확인 python cli.py run --batch dev_smoke --team v1-baseline --limit 2 --exclude-web # ② GAIA level-1 전체를 v5로 (첨부 멀티포맷 도구) python cli.py run --batch gaia_l1_v5 --team v5-multitool --benchmark gaia --level 1 --concurrency 4 # ③ pass@3 (task별 3회 반복) — 변동성 측정 python cli.py run --batch gaia_l1_v5_k3 --team v5-multitool --level 1 --attempts 3 # ④ 1변수 비교쌍: 같은 task 셋을 v1 / v6 두 배치로 python cli.py run --batch cmp_v1 --team v1-baseline --level 1 python cli.py run --batch cmp_v6 --team v6-verify-gate --level 1 python cli.py compare --batches cmp_v1,cmp_v6 # ⑤ 특정 실패 task만 골라 크래시 재수렴 (1개씩 독립 권장 — 아래 §M.4) python cli.py run --batch gaia_l1_v5 --task-ids c61d22de-…,8f80a1b2-… --retry-crashed
| 명령 | 역할 | 주요 옵션 |
|---|---|---|
init-db | migrations 001+ 멱등 적용(000_provision은 DBA 수동). 2회 실행해도 성공(AC1) | — |
load-gaia | EVAL_GAIA_DIR/metadata.jsonl → tasks 적재(첨부 누락 task는 필터 표시) | — |
load-research | tasks.jsonl → tasks | --dir --benchmark research|research_hard |
load-cppx | Expert 문제 → tasks(benchmark=cppx) | --dir --level-dir L5_full |
load-learn | algo_seed.jsonl → tasks(learn_algo) | — |
| 명령 | 역할 | 옵션 |
|---|---|---|
report | 배치 요약: pass@1·pass@k·레벨별 p50·도구 사용 | --batch (필수) |
compare | 2개 이상 배치를 레벨별로 나란히 비교 | --batches a,b[,c] (2개↑) |
failures | 실패 run 트랜스크립트 요약(마지막 이벤트들) | --batch --limit 10 |
| 명령 | 역할 | 옵션 |
|---|---|---|
grounding-score | research 배치 인용 grounding(F1) → research_grounding | --batch |
cppx-grounding | cppx 배치 사실 인용 grounding → cppx_grounding | --batch |
export-team | 팀 → AutoGen Studio 컴포넌트 JSON(비밀 스크럽·날짜 동적화) | --team --out --baseline-tool |
brain-digest | 배치 per-claim verdict(events.eval_meta) → grounded 다이제스트 | --batch --interest --period --write |
learn-next / learn-update | 학습 슬라이스: 다음 출제 토픽 선택 / 결과를 learner_skill 반영 | --batch(update) |
fleet-run | 야간 플릿 1회전(브리프). 기본 --dry-run, 저장은 --commit | --date --domains --commit |
propose | 복리 엔지니어링: 이상치/반복 → 1변수 실험·규칙 제안. 기본 dry-run | --domains --commit --git-lookback 30 |
narrate(LLM 서사 리포트)는 SPEC §0.3대로 의도적 미구현.
“새 실험을 하나 추가해서 돌리고 비교한다”의 전 과정. 핵심 규율: 1배치=1조건, 1팀=1변수, 비교는 같은 task 셋으로.
pip install -e ".[dev]" # 폐쇄망이면 사내 PyPI 미러 cp .env.example .env # EVAL_PG_DSN / EVAL_LLM_* 채우기 python cli.py init-db # 001+ 멱등 적용 (2회 돌려도 성공해야 정상) python cli.py load-gaia # metadata.jsonl → tasks python scripts/smoke_llm.py # GLM function calling 왕복 (M2 전 필수) python cli.py run --batch dev_smoke --team v1-baseline --limit 2 --exclude-web
--enable-auto-tool-choice --tool-call-parser <glm parser> 없이는 도구 호출이 왕복하지 않는다.python cli.py run --batch <새 batch ID> --team v5-multitool --benchmark gaia --level 1
python cli.py report --batch <새 batch ID> # pass@1·p50·도구
배치 ID는 의미를 담아 유일하게(예: gaia_l1_v5_20260623). 중간에 끊겨도 같은 ID로 다시 돌리면 이어서 채운다.
§J의 v2~v6이 바로 이 절차로 만들어졌다. “프롬프트 한 글자라도” 바꾸면 새 team_id(AC9).
| # | 단계 | 구체 |
|---|---|---|
| 1 | 팀 파일 작성 | src/agent_eval/teams/v7_xxx.py — TEAM_ID, 프롬프트(또는 v1 재사용), build_team()가 (SelectorGroupChat, team_meta) 반환. team_meta엔 prompt_bundle_hash 필수. 입력을 가공하면 build_run_message(task, settings)도 정의 |
| 2 | 레지스트리 등록 | teams/registry.py: import 후 TEAM_REGISTRY에 team_id → build_team 한 줄. 입력 가공팀이면 _RUN_MESSAGE_BUILDERS에도 추가 |
| 3 | 새 team_id | 프롬프트가 바뀌었으면 반드시 새 ID. 기존 ID에 다른 prompt_bundle_hash를 쓰면 ensure_team_record가 TeamConfigMismatchError로 즉시 중단(AC9) |
| 4 | 테스트 | pytest -x. 매핑을 건드렸으면 tests/test_mapping.py, 채점기면 tests/test_scorer.py 동반 갱신 |
| 5 | 스모크 | run --team v7-xxx --limit 2 --exclude-web — 끝까지 돌고 정합성 SQL 0행 확인(§M.5) |
| 6 | 본 실행 + 비교 | 전체 run 후 baseline 배치와 compare --batches base,v7 |
teams/registry.py)# ① import (파일 상단 v-시리즈 import 옆) +from agent_eval.teams.v7_xxx import TEAM_ID as _V7_TEAM_ID +from agent_eval.teams.v7_xxx import build_team as _build_team_v7 # ② 빌더 매핑에 한 줄 TEAM_REGISTRY: dict[str, Callable] = { ... + _V7_TEAM_ID: _build_team_v7, } # ③ (입력 가공팀일 때만) run 메시지 빌더 등록 _RUN_MESSAGE_BUILDERS: dict[str, Callable] = { ... + _V7_TEAM_ID: _v7_build_run_message, }
migrations/0NN_*.sql 번호 증가 파일을 추가(기존 파일 수정 금지)하고 init-db로 적용. 모델은 EVAL_LLM_BASE_URL(내부 vLLM)만.pytest -x # 전체. LLM은 mock — 실 네트워크 호출 금지 pytest tests/test_scorer.py -x # 채점 규칙(AC3: 최소 15케이스) pytest tests/test_mapping.py -x # 이벤트→message_type 매핑
신규 코드엔 신규 테스트 동반(scorer/매핑/제약은 필수 대상). 동작을 바꾸면 해당 테스트부터 확인한다.
--batch로 재실행 → 완료분 skip, 나머지만. crashed를 다시 돌리려면 --retry-crashed.--retry-crashed 반복, 또는 ⓑ 남은 것을 --task-ids로 1개씩 독립 프로세스로 실행.--concurrency를 올려도 됨.# 크래시 1건씩 독립 재시도 (bash) for t in $(M.5 정합성 점검 SQL (Definition of Done)
스모크 후 아래가 모두 0행이어야 완료 선언:
-- 완료 run인데 success 누락 SELECT run_id FROM runs WHERE status='completed' AND success IS NULL; -- tool_call인데 응답(result/error) 짝이 없는 이벤트 SELECT e.run_id, e.seq FROM events e WHERE e.message_type='tool_call' AND NOT EXISTS ( SELECT 1 FROM events r WHERE r.run_id=e.run_id AND r.tool_call_id=e.tool_call_id AND r.message_type IN ('tool_result','tool_error'));M.6 한 배치의 체크리스트
①.env고정값(temperature·seed·model) 확인 → ②pytest -x→ ③run --limit 2스모크 + 정합성 SQL 0행 → ④ 전체run(같은 batch로 재개 가능) → ⑤report --batch/ 1변수면compare→ ⑥ (research/cppx면)grounding-score→ ⑦ Grafana/eval-metrics에서 확인. 검증 못 하면 완료 선언 안 함.