backend/frontend/runtime: 训练面板 / session 隔离 / agent runtime 调整

- backend/api/server.py:训练 step-detail / pipeline 卡片排序逻辑
- frontend:thread-list / thread / training-pipeline-panel / step-detail-sheet 配套调整,新增 metrics-chart-panel
- src/agent_*:tool spec / runtime / prompting 配套
- .gitignore 加 .logs/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
hupenglong1
2026-05-25 11:43:10 +08:00
parent 57f5b60a3e
commit f068311cac
17 changed files with 1571 additions and 270 deletions
+520 -75
View File
@@ -1696,12 +1696,31 @@ def create_app(state: AgentState) -> FastAPI:
async def get_training_step_detail(
session_id: str,
step: str,
run_id: str | None = None,
account_id: str | None = None,
) -> Response:
sessions_dir = state.account_paths(account_id)['sessions']
state_path = _session_state_path(sessions_dir, session_id)
state_entries, _ = _read_program_state(state_path)
run_dic = _resolve_run_dic_from_state(state_entries)
# Per-round: if frontend passed run_id, use that round's actual runDic
# so historical step views (e.g., R1's augment) read the right
# workflow<N> directory. Fall back to "max in state" only when run_id
# is absent or the round has no resolvable runDic — preserves prior
# behaviour for unbundled callers.
run_dic: int | None = None
if isinstance(run_id, str) and run_id.strip():
rid = run_id.strip()
per_round = _resolve_run_dic_per_round(state_entries)
# augment 处理的是「上一轮评测的错例」,产物按 R{n-1} 的 runDic 命名
# data_clean_<R{n-1}>/、augment_<R{n-1}>.jsonl ...),不是本轮 eval workflow
if step == 'augment':
m = re.match(r'^[Rr](\d+)$', rid)
if m and int(m.group(1)) >= 1:
run_dic = per_round.get(f'R{int(m.group(1)) - 1}')
if run_dic is None:
run_dic = per_round.get(rid)
if run_dic is None:
run_dic = _resolve_run_dic_from_state(state_entries)
runtime = _jupyter_runtime_for_session(state, account_id, session_id)
artifact_paths = _step_artifact_paths(step, run_dic, runtime)
@@ -1850,6 +1869,33 @@ def create_app(state: AgentState) -> FastAPI:
'shown_rows': len(table_rows),
}
)
if step == 'dist-analysis':
# 软警告:dist-analysis 标 complete 时若 H1 候选 CSV 缺失,把 section
# 的兜底 "产物文件不存在" 换成更明确的提示。某些轮次(hparam 类假设、
# 无标签问题)确实可能没有候选,所以不 fail 这一步、不阻塞 human-check。
target_run = (run_id or '').strip()
last_status: str | None = None
for entry in state_entries:
if not isinstance(entry, dict) or entry.get('step') != 'dist-analysis':
continue
eid = (entry.get('run_id') or '').strip()
if target_run and eid and eid != target_run:
continue
if 'status' in entry:
last_status = entry.get('status')
if last_status == 'complete':
rd_label = str(run_dic) if run_dic is not None else '<runDic>'
for sec in table_sections:
if (
str(sec.get('title', '')).startswith('H1 预计修改候选')
and sec.get('kind') == 'missing'
):
sec['error'] = (
f'未检测到 output/relabel_candidates_{rd_label}.csv —— '
f'若本轮无 H1 候选可忽略;如有候选请按 SKILL.md 「阶段一」'
f'落盘 file,line,query,old_label,是否改(1/0),建议新label。'
)
break
payload = {
'step': step,
'run_dic': run_dic,
@@ -1923,6 +1969,12 @@ def create_app(state: AgentState) -> FastAPI:
agent_state=state,
account_id=account_id,
)
metrics_history = await asyncio.to_thread(
_read_iteration_log_metrics_history,
session_id,
agent_state=state,
account_id=account_id,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
session_id,
@@ -1931,6 +1983,8 @@ def create_app(state: AgentState) -> FastAPI:
model_config=state.model_config_for(account_id),
agent_state=state,
account_id=account_id,
iteration_log_count=iter_count,
metrics_history=metrics_history,
)
failure_status = await asyncio.to_thread(
_read_session_failure_status,
@@ -1964,48 +2018,82 @@ def create_app(state: AgentState) -> FastAPI:
async def event_gen():
last_signature: str | None = None
last_heartbeat = time.monotonic()
while True:
if await request.is_disconnected():
break
state_entries, state_mtime = _read_program_state(state_path)
iter_count = await asyncio.to_thread(
_read_iteration_log_count,
refresher = asyncio.create_task(
_iter_count_refresh_loop(
session_id,
agent_state=state,
account_id=account_id,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
)
metrics_refresher = asyncio.create_task(
_metrics_history_refresh_loop(
session_id,
sessions_dir=sessions_dir,
state_entries=state_entries,
agent_state=state,
account_id=account_id,
)
failure_status = await asyncio.to_thread(
_read_session_failure_status,
sessions_dir,
session_id,
)
run_active = _is_run_active(state, account_id, session_id)
_apply_program_state(
payload,
state_entries,
iteration_log_count=iter_count or 0,
session_failure_status=failure_status,
run_active=run_active,
session_id=session_id,
)
payload_str = json.dumps(payload, ensure_ascii=False)
signature = f'{state_mtime}|{hash(payload_str)}'
if signature != last_signature:
last_signature = signature
yield f'data: {payload_str}\n\n'
now = time.monotonic()
if now - last_heartbeat > 15:
yield ': heartbeat\n\n'
last_heartbeat = now
await asyncio.sleep(0.5)
)
try:
while True:
if await request.is_disconnected():
break
state_entries, state_mtime = _read_program_state(state_path)
iter_count = await asyncio.to_thread(
_read_iteration_log_count,
session_id,
agent_state=state,
account_id=account_id,
)
metrics_history = await asyncio.to_thread(
_read_iteration_log_metrics_history,
session_id,
agent_state=state,
account_id=account_id,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
session_id,
sessions_dir=sessions_dir,
state_entries=state_entries,
agent_state=state,
account_id=account_id,
iteration_log_count=iter_count,
metrics_history=metrics_history,
)
failure_status = await asyncio.to_thread(
_read_session_failure_status,
sessions_dir,
session_id,
)
run_active = _is_run_active(state, account_id, session_id)
_apply_program_state(
payload,
state_entries,
iteration_log_count=iter_count or 0,
session_failure_status=failure_status,
run_active=run_active,
session_id=session_id,
)
payload_str = json.dumps(payload, ensure_ascii=False)
signature = f'{state_mtime}|{hash(payload_str)}'
if signature != last_signature:
last_signature = signature
yield f'data: {payload_str}\n\n'
now = time.monotonic()
if now - last_heartbeat > 15:
yield ': heartbeat\n\n'
last_heartbeat = now
await asyncio.sleep(0.5)
finally:
refresher.cancel()
try:
await refresher
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
metrics_refresher.cancel()
try:
await metrics_refresher
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
return StreamingResponse(
event_gen(),
@@ -2886,7 +2974,7 @@ def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
'exceeds_soft_limit',
}
normalized = {
key: _json_safe_limited(value)
key: _json_safe_limited(value, parent_key=key)
for key, value in event.items()
if key in allowed and value is not None
}
@@ -2894,20 +2982,25 @@ def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
return normalized
def _json_safe_limited(value: Any, *, depth: int = 0) -> Any:
_VERBOSE_STRING_KEYS = {'code', 'command', 'content', 'delta', 'script', 'stdout', 'output'}
_VERBOSE_STRING_LIMIT = 20000
def _json_safe_limited(value: Any, *, depth: int = 0, parent_key: str | None = None) -> Any:
if depth > 4:
return str(value)[:500]
if isinstance(value, str):
return value[:2000]
limit = _VERBOSE_STRING_LIMIT if parent_key in _VERBOSE_STRING_KEYS else 2000
return value[:limit]
if isinstance(value, (int, float, bool)) or value is None:
return value
if isinstance(value, dict):
return {
str(key)[:200]: _json_safe_limited(item, depth=depth + 1)
str(key)[:200]: _json_safe_limited(item, depth=depth + 1, parent_key=str(key))
for key, item in list(value.items())[:80]
}
if isinstance(value, (list, tuple)):
return [_json_safe_limited(item, depth=depth + 1) for item in value[:80]]
return [_json_safe_limited(item, depth=depth + 1, parent_key=parent_key) for item in value[:80]]
try:
json.dumps(value)
return value
@@ -4885,6 +4978,35 @@ class _WatcherManager:
account_id=account_id,
session_id=session_id,
)
# R{n>=1} 链路:SFT 训练完后 submit_sft_via_cml.sh 内置 nohup
# watcher 自动起 R{n} 评测,agent 不在回路中,没人写
# cml=runningUI 会从 sft=complete 直接跳到 cml=complete
# 中间 ~20min 评测期看起来像"流程卡死"。检测到同 session
# 已注册 cml watch(说明 agent 提交 SFT 时预注册了下游评测
# 的 watch)就顺手补写 cml=running 同 run_id。
if (
step == 'sft'
and session_id is not None
and self.is_watching(session_id, 'cml')
):
await self._write_step(
state_path,
step='cml',
status='running',
run_id=run_id,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
await self._write_log(
state_path,
run_id,
'↪ sft 完成自动衔接 → step cml running '
'(submit_sft_via_cml.sh 内置 watcher 已触发 R 评测)',
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
return
if time.monotonic() - start > timeout:
await self._write_step(
@@ -5507,13 +5629,27 @@ def _scan_for_watchers(state: AgentState) -> None:
status = entry.get('status')
if isinstance(step, str) and step and isinstance(status, str):
last_status_per_step[step] = status
# See note in _scan_for_watchers_async: only the latest watch
# entry per step is current.
latest_watch_per_step: dict[str, dict[str, Any]] = {}
latest_running_run_id: dict[str, str] = {}
for entry in entries:
watch = entry.get('watch')
if not isinstance(watch, dict):
continue
step = watch.get('step')
if not isinstance(step, str) or not step:
if isinstance(watch, dict):
w_step = watch.get('step')
if isinstance(w_step, str) and w_step:
latest_watch_per_step[w_step] = watch
continue
e_step = entry.get('step')
e_status = entry.get('status')
e_run = entry.get('run_id')
if (
isinstance(e_step, str) and e_step
and e_status == 'running'
and isinstance(e_run, str) and e_run
):
latest_running_run_id[e_step] = e_run
for step, watch in latest_watch_per_step.items():
if last_status_per_step.get(step) in {
'complete',
'failed',
@@ -5535,6 +5671,9 @@ def _scan_for_watchers(state: AgentState) -> None:
interval = 30.0
timeout = 3600.0
run_id = str(watch.get('run_id') or '?')
running_run_id = latest_running_run_id.get(step)
if running_run_id and run_id != running_run_id:
continue
_watcher_manager.register_file_exists(
session_id=session_id,
state_path=state_path,
@@ -5617,13 +5756,30 @@ async def _scan_for_watchers_async(state: AgentState) -> None:
status = entry.get('status')
if isinstance(step, str) and step and isinstance(status, str):
last_status_per_step[step] = status
# Only the LAST watch entry per step represents the current round's
# intent. Earlier watch entries from previous rounds must NOT be
# re-registered when a new round resets the step to running, or
# the resulting watcher will fire with a stale run_id and the UI
# will never see a complete tagged with the current round.
latest_watch_per_step: dict[str, dict[str, Any]] = {}
latest_running_run_id: dict[str, str] = {}
for entry in entries:
watch = entry.get('watch')
if not isinstance(watch, dict):
continue
step = watch.get('step')
if not isinstance(step, str) or not step:
if isinstance(watch, dict):
w_step = watch.get('step')
if isinstance(w_step, str) and w_step:
latest_watch_per_step[w_step] = watch
continue
e_step = entry.get('step')
e_status = entry.get('status')
e_run = entry.get('run_id')
if (
isinstance(e_step, str) and e_step
and e_status == 'running'
and isinstance(e_run, str) and e_run
):
latest_running_run_id[e_step] = e_run
for step, watch in latest_watch_per_step.items():
if last_status_per_step.get(step) in {
'complete',
'failed',
@@ -5645,6 +5801,13 @@ async def _scan_for_watchers_async(state: AgentState) -> None:
interval_s = 30.0
timeout_s = 3600.0
run_id = str(watch.get('run_id') or '?')
# If a newer round has written `step=<step>, status=running`
# but the matching watch entry hasn't landed yet, skip and
# wait for the next scan rather than spawning a watcher tied
# to the previous round's run_id.
running_run_id = latest_running_run_id.get(step)
if running_run_id and run_id != running_run_id:
continue
_watcher_manager.register_file_exists(
session_id=session_id,
state_path=state_path,
@@ -5752,7 +5915,6 @@ _STEP_KIND: dict[str, str] = {
'cml': 'analysis',
'gold-drift': 'analysis',
'dist-analysis': 'analysis',
'report': 'analysis',
'hypothesis': 'analysis',
'log': 'analysis',
'augment': 'train',
@@ -5780,8 +5942,7 @@ _CARD_META: dict[tuple[str, str], dict[str, Any]] = {
# R0·Baseline
('cml', 'baseline'): {'icon': 'bar-chart', 'title': 'Baseline 评测', 'subtitle': 'CML workflow metric_diff'},
('gold-drift', 'baseline'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析', 'subtitle': '需求集合 / 车载 / specific'},
('report', 'baseline'): {'icon': 'file-text', 'title': '问题分析 & 报告', 'subtitle': 'workflow<runDic>.md'},
('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow<runDic>.md'},
('hypothesis', 'baseline'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
('log', 'baseline'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
# R{n}·Train
@@ -5791,8 +5952,7 @@ _CARD_META: dict[tuple[str, str], dict[str, Any]] = {
# R{n}·Analysis
('cml', 'analysis'): {'icon': 'bar-chart', 'title': 'CML 评测', 'subtitle': 'workflow metric_diff'},
('gold-drift', 'analysis'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析', 'subtitle': '需求集合 / 车载 / specific'},
('report', 'analysis'): {'icon': 'file-text', 'title': '问题分析 & 报告', 'subtitle': 'workflow<runDic>.md'},
('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow<runDic>.md'},
('hypothesis', 'analysis'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
('log', 'analysis'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
}
@@ -5803,9 +5963,8 @@ _CARD_ORDER: dict[tuple[str, str], int] = {
('cml', 'baseline'): 0,
('gold-drift', 'baseline'): 1,
('dist-analysis', 'baseline'): 2,
('report', 'baseline'): 3,
('hypothesis', 'baseline'): 4,
('log', 'baseline'): 5,
('hypothesis', 'baseline'): 3,
('log', 'baseline'): 4,
# train
('augment', 'train'): 0,
('verify', 'train'): 1,
@@ -5814,9 +5973,8 @@ _CARD_ORDER: dict[tuple[str, str], int] = {
('cml', 'analysis'): 0,
('gold-drift', 'analysis'): 1,
('dist-analysis', 'analysis'): 2,
('report', 'analysis'): 3,
('hypothesis', 'analysis'): 4,
('log', 'analysis'): 5,
('hypothesis', 'analysis'): 3,
('log', 'analysis'): 4,
}
_SECTION_LABELS: dict[str, str] = {
@@ -6514,6 +6672,13 @@ def _scan_run_history_max() -> int | None:
return best if best >= 0 else None
_ITER_COUNT_CACHE: dict[str, int | None] = {}
def _iter_count_cache_key(session_id: str | None, account_id: str | None) -> str:
return f'{account_id or "_"}|{session_id or "_"}'
def _read_iteration_log_count(
session_id: str | None = None,
*,
@@ -6523,10 +6688,53 @@ def _read_iteration_log_count(
"""Count non-empty lines in iteration_log.jsonl.
Chat-root lives under the remote workspace_cwd, so reads MUST go through
the bound jupyter runtime backend host typically can't read this path
locally. Falls back to the legacy global path on the local fs only when
no runtime is bound (session never connected, or transient lookup miss).
the bound jupyter runtime the round-trip is multi-second. To keep the
SSE pipeline loop responsive, this function returns the last cached value
when one exists; only a cold cache pays the synchronous remote cost. The
cache is refreshed in the background by `_iter_count_refresh_loop`, which
the SSE handler spawns alongside its event loop.
"""
cache_key = _iter_count_cache_key(session_id, account_id)
if cache_key in _ITER_COUNT_CACHE:
return _ITER_COUNT_CACHE[cache_key]
value = _read_iteration_log_count_uncached(
session_id, agent_state=agent_state, account_id=account_id,
)
_ITER_COUNT_CACHE[cache_key] = value
return value
async def _iter_count_refresh_loop(
session_id: str | None,
*,
agent_state: 'AgentState | None',
account_id: str | None,
interval_seconds: float = 3.0,
) -> None:
"""Background task that re-reads iteration_log_count every `interval_seconds`
and updates the in-memory cache. Cancelled when the caller (SSE handler)
exits."""
cache_key = _iter_count_cache_key(session_id, account_id)
while True:
try:
value = await asyncio.to_thread(
_read_iteration_log_count_uncached,
session_id,
agent_state=agent_state,
account_id=account_id,
)
_ITER_COUNT_CACHE[cache_key] = value
except Exception: # noqa: BLE001
pass
await asyncio.sleep(interval_seconds)
def _read_iteration_log_count_uncached(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> int | None:
runtime = (
_jupyter_runtime_for_session(agent_state, account_id, session_id)
if session_id and agent_state is not None
@@ -6565,6 +6773,150 @@ def _read_iteration_log_count(
return None
_METRICS_HISTORY_CACHE: dict[str, dict[str, Any] | None] = {}
def _read_iteration_log_metrics_history(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> dict[str, Any] | None:
"""Cached read of full iteration_log.jsonl as metrics time-series."""
cache_key = _iter_count_cache_key(session_id, account_id)
if cache_key in _METRICS_HISTORY_CACHE:
return _METRICS_HISTORY_CACHE[cache_key]
value = _read_iteration_log_metrics_history_uncached(
session_id, agent_state=agent_state, account_id=account_id,
)
_METRICS_HISTORY_CACHE[cache_key] = value
return value
async def _metrics_history_refresh_loop(
session_id: str | None,
*,
agent_state: 'AgentState | None',
account_id: str | None,
interval_seconds: float = 3.0,
) -> None:
cache_key = _iter_count_cache_key(session_id, account_id)
while True:
try:
value = await asyncio.to_thread(
_read_iteration_log_metrics_history_uncached,
session_id,
agent_state=agent_state,
account_id=account_id,
)
_METRICS_HISTORY_CACHE[cache_key] = value
except Exception: # noqa: BLE001
pass
await asyncio.sleep(interval_seconds)
def _read_iteration_log_metrics_history_uncached(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> dict[str, Any] | None:
"""Parse iteration_log.jsonl into {'metrics': [keys], 'rounds': [...]}.
Each entry's `results` dict contributes one round point; metric key union
is taken across all rounds (sorted by first-appearance for stable column
order). Missing values produce nulls so frontend line charts break the
series naturally."""
runtime = (
_jupyter_runtime_for_session(agent_state, account_id, session_id)
if session_id and agent_state is not None
else None
)
text: str | None = None
if runtime is not None:
remote_root = _chat_root_for_runtime(runtime)
if remote_root is not None:
remote_path = f'{remote_root}/results/iteration_log.jsonl'
cmd = (
f'test -f {shlex.quote(remote_path)} '
f'&& cat {shlex.quote(remote_path)} '
f'|| echo __MISSING__'
)
try:
result = runtime.run_command(
cmd, timeout_seconds=10.0, max_output_chars=400_000,
)
except Exception: # noqa: BLE001
return None
stdout = (result.stdout or '').strip() if result is not None else ''
if stdout and stdout != '__MISSING__':
text = stdout
if text is None:
legacy = _autoresearch_root() / 'results' / 'iteration_log.jsonl'
try:
if legacy.is_file():
text = legacy.read_text(encoding='utf-8', errors='replace')
except OSError:
return None
if not text:
return None
metrics_order: list[str] = []
metrics_seen: set[str] = set()
rounds: list[dict[str, Any]] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except (ValueError, TypeError):
continue
if not isinstance(obj, dict):
continue
results = obj.get('results')
if not isinstance(results, dict):
continue
values: dict[str, float] = {}
for k, v in results.items():
if not isinstance(k, str):
continue
try:
fv = float(v)
except (TypeError, ValueError):
continue
values[k] = fv
if k not in metrics_seen:
metrics_seen.add(k)
metrics_order.append(k)
if not values:
continue
iteration = obj.get('iteration')
if isinstance(iteration, str) and iteration.lstrip('-').isdigit():
iteration = int(iteration)
if not isinstance(iteration, int):
iteration = len(rounds)
run_dic = obj.get('runDic') or obj.get('run_dic')
if isinstance(run_dic, str) and run_dic.isdigit():
run_dic = int(run_dic)
if not isinstance(run_dic, int):
run_dic = 0
timestamp = obj.get('timestamp')
if not isinstance(timestamp, str):
timestamp = ''
rounds.append({
'iteration': iteration,
'runDic': run_dic,
'label': f'R{iteration}',
'timestamp': timestamp,
'values': values,
})
if not rounds:
return None
return {'metrics': metrics_order, 'rounds': rounds}
def _read_iteration_log_h1_label(
runtime: 'JupyterRuntimeSession | None',
run_dic: int | None,
@@ -7019,6 +7371,8 @@ def _compute_kpis(
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
iteration_log_count: int | None = None,
metrics_history: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Compute the 7 top KPIs from authoritative sources. Falls back to ''.
@@ -7054,17 +7408,33 @@ def _compute_kpis(
target_metric = _format_metric_value(metrics, 'target_set_pass_rate')
overall_metric = _format_metric_value(metrics, 'overall_car_pass_rate')
specific_metric = _format_metric_value(metrics, 'specific_test_pass_rate')
iter_count = _read_iteration_log_count(
session_id,
agent_state=agent_state,
account_id=account_id,
)
if iter_count is None:
iteration = 'R0-baseline'
elif iter_count == 0:
iteration = 'R0-baseline'
# Round number = MAX numeric `iteration` field across iteration_log.jsonl
# entries, NOT the line count. iteration_log.jsonl carries multiple rows
# per round (separate hypothesis-only / results / final-summary entries),
# so line count overcounts. Falls back to line-count for backwards
# compatibility when metrics_history isn't available.
max_iteration: int | None = None
if metrics_history and isinstance(metrics_history.get('rounds'), list):
for r in metrics_history['rounds']:
it = r.get('iteration') if isinstance(r, dict) else None
if isinstance(it, int):
if max_iteration is None or it > max_iteration:
max_iteration = it
if max_iteration is not None:
iteration = 'R0-baseline' if max_iteration == 0 else f'R{max_iteration}'
else:
iteration = f'R{iter_count}'
if iteration_log_count is not None:
iter_count = iteration_log_count
else:
iter_count = _read_iteration_log_count(
session_id,
agent_state=agent_state,
account_id=account_id,
)
if iter_count is None or iter_count == 0:
iteration = 'R0-baseline'
else:
iteration = f'R{iter_count}'
elapsed = _format_elapsed(_compute_step0_start_seconds(state_entries))
target_value = (
f'{target_set} · {target_metric}' if target_metric != '' else target_set
@@ -7134,6 +7504,79 @@ def _resolve_run_dic_from_state(
return best
def _resolve_run_dic_per_round(
state_entries: list[dict[str, Any]],
) -> dict[str, int]:
"""Map each run_id (e.g., 'R2') to the runDic that round actually used.
Same priority as ``_resolve_run_dic_from_state`` but bucketed per run_id
so per-round step-detail views read the right artifact directory instead
of always using the latest workflow id seen anywhere in state."""
workflow_re = re.compile(r'workflow(\d+)')
rundic_re = re.compile(r'runDic\s*[=:取]?\s*(\d+)')
def _scan(obj: Any) -> int | None:
if isinstance(obj, str):
cands = [int(x) for x in workflow_re.findall(obj)]
cands += [int(x) for x in rundic_re.findall(obj)]
return max(cands) if cands else None
if isinstance(obj, dict):
best: int | None = None
for v in obj.values():
hit = _scan(v)
if hit is not None and (best is None or hit > best):
best = hit
return best
if isinstance(obj, list):
best = None
for v in obj:
hit = _scan(v)
if hit is not None and (best is None or hit > best):
best = hit
return best
return None
def _entry_run_id(entry: dict[str, Any]) -> str | None:
# 顶层 run_id 优先;否则从 watch.run_id / log.iter 兜底,让 watcher/log
# 这种 agent 不写顶层 run_id 但嵌套里带轮次信号的条目也能贡献 runDic。
rid = entry.get('run_id')
if isinstance(rid, str) and rid.strip():
return rid.strip()
watch = entry.get('watch')
if isinstance(watch, dict):
wrid = watch.get('run_id')
if isinstance(wrid, str) and wrid.strip():
return wrid.strip()
log = entry.get('log')
if isinstance(log, dict):
lit = log.get('iter')
if isinstance(lit, str) and lit.strip():
return lit.strip()
return None
explicit: dict[str, int] = {}
fallback: dict[str, int] = {}
for entry in state_entries:
run_id = _entry_run_id(entry)
if not run_id:
continue
rd = entry.get('runDic') or entry.get('run_dic') or entry.get('rundic')
if isinstance(rd, str) and rd.isdigit():
rd = int(rd)
if isinstance(rd, int):
explicit[run_id] = rd # latest wins
continue
hit = _scan(entry)
if hit is not None:
cur = fallback.get(run_id)
if cur is None or hit > cur:
fallback[run_id] = hit
out = dict(fallback)
out.update(explicit)
return out
def _step_artifact_paths(
step: str,
run_dic: int | None,
@@ -7176,9 +7619,6 @@ def _step_artifact_paths(
paths.append(
f'{runtime.binding.workspace_cwd}/output/relabel_candidates_{run_dic}.csv'
)
elif step == 'report':
if run_dic is not None:
paths.append(f'{chat_root}/results/workflow{run_dic}.md')
elif step == 'hypothesis':
paths.append(f'{chat_root}/results/iteration_log.jsonl')
elif step == 'augment':
@@ -7342,6 +7782,8 @@ def _hardcoded_autoresearch_pipeline(
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
iteration_log_count: int | None = None,
metrics_history: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Canonical autoresearch model-iteration pipeline structure.
@@ -7359,6 +7801,8 @@ def _hardcoded_autoresearch_pipeline(
model_config=model_config,
agent_state=agent_state,
account_id=account_id,
iteration_log_count=iteration_log_count,
metrics_history=metrics_history,
)
else:
# Defensive default if caller didn't pass sessions_dir.
@@ -7385,6 +7829,7 @@ def _hardcoded_autoresearch_pipeline(
'items': [],
'in_flight': None,
'logs': [],
'metrics_history': metrics_history,
}