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:
+173
-19
@@ -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_id(R7=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(
|
||||
|
||||
Reference in New Issue
Block a user