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
+120
View File
@@ -154,6 +154,119 @@ def _prepend_runtime_context(prompt: str, runtime_context: str | None) -> str:
)
def _log_child_skill_calls(
child_result: 'AgentRunResult',
child_agent: 'LocalCodingAgent',
subtask_label: str,
) -> None:
"""Extract Skill tool calls from a child agent's transcript and write to a log file.
Logs each Skill invocation with the args sent and the agent's response,
so we can audit whether sub-agents actually called skills like label-master.
"""
from .agent_types import AgentRunResult # noqa: F811
transcript = child_result.transcript
if not transcript:
return
log_dir = child_agent.runtime_config.session_directory / '_subagent_logs'
try:
log_dir.mkdir(parents=True, exist_ok=True)
except OSError:
return
session_id = child_result.session_id or 'unknown'
log_path = log_dir / f'{session_id}.jsonl'
skill_calls: list[dict[str, object]] = []
# Build a map of tool_call_id -> skill args from assistant messages with tool_calls
pending_skills: dict[str, dict[str, str]] = {}
for entry in transcript:
if not isinstance(entry, dict):
continue
role = entry.get('role')
if role == 'assistant':
tool_calls = entry.get('tool_calls')
if isinstance(tool_calls, list):
for tc in tool_calls:
if not isinstance(tc, dict):
continue
fn = tc.get('function') or {}
if not isinstance(fn, dict):
continue
if fn.get('name') == 'Skill':
tc_id = tc.get('id', '')
raw_args = fn.get('arguments', '{}')
try:
args_dict = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except (json.JSONDecodeError, TypeError):
args_dict = {'raw': raw_args}
pending_skills[tc_id] = {
'skill': args_dict.get('skill', ''),
'args': args_dict.get('args', ''),
}
elif role == 'tool':
tc_id = entry.get('tool_call_id', '')
if tc_id in pending_skills:
skill_info = pending_skills.pop(tc_id)
skill_calls.append({
'skill': skill_info['skill'],
'args': skill_info['args'],
'prompt_injected': entry.get('content') or '',
'ts': datetime.now(timezone.utc).isoformat(),
})
# Also capture the assistant responses that follow skill injections
# by looking at assistant messages after tool messages
assistant_after_skill: list[str] = []
saw_skill_tool = False
for entry in transcript:
if not isinstance(entry, dict):
continue
role = entry.get('role')
if role == 'tool' and entry.get('tool_call_id', '') in [
tc.get('id', '') for tc in _all_skill_tool_call_ids(transcript)
]:
saw_skill_tool = True
elif role == 'assistant' and saw_skill_tool:
content = entry.get('content', '')
if content:
assistant_after_skill.append(content)
saw_skill_tool = False
# Merge assistant responses into skill_calls
for i, response_text in enumerate(assistant_after_skill):
if i < len(skill_calls):
skill_calls[i]['agent_response'] = response_text
if not skill_calls:
return
try:
with open(log_path, 'a', encoding='utf-8') as f:
for call in skill_calls:
call['subtask_label'] = subtask_label
f.write(json.dumps(call, ensure_ascii=False) + '\n')
except OSError:
pass
def _all_skill_tool_call_ids(transcript: tuple[dict[str, object], ...]) -> list[dict[str, str]]:
"""Collect all tool_call entries for Skill from assistant messages."""
results = []
for entry in transcript:
if not isinstance(entry, dict) or entry.get('role') != 'assistant':
continue
tool_calls = entry.get('tool_calls')
if not isinstance(tool_calls, list):
continue
for tc in tool_calls:
if not isinstance(tc, dict):
continue
fn = tc.get('function') or {}
if isinstance(fn, dict) and fn.get('name') == 'Skill':
results.append(tc)
return results
@dataclass
class LocalCodingAgent:
model_config: ModelConfig
@@ -3052,6 +3165,11 @@ class LocalCodingAgent:
managed_child_index=index,
managed_label=subtask_label,
)
if self.tool_context.jupyter_runtime is not None:
child_agent.tool_context = replace(
child_agent.tool_context,
jupyter_runtime=self.tool_context.jupyter_runtime,
)
if group_id is not None and child_agent.managed_agent_id is not None:
self.agent_manager.register_group_child(
group_id,
@@ -3118,9 +3236,11 @@ class LocalCodingAgent:
break
continue
child_result = child_agent.resume(child_prompt, stored_child_session)
_log_child_skill_calls(child_result, child_agent, subtask_label)
resume_used = True
else:
child_result = child_agent.run(child_prompt)
_log_child_skill_calls(child_result, child_agent, subtask_label)
if group_id is not None and child_agent.managed_agent_id is not None:
self.agent_manager.register_group_child(
group_id,
+1 -1
View File
@@ -340,7 +340,7 @@ def default_tool_registry() -> dict[str, AgentTool]:
{'type': 'number'},
{'type': 'integer'},
{'type': 'boolean'},
{'type': 'array'},
{'type': 'array', 'items': {}},
{'type': 'object'},
{'type': 'null'},
]
+9 -2
View File
@@ -268,10 +268,12 @@ class JupyterRuntimeSession:
'远端 jupyter pod 未挂载 /mnt/wangsenhao 或不可写:'
+ (probe.stdout.strip() or probe.stderr.strip() or 'unknown error')
)
ws_output = f'{shlex.quote(self.binding.workspace_cwd)}/output'
chat_output_link = f'{shlex.quote(chat_root)}/output'
result = self.run_command(
(
'mkdir -p '
f'{shlex.quote(self.binding.workspace_cwd)}/output '
f'{ws_output} '
f'{shlex.quote(self.binding.workspace_cwd)}/input '
f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad '
f'{shlex.quote(self.binding.workspace_root)}/.runtime/uploads '
@@ -279,7 +281,12 @@ class JupyterRuntimeSession:
f'{shlex.quote(self.account_runtime_root)}/python '
f'{shlex.quote(chat_root)}/results '
f'{shlex.quote(chat_root)}/sft_output '
f'{shlex.quote(chat_root)}/scripts'
f'{shlex.quote(chat_root)}/scripts && '
f'if [ -d {chat_output_link} ] && [ ! -L {chat_output_link} ]; then '
f'cp -a {chat_output_link}/* {ws_output}/ 2>/dev/null; '
f'rm -rf {chat_output_link}; '
f'fi && '
f'ln -sfnT {ws_output} {chat_output_link}'
),
timeout_seconds=timeout_seconds,
max_output_chars=4000,