From 1aafc0fce7be07cbc5c54e3651598b004f9f60ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E9=98=B3?= Date: Wed, 6 May 2026 21:46:09 +0800 Subject: [PATCH] Improve data skill routing and tool organization --- scripts/start-webui.sh | 49 +- src/agent_prompting.py | 18 + src/agent_runtime.py | 155 ++-- src/agent_tool_core.py | 173 ++++ src/agent_tool_specs/__init__.py | 5 + src/agent_tool_specs/_builder.py | 15 + src/agent_tool_specs/data_agent.py | 523 ++++++++++++ src/agent_tool_specs/execution.py | 123 +++ src/agent_tool_specs/files.py | 111 +++ src/agent_tools.py | 943 ++-------------------- src/data_agent_records.py | 8 + src/skills/bundled/online-mining/SKILL.md | 2 + src/skills/bundled/product-data/SKILL.md | 40 +- tests/test_agent_prompting.py | 17 +- tests/test_agent_runtime.py | 22 +- tests/test_data_agent_records.py | 93 +++ 16 files changed, 1342 insertions(+), 955 deletions(-) create mode 100644 src/agent_tool_core.py create mode 100644 src/agent_tool_specs/__init__.py create mode 100644 src/agent_tool_specs/_builder.py create mode 100644 src/agent_tool_specs/data_agent.py create mode 100644 src/agent_tool_specs/execution.py create mode 100644 src/agent_tool_specs/files.py diff --git a/scripts/start-webui.sh b/scripts/start-webui.sh index f011acf..bf3f4d3 100755 --- a/scripts/start-webui.sh +++ b/scripts/start-webui.sh @@ -10,10 +10,14 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RUN_DIR="${ROOT_DIR}/.port_sessions" BACKEND_HOST="${CLAW_BACKEND_HOST:-127.0.0.1}" BACKEND_PORT="${CLAW_BACKEND_PORT:-8765}" -FRONTEND_HOST="${CLAW_FRONTEND_HOST:-127.0.0.1}" +FRONTEND_HOST="${CLAW_FRONTEND_HOST:-0.0.0.0}" FRONTEND_PORT="${CLAW_FRONTEND_PORT:-3000}" BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}" FRONTEND_URL="http://${FRONTEND_HOST}:${FRONTEND_PORT}" +PYTHON_BIN="${CLAW_PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}" +if [[ ! -x "${PYTHON_BIN}" ]]; then + PYTHON_BIN="python3" +fi mkdir -p "${RUN_DIR}" @@ -41,6 +45,31 @@ stop_pid_file() { rm -f "${pid_file}" } +stop_port_listener() { + local port="$1" + local name="$2" + local pids + pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [[ -z "${pids}" ]]; then + return + fi + echo "Stopping ${name} listener(s) on port ${port}: ${pids//$'\n'/ }..." + while IFS= read -r pid; do + [[ -z "${pid}" ]] && continue + kill "${pid}" 2>/dev/null || true + done <<<"${pids}" + for _ in {1..20}; do + if ! lsof -tiTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then + return + fi + sleep 0.1 + done + while IFS= read -r pid; do + [[ -z "${pid}" ]] && continue + kill -9 "${pid}" 2>/dev/null || true + done <<<"${pids}" +} + wait_for_url() { local url="$1" local name="$2" @@ -60,6 +89,8 @@ wait_for_url() { stop_pid_file "${RUN_DIR}/webui-frontend.pid" "frontend" stop_pid_file "${RUN_DIR}/webui-backend.pid" "backend" +stop_port_listener "${FRONTEND_PORT}" "frontend" +stop_port_listener "${BACKEND_PORT}" "backend" export OPENAI_API_KEY="${OPENAI_API_KEY:-sk-Jxoh5lf1jWg6HQZYYyfyagXudGxUXNAgkZklxMnnXHn7pFmU}" export OPENAI_BASE_URL="${OPENAI_BASE_URL:-http://model.mify.ai.srv/v1}" @@ -69,27 +100,23 @@ BACKEND_LOG="${RUN_DIR}/webui-backend.log" FRONTEND_LOG="${RUN_DIR}/webui-frontend.log" echo "Starting backend on ${BACKEND_URL}..." -( - cd "${ROOT_DIR}" - exec python3 -m src.gui \ +cd "${ROOT_DIR}" +nohup "${PYTHON_BIN}" -m src.gui \ --host "${BACKEND_HOST}" \ --port "${BACKEND_PORT}" \ --cwd "${ROOT_DIR}" \ --session-dir "${RUN_DIR}/agent" \ --allow-write \ --allow-shell \ - --no-browser -) >"${BACKEND_LOG}" 2>&1 & + --no-browser >"${BACKEND_LOG}" 2>&1 & echo "$!" >"${RUN_DIR}/webui-backend.pid" wait_for_url "${BACKEND_URL}/api/state" "backend" "${BACKEND_LOG}" echo "Starting frontend on ${FRONTEND_URL}..." -( - cd "${ROOT_DIR}/frontend/app" - export CLAW_API_URL="${BACKEND_URL}" - exec npm run dev -- --hostname "${FRONTEND_HOST}" --port "${FRONTEND_PORT}" -) >"${FRONTEND_LOG}" 2>&1 & +cd "${ROOT_DIR}/frontend/app" +export CLAW_API_URL="${BACKEND_URL}" +nohup npm run dev -- --hostname "${FRONTEND_HOST}" --port "${FRONTEND_PORT}" >"${FRONTEND_LOG}" 2>&1 & echo "$!" >"${RUN_DIR}/webui-frontend.pid" wait_for_url "${FRONTEND_URL}" "frontend" "${FRONTEND_LOG}" diff --git a/src/agent_prompting.py b/src/agent_prompting.py index 3b8f029..94cfad7 100644 --- a/src/agent_prompting.py +++ b/src/agent_prompting.py @@ -6,6 +6,7 @@ from pathlib import Path from .agent_context import build_context_snapshot from .agent_tools import AgentTool from .agent_types import AgentRuntimeConfig, ModelConfig +from .bundled_skills import format_skills_for_system_prompt from .builtin_agents import AgentDefinition, format_agent_listing SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__' @@ -98,6 +99,7 @@ def build_system_prompt_parts( get_doing_tasks_section(), get_actions_section(), get_using_your_tools_section(enabled_tool_names), + get_skill_guidance_section(prompt_context, enabled_tool_names), get_agent_guidance_section(enabled_tool_names, available_agents), get_plugin_guidance_section(prompt_context), get_mcp_guidance_section(prompt_context), @@ -236,6 +238,22 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str: return '\n'.join(['# 使用工具', *prepend_bullets(items)]) +def get_skill_guidance_section( + prompt_context: PromptContext, + enabled_tool_names: set[str], +) -> str: + if 'Skill' not in enabled_tool_names: + return '' + skill_listing = format_skills_for_system_prompt(cwd=prompt_context.cwd) + items = [ + '当用户请求符合某个 skill 的适用范围时,优先调用 Skill 工具进入该 skill,不要先用 Agent 子任务或通用搜索绕路。', + '数据生成、标签边界、产品定义、示例 query 到数据集这类任务,优先使用 product-data skill。', + '线上 badcase 挖掘、router session 检索、候选转样本这类任务,优先使用 online-mining skill。', + '调用 skill 后遵守 skill 内部的人类 review 门禁和允许工具列表。', + ] + return '\n'.join(['# Skills', *prepend_bullets(items), '', skill_listing]) + + def get_tone_and_style_section() -> str: items = [ '回复保持简洁直接。', diff --git a/src/agent_runtime.py b/src/agent_runtime.py index c9390fa..3d5745c 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -2012,78 +2012,96 @@ class LocalCodingAgent: return fallback def _format_generation_goal_review(self, goal: dict[str, object]) -> str: + target_summary = self._format_review_targets(goal) lines = [ - '已生成待 review 的数据生成目标,本轮已暂停。', - '', - f"- goal_id: `{goal.get('goal_id', '')}`", - f"- revision: `{goal.get('revision', '')}`", - f"- dataset_label: {goal.get('dataset_label', '')}", - f"- 目标摘要: {goal.get('goal_summary', '')}", + '我先把生成目标整理好了,先确认边界,暂时不生成数据。', + f"- 数据集:{goal.get('dataset_label', '')}", ] - targets = goal.get('target_definitions') - if isinstance(targets, list) and targets: - lines.append('- target_definitions:') - for item in targets: - if isinstance(item, dict): - lines.append( - f" - {item.get('name', '')}: `{item.get('target', '')}`;规则:{item.get('rule', '')}" - ) - elif goal.get('target'): - lines.append(f"- target: `{goal.get('target')}`") - if goal.get('plan_hint'): - lines.append(f"- 计划提示: {goal.get('plan_hint')}") + if target_summary: + lines.append(f'- 标签:{target_summary}') + if goal.get('goal_summary'): + lines.append(f"- 边界:{self._short_review_text(goal.get('goal_summary'))}") + scope_parts = [] + if goal.get('coverage'): + scope_parts.append(f"覆盖:{self._short_review_text(goal.get('coverage'), limit=70)}") + if goal.get('exclusions'): + scope_parts.append(f"排除:{self._short_review_text(goal.get('exclusions'), limit=50)}") + if scope_parts: + lines.append('- 范围:' + ';'.join(scope_parts)) open_questions = goal.get('open_questions') if isinstance(open_questions, list) and open_questions: - lines.append('- 待确认问题:') - for item in open_questions: - if isinstance(item, str) and item: - lines.append(f' - {item}') - source_refs = goal.get('source_refs') - if isinstance(source_refs, list) and source_refs: - lines.append('- 来源:') - for item in source_refs: - if isinstance(item, str) and item: - lines.append(f' - {item}') + questions = ';'.join(str(item).strip() for item in open_questions if isinstance(item, str) and item.strip()) + if questions: + lines.append(f'- 待确认:{self._short_review_text(questions)}') + elif goal.get('plan_hint'): + lines.append(f"- 下一步:{self._short_review_text(goal.get('plan_hint'))}") + lines.append(f"- 内部:goal_id `{goal.get('goal_id', '')}`,revision `{goal.get('revision', '')}`") + lines.append('') + lines.append('你看这个目标是否准确?没问题就回“确认目标”;想改的话直接说哪里不对。') + return '\n'.join(lines) + + def _format_generation_plan_review(self, plan: dict[str, object]) -> str: + if not plan.get('confirmed_goal_id'): + return self._format_direct_generation_plan_review(plan) + lines = [ + '目标已确认。现在只补充生成参数,标签边界沿用上一步。', + f"- 数量:{plan.get('total_count', '')} 条", + f"- 轮次:{plan.get('turn_mix', '')}", + f"- 覆盖补充:{self._short_review_text(plan.get('coverage'))}", + f"- 排除:{self._short_review_text(plan.get('exclusions'))}", + f"- 输出:{plan.get('output_path', '')}", + f"- 内部:plan_id `{plan.get('plan_id', '')}`,revision `{plan.get('revision', '')}`", + ] + lines.append('') + lines.append('如果这个数量和路径可以,就回“确认,开始生成”;想调整就直接说,比如“改成 20 条,全单轮”。') + return '\n'.join(lines) + + def _format_direct_generation_plan_review(self, plan: dict[str, object]) -> str: + target_summary = self._format_review_targets(plan) + lines = [ + '我把目标和生成参数合成一次确认,确认后就开始生成。', + f"- 数据集:{plan.get('dataset_label', '')}", + ] + if target_summary: + lines.append(f'- 标签:{target_summary}') lines.extend( [ - f"- 覆盖范围: {goal.get('coverage', '')}", - f"- 排除项: {goal.get('exclusions', '')}", + f"- 数量:{plan.get('total_count', '')} 条;轮次:{plan.get('turn_mix', '')}", + ( + '- 范围:' + f"覆盖:{self._short_review_text(plan.get('coverage'), limit=70)};" + f"排除:{self._short_review_text(plan.get('exclusions'), limit=50)}" + ), + f"- 输出:{plan.get('output_path', '')}", + f"- 内部:plan_id `{plan.get('plan_id', '')}`,revision `{plan.get('revision', '')}`", '', - '请 review 这个生成目标:需要修改就直接回复修改意见;认可的话回复“确认目标”。', + '如果目标、数量和路径都可以,就回“确认,开始生成”;想调整就直接说哪里改。', ] ) return '\n'.join(lines) - def _format_generation_plan_review(self, plan: dict[str, object]) -> str: - lines = [ - '已生成待 review 的数据生成计划,本轮已暂停。', - '', - f"- plan_id: `{plan.get('plan_id', '')}`", - f"- revision: `{plan.get('revision', '')}`", - f"- dataset_label: {plan.get('dataset_label', '')}", - ] - targets = plan.get('target_definitions') + def _format_review_targets(self, payload: dict[str, object]) -> str: + targets = payload.get('target_definitions') if isinstance(targets, list) and targets: - lines.append('- target_definitions:') + parts: list[str] = [] for item in targets: - if isinstance(item, dict): - lines.append( - f" - {item.get('name', '')}: `{item.get('target', '')}`;规则:{item.get('rule', '')}" - ) - elif plan.get('target'): - lines.append(f"- target: `{plan.get('target')}`") - lines.extend( - [ - f"- 生成数量: {plan.get('total_count', '')}", - f"- 单轮/多轮: {plan.get('turn_mix', '')}", - f"- 覆盖范围: {plan.get('coverage', '')}", - f"- 排除项: {plan.get('exclusions', '')}", - f"- 落盘路径: {plan.get('output_path', '')}", - '', - '请 review 这份计划:需要修改就直接回复修改意见;认可的话回复“确认,开始生成”。', - ] - ) - return '\n'.join(lines) + if not isinstance(item, dict): + continue + name = str(item.get('name') or '').strip() + target = str(item.get('target') or '').strip() + if name and target: + parts.append(f'{name} -> `{target}`') + elif target: + parts.append(f'`{target}`') + return ';'.join(parts) + target = payload.get('target') + return f'`{target}`' if isinstance(target, str) and target.strip() else '' + + def _short_review_text(self, value: object, *, limit: int = 120) -> str: + text = ' '.join(str(value or '').split()) + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + '…' def _compact_prefix_count(self, session: AgentSessionState) -> int: prefix_count = 0 @@ -2388,14 +2406,29 @@ class LocalCodingAgent: # Explicit model param in arguments takes priority if isinstance(model_override, str) and model_override.strip(): - return replace(self.model_config, model=model_override.strip()) + resolved_model = self._resolve_child_model_name(model_override.strip()) + return replace(self.model_config, model=resolved_model) # Agent definition model if agent_model and agent_model != 'inherit': - return replace(self.model_config, model=agent_model) + resolved_model = self._resolve_child_model_name(agent_model) + return replace(self.model_config, model=resolved_model) return self.model_config + def _resolve_child_model_name(self, requested_model: str) -> str: + """Map Claude-style child model aliases only when the parent model is Claude-like.""" + + normalized = requested_model.strip() + if normalized in {'haiku', 'sonnet', 'opus'} and not self._is_claude_model(self.model_config.model): + return self.model_config.model + return normalized + + @staticmethod + def _is_claude_model(model: str) -> bool: + lowered = model.lower() + return lowered.startswith('claude') or '/claude' in lowered or 'anthropic' in lowered + def _filter_tools_for_agent( self, agent_def: AgentDefinition, diff --git a/src/agent_tool_core.py b/src/agent_tool_core.py new file mode 100644 index 0000000..a089b86 --- /dev/null +++ b/src/agent_tool_core.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Union + +from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult + +if TYPE_CHECKING: + from .account_runtime import AccountRuntime + from .ask_user_runtime import AskUserRuntime + from .config_runtime import ConfigRuntime + from .lsp_runtime import LSPRuntime + from .mcp_runtime import MCPRuntime + from .plan_runtime import PlanRuntime + from .remote_runtime import RemoteRuntime + from .remote_trigger_runtime import RemoteTriggerRuntime + from .search_runtime import SearchRuntime + from .task_runtime import TaskRuntime + from .team_runtime import TeamRuntime + from .workflow_runtime import WorkflowRuntime + from .worktree_runtime import WorktreeRuntime + + +class ToolPermissionError(RuntimeError): + """Raised when the runtime configuration does not allow a tool action.""" + + +class ToolExecutionError(RuntimeError): + """Raised when a tool cannot complete because of invalid input or state.""" + + +@dataclass(frozen=True) +class ToolExecutionContext: + root: Path + command_timeout_seconds: float + max_output_chars: int + permissions: AgentPermissions + scratchpad_directory: Path | None = None + python_env_dir: Path | None = None + extra_env: dict[str, str] = field(default_factory=dict) + tool_registry: dict[str, 'AgentTool'] | None = None + search_runtime: 'SearchRuntime | None' = None + account_runtime: 'AccountRuntime | None' = None + ask_user_runtime: 'AskUserRuntime | None' = None + config_runtime: 'ConfigRuntime | None' = None + lsp_runtime: 'LSPRuntime | None' = None + mcp_runtime: 'MCPRuntime | None' = None + remote_runtime: 'RemoteRuntime | None' = None + remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None + plan_runtime: 'PlanRuntime | None' = None + task_runtime: 'TaskRuntime | None' = None + team_runtime: 'TeamRuntime | None' = None + workflow_runtime: 'WorkflowRuntime | None' = None + worktree_runtime: 'WorktreeRuntime | None' = None + + +ToolHandler = Callable[ + [dict[str, Any], ToolExecutionContext], + Union[str, tuple[str, dict[str, Any]]], +] + + +@dataclass(frozen=True) +class AgentTool: + name: str + description: str + parameters: dict[str, Any] + handler: ToolHandler + + def to_openai_tool(self) -> dict[str, object]: + return { + 'type': 'function', + 'function': { + 'name': self.name, + 'description': self.description, + 'parameters': self.parameters, + }, + } + + def execute( + self, + arguments: dict[str, Any], + context: ToolExecutionContext, + ) -> ToolExecutionResult: + try: + result = self.handler(arguments, context) + if isinstance(result, tuple): + content, metadata = result + else: + content, metadata = result, {} + return ToolExecutionResult( + name=self.name, + ok=True, + content=content, + metadata=metadata, + ) + except ToolPermissionError as exc: + return ToolExecutionResult( + name=self.name, + ok=False, + content=str(exc), + metadata={'error_kind': 'permission_denied'}, + ) + except (ToolExecutionError, OSError, subprocess.SubprocessError) as exc: + return ToolExecutionResult( + name=self.name, + ok=False, + content=str(exc), + metadata={'error_kind': 'tool_execution_error'}, + ) + + +@dataclass(frozen=True) +class ToolStreamUpdate: + kind: str + content: str = '' + stream: str | None = None + result: ToolExecutionResult | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +def build_tool_context( + config: AgentRuntimeConfig, + *, + scratchpad_directory: Path | None = None, + python_env_dir: Path | None = None, + extra_env: dict[str, str] | None = None, + tool_registry: dict[str, AgentTool] | None = None, + search_runtime: 'SearchRuntime | None' = None, + account_runtime: 'AccountRuntime | None' = None, + ask_user_runtime: 'AskUserRuntime | None' = None, + config_runtime: 'ConfigRuntime | None' = None, + lsp_runtime: 'LSPRuntime | None' = None, + mcp_runtime: 'MCPRuntime | None' = None, + remote_runtime: 'RemoteRuntime | None' = None, + remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None, + plan_runtime: 'PlanRuntime | None' = None, + task_runtime: 'TaskRuntime | None' = None, + team_runtime: 'TeamRuntime | None' = None, + workflow_runtime: 'WorkflowRuntime | None' = None, + worktree_runtime: 'WorktreeRuntime | None' = None, +) -> ToolExecutionContext: + return ToolExecutionContext( + root=config.cwd.resolve(), + command_timeout_seconds=config.command_timeout_seconds, + max_output_chars=config.max_output_chars, + permissions=config.permissions, + scratchpad_directory=scratchpad_directory.resolve() if scratchpad_directory else None, + python_env_dir=( + python_env_dir.resolve() + if python_env_dir + else config.python_env_dir.resolve() + if config.python_env_dir + else None + ), + extra_env=dict(extra_env or {}), + tool_registry=tool_registry, + search_runtime=search_runtime, + account_runtime=account_runtime, + ask_user_runtime=ask_user_runtime, + config_runtime=config_runtime, + lsp_runtime=lsp_runtime, + mcp_runtime=mcp_runtime, + remote_runtime=remote_runtime, + remote_trigger_runtime=remote_trigger_runtime, + plan_runtime=plan_runtime, + task_runtime=task_runtime, + team_runtime=team_runtime, + workflow_runtime=workflow_runtime, + worktree_runtime=worktree_runtime, + ) diff --git a/src/agent_tool_specs/__init__.py b/src/agent_tool_specs/__init__.py new file mode 100644 index 0000000..c2dc6e6 --- /dev/null +++ b/src/agent_tool_specs/__init__.py @@ -0,0 +1,5 @@ +"""工具声明目录。 + +每个子模块负责一类工具的名称、描述和 JSON Schema;handler 由运行层注入。 +""" + diff --git a/src/agent_tool_specs/_builder.py b/src/agent_tool_specs/_builder.py new file mode 100644 index 0000000..091da7a --- /dev/null +++ b/src/agent_tool_specs/_builder.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from ..agent_tool_core import ToolHandler + + +def resolve_handler(handlers: Mapping[str, ToolHandler], name: str, group: str) -> ToolHandler: + """从工具组 handler 映射中取出指定工具的执行函数。""" + + try: + return handlers[name] + except KeyError as exc: + raise KeyError(f'Missing {group} tool handler: {name}') from exc + diff --git a/src/agent_tool_specs/data_agent.py b/src/agent_tool_specs/data_agent.py new file mode 100644 index 0000000..2975d13 --- /dev/null +++ b/src/agent_tool_specs/data_agent.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from ..agent_tool_core import AgentTool, ToolHandler +from ._builder import resolve_handler + + +def build_data_agent_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]: + """构建数据 Agent 专用工具声明。 + + 这里仅保存工具名称、描述和 JSON Schema,具体执行逻辑由调用方传入的 handler 提供。 + 这样新增数据 Agent 工具时,可以优先在本文件补充声明,再在实现模块中补充 handler。 + """ + return [ + AgentTool( + name='data_agent_prepare_generation_goal', + description=( + 'Create a pending data-agent generation goal from source evidence or user rules. ' + 'Use this before data_agent_prepare_generation_plan; show the returned goal to the user and wait for confirmation.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'dataset_label': {'type': 'string'}, + 'goal_summary': {'type': 'string'}, + 'target': { + 'type': 'string', + 'description': 'Single full target expression, e.g. Agent(tag="餐饮服务") or a function call. For multi-target boundary tasks, use target_definitions.', + }, + 'target_definitions': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'target': { + 'type': 'string', + 'description': 'Full target expression, e.g. Agent(tag="餐饮服务"); do not shorten to a display name.', + }, + 'rule': {'type': 'string'}, + }, + 'required': ['target'], + }, + }, + 'plan_hint': { + 'type': 'string', + 'description': ( + 'Optional plain-language hint for the later plan, such as ' + '"建议先生成 50 条单轮,输出到 tasks/.../records.jsonl". ' + 'Structured total_count, turn_mix, and output_path belong to data_agent_prepare_generation_plan.' + ), + }, + 'coverage': {'type': 'string'}, + 'exclusions': {'type': 'string'}, + 'open_questions': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'source_refs': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'notes': {'type': 'string'}, + }, + 'required': ['dataset_label', 'goal_summary', 'coverage', 'exclusions'], + }, + handler=resolve_handler(handlers, 'data_agent_prepare_generation_goal', 'data-agent'), + ), + AgentTool( + name='data_agent_confirm_generation_goal', + description=( + 'Mark a pending data-agent generation goal as confirmed after the user explicitly approves it. ' + 'The returned confirmed_goal_id is required by data_agent_prepare_generation_plan.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'goal_id': {'type': 'string'}, + 'confirmation': { + 'type': 'string', + 'description': 'The user approval text, such as 确认, 开始生成, or approve.', + }, + 'reviewed_revision': { + 'type': 'integer', + 'description': 'The goal revision shown to the user before confirmation.', + }, + }, + 'required': ['goal_id', 'confirmation'], + }, + handler=resolve_handler(handlers, 'data_agent_confirm_generation_goal', 'data-agent'), + ), + AgentTool( + name='data_agent_prepare_generation_plan', + description=( + 'Create a pending data-agent generation plan. Use this before generating dataset draft text; ' + 'requires confirmed_goal_id from data_agent_confirm_generation_goal; show the returned plan to the user and wait for confirmation.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'confirmed_goal_id': { + 'type': 'string', + 'description': 'Returned by data_agent_confirm_generation_goal after the user reviews a separate generation goal. Omit only when direct_review is true.', + }, + 'direct_review': { + 'type': 'boolean', + 'description': ( + 'Set true for simple hand-written rules where the user already supplied target labels ' + 'and wants generation; this creates one combined goal+plan review instead of a separate goal review.' + ), + }, + 'dataset_label': {'type': 'string'}, + 'target': { + 'type': 'string', + 'description': 'Single full target expression. For multi-target boundary tasks, omit this and fill target_definitions.', + }, + 'target_definitions': { + 'type': 'array', + 'description': 'Optional target labels for multi-class boundary data.', + 'items': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'target': { + 'type': 'string', + 'description': 'Full target expression inherited from the confirmed goal, e.g. Agent(tag="餐饮服务").', + }, + 'rule': {'type': 'string'}, + }, + 'required': ['target'], + }, + }, + 'total_count': {'type': 'integer', 'minimum': 1}, + 'turn_mix': {'type': 'string', 'description': 'Single-turn and multi-turn count or ratio.'}, + 'coverage': {'type': 'string', 'description': 'Query types, intent boundaries, or error types to cover.'}, + 'exclusions': {'type': 'string', 'description': 'Negative examples or boundaries to avoid.'}, + 'output_path': {'type': 'string', 'description': 'Where draft, records, and validation files should be written.'}, + 'notes': {'type': 'string'}, + }, + 'required': [ + 'dataset_label', + 'total_count', + 'turn_mix', + 'coverage', + 'exclusions', + 'output_path', + ], + }, + handler=resolve_handler(handlers, 'data_agent_prepare_generation_plan', 'data-agent'), + ), + AgentTool( + name='data_agent_load_input_sources', + description=( + 'Load data-agent input files or directories and extract structured paragraphs and table previews ' + 'from xlsx, csv, docx, pdf, txt, md, json, or jsonl sources.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'paths': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Workspace-relative files or directories to load.', + }, + 'max_files': {'type': 'integer', 'minimum': 1, 'maximum': 100}, + 'max_paragraphs_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 300}, + 'max_tables_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 100}, + 'max_rows_per_table': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + 'max_cell_chars': {'type': 'integer', 'minimum': 20, 'maximum': 2000}, + }, + 'required': ['paths'], + }, + handler=resolve_handler(handlers, 'data_agent_load_input_sources', 'data-agent'), + ), + AgentTool( + name='data_agent_extract_case_evidence', + description=( + 'Extract query/badcase evidence from loaded input sources or source paths. ' + 'Profiles table columns first and returns required questions when query or expected label columns are ambiguous.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'paths': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Workspace-relative files or directories. Omit when loaded_sources is provided.', + }, + 'loaded_sources': { + 'type': 'object', + 'description': 'Output from data_agent_load_input_sources.', + }, + 'field_mapping': { + 'type': 'object', + 'description': 'Optional role-to-column mapping, e.g. {"query":"query","expected_label":"预期domain"}.', + }, + 'max_cases': {'type': 'integer', 'minimum': 1, 'maximum': 1000}, + }, + }, + handler=resolve_handler(handlers, 'data_agent_extract_case_evidence', 'data-agent'), + ), + AgentTool( + name='data_agent_render_source_context', + description=( + 'Render loaded data-agent sources into LLM-readable evidence text with source refs. ' + 'Use this after data_agent_load_input_sources before asking the model to structure product semantics.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'paths': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Workspace-relative files or directories. Omit when loaded_sources is provided.', + }, + 'loaded_sources': { + 'type': 'object', + 'description': 'Output from data_agent_load_input_sources.', + }, + 'max_chars': {'type': 'integer', 'minimum': 1000, 'maximum': 200000}, + 'max_tables_per_source': {'type': 'integer', 'minimum': 1, 'maximum': 100}, + 'max_rows_per_table': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + 'focus_keywords': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Optional keywords used to keep only matching paragraphs/table rows.', + }, + }, + }, + handler=resolve_handler(handlers, 'data_agent_render_source_context', 'data-agent'), + ), + AgentTool( + name='data_agent_profile_router_sessions', + description=( + 'Profile local router_session_parquet date partitions before mining online sessions. ' + 'Returns schema, sampled row count, distributions, and example sessions.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'dates': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Date partitions such as ["20260428"]. Resolved under router_session_parquet/date=YYYYMMDD.', + }, + 'paths': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Workspace-relative parquet files or date directories. Use when dates is not enough.', + }, + 'max_files': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + 'max_rows_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 100000}, + 'examples_limit': {'type': 'integer', 'minimum': 0, 'maximum': 20}, + }, + }, + handler=resolve_handler(handlers, 'data_agent_profile_router_sessions', 'data-agent'), + ), + AgentTool( + name='data_agent_search_router_sessions', + description=( + 'Search local router_session_parquet data with reviewable filters such as device, domain, intent, func, ' + 'query keywords, regex, turn count, and match scope. Returns turn-level candidates with prev turns.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'dates': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Date partitions such as ["20260428"]. Resolved under router_session_parquet/date=YYYYMMDD.', + }, + 'paths': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Workspace-relative parquet files or date directories. Use when dates is not enough.', + }, + 'devices': {'type': 'array', 'items': {'type': 'string'}}, + 'domains': {'type': 'array', 'items': {'type': 'string'}}, + 'intents': {'type': 'array', 'items': {'type': 'string'}}, + 'funcs': {'type': 'array', 'items': {'type': 'string'}}, + 'query_keywords': {'type': 'array', 'items': {'type': 'string'}}, + 'keyword_match_mode': {'type': 'string', 'enum': ['any', 'all']}, + 'query_regex': {'type': 'string'}, + 'match_scope': {'type': 'string', 'enum': ['any_turn', 'last_turn']}, + 'turn_count_min': {'type': 'integer', 'minimum': 1}, + 'turn_count_max': {'type': 'integer', 'minimum': 1}, + 'max_files': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + 'max_rows_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 100000}, + 'max_candidates': {'type': 'integer', 'minimum': 1, 'maximum': 5000}, + }, + }, + handler=resolve_handler(handlers, 'data_agent_search_router_sessions', 'data-agent'), + ), + AgentTool( + name='data_agent_sample_router_candidates', + description=( + 'Sample router session candidates for human review and return candidate-level summary statistics. ' + 'Use after data_agent_search_router_sessions.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'candidates': { + 'oneOf': [ + {'type': 'array'}, + {'type': 'object'}, + {'type': 'string'}, + ], + 'description': 'Candidates array, search result object, or JSON string containing candidates.', + }, + 'sample_size': {'type': 'integer', 'minimum': 1, 'maximum': 1000}, + 'strategy': {'type': 'string', 'enum': ['random', 'first', 'stride']}, + 'seed': {'type': 'integer'}, + }, + 'required': ['candidates'], + }, + handler=resolve_handler(handlers, 'data_agent_sample_router_candidates', 'data-agent'), + ), + AgentTool( + name='data_agent_convert_router_candidates_to_records', + description=( + 'Convert reviewed online router session candidates directly into canonical data-agent records. ' + 'Use this when the user wants to keep mined online data as samples; do not use generation-plan tools for this path.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'candidates': { + 'oneOf': [ + {'type': 'array'}, + {'type': 'object'}, + {'type': 'string'}, + ], + 'description': 'Candidates array, search/sample result object, or JSON string containing candidates.', + }, + 'dataset_label': {'type': 'string'}, + 'default_target': { + 'type': 'string', + 'description': 'Target label used for included candidates unless review_decisions provides a target.', + }, + 'review_decisions': { + 'type': 'array', + 'description': 'Optional include/exclude/uncertain decisions keyed by semantic_session_id or req_id.', + 'items': { + 'type': 'object', + 'properties': { + 'semantic_session_id': {'type': 'string'}, + 'req_id': {'type': 'string'}, + 'matched_turn_index': {'type': 'integer'}, + 'decision': {'type': 'string', 'enum': ['include', 'exclude', 'uncertain']}, + 'target': {'type': 'string'}, + 'notes': {'type': 'string'}, + }, + }, + }, + 'batch_id': {'type': 'string'}, + 'include_uncertain': {'type': 'boolean'}, + }, + 'required': ['candidates', 'dataset_label'], + }, + handler=resolve_handler(handlers, 'data_agent_convert_router_candidates_to_records', 'data-agent'), + ), + AgentTool( + name='data_agent_show_generation_plan', + description='Show a data-agent generation plan for human review.', + parameters={ + 'type': 'object', + 'properties': { + 'plan_id': {'type': 'string'}, + }, + 'required': ['plan_id'], + }, + handler=resolve_handler(handlers, 'data_agent_show_generation_plan', 'data-agent'), + ), + AgentTool( + name='data_agent_update_generation_plan', + description=( + 'Update a pending data-agent generation plan from human review feedback, then show it again for review.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'plan_id': {'type': 'string'}, + 'review_feedback': { + 'type': 'string', + 'description': 'Human review feedback that explains why the plan is changing.', + }, + 'updates': { + 'type': 'object', + 'description': 'Fields to update on the plan.', + }, + }, + 'required': ['plan_id', 'review_feedback', 'updates'], + }, + handler=resolve_handler(handlers, 'data_agent_update_generation_plan', 'data-agent'), + ), + AgentTool( + name='data_agent_confirm_generation_plan', + description=( + 'Mark a pending data-agent generation plan as confirmed after the user explicitly approves it.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'plan_id': {'type': 'string'}, + 'confirmation': { + 'type': 'string', + 'description': 'The user approval text, such as 确认, 开始生成, or approve.', + }, + 'reviewed_revision': { + 'type': 'integer', + 'description': 'The plan revision shown to the user before confirmation.', + }, + }, + 'required': ['plan_id', 'confirmation'], + }, + handler=resolve_handler(handlers, 'data_agent_confirm_generation_plan', 'data-agent'), + ), + AgentTool( + name='data_agent_normalize_dataset_draft', + description=( + 'Parse dataset draft text written with 用户/小爱/target blocks and build canonical ' + 'data-agent records with generated record IDs, timestamps, source metadata, context, and labels. ' + 'Generated data requires a confirmed_plan_id from data_agent_confirm_generation_plan.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'draft_text': { + 'type': 'string', + 'description': 'Dataset draft text in dataset draft text v1 format.', + }, + 'batch_id': { + 'type': 'string', + 'description': 'Batch identifier used in generated record_id values.', + }, + 'source_type': { + 'type': 'string', + 'enum': ['generated', 'online', 'manual', 'mixed'], + }, + 'base_timestamp': { + 'type': 'integer', + 'description': 'Optional millisecond timestamp for deterministic generated records.', + }, + 'timestamp_step_ms': { + 'type': 'integer', + 'minimum': 1, + 'description': 'Millisecond gap between adjacent turns. Defaults to 60000.', + }, + 'default_request_id': { + 'type': 'string', + 'description': 'Request ID to use when a case does not provide a real request_id.', + }, + 'confirmed_plan_id': { + 'type': 'string', + 'description': 'Required for generated data; returned by data_agent_confirm_generation_plan.', + }, + }, + 'required': ['draft_text'], + }, + handler=resolve_handler(handlers, 'data_agent_normalize_dataset_draft', 'data-agent'), + ), + AgentTool( + name='data_agent_validate_dataset_records', + description=( + 'Validate canonical data-agent records for required fields, labels, source metadata, ' + 'prev_session structure, timestamp order, and 5-minute prompt stitching constraints.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'records': { + 'oneOf': [ + {'type': 'array'}, + {'type': 'string'}, + ], + 'description': 'Canonical records as an array, or a JSON string containing the array.', + }, + }, + 'required': ['records'], + }, + handler=resolve_handler(handlers, 'data_agent_validate_dataset_records', 'data-agent'), + ), + AgentTool( + name='data_agent_export_dataset_records', + description=( + 'Validate canonical data-agent records and write them to a workspace file. ' + 'Defaults to compact JSONL, one canonical record per line, so the model does not hand-write JSON output.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'records': { + 'oneOf': [ + {'type': 'array'}, + {'type': 'string'}, + ], + 'description': 'Canonical records as an array, or a JSON string containing the array.', + }, + 'output_path': { + 'type': 'string', + 'description': 'Workspace-relative path to write, usually ending in .jsonl.', + }, + 'output_format': { + 'type': 'string', + 'enum': ['jsonl', 'json'], + 'description': 'Defaults to jsonl. json writes a compact JSON array.', + }, + 'require_validation_ok': { + 'type': 'boolean', + 'description': 'When true, refuse to write records with validation errors.', + }, + 'overwrite': { + 'type': 'boolean', + 'description': 'When false, refuse to overwrite an existing file.', + }, + }, + 'required': ['records', 'output_path'], + }, + handler=resolve_handler(handlers, 'data_agent_export_dataset_records', 'data-agent'), + ), + ] diff --git a/src/agent_tool_specs/execution.py b/src/agent_tool_specs/execution.py new file mode 100644 index 0000000..76cd163 --- /dev/null +++ b/src/agent_tool_specs/execution.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from ..agent_tool_core import AgentTool, ToolHandler +from ._builder import resolve_handler + + +def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]: + """构建本地命令、Python 执行与短等待工具声明。""" + + return [ + AgentTool( + name='python_exec', + description=( + '优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、' + 'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv;' + '不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。' + '缺包时应向用户确认后再处理依赖。一次性分析优先传 code;如需写临时文件,' + '必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,' + '不要在项目根目录创建临时 .py 脚本。' + ), + parameters={ + 'type': 'object', + 'properties': { + 'code': { + 'type': 'string', + 'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。', + }, + 'script_path': { + 'type': 'string', + 'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。', + }, + 'args': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': '传给脚本的命令行参数。仅在 script_path 模式下使用。', + }, + 'stdin': { + 'type': 'string', + 'description': '可选标准输入内容。', + }, + 'timeout_seconds': { + 'type': 'number', + 'minimum': 1, + 'description': '可选超时时间,默认使用当前会话命令超时。', + }, + 'max_output_chars': { + 'type': 'integer', + 'minimum': 100, + 'description': '可选输出截断长度,默认使用当前会话输出限制。', + }, + }, + }, + handler=resolve_handler(handlers, 'python_exec', 'execution'), + ), + AgentTool( + name='python_package', + description=( + '在当前用户独立 Python venv 中检查或安装 Python 包。' + '用于 python_exec 缺少 pandas、pyarrow、openpyxl 等分析依赖时;' + '不要通过 bash 执行 pip,也不要安装到项目 .venv。' + ), + parameters={ + 'type': 'object', + 'properties': { + 'action': { + 'type': 'string', + 'enum': ['show', 'install'], + 'description': 'show 查看当前 Python 环境和 pip;install 安装 packages。', + }, + 'packages': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': '要安装的包名或 pip requirement spec,例如 pandas、pyarrow==15.0.0。', + }, + 'timeout_seconds': { + 'type': 'number', + 'minimum': 1, + 'maximum': 600, + 'description': '安装超时时间,默认 120 秒。', + }, + 'max_output_chars': { + 'type': 'integer', + 'minimum': 1000, + 'maximum': 50000, + }, + }, + 'required': ['action'], + }, + handler=resolve_handler(handlers, 'python_package', 'execution'), + ), + AgentTool( + name='bash', + description=( + '运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。' + '不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、' + 'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。' + 'Python 包安装必须优先使用 python_package。' + ), + parameters={ + 'type': 'object', + 'properties': { + 'command': {'type': 'string'}, + }, + 'required': ['command'], + }, + handler=resolve_handler(handlers, 'bash', 'execution'), + ), + AgentTool( + name='sleep', + description='Pause execution briefly for bounded local wait flows.', + parameters={ + 'type': 'object', + 'properties': { + 'seconds': {'type': 'number', 'minimum': 0.0, 'maximum': 5.0}, + }, + 'required': ['seconds'], + }, + handler=resolve_handler(handlers, 'sleep', 'execution'), + ), + ] + diff --git a/src/agent_tool_specs/files.py b/src/agent_tool_specs/files.py new file mode 100644 index 0000000..3cbf8ad --- /dev/null +++ b/src/agent_tool_specs/files.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from ..agent_tool_core import AgentTool, ToolHandler +from ._builder import resolve_handler + + +def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]: + """构建工作区文件读写与文本检索工具声明。""" + + return [ + AgentTool( + name='list_dir', + description='List files and directories under a workspace path.', + parameters={ + 'type': 'object', + 'properties': { + 'path': {'type': 'string', 'description': 'Relative path from workspace root.'}, + 'max_entries': {'type': 'integer', 'minimum': 1, 'maximum': 500}, + }, + }, + handler=resolve_handler(handlers, 'list_dir', 'file'), + ), + AgentTool( + name='read_file', + description='Read the contents of a UTF-8 text file inside the workspace.', + parameters={ + 'type': 'object', + 'properties': { + 'path': {'type': 'string', 'description': 'Relative file path from workspace root.'}, + 'start_line': {'type': 'integer', 'minimum': 1}, + 'end_line': {'type': 'integer', 'minimum': 1}, + }, + 'required': ['path'], + }, + handler=resolve_handler(handlers, 'read_file', 'file'), + ), + AgentTool( + name='write_file', + description='Write a complete file inside the workspace. Creates parent directories when needed.', + parameters={ + 'type': 'object', + 'properties': { + 'path': {'type': 'string'}, + 'content': {'type': 'string'}, + }, + 'required': ['path', 'content'], + }, + handler=resolve_handler(handlers, 'write_file', 'file'), + ), + AgentTool( + name='edit_file', + description='Replace text inside a workspace file using exact string matching.', + parameters={ + 'type': 'object', + 'properties': { + 'path': {'type': 'string'}, + 'old_text': {'type': 'string'}, + 'new_text': {'type': 'string'}, + 'replace_all': {'type': 'boolean'}, + }, + 'required': ['path', 'old_text', 'new_text'], + }, + handler=resolve_handler(handlers, 'edit_file', 'file'), + ), + AgentTool( + name='notebook_edit', + description='Edit a Jupyter notebook cell by replacing or appending source in a .ipynb file.', + parameters={ + 'type': 'object', + 'properties': { + 'path': {'type': 'string'}, + 'cell_index': {'type': 'integer', 'minimum': 0}, + 'source': {'type': 'string'}, + 'cell_type': {'type': 'string'}, + 'create_cell': {'type': 'boolean'}, + }, + 'required': ['path', 'cell_index', 'source'], + }, + handler=resolve_handler(handlers, 'notebook_edit', 'file'), + ), + AgentTool( + name='glob_search', + description='Find files matching a glob pattern inside the workspace.', + parameters={ + 'type': 'object', + 'properties': { + 'pattern': {'type': 'string'}, + }, + 'required': ['pattern'], + }, + handler=resolve_handler(handlers, 'glob_search', 'file'), + ), + AgentTool( + name='grep_search', + description='Search for a string or regular expression inside workspace files.', + parameters={ + 'type': 'object', + 'properties': { + 'pattern': {'type': 'string'}, + 'path': {'type': 'string'}, + 'literal': {'type': 'boolean'}, + 'max_matches': {'type': 'integer', 'minimum': 1, 'maximum': 500}, + }, + 'required': ['pattern'], + }, + handler=resolve_handler(handlers, 'grep_search', 'file'), + ), + ] + diff --git a/src/agent_tools.py b/src/agent_tools.py index 19d2dd1..c265cda 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -12,11 +12,22 @@ import time import urllib.error import urllib.parse import urllib.request -from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Iterator, Union +from typing import Any, Iterator -from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult +from .agent_tool_core import ( + AgentTool, + ToolExecutionContext, + ToolExecutionError, + ToolPermissionError, + ToolStreamUpdate, + build_tool_context, +) + +from .agent_tool_specs.data_agent import build_data_agent_tools +from .agent_tool_specs.execution import build_execution_tools +from .agent_tool_specs.files import build_file_tools +from .agent_types import ToolExecutionResult from .data_agent_records import ( DataRecordError, confirm_generation_goal, @@ -46,162 +57,6 @@ from .data_agent_router_sessions import ( ) from .session_env_vars import get_session_env_vars -if TYPE_CHECKING: - from .account_runtime import AccountRuntime - from .ask_user_runtime import AskUserRuntime - from .config_runtime import ConfigRuntime - from .lsp_runtime import LSPRuntime - from .mcp_runtime import MCPRuntime - from .plan_runtime import PlanRuntime - from .remote_runtime import RemoteRuntime - from .remote_trigger_runtime import RemoteTriggerRuntime - from .search_runtime import SearchRuntime - from .task_runtime import TaskRuntime - from .team_runtime import TeamRuntime - from .workflow_runtime import WorkflowRuntime - from .worktree_runtime import WorktreeRuntime - - -class ToolPermissionError(RuntimeError): - """Raised when the runtime configuration does not allow a tool action.""" - - -class ToolExecutionError(RuntimeError): - """Raised when a tool cannot complete because of invalid input or state.""" - - -@dataclass(frozen=True) -class ToolExecutionContext: - root: Path - command_timeout_seconds: float - max_output_chars: int - permissions: AgentPermissions - scratchpad_directory: Path | None = None - python_env_dir: Path | None = None - extra_env: dict[str, str] = field(default_factory=dict) - tool_registry: dict[str, 'AgentTool'] | None = None - search_runtime: 'SearchRuntime | None' = None - account_runtime: 'AccountRuntime | None' = None - ask_user_runtime: 'AskUserRuntime | None' = None - config_runtime: 'ConfigRuntime | None' = None - lsp_runtime: 'LSPRuntime | None' = None - mcp_runtime: 'MCPRuntime | None' = None - remote_runtime: 'RemoteRuntime | None' = None - remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None - plan_runtime: 'PlanRuntime | None' = None - task_runtime: 'TaskRuntime | None' = None - team_runtime: 'TeamRuntime | None' = None - workflow_runtime: 'WorkflowRuntime | None' = None - worktree_runtime: 'WorktreeRuntime | None' = None - - -ToolHandler = Callable[ - [dict[str, Any], ToolExecutionContext], - Union[str, tuple[str, dict[str, Any]]], -] - - -@dataclass(frozen=True) -class AgentTool: - name: str - description: str - parameters: dict[str, Any] - handler: ToolHandler - - def to_openai_tool(self) -> dict[str, object]: - return { - 'type': 'function', - 'function': { - 'name': self.name, - 'description': self.description, - 'parameters': self.parameters, - }, - } - - def execute(self, arguments: dict[str, Any], context: ToolExecutionContext) -> ToolExecutionResult: - try: - result = self.handler(arguments, context) - if isinstance(result, tuple): - content, metadata = result - else: - content, metadata = result, {} - return ToolExecutionResult(name=self.name, ok=True, content=content, metadata=metadata) - except ToolPermissionError as exc: - return ToolExecutionResult( - name=self.name, - ok=False, - content=str(exc), - metadata={'error_kind': 'permission_denied'}, - ) - except (ToolExecutionError, OSError, subprocess.SubprocessError) as exc: - return ToolExecutionResult( - name=self.name, - ok=False, - content=str(exc), - metadata={'error_kind': 'tool_execution_error'}, - ) - - -@dataclass(frozen=True) -class ToolStreamUpdate: - kind: str - content: str = '' - stream: str | None = None - result: ToolExecutionResult | None = None - metadata: dict[str, Any] = field(default_factory=dict) - - -def build_tool_context( - config: AgentRuntimeConfig, - *, - scratchpad_directory: Path | None = None, - python_env_dir: Path | None = None, - extra_env: dict[str, str] | None = None, - tool_registry: dict[str, AgentTool] | None = None, - search_runtime: 'SearchRuntime | None' = None, - account_runtime: 'AccountRuntime | None' = None, - ask_user_runtime: 'AskUserRuntime | None' = None, - config_runtime: 'ConfigRuntime | None' = None, - lsp_runtime: 'LSPRuntime | None' = None, - mcp_runtime: 'MCPRuntime | None' = None, - remote_runtime: 'RemoteRuntime | None' = None, - remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None, - plan_runtime: 'PlanRuntime | None' = None, - task_runtime: 'TaskRuntime | None' = None, - team_runtime: 'TeamRuntime | None' = None, - workflow_runtime: 'WorkflowRuntime | None' = None, - worktree_runtime: 'WorktreeRuntime | None' = None, -) -> ToolExecutionContext: - return ToolExecutionContext( - root=config.cwd.resolve(), - command_timeout_seconds=config.command_timeout_seconds, - max_output_chars=config.max_output_chars, - permissions=config.permissions, - scratchpad_directory=scratchpad_directory.resolve() if scratchpad_directory else None, - python_env_dir=( - python_env_dir.resolve() - if python_env_dir - else config.python_env_dir.resolve() - if config.python_env_dir - else None - ), - extra_env=dict(extra_env or {}), - tool_registry=tool_registry, - search_runtime=search_runtime, - account_runtime=account_runtime, - ask_user_runtime=ask_user_runtime, - config_runtime=config_runtime, - lsp_runtime=lsp_runtime, - mcp_runtime=mcp_runtime, - remote_runtime=remote_runtime, - remote_trigger_runtime=remote_trigger_runtime, - plan_runtime=plan_runtime, - task_runtime=task_runtime, - team_runtime=team_runtime, - workflow_runtime=workflow_runtime, - worktree_runtime=worktree_runtime, - ) - def execute_tool( tool_registry: dict[str, AgentTool], @@ -250,200 +105,21 @@ def execute_tool_streaming( def default_tool_registry() -> dict[str, AgentTool]: tools = [ - AgentTool( - name='list_dir', - description='List files and directories under a workspace path.', - parameters={ - 'type': 'object', - 'properties': { - 'path': {'type': 'string', 'description': 'Relative path from workspace root.'}, - 'max_entries': {'type': 'integer', 'minimum': 1, 'maximum': 500}, - }, - }, - handler=_list_dir, - ), - AgentTool( - name='read_file', - description='Read the contents of a UTF-8 text file inside the workspace.', - parameters={ - 'type': 'object', - 'properties': { - 'path': {'type': 'string', 'description': 'Relative file path from workspace root.'}, - 'start_line': {'type': 'integer', 'minimum': 1}, - 'end_line': {'type': 'integer', 'minimum': 1}, - }, - 'required': ['path'], - }, - handler=_read_file, - ), - AgentTool( - name='write_file', - description='Write a complete file inside the workspace. Creates parent directories when needed.', - parameters={ - 'type': 'object', - 'properties': { - 'path': {'type': 'string'}, - 'content': {'type': 'string'}, - }, - 'required': ['path', 'content'], - }, - handler=_write_file, - ), - AgentTool( - name='edit_file', - description='Replace text inside a workspace file using exact string matching.', - parameters={ - 'type': 'object', - 'properties': { - 'path': {'type': 'string'}, - 'old_text': {'type': 'string'}, - 'new_text': {'type': 'string'}, - 'replace_all': {'type': 'boolean'}, - }, - 'required': ['path', 'old_text', 'new_text'], - }, - handler=_edit_file, - ), - AgentTool( - name='notebook_edit', - description='Edit a Jupyter notebook cell by replacing or appending source in a .ipynb file.', - parameters={ - 'type': 'object', - 'properties': { - 'path': {'type': 'string'}, - 'cell_index': {'type': 'integer', 'minimum': 0}, - 'source': {'type': 'string'}, - 'cell_type': {'type': 'string'}, - 'create_cell': {'type': 'boolean'}, - }, - 'required': ['path', 'cell_index', 'source'], - }, - handler=_notebook_edit, - ), - AgentTool( - name='glob_search', - description='Find files matching a glob pattern inside the workspace.', - parameters={ - 'type': 'object', - 'properties': { - 'pattern': {'type': 'string'}, - }, - 'required': ['pattern'], - }, - handler=_glob_search, - ), - AgentTool( - name='grep_search', - description='Search for a string or regular expression inside workspace files.', - parameters={ - 'type': 'object', - 'properties': { - 'pattern': {'type': 'string'}, - 'path': {'type': 'string'}, - 'literal': {'type': 'boolean'}, - 'max_matches': {'type': 'integer', 'minimum': 1, 'maximum': 500}, - }, - 'required': ['pattern'], - }, - handler=_grep_search, - ), - AgentTool( - name='python_exec', - description=( - '优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、' - 'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv;' - '不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。' - '缺包时应向用户确认后再处理依赖。一次性分析优先传 code;如需写临时文件,' - '必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,' - '不要在项目根目录创建临时 .py 脚本。' - ), - parameters={ - 'type': 'object', - 'properties': { - 'code': { - 'type': 'string', - 'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。', - }, - 'script_path': { - 'type': 'string', - 'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。', - }, - 'args': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': '传给脚本的命令行参数。仅在 script_path 模式下使用。', - }, - 'stdin': { - 'type': 'string', - 'description': '可选标准输入内容。', - }, - 'timeout_seconds': { - 'type': 'number', - 'minimum': 1, - 'description': '可选超时时间,默认使用当前会话命令超时。', - }, - 'max_output_chars': { - 'type': 'integer', - 'minimum': 100, - 'description': '可选输出截断长度,默认使用当前会话输出限制。', - }, - }, - }, - handler=_run_python_exec, - ), - AgentTool( - name='python_package', - description=( - '在当前用户独立 Python venv 中检查或安装 Python 包。' - '用于 python_exec 缺少 pandas、pyarrow、openpyxl 等分析依赖时;' - '不要通过 bash 执行 pip,也不要安装到项目 .venv。' - ), - parameters={ - 'type': 'object', - 'properties': { - 'action': { - 'type': 'string', - 'enum': ['show', 'install'], - 'description': 'show 查看当前 Python 环境和 pip;install 安装 packages。', - }, - 'packages': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': '要安装的包名或 pip requirement spec,例如 pandas、pyarrow==15.0.0。', - }, - 'timeout_seconds': { - 'type': 'number', - 'minimum': 1, - 'maximum': 600, - 'description': '安装超时时间,默认 120 秒。', - }, - 'max_output_chars': { - 'type': 'integer', - 'minimum': 1000, - 'maximum': 50000, - }, - }, - 'required': ['action'], - }, - handler=_run_python_package, - ), - AgentTool( - name='bash', - description=( - '运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。' - '不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、' - 'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。' - 'Python 包安装必须优先使用 python_package。' - ), - parameters={ - 'type': 'object', - 'properties': { - 'command': {'type': 'string'}, - }, - 'required': ['command'], - }, - handler=_run_bash, - ), + *build_file_tools({ + 'list_dir': _list_dir, + 'read_file': _read_file, + 'write_file': _write_file, + 'edit_file': _edit_file, + 'notebook_edit': _notebook_edit, + 'glob_search': _glob_search, + 'grep_search': _grep_search, + }), + *build_execution_tools({ + 'python_exec': _run_python_exec, + 'python_package': _run_python_package, + 'bash': _run_bash, + 'sleep': _sleep, + }), AgentTool( name='LSP', description='Use local LSP-style code intelligence for definitions, references, hover, symbols, and call hierarchy.', @@ -553,18 +229,6 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_tool_search, ), - AgentTool( - name='sleep', - description='Pause execution briefly for bounded local wait flows.', - parameters={ - 'type': 'object', - 'properties': { - 'seconds': {'type': 'number', 'minimum': 0.0, 'maximum': 5.0}, - }, - 'required': ['seconds'], - }, - handler=_sleep, - ), AgentTool( name='ask_user_question', description='Request an answer from the local ask-user runtime using queued or interactive answers.', @@ -1367,501 +1031,24 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_execute_skill, ), - AgentTool( - name='data_agent_prepare_generation_goal', - description=( - 'Create a pending data-agent generation goal from source evidence or user rules. ' - 'Use this before data_agent_prepare_generation_plan; show the returned goal to the user and wait for confirmation.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'dataset_label': {'type': 'string'}, - 'goal_summary': {'type': 'string'}, - 'target': { - 'type': 'string', - 'description': 'Single target label. For multi-target boundary tasks, use target_definitions.', - }, - 'target_definitions': { - 'type': 'array', - 'items': { - 'type': 'object', - 'properties': { - 'name': {'type': 'string'}, - 'target': {'type': 'string'}, - 'rule': {'type': 'string'}, - }, - 'required': ['target'], - }, - }, - 'plan_hint': { - 'type': 'string', - 'description': ( - 'Optional plain-language hint for the later plan, such as ' - '"建议先生成 50 条单轮,输出到 tasks/.../records.jsonl". ' - 'Structured total_count, turn_mix, and output_path belong to data_agent_prepare_generation_plan.' - ), - }, - 'coverage': {'type': 'string'}, - 'exclusions': {'type': 'string'}, - 'open_questions': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - 'source_refs': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - 'notes': {'type': 'string'}, - }, - 'required': ['dataset_label', 'goal_summary', 'coverage', 'exclusions'], - }, - handler=_data_agent_prepare_generation_goal_tool, - ), - AgentTool( - name='data_agent_confirm_generation_goal', - description=( - 'Mark a pending data-agent generation goal as confirmed after the user explicitly approves it. ' - 'The returned confirmed_goal_id is required by data_agent_prepare_generation_plan.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'goal_id': {'type': 'string'}, - 'confirmation': { - 'type': 'string', - 'description': 'The user approval text, such as 确认, 开始生成, or approve.', - }, - 'reviewed_revision': { - 'type': 'integer', - 'description': 'The goal revision shown to the user before confirmation.', - }, - }, - 'required': ['goal_id', 'confirmation'], - }, - handler=_data_agent_confirm_generation_goal_tool, - ), - AgentTool( - name='data_agent_prepare_generation_plan', - description=( - 'Create a pending data-agent generation plan. Use this before generating dataset draft text; ' - 'requires confirmed_goal_id from data_agent_confirm_generation_goal; show the returned plan to the user and wait for confirmation.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'confirmed_goal_id': { - 'type': 'string', - 'description': 'Required; returned by data_agent_confirm_generation_goal after the user reviews the generation goal.', - }, - 'dataset_label': {'type': 'string'}, - 'target': { - 'type': 'string', - 'description': 'Single target label. For multi-target boundary tasks, use a short summary and fill target_definitions.', - }, - 'target_definitions': { - 'type': 'array', - 'description': 'Optional target labels for multi-class boundary data.', - 'items': { - 'type': 'object', - 'properties': { - 'name': {'type': 'string'}, - 'target': {'type': 'string'}, - 'rule': {'type': 'string'}, - }, - 'required': ['target'], - }, - }, - 'total_count': {'type': 'integer', 'minimum': 1}, - 'turn_mix': {'type': 'string', 'description': 'Single-turn and multi-turn count or ratio.'}, - 'coverage': {'type': 'string', 'description': 'Query types, intent boundaries, or error types to cover.'}, - 'exclusions': {'type': 'string', 'description': 'Negative examples or boundaries to avoid.'}, - 'output_path': {'type': 'string', 'description': 'Where draft, records, and validation files should be written.'}, - 'notes': {'type': 'string'}, - }, - 'required': [ - 'confirmed_goal_id', - 'dataset_label', - 'total_count', - 'turn_mix', - 'coverage', - 'exclusions', - 'output_path', - ], - }, - handler=_data_agent_prepare_generation_plan_tool, - ), - AgentTool( - name='data_agent_load_input_sources', - description=( - 'Load data-agent input files or directories and extract structured paragraphs and table previews ' - 'from xlsx, csv, docx, pdf, txt, md, json, or jsonl sources.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'paths': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Workspace-relative files or directories to load.', - }, - 'max_files': {'type': 'integer', 'minimum': 1, 'maximum': 100}, - 'max_paragraphs_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 300}, - 'max_tables_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 100}, - 'max_rows_per_table': {'type': 'integer', 'minimum': 1, 'maximum': 200}, - 'max_cell_chars': {'type': 'integer', 'minimum': 20, 'maximum': 2000}, - }, - 'required': ['paths'], - }, - handler=_data_agent_load_input_sources_tool, - ), - AgentTool( - name='data_agent_extract_case_evidence', - description=( - 'Extract query/badcase evidence from loaded input sources or source paths. ' - 'Profiles table columns first and returns required questions when query or expected label columns are ambiguous.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'paths': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Workspace-relative files or directories. Omit when loaded_sources is provided.', - }, - 'loaded_sources': { - 'type': 'object', - 'description': 'Output from data_agent_load_input_sources.', - }, - 'field_mapping': { - 'type': 'object', - 'description': 'Optional role-to-column mapping, e.g. {"query":"query","expected_label":"预期domain"}.', - }, - 'max_cases': {'type': 'integer', 'minimum': 1, 'maximum': 1000}, - }, - }, - handler=_data_agent_extract_case_evidence_tool, - ), - AgentTool( - name='data_agent_render_source_context', - description=( - 'Render loaded data-agent sources into LLM-readable evidence text with source refs. ' - 'Use this after data_agent_load_input_sources before asking the model to structure product semantics.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'paths': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Workspace-relative files or directories. Omit when loaded_sources is provided.', - }, - 'loaded_sources': { - 'type': 'object', - 'description': 'Output from data_agent_load_input_sources.', - }, - 'max_chars': {'type': 'integer', 'minimum': 1000, 'maximum': 200000}, - 'max_tables_per_source': {'type': 'integer', 'minimum': 1, 'maximum': 100}, - 'max_rows_per_table': {'type': 'integer', 'minimum': 1, 'maximum': 200}, - 'focus_keywords': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Optional keywords used to keep only matching paragraphs/table rows.', - }, - }, - }, - handler=_data_agent_render_source_context_tool, - ), - AgentTool( - name='data_agent_profile_router_sessions', - description=( - 'Profile local router_session_parquet date partitions before mining online sessions. ' - 'Returns schema, sampled row count, distributions, and example sessions.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'dates': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Date partitions such as ["20260428"]. Resolved under router_session_parquet/date=YYYYMMDD.', - }, - 'paths': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Workspace-relative parquet files or date directories. Use when dates is not enough.', - }, - 'max_files': {'type': 'integer', 'minimum': 1, 'maximum': 200}, - 'max_rows_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 100000}, - 'examples_limit': {'type': 'integer', 'minimum': 0, 'maximum': 20}, - }, - }, - handler=_data_agent_profile_router_sessions_tool, - ), - AgentTool( - name='data_agent_search_router_sessions', - description=( - 'Search local router_session_parquet data with reviewable filters such as device, domain, intent, func, ' - 'query keywords, regex, turn count, and match scope. Returns turn-level candidates with prev turns.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'dates': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Date partitions such as ["20260428"]. Resolved under router_session_parquet/date=YYYYMMDD.', - }, - 'paths': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Workspace-relative parquet files or date directories. Use when dates is not enough.', - }, - 'devices': {'type': 'array', 'items': {'type': 'string'}}, - 'domains': {'type': 'array', 'items': {'type': 'string'}}, - 'intents': {'type': 'array', 'items': {'type': 'string'}}, - 'funcs': {'type': 'array', 'items': {'type': 'string'}}, - 'query_keywords': {'type': 'array', 'items': {'type': 'string'}}, - 'keyword_match_mode': {'type': 'string', 'enum': ['any', 'all']}, - 'query_regex': {'type': 'string'}, - 'match_scope': {'type': 'string', 'enum': ['any_turn', 'last_turn']}, - 'turn_count_min': {'type': 'integer', 'minimum': 1}, - 'turn_count_max': {'type': 'integer', 'minimum': 1}, - 'max_files': {'type': 'integer', 'minimum': 1, 'maximum': 200}, - 'max_rows_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 100000}, - 'max_candidates': {'type': 'integer', 'minimum': 1, 'maximum': 5000}, - }, - }, - handler=_data_agent_search_router_sessions_tool, - ), - AgentTool( - name='data_agent_sample_router_candidates', - description=( - 'Sample router session candidates for human review and return candidate-level summary statistics. ' - 'Use after data_agent_search_router_sessions.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'candidates': { - 'oneOf': [ - {'type': 'array'}, - {'type': 'object'}, - {'type': 'string'}, - ], - 'description': 'Candidates array, search result object, or JSON string containing candidates.', - }, - 'sample_size': {'type': 'integer', 'minimum': 1, 'maximum': 1000}, - 'strategy': {'type': 'string', 'enum': ['random', 'first', 'stride']}, - 'seed': {'type': 'integer'}, - }, - 'required': ['candidates'], - }, - handler=_data_agent_sample_router_candidates_tool, - ), - AgentTool( - name='data_agent_convert_router_candidates_to_records', - description=( - 'Convert reviewed online router session candidates directly into canonical data-agent records. ' - 'Use this when the user wants to keep mined online data as samples; do not use generation-plan tools for this path.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'candidates': { - 'oneOf': [ - {'type': 'array'}, - {'type': 'object'}, - {'type': 'string'}, - ], - 'description': 'Candidates array, search/sample result object, or JSON string containing candidates.', - }, - 'dataset_label': {'type': 'string'}, - 'default_target': { - 'type': 'string', - 'description': 'Target label used for included candidates unless review_decisions provides a target.', - }, - 'review_decisions': { - 'type': 'array', - 'description': 'Optional include/exclude/uncertain decisions keyed by semantic_session_id or req_id.', - 'items': { - 'type': 'object', - 'properties': { - 'semantic_session_id': {'type': 'string'}, - 'req_id': {'type': 'string'}, - 'matched_turn_index': {'type': 'integer'}, - 'decision': {'type': 'string', 'enum': ['include', 'exclude', 'uncertain']}, - 'target': {'type': 'string'}, - 'notes': {'type': 'string'}, - }, - }, - }, - 'batch_id': {'type': 'string'}, - 'include_uncertain': {'type': 'boolean'}, - }, - 'required': ['candidates', 'dataset_label'], - }, - handler=_data_agent_convert_router_candidates_to_records_tool, - ), - AgentTool( - name='data_agent_show_generation_plan', - description='Show a data-agent generation plan for human review.', - parameters={ - 'type': 'object', - 'properties': { - 'plan_id': {'type': 'string'}, - }, - 'required': ['plan_id'], - }, - handler=_data_agent_show_generation_plan_tool, - ), - AgentTool( - name='data_agent_update_generation_plan', - description=( - 'Update a pending data-agent generation plan from human review feedback, then show it again for review.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'plan_id': {'type': 'string'}, - 'review_feedback': { - 'type': 'string', - 'description': 'Human review feedback that explains why the plan is changing.', - }, - 'updates': { - 'type': 'object', - 'description': 'Fields to update on the plan.', - }, - }, - 'required': ['plan_id', 'review_feedback', 'updates'], - }, - handler=_data_agent_update_generation_plan_tool, - ), - AgentTool( - name='data_agent_confirm_generation_plan', - description=( - 'Mark a pending data-agent generation plan as confirmed after the user explicitly approves it.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'plan_id': {'type': 'string'}, - 'confirmation': { - 'type': 'string', - 'description': 'The user approval text, such as 确认, 开始生成, or approve.', - }, - 'reviewed_revision': { - 'type': 'integer', - 'description': 'The plan revision shown to the user before confirmation.', - }, - }, - 'required': ['plan_id', 'confirmation'], - }, - handler=_data_agent_confirm_generation_plan_tool, - ), - AgentTool( - name='data_agent_normalize_dataset_draft', - description=( - 'Parse dataset draft text written with 用户/小爱/target blocks and build canonical ' - 'data-agent records with generated record IDs, timestamps, source metadata, context, and labels. ' - 'Generated data requires a confirmed_plan_id from data_agent_confirm_generation_plan.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'draft_text': { - 'type': 'string', - 'description': 'Dataset draft text in dataset draft text v1 format.', - }, - 'batch_id': { - 'type': 'string', - 'description': 'Batch identifier used in generated record_id values.', - }, - 'source_type': { - 'type': 'string', - 'enum': ['generated', 'online', 'manual', 'mixed'], - }, - 'base_timestamp': { - 'type': 'integer', - 'description': 'Optional millisecond timestamp for deterministic generated records.', - }, - 'timestamp_step_ms': { - 'type': 'integer', - 'minimum': 1, - 'description': 'Millisecond gap between adjacent turns. Defaults to 60000.', - }, - 'default_request_id': { - 'type': 'string', - 'description': 'Request ID to use when a case does not provide a real request_id.', - }, - 'confirmed_plan_id': { - 'type': 'string', - 'description': 'Required for generated data; returned by data_agent_confirm_generation_plan.', - }, - }, - 'required': ['draft_text'], - }, - handler=_normalize_dataset_draft_tool, - ), - AgentTool( - name='data_agent_validate_dataset_records', - description=( - 'Validate canonical data-agent records for required fields, labels, source metadata, ' - 'prev_session structure, timestamp order, and 5-minute prompt stitching constraints.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'records': { - 'oneOf': [ - {'type': 'array'}, - {'type': 'string'}, - ], - 'description': 'Canonical records as an array, or a JSON string containing the array.', - }, - }, - 'required': ['records'], - }, - handler=_validate_dataset_records_tool, - ), - AgentTool( - name='data_agent_export_dataset_records', - description=( - 'Validate canonical data-agent records and write them to a workspace file. ' - 'Defaults to compact JSONL, one canonical record per line, so the model does not hand-write JSON output.' - ), - parameters={ - 'type': 'object', - 'properties': { - 'records': { - 'oneOf': [ - {'type': 'array'}, - {'type': 'string'}, - ], - 'description': 'Canonical records as an array, or a JSON string containing the array.', - }, - 'output_path': { - 'type': 'string', - 'description': 'Workspace-relative path to write, usually ending in .jsonl.', - }, - 'output_format': { - 'type': 'string', - 'enum': ['jsonl', 'json'], - 'description': 'Defaults to jsonl. json writes a compact JSON array.', - }, - 'require_validation_ok': { - 'type': 'boolean', - 'description': 'When true, refuse to write records with validation errors.', - }, - 'overwrite': { - 'type': 'boolean', - 'description': 'When false, refuse to overwrite an existing file.', - }, - }, - 'required': ['records', 'output_path'], - }, - handler=_export_dataset_records_tool, - ), + *build_data_agent_tools({ + 'data_agent_prepare_generation_goal': _data_agent_prepare_generation_goal_tool, + 'data_agent_confirm_generation_goal': _data_agent_confirm_generation_goal_tool, + 'data_agent_prepare_generation_plan': _data_agent_prepare_generation_plan_tool, + 'data_agent_load_input_sources': _data_agent_load_input_sources_tool, + 'data_agent_extract_case_evidence': _data_agent_extract_case_evidence_tool, + 'data_agent_render_source_context': _data_agent_render_source_context_tool, + 'data_agent_profile_router_sessions': _data_agent_profile_router_sessions_tool, + 'data_agent_search_router_sessions': _data_agent_search_router_sessions_tool, + 'data_agent_sample_router_candidates': _data_agent_sample_router_candidates_tool, + 'data_agent_convert_router_candidates_to_records': _data_agent_convert_router_candidates_to_records_tool, + 'data_agent_show_generation_plan': _data_agent_show_generation_plan_tool, + 'data_agent_update_generation_plan': _data_agent_update_generation_plan_tool, + 'data_agent_confirm_generation_plan': _data_agent_confirm_generation_plan_tool, + 'data_agent_normalize_dataset_draft': _normalize_dataset_draft_tool, + 'data_agent_validate_dataset_records': _validate_dataset_records_tool, + 'data_agent_export_dataset_records': _export_dataset_records_tool, + }), ] return {tool.name: tool for tool in tools} @@ -2005,6 +1192,12 @@ def _data_agent_prepare_generation_plan_tool(arguments: dict[str, Any], context: notes = arguments.get('notes', '') if not isinstance(notes, str): raise ToolExecutionError('notes must be a string') + confirmed_goal_id = _optional_string(arguments, 'confirmed_goal_id') + direct_review = arguments.get('direct_review', False) + if not isinstance(direct_review, bool): + raise ToolExecutionError('direct_review must be a boolean') + if not confirmed_goal_id and not direct_review: + raise ToolExecutionError('confirmed_goal_id is required unless direct_review is true') try: payload = prepare_generation_plan( root=str(context.root), @@ -2014,10 +1207,10 @@ def _data_agent_prepare_generation_plan_tool(arguments: dict[str, Any], context: turn_mix=_require_string(arguments, 'turn_mix'), coverage=_require_string(arguments, 'coverage'), exclusions=_require_string(arguments, 'exclusions'), - output_path=_require_string(arguments, 'output_path'), + output_path=_data_agent_output_path(_require_string(arguments, 'output_path'), context), target_definitions=_optional_target_definitions(arguments.get('target_definitions')), notes=notes, - confirmed_goal_id=_require_string(arguments, 'confirmed_goal_id'), + confirmed_goal_id=confirmed_goal_id or None, ) except DataRecordError as exc: raise ToolExecutionError(str(exc)) from exc @@ -2290,7 +1483,7 @@ def _export_dataset_records_tool(arguments: dict[str, Any], context: ToolExecuti payload = export_dataset_records( records, root=str(context.root), - output_path=_require_string(arguments, 'output_path'), + output_path=_data_agent_output_path(_require_string(arguments, 'output_path'), context), output_format=output_format, require_validation_ok=require_validation_ok, overwrite=overwrite, @@ -2300,6 +1493,30 @@ def _export_dataset_records_tool(arguments: dict[str, Any], context: ToolExecuti return json.dumps(payload, ensure_ascii=False, indent=2) +def _data_agent_output_path(output_path: str, context: ToolExecutionContext) -> str: + """把数据 Agent 产物默认路由到当前用户/会话的 output 目录。""" + + raw = output_path.strip() + path = Path(raw).expanduser() + if path.is_absolute() or path.parts[:1] == ('.port_sessions',): + return raw + + output_root = _data_agent_session_output_root(context) + parts = path.parts + suffix = Path(*parts[1:]) if parts and parts[0] in {'output', 'outputs'} else path + resolved = (output_root / suffix).resolve(strict=False) + try: + return resolved.relative_to(context.root).as_posix() + except ValueError: + return resolved.as_posix() + + +def _data_agent_session_output_root(context: ToolExecutionContext) -> Path: + if context.scratchpad_directory is not None: + return context.scratchpad_directory.parent / 'output' + return context.root / '.port_sessions' / 'data_agent_output' + + def _resolve_path(raw_path: str, context: ToolExecutionContext, *, allow_missing: bool = True) -> Path: expanded = Path(raw_path).expanduser() candidate = expanded if expanded.is_absolute() else context.root / expanded diff --git a/src/data_agent_records.py b/src/data_agent_records.py index ff54299..053df5f 100644 --- a/src/data_agent_records.py +++ b/src/data_agent_records.py @@ -49,6 +49,8 @@ def prepare_generation_goal( if missing: raise DataRecordError('missing required goal fields: ' + ', '.join(missing)) normalized_targets = _normalize_target_definitions(target_definitions) + if not target.strip() and not normalized_targets: + raise DataRecordError('generation goal must include target or target_definitions') state = _load_goal_state(root) sequence = int(state.get('next_sequence') or 1) goal_id = f'data_goal_{sequence:06d}' @@ -186,6 +188,12 @@ def prepare_generation_plan( } ) normalized_targets = _normalize_target_definitions(target_definitions) + if confirmed_goal is not None and not target.strip() and not normalized_targets: + normalized_targets = _normalize_target_definitions( + confirmed_goal.get('target_definitions') if isinstance(confirmed_goal, dict) else None + ) + if not normalized_targets: + target = str(confirmed_goal.get('target') or '').strip() if not target.strip() and not normalized_targets: missing.append('target') if missing: diff --git a/src/skills/bundled/online-mining/SKILL.md b/src/skills/bundled/online-mining/SKILL.md index 2e11d09..6c9a8fe 100644 --- a/src/skills/bundled/online-mining/SKILL.md +++ b/src/skills/bundled/online-mining/SKILL.md @@ -8,6 +8,8 @@ allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_u 使用这个 skill 处理“badcase 或标签定义 -> 挖掘策略 -> 候选召回 -> 抽样 review -> 策略迭代 -> mined dataset”的工作流。 +所有线上挖掘产物必须放在当前用户当前会话的 output 目录下。不要把 records 或 review 结果写到项目根目录的 `output/`、`tasks/` 或源码目录。工具会把相对 `output_path` 自动路由到会话 output 目录;展示给用户时以工具返回的实际路径为准。 + ## 两条分支 线上挖掘后必须先判断用户要的是哪条分支。 diff --git a/src/skills/bundled/product-data/SKILL.md b/src/skills/bundled/product-data/SKILL.md index f26b343..8c9c6ed 100644 --- a/src/skills/bundled/product-data/SKILL.md +++ b/src/skills/bundled/product-data/SKILL.md @@ -50,6 +50,13 @@ source_refs: 默认不要一步到位生成数据。除非用户已经明确说“开始生成”“确认计划”“按这个计划生成”或同义表达,否则只能做目标对齐、计划草案和问题确认。 +为了减少重复确认,优先按下面两种门禁模式选择: + +- **一次确认模式**:用户直接给出手写规则、完整 target 表达,并且明确希望生成数据时,直接调用 `data_agent_prepare_generation_plan`,传 `direct_review=true` 和 `target_definitions`。这一次 review 同时确认目标、数量、轮次和路径;用户回复“确认,开始生成”后即可调用 `data_agent_confirm_generation_plan`。 +- **两段确认模式**:用户提供文件、表格、badcase、长文档,或者标签/边界/字段含义有歧义时,先用 `data_agent_prepare_generation_goal` 做目标 review;目标确认后再做 plan review。 + +所有数据产物必须放在当前用户当前会话的 output 目录下。不要把 records、draft 或 validation 写到项目根目录的 `output/`、`tasks/` 或其他源码目录。工具会把相对 `output_path` 自动路由到会话 output 目录;展示给用户时以工具返回的实际路径为准。 + 开始生成前必须确认这些信息: - `dataset_label`:数据集或专题名称。 @@ -61,12 +68,31 @@ source_refs: 如果任一信息缺失,不要生成数据,不要调用 `data_agent_prepare_generation_plan`,不要调用 `data_agent_normalize_dataset_draft`,不要调用 `data_agent_validate_dataset_records`,只向用户提出需要确认的问题。 -信息完整后,调用 `data_agent_prepare_generation_goal` 创建 pending goal。这个工具会暂停本轮,必须把返回的 `generation_goal` 展示给用户 review。用户确认 goal 之后,调用 `data_agent_confirm_generation_goal` 获取 `confirmed_goal_id`,再调用 `data_agent_prepare_generation_plan` 创建 pending plan。创建 plan 后也会暂停本轮,必须等待用户 review。 +两段确认模式下,信息完整后,调用 `data_agent_prepare_generation_goal` 创建 pending goal。这个工具会暂停本轮,必须把返回的 `generation_goal` 展示给用户 review。用户确认 goal 之后,调用 `data_agent_confirm_generation_goal` 获取 `confirmed_goal_id`,再调用 `data_agent_prepare_generation_plan` 创建 pending plan。创建 plan 后也会暂停本轮,必须等待用户 review。 + +一次确认模式下,不要先创建 generation goal;直接创建 pending plan,并在 plan 里包含 `target_definitions`、`total_count`、`turn_mix`、`coverage`、`exclusions`、`output_path`。不要让用户先确认目标再确认计划。 用户确认后,拿到 `confirmed_plan_id`,才能生成 dataset draft text,并继续调用工具。 `data_agent_normalize_dataset_draft` 对生成数据有代码级门禁:没有 `confirmed_plan_id`,或者计划未确认,会拒绝执行。 +如果用户在原始需求里已经写出 `Agent(tag="xxx")`、function 调用或其他完整标签表达,`target` 必须原样保留这个完整表达,不要简化成纯标签名。例如用户说 `Agent(tag="餐饮服务")`,则 `target_definitions[*].target` 和后续 draft 的 `target:` 都必须写 `Agent(tag="餐饮服务")`,不要写成 `餐饮服务`。 + +review 展示必须简短清晰,不要重复解释工具和流程。每次 review 最多展示 6 行,格式优先如下: + +```text +我先把生成目标整理好了,先确认边界,暂时不生成数据。 +- 数据集:xxx +- 标签:A -> Agent(tag="A");B -> Agent(tag="B") +- 边界:一句话说明核心判定规则 +- 覆盖:一句话说明主要 case 类型 +- 内部:goal_id `...`,revision `...` + +你看这个目标是否准确?没问题就回“确认目标”;想改的话直接说哪里不对。 +``` + +计划 review 也最多展示 6 行,只展示数量、轮次、覆盖、输出路径和确认口令。不要把 goal 的完整内容再次复制到 plan review 中,开头必须说明“目标已确认,现在只补充生成参数,标签边界沿用上一步”。确认口令可以自然一点,例如“如果这个数量和路径可以,就回‘确认,开始生成’;想调整就直接说,比如‘改成 20 条,全单轮’。” + 多标签边界数据不要拆成多个互不相关的单标签计划。应该创建一个计划,并在 `target_definitions` 中列出所有候选标签。例如: ```json @@ -96,9 +122,9 @@ source_refs: 2. 用 `data_agent_render_source_context` 把输入渲染成大模型可读 evidence text。 3. 大模型只基于 evidence text 抽取 `generation_goal`,包括 `dataset_label`、`target_definitions`、`plan_hint`、`coverage`、`exclusions`、`open_questions`、`source_refs`。 4. 如果目标标签、边界或字段含义不清楚,先用普通回复向用户提问并停止。 -5. 信息足够时,调用 `data_agent_prepare_generation_goal` 创建 pending goal,并停止等待用户 review。 -6. 用户确认 generation goal 后,调用 `data_agent_confirm_generation_goal` 获取 `confirmed_goal_id`。 -7. 调用 `data_agent_prepare_generation_plan` 创建 pending 计划,必须传入 `confirmed_goal_id`。 +5. 如果是手写规则且信息完整,调用 `data_agent_prepare_generation_plan`,设置 `direct_review=true`,创建一次确认的 pending 计划,并停止等待用户 review。 +6. 如果是文件/示例归纳/歧义场景,调用 `data_agent_prepare_generation_goal` 创建 pending goal,并停止等待用户 review。 +7. 用户确认 generation goal 后,调用 `data_agent_confirm_generation_goal` 获取 `confirmed_goal_id`,再调用 `data_agent_prepare_generation_plan` 创建 pending 计划。 8. 展示计划后停止本轮,等待用户 review。 9. 用户提出修改意见时,调用 `data_agent_update_generation_plan`,再展示计划。 10. 用户明确确认当前计划版本后,调用 `data_agent_confirm_generation_plan`。 @@ -201,9 +227,9 @@ canonical records 落盘必须使用 `data_agent_export_dataset_records`,默 1. 直接从用户描述里抽取标签边界。 2. 多标签边界必须使用 `target_definitions`,不要拆成多个无关单标签计划。 3. 识别规则中的冲突词、优先级和反例。 -4. 整理为 `generation_goal` 并调用 `data_agent_prepare_generation_goal`。 -5. 对不明确的 target、数量、单轮/多轮比例、输出路径提出问题。 -6. 用户确认 generation goal 后,调用 `data_agent_confirm_generation_goal`,再调用 `data_agent_prepare_generation_plan`。 +4. 如果用户已经给出完整 target 表达,直接写入 `target_definitions`;如果 target 表达不明确,先提出问题。 +5. 如果数量、轮次或输出路径未指定,可以由模型给出保守建议,走一次确认模式;不要为了这些默认参数单独多问一轮。 +6. 调用 `data_agent_prepare_generation_plan`,传入 `direct_review=true`,让用户一次确认目标和生成参数。 ### 示例归纳型 diff --git a/tests/test_agent_prompting.py b/tests/test_agent_prompting.py index 2621763..ab047fe 100644 --- a/tests/test_agent_prompting.py +++ b/tests/test_agent_prompting.py @@ -32,12 +32,13 @@ class AgentPromptingTests(unittest.TestCase): ) prompt = render_system_prompt(parts) - self.assertIn('# System', prompt) - self.assertIn('# Doing tasks', prompt) - self.assertIn('# Using your tools', prompt) - self.assertIn('# Environment', prompt) + self.assertIn('# 系统规则', prompt) + self.assertIn('# 处理任务', prompt) + self.assertIn('# 使用工具', prompt) + self.assertIn('# Skills', prompt) + self.assertIn('product-data', prompt) self.assertIn('__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__', prompt) - self.assertIn('Primary working directory:', prompt) + self.assertIn('主工作目录:', prompt) def test_session_state_exports_messages_in_order(self) -> None: state = AgentSessionState.create(['sys one', 'sys two'], 'hello') @@ -57,9 +58,9 @@ class AgentPromptingTests(unittest.TestCase): runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), ) prompt = agent.render_system_prompt() - self.assertIn('Claw Code Python', prompt) - self.assertIn('# System', prompt) - self.assertIn('# Environment', prompt) + self.assertIn('Zhongkong Agent', prompt) + self.assertIn('# 系统规则', prompt) + self.assertIn('# 环境信息', prompt) def test_prompt_builder_mentions_plugins_when_cache_is_loaded(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index b04e477..1945b29 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -144,6 +144,17 @@ class AgentRuntimeTests(unittest.TestCase): self.assertEqual(child_model.model, 'child-model') self.assertEqual(sorted(child_tools), ['grep_search', 'read_file']) + def test_builtin_child_model_alias_inherits_non_claude_parent_model(self) -> None: + agent = LocalCodingAgent( + model_config=ModelConfig(model='xiaomi/mimo-v2.5-pro'), + runtime_config=AgentRuntimeConfig(cwd=Path.cwd()), + ) + agent_def = agent._resolve_agent_definition({'subagent_type': 'Explore'}) + child_model = agent._resolve_child_model_config({}, agent_def) + + self.assertEqual(agent_def.model, 'haiku') + self.assertEqual(child_model.model, 'xiaomi/mimo-v2.5-pro') + def test_openai_client_parses_tool_calls(self) -> None: responses = [ { @@ -322,9 +333,9 @@ class AgentRuntimeTests(unittest.TestCase): self.assertEqual(result.stop_reason, 'user_review_required') self.assertEqual(result.tool_calls, 1) - self.assertIn('本轮已暂停', result.final_output) + self.assertIn('先确认边界', result.final_output) self.assertIn('goal_id', result.final_output) - self.assertIn('计划提示', result.final_output) + self.assertIn('下一步', result.final_output) self.assertIn('建议先生成 10 条单轮', result.final_output) review_messages = [ entry @@ -333,7 +344,7 @@ class AgentRuntimeTests(unittest.TestCase): and entry.get('stop_reason') == 'user_review_required' ] self.assertEqual(len(review_messages), 1) - self.assertIn('数据生成目标', str(review_messages[0].get('content', ''))) + self.assertIn('生成目标', str(review_messages[0].get('content', ''))) self.assertTrue( any(event.get('type') == 'user_review_required' for event in result.events) ) @@ -416,8 +427,9 @@ class AgentRuntimeTests(unittest.TestCase): result = agent.run('Prepare data generation plan') self.assertEqual(result.stop_reason, 'user_review_required') - self.assertIn('数据生成计划', result.final_output) + self.assertIn('只补充生成参数', result.final_output) self.assertIn('plan_id', result.final_output) + self.assertNotIn('target_definitions', result.final_output) review_messages = [ entry for entry in result.transcript @@ -425,7 +437,7 @@ class AgentRuntimeTests(unittest.TestCase): and entry.get('stop_reason') == 'user_review_required' ] self.assertEqual(len(review_messages), 1) - self.assertIn('数据生成计划', str(review_messages[0].get('content', ''))) + self.assertIn('生成参数', str(review_messages[0].get('content', ''))) def test_write_tool_is_blocked_without_permission(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/test_data_agent_records.py b/tests/test_data_agent_records.py index 28982ec..e8f5970 100644 --- a/tests/test_data_agent_records.py +++ b/tests/test_data_agent_records.py @@ -229,6 +229,50 @@ target: Agent(tag="life_service") '建议先生成 2 条单轮,输出到 tasks/demo/records.jsonl', ) + def test_generation_goal_requires_declared_targets_before_review(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + with self.assertRaisesRegex(Exception, 'must include target or target_definitions'): + prepare_generation_goal( + root=tmp_dir, + dataset_label='地图和生活边界数据', + goal_summary='生成附近生活服务边界数据', + coverage='附近吃喝玩乐', + exclusions='不要生成导航路线类 query', + ) + + def test_generation_plan_inherits_targets_from_confirmed_goal(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + goal_payload = prepare_generation_goal( + root=tmp_dir, + dataset_label='餐饮和导航边界数据', + goal_summary='生成餐饮服务和地图导航边界数据', + target_definitions=[ + {'name': '餐饮服务', 'target': 'Agent(tag="餐饮服务")', 'rule': '找附近餐饮'}, + {'name': '地图导航', 'target': 'Agent(tag="地图导航")', 'rule': '明确导航'}, + ], + coverage='餐饮服务和地图导航边界', + exclusions='不要生成无关闲聊', + ) + goal_id = goal_payload['goal']['goal_id'] + confirm_generation_goal(root=tmp_dir, goal_id=goal_id, confirmation='确认目标') + + plan_payload = prepare_generation_plan( + root=tmp_dir, + confirmed_goal_id=goal_id, + dataset_label='餐饮和导航边界数据', + target='', + total_count=2, + turn_mix='2 条单轮', + coverage='餐饮服务和地图导航边界', + exclusions='不要生成无关闲聊', + output_path='tasks/demo/artifacts', + ) + + self.assertEqual( + [item['target'] for item in plan_payload['plan']['target_definitions']], + ['Agent(tag="餐饮服务")', 'Agent(tag="地图导航")'], + ) + def test_generation_plan_dedupes_existing_task_output_path(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: existing = Path(tmp_dir) / 'tasks' / 'demo' / 'artifacts' @@ -488,6 +532,55 @@ target: Agent(tag="life_service") self.assertEqual(export_payload['record_count'], 1) self.assertEqual(len(exported_lines), 1) + def test_data_agent_tools_route_relative_outputs_to_session_output(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + scratchpad = root / '.port_sessions' / 'accounts' / 'alice' / 'sessions' / 's1' / 'scratchpad' + scratchpad.mkdir(parents=True) + context = build_tool_context(AgentRuntimeConfig(cwd=root), scratchpad_directory=scratchpad) + registry = default_tool_registry() + + plan_result = execute_tool( + registry, + 'data_agent_prepare_generation_plan', + { + 'direct_review': True, + 'dataset_label': '地图和生活边界数据', + 'target': 'Agent(tag="life_service")', + 'total_count': 1, + 'turn_mix': '1 条单轮', + 'coverage': '附近吃喝玩乐', + 'exclusions': '不要生成导航路线类 query', + 'output_path': 'output/demo/records.jsonl', + }, + context, + ) + self.assertTrue(plan_result.ok, plan_result.content) + plan_payload = json.loads(plan_result.content) + + records = normalize_dataset_draft( + ''' +# dataset_label: 地图和生活边界数据 +### case: 附近餐饮查询 +用户: 附近有什么好吃的 +target: Agent(tag="life_service") +'''.strip(), + batch_id='demo', + base_timestamp=1_755_567_930_500, + )['records'] + export_result = execute_tool( + registry, + 'data_agent_export_dataset_records', + {'records': records, 'output_path': 'output/demo/records.jsonl'}, + context, + ) + self.assertTrue(export_result.ok, export_result.content) + export_payload = json.loads(export_result.content) + + expected = '.port_sessions/accounts/alice/sessions/s1/output/demo/records.jsonl' + self.assertEqual(plan_payload['plan']['output_path'], expected) + self.assertEqual(export_payload['output_path'], expected) + if __name__ == '__main__': unittest.main()