fix: session resume, config_set schema, metrics chart, subagent logging

- Fix session resume: remove messages.length>1 guard so sessionStorage
  ID is used as resumeSessionId on first message (prevents new session
  creation when user sends "继续")
- Fix config_set tool schema: add missing `items: {}` to array type in
  oneOf (Azure OpenAI strict validation rejects it otherwise)
- Metrics chart: use actual target set filename from trigger text
  instead of generic "target_subset" key
- Add sub-agent skill call logging to agent_runtime.py (full
  prompt/response, no truncation)
- Various metric enrichment and dedup fixes in server.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wangsenhao
2026-05-27 17:02:24 +08:00
parent 89e77140e7
commit 85ce8c0e69
8 changed files with 411 additions and 40 deletions
+173 -19
View File
@@ -1751,11 +1751,17 @@ def create_app(state: AgentState) -> FastAPI:
if isinstance(run_id, str) and run_id.strip():
rid = run_id.strip()
per_round = _resolve_run_dic_per_round(state_entries)
# augment 产物的 runDic = resolve_run_ids.sh 在当轮 eval 落盘后取到的
# max(已落盘 workflow) = 当轮 eval 自身的 workflow id,即 per_round[R{n}]
# (不是 R{n-1},脚本取的是 eval 之后的 max,此时 max 已经包含当轮 eval)
# 正确标号: R{n}·Train 和 R{n}·Analysis 共用同一 run_id
# per_round[R{n}] 从 Analysis 的 watch/log 中解析到 runDic。
# 兼容旧 session 错误标号: agent 每阶段递增 run_idR7=train, R6=analysis),
# 导致 per_round[R_train] = None,需 fallback 到 R{n-1}。
if step == 'augment':
pass # 直接走下面 per_round.get(rid) 取本轮 runDic
run_dic = per_round.get(rid)
if run_dic is None:
run_index = _parse_run_index(rid)
if run_index is not None and run_index >= 1:
prev_rid = f'R{run_index - 1}'
run_dic = per_round.get(prev_rid)
if run_dic is None:
run_dic = per_round.get(rid)
if run_dic is None:
@@ -2001,7 +2007,14 @@ def create_app(state: AgentState) -> FastAPI:
) -> Response:
sessions_dir = state.account_paths(account_id)['sessions']
state_path = _session_state_path(sessions_dir, session_id)
await asyncio.to_thread(
_sync_remote_program_state, state, account_id, session_id, state_path,
)
state_entries, _ = _read_program_state(state_path)
trigger = _extract_trigger_from_state(state_entries) or _extract_trigger_from_session(
sessions_dir, session_id
)
_target_set_name = _parse_target_set(trigger, model_config=state.model_config_for(account_id))
iter_count = await asyncio.to_thread(
_read_iteration_log_count,
session_id,
@@ -2013,6 +2026,7 @@ def create_app(state: AgentState) -> FastAPI:
session_id,
agent_state=state,
account_id=account_id,
target_set_name=_target_set_name,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
@@ -2060,6 +2074,12 @@ def create_app(state: AgentState) -> FastAPI:
async def event_gen():
last_signature: str | None = None
last_heartbeat = time.monotonic()
last_remote_sync = 0.0
_state_entries_init, _ = _read_program_state(state_path)
_trigger_init = _extract_trigger_from_state(_state_entries_init) or _extract_trigger_from_session(
sessions_dir, session_id
)
_stream_target_set = _parse_target_set(_trigger_init, model_config=state.model_config_for(account_id))
refresher = asyncio.create_task(
_iter_count_refresh_loop(
session_id,
@@ -2072,12 +2092,19 @@ def create_app(state: AgentState) -> FastAPI:
session_id,
agent_state=state,
account_id=account_id,
target_set_name=_stream_target_set,
)
)
try:
while True:
if await request.is_disconnected():
break
now_mono = time.monotonic()
if now_mono - last_remote_sync >= 5.0:
await asyncio.to_thread(
_sync_remote_program_state, state, account_id, session_id, state_path,
)
last_remote_sync = now_mono
state_entries, state_mtime = _read_program_state(state_path)
iter_count = await asyncio.to_thread(
_read_iteration_log_count,
@@ -2090,6 +2117,7 @@ def create_app(state: AgentState) -> FastAPI:
session_id,
agent_state=state,
account_id=account_id,
target_set_name=_stream_target_set,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
@@ -5714,11 +5742,43 @@ def _sync_remote_program_state(
new_content = result.stdout or ''
if not new_content.strip():
return
# Skip rewrite if local already matches remote (avoid touching mtime, which
# Preserve local-only synthetic gate entries that remote doesn't have.
local_synthetics: list[str] = []
try:
if local_state_path.is_file():
# Parse remote gate keys once
remote_gate_keys: set[str] = set()
for raw_line in new_content.splitlines():
if not raw_line.strip():
continue
try:
r = json.loads(raw_line)
if r.get('step') and not r.get('_synthetic'):
remote_gate_keys.add(f"{r.get('run_id','')}:{r.get('step','')}")
except (json.JSONDecodeError, ValueError):
pass
for line in local_state_path.read_text(encoding='utf-8').splitlines():
if '"_synthetic"' not in line:
continue
try:
obj = json.loads(line)
if obj.get('_synthetic'):
gate_key = f"{obj.get('run_id','')}:{obj.get('step','')}"
if gate_key not in remote_gate_keys:
local_synthetics.append(line)
except (json.JSONDecodeError, AttributeError):
pass
except OSError:
pass
merged = new_content.rstrip('\n')
if local_synthetics:
merged += '\n' + '\n'.join(local_synthetics)
merged += '\n'
# Skip rewrite if local already matches (avoid touching mtime, which
# SSE uses to detect changes).
try:
if local_state_path.is_file() and (
local_state_path.read_text(encoding='utf-8') == new_content
local_state_path.read_text(encoding='utf-8') == merged
):
return
except OSError:
@@ -5726,7 +5786,7 @@ def _sync_remote_program_state(
try:
local_state_path.parent.mkdir(parents=True, exist_ok=True)
tmp = local_state_path.with_suffix('.jsonl.sync-tmp')
tmp.write_text(new_content, encoding='utf-8')
tmp.write_text(merged, encoding='utf-8')
tmp.replace(local_state_path)
except OSError:
return
@@ -6055,7 +6115,7 @@ _STEP_KIND: dict[str, str] = {
'dist-analysis': 'analysis',
'hypothesis': 'analysis',
'log': 'analysis',
'augment': 'train',
'augment': 'analysis',
'verify': 'train',
'sft': 'train',
'human-check': 'gate',
@@ -6083,8 +6143,10 @@ _CARD_META: dict[tuple[str, str], dict[str, Any]] = {
('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'},
# augment — lives under previous round's analysis/baseline
('augment', 'baseline'): {'icon': 'flask', 'title': '数据增强', 'subtitle': 'augment_<runDic>.jsonl'},
('augment', 'analysis'): {'icon': 'flask', 'title': '数据增强', 'subtitle': 'augment_<runDic>.jsonl'},
# R{n}·Train
('augment', 'train'): {'icon': 'flask', 'title': '数据增强', 'subtitle': 'augment_<runDic>.jsonl'},
('verify', 'train'): {'icon': 'shield', 'title': '修改返回验证', 'subtitle': 'sanity + 人审'},
('sft', 'train'): {'icon': 'graduation-cap','title': 'SFT 训练', 'subtitle': 'submit_sft_via_cml.sh'},
# R{n}·Analysis
@@ -6103,16 +6165,17 @@ _CARD_ORDER: dict[tuple[str, str], int] = {
('dist-analysis', 'baseline'): 2,
('hypothesis', 'baseline'): 3,
('log', 'baseline'): 4,
('augment', 'baseline'): 5,
# train
('augment', 'train'): 0,
('verify', 'train'): 1,
('sft', 'train'): 2,
('verify', 'train'): 0,
('sft', 'train'): 1,
# analysis
('cml', 'analysis'): 0,
('gold-drift', 'analysis'): 1,
('dist-analysis', 'analysis'): 2,
('hypothesis', 'analysis'): 3,
('log', 'analysis'): 4,
('augment', 'analysis'): 5,
}
_SECTION_LABELS: dict[str, str] = {
@@ -6253,9 +6316,9 @@ def _derive_section_status(card_statuses: list[str]) -> str:
return 'pending'
if all(s == 'complete' for s in card_statuses):
return 'complete'
if 'failed' in card_statuses and not any(s in ('running', 'complete') for s in card_statuses):
if 'failed' in card_statuses and not any(s in ('running', 'complete', 'waiting') for s in card_statuses):
return 'failed'
if 'running' in card_statuses or 'complete' in card_statuses:
if any(s in ('running', 'complete', 'waiting') for s in card_statuses):
return 'running'
return 'pending'
@@ -6315,6 +6378,11 @@ def _build_pipeline_items(
if not run_id:
continue
last_non_gate_run_id = run_id
# augment belongs to the previous round's analysis/baseline section
if step == 'augment':
aug_index = _parse_run_index(run_id)
if aug_index is not None and aug_index >= 1:
run_id = f'R{aug_index - 1}'
run_index = _parse_run_index(run_id)
if run_index is None:
continue
@@ -6375,6 +6443,11 @@ def _build_pipeline_items(
card['progress'] = max(0.0, min(1.0, value))
except (TypeError, ValueError):
pass
if step == 'augment' and 'count' in entry:
try:
card['subtitle'] = f'{int(entry["count"])} 条新增样本'
except (TypeError, ValueError):
pass
sections[section_key]['cards'].append(card)
# 3. Sort cards within each section.
@@ -6451,7 +6524,7 @@ def _build_pipeline_items(
'summary': entry.get('summary'),
'proposal': entry.get('proposal'),
'ask': entry.get('ask'),
'_order_key': (run_index, 99, gate_position_index.get(gate_key, 0)),
'_order_key': entry.get('ts', ''),
})
# 6. Filter: drop empty/pending sections (no cards or all pending). Running
@@ -6589,12 +6662,18 @@ def _compute_in_flight(
progress = max(0.0, min(1.0, value))
except (TypeError, ValueError):
pass
subtitle = meta['subtitle']
if step == 'augment' and 'count' in entry:
try:
subtitle = f'{int(entry["count"])} 条新增样本'
except (TypeError, ValueError):
pass
cards.append({
'key': f'{target_run_id}:{step}',
'step': step,
'icon': meta['icon'],
'title': meta['title'],
'subtitle': meta['subtitle'],
'subtitle': subtitle,
'status': status,
'progress': progress,
})
@@ -6679,7 +6758,7 @@ def _maybe_inject_gate_fallback(
'status': 'running',
'run_id': run_id or 'R0',
'reason': last_text[-300:],
'ts': datetime_utc_iso(),
'ts': time.strftime('%Y-%m-%dT%H:%M:%S+08:00', time.localtime()),
'_synthetic': True,
}
try:
@@ -7033,6 +7112,7 @@ def _read_iteration_log_metrics_history(
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
target_set_name: 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)
@@ -7040,6 +7120,7 @@ def _read_iteration_log_metrics_history(
return _METRICS_HISTORY_CACHE[cache_key]
value = _read_iteration_log_metrics_history_uncached(
session_id, agent_state=agent_state, account_id=account_id,
target_set_name=target_set_name,
)
_METRICS_HISTORY_CACHE[cache_key] = value
return value
@@ -7050,6 +7131,7 @@ async def _metrics_history_refresh_loop(
*,
agent_state: 'AgentState | None',
account_id: str | None,
target_set_name: str | None = None,
interval_seconds: float = 3.0,
) -> None:
cache_key = _iter_count_cache_key(session_id, account_id)
@@ -7060,6 +7142,7 @@ async def _metrics_history_refresh_loop(
session_id,
agent_state=agent_state,
account_id=account_id,
target_set_name=target_set_name,
)
_METRICS_HISTORY_CACHE[cache_key] = value
except Exception: # noqa: BLE001
@@ -7072,6 +7155,7 @@ def _read_iteration_log_metrics_history_uncached(
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
target_set_name: str | None = None,
) -> dict[str, Any] | None:
"""Parse iteration_log.jsonl into {'metrics': [keys], 'rounds': [...]}.
@@ -7164,8 +7248,70 @@ def _read_iteration_log_metrics_history_uncached(
'values': values,
})
# Deduplicate: keep only the last entry per (iteration, runDic).
# Agent may write multiple entries for the same round (e.g. re-running
# dist-analysis); the last one has the most complete metrics.
seen_keys: dict[tuple[int, int], int] = {}
for idx, r in enumerate(rounds):
key = (r['iteration'], r['runDic'])
seen_keys[key] = idx
rounds = [rounds[i] for i in sorted(seen_keys.values())]
# Rebuild metrics_order from surviving rounds only (dedup may have removed
# entries whose keys shouldn't appear in the chart).
metrics_order = []
metrics_seen = set()
for r in rounds:
for k in r['values']:
if k not in metrics_seen:
metrics_seen.add(k)
metrics_order.append(k)
if not rounds:
return None
# Enrich each round with KPI-equivalent metrics from lark_template.json.
# This ensures dapan_car / specific_test / target_subset are always present
# even if the agent omitted them from iteration_log.
target_key = target_set_name if target_set_name and target_set_name != '' else 'target_subset'
_KPI_METRIC_MAP = {
'specific_test_pass_rate': 'specific_test',
'overall_car_pass_rate': 'dapan_car',
'target_set_pass_rate': target_key,
}
# Rename agent-written 'target_subset' to the actual target set name first,
# so enrichment doesn't create duplicates.
if target_key != 'target_subset':
if 'target_subset' in metrics_seen:
metrics_order = [target_key if m == 'target_subset' else m for m in metrics_order]
metrics_seen.discard('target_subset')
metrics_seen.add(target_key)
for r in rounds:
if 'target_subset' in r['values']:
r['values'][target_key] = r['values'].pop('target_subset')
for r in rounds:
run_dic = r.get('runDic')
if not isinstance(run_dic, int) or run_dic == 0:
continue
metric_diff = _read_workflow_metric_diff(
run_dic,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
if metric_diff is None:
continue
_enrich_metrics_from_eval_output(metric_diff, None)
for src_key, dst_key in _KPI_METRIC_MAP.items():
if dst_key not in r['values']:
raw = metric_diff.get(src_key)
if isinstance(raw, (int, float)):
r['values'][dst_key] = round(raw * 100, 2)
if dst_key not in metrics_seen:
metrics_seen.add(dst_key)
metrics_order.append(dst_key)
return {'metrics': metrics_order, 'rounds': rounds}
@@ -7385,7 +7531,8 @@ def _parse_target_set(
) -> str | None:
"""Sync, non-blocking. Reads cache only — never makes the LLM call inline.
Cache is filled by the watcher scanner background loop via
_refresh_target_set_cache_blocking (which is run in a thread)."""
_refresh_target_set_cache_blocking (which is run in a thread).
Falls back to regex extraction of .csv filenames from trigger text."""
if not trigger:
return None
text = trigger.strip()
@@ -7394,7 +7541,14 @@ def _parse_target_set(
if len(text) > 4000:
text = text[:4000]
cache_key = hashlib.sha256(text.encode('utf-8')).hexdigest()
return _TARGET_SET_LLM_CACHE.get(cache_key)
cached = _TARGET_SET_LLM_CACHE.get(cache_key)
if cached is not None:
return cached
# Regex fallback: extract .csv filename from trigger text.
m = re.search(r'(?:(?<=[的/,\s])|(?<=^))([^\s,。;的中是在]+\.csv)', text)
if m:
return m.group(1)
return None
def _refresh_target_set_cache_blocking(