diff --git a/.gitignore b/.gitignore index ed4e265..53c246e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__/ *.py[cod] +.DS_Store .pytest_cache/ .mypy_cache/ .ruff_cache/ @@ -34,4 +35,4 @@ test_cases e-commerce benchmarks/data/*.jsonl benchmarks/data/manifest.json -claude-code-sourcemap-main \ No newline at end of file +claude-code-sourcemap-main diff --git a/docs/data_agent_implementation_tasks.md b/docs/data_agent_implementation_tasks.md new file mode 100644 index 0000000..4e9db53 --- /dev/null +++ b/docs/data_agent_implementation_tasks.md @@ -0,0 +1,41 @@ +# 数据 Agent 实现任务表 + +范围:先在现有 agent loop 上开发和调试 data-agent 的 tools 与 skills,暂时不改主运行链路。 + +## 极简任务表 + +| 顺序 | 任务 | 产物 | 验证方式 | +|---|---|---|---| +| 1 | 定义 canonical record v1 | `src/data_agent_schema.py` 或 `src/data_agent/records.py` | 单测覆盖合法和非法样本 | +| 2 | 实现 `validate_dataset_records` 工具 | 新增 `AgentTool` 注册和 handler | 用 JSONL 样例跑工具,检查结构化错误 | +| 3 | 实现 `render_dataset_preview` 工具 | 生成面向人工 review 的 Markdown 预览 | 用 3 条样本生成预览 | +| 4 | 实现 `convert_dataset_format` 工具 | 至少支持 `training_jsonl`、`eval_prompt_csv`、`review_table_csv` | 输入 canonical JSONL,检查导出文件 | +| 5 | 实现 `export_dataset` 工具 | 多格式导出和 manifest | 校验失败时拒绝导出 | +| 6 | 增加最小 data skill | 新增类似 `dataset-construction` 的目录化 skill | Web UI 中可见,并指导 validate/export 循环 | +| 7 | 准备调试样例 | `test_data/data_agent/*.jsonl` | 通过 Web UI 跑 skill + tools | +| 8 | 实现 `normalize_dataset_records` 工具 | 支持简单 CSV/JSONL 字段映射到 canonical JSONL | 输入 CSV,输出 canonical JSONL | +| 9 | 实现 `dedup_dataset_records` 工具 | 按 query + label 做精确去重 | 输出去重后 JSONL 和重复样本报告 | +| 10 | 按需增强 Web UI trace | 更清晰展示 tool 参数和结果 | 人工 UI 验证 | + +## 第一批实现切片 + +先做任务 1、2、6、7: + +```text +skill 指导 + -> Agent 生成 canonical JSONL + -> validate_dataset_records 校验 + -> Web UI 展示工具调用参数和校验错误 +``` + +## 预计第一批文件 + +```text +src/data_agent_schema.py +src/agent_tools.py +src/bundled_skills.py +tests/test_data_agent_schema.py +tests/test_data_agent_tools.py +test_data/data_agent/valid_records.jsonl +test_data/data_agent/invalid_records.jsonl +``` diff --git a/docs/data_agent_new_tools.md b/docs/data_agent_new_tools.md new file mode 100644 index 0000000..088be46 --- /dev/null +++ b/docs/data_agent_new_tools.md @@ -0,0 +1,730 @@ +下面是中文翻译: + +# Data Agent 新工具提案 + +本文档列出了 data-agent 改造中建议新增的工具。范围刻意聚焦在第一阶段实现:数据 schema、生成、校验、格式转换、评审,以及后续在线挖掘。 + +## 设计原则 + +* 优先使用窄口径的数据工具,而不是通用 shell、SQL 或临时代码。 +* 工具应该稳定数据格式和可重复操作。 +* 开放式推理、聚类解释和生成策略可以保留在 Agent loop 中;工具负责提供结构化输入、校验和可重复转换。 +* 每个工具都应该返回机器可读的 metadata,以及人类可读的摘要。 +* 生成的数据集必须在导出或持久化前完成校验。 + +## 优先级 + +* `P0`:短期立即实现所必需。 +* `P1`:很快需要,但可以在第一条 schema / 生成链路跑通后再加。 +* `P2`:用于在线挖掘或生产级加固。 + +# P0:数据集 Schema 与格式工具 + +这些是基础工具,避免 Agent 手写脆弱的格式转换逻辑。 + +数据格式设计分两层: + +1. **标准数据记录层**:验证数据要素是否完整。该层回答一个 record 是否具备所需的对话轮次、`query`、`tts` 和最终 label。数据生成和在线挖掘工具应该直接产出这一标准层,或者在进一步使用前先 normalize 到这一层。 +2. **导出格式层**:把标准 records 转换成固定的下游格式。一旦标准 metadata 格式稳定,转换到 training JSONL、prompt/eval CSV、review tables、display markdown 和其他消费格式,就应该成为确定性的工具逻辑。 + +实际规则是: + +```text +generation/mining output + -> canonical records + -> validation + -> dedup/review + -> deterministic exports +``` + +Agent 应该负责理解数据意图和编写计划,但 schema 校验和格式转换应该由工具负责。 + +## 标准 Record 层 + +这一层定义生成或挖掘数据的内部事实源。第一版应该足够严格,避免缺失关键元素;同时也要足够灵活,以支持单轮和多轮对话。 + +建议的标准 record 结构: + +```json +{ + "record_id": "optional-stable-id", + "conversation": [ + { + "query": "...", + "tts": "...", + "metadata": {} + } + ], + "label": { + "type": "function_or_agent", + "name": "...", + "arguments": {} + }, + "source": { + "type": "generated|mined|eval_error|manual", + "task_id": "...", + "notes": "..." + }, + "metadata": {} +} +``` + +待确认的 schema 问题: + +* `tts` 是否应该每一轮都必填?还是当源数据没有 TTS 时可以为空? +* label type 是否应该标准化为 `function` 和 `agent` 这样的 enum? +* label arguments 应该是必填、可选,还是由目标格式决定? +* record-level metadata 是否应该包含 model version、device、date、source table、mining strategy、reviewer decision 等信息? + +## `validate_dataset_records` + +用途:校验 records 是否符合标准数据 schema。 + +建议输入: + +```json +{ + "input_path": "artifacts/candidates.jsonl", + "schema_name": "router_conversation_v1", + "max_errors": 50 +} +``` + +建议输出: + +```json +{ + "valid": true, + "record_count": 120, + "error_count": 0, + "errors": [] +} +``` + +说明: + +* 检查多轮对话、`query`、`tts`、最终 `label` 等必填字段。 +* 应同时支持单轮和多轮 records。 +* 应在生成 / 挖掘之后运行,并在 dedup、export 或 persistence 之前运行。 +* 这是完整性和结构校验,不是下游格式校验。 + +## `normalize_dataset_records` + +用途:把历史数据、生成数据、挖掘数据或 eval result 中略有差异的 record 形态转换成标准 record schema。 + +建议输入: + +```json +{ + "input_path": "context/raw_cases.csv", + "output_path": "artifacts/normalized_cases.jsonl", + "source_format": "auto", + "schema_name": "router_conversation_v1", + "field_mapping": { + "query": "query", + "tts": "tts", + "label": "correct_label" + } +} +``` + +说明: + +* 现有 eval 文件、文档和表格可能存在格式差异,因此这个工具有用。 +* 不应该过度猜测。如果必填字段含义不明确,应返回 mapping error,让 Agent 向用户确认。 +* 对于在线挖掘,挖掘工具最好直接输出标准 records。这个 normalizer 主要适合 legacy 文件和用户提供的表格。 + +## `generate_record_ids` + +用途:为标准 records 填充稳定的 `record_id`。 + +建议输入: + +```json +{ + "input_path": "artifacts/normalized_cases.jsonl", + "output_path": "artifacts/normalized_cases.with_ids.jsonl", + "id_strategy": "hash" +} +``` + +说明: + +* 稳定 ID 会让 review、dedup、export manifest 和 git diff 更容易处理。 +* 默认策略可以对标准化后的 conversation 和 label 做 hash。 + +# 数据集质量层 + +这些工具在导出前作用于标准 records。 + +## `dedup_dataset_records` + +用途:导出前删除或标记重复、近重复 records。 + +建议输入: + +```json +{ + "input_path": "artifacts/eval_candidates.jsonl", + "output_path": "artifacts/eval_candidates_deduped.jsonl", + "keys": ["conversation.query", "label.name"], + "mode": "flag" +} +``` + +说明: + +* 在 eval set 中,重复样本会抬高指标,或让专项集看起来比实际更大。 +* 在 training set 中,重复样本可能会无意中加重某种 pattern 的权重。 +* MVP 可以先从精确重复检测开始。近重复检测可以放到 P1 / P2。 +* `mode="flag"` 比直接删除更安全,因为 reviewer 可以检查哪些内容被移除了。 + +# 导出格式层 + +## `convert_dataset_format` + +用途:把已校验的标准 records 转换成一个批准的下游格式。 + +建议格式: + +```text +training_jsonl +eval_prompt_csv +review_table_csv +markdown_preview +``` + +建议输入: + +```json +{ + "input_path": "artifacts/eval_candidates.jsonl", + "output_path": "artifacts/eval_candidates.csv", + "target_format": "eval_prompt_csv", + "schema_name": "router_conversation_v1" +} +``` + +说明: + +* 这个工具和 validation 分开,因为不同转换目标可能有不同的列要求和布局要求。 +* 它不应该改变语义内容。如果目标格式无法表达某个字段,工具应该在 metadata 中说明哪些字段被省略。 + +## `render_dataset_preview` + +用途:把 records 渲染成人类评审用的格式。 + +建议输入: + +```json +{ + "input_path": "artifacts/eval_candidates.jsonl", + "output_path": "artifacts/eval_candidates_preview.md", + "max_records": 50, + "group_by": "label" +} +``` + +说明: + +* Review 格式应该对人友好,但不应被当作训练 / 评测的标准格式。 + +## `export_dataset` + +用途:把已校验的标准 records 导出为一个或多个批准的 artifact 文件,并写入 export manifest。 + +建议输入: + +```json +{ + "input_path": "artifacts/eval_candidates_deduped.jsonl", + "exports": [ + { + "target_format": "training_jsonl", + "output_path": "artifacts/export/train.jsonl" + }, + { + "target_format": "eval_prompt_csv", + "output_path": "artifacts/export/eval.csv" + }, + { + "target_format": "markdown_preview", + "output_path": "artifacts/export/review.md" + } + ], + "require_valid": true +} +``` + +说明: + +* 这个工具可以在写入最终输出前内部调用 validation。 +* 应生成 manifest,包含 record 数量、schema version、export paths 和 validation status。 +* 这是基于确定性转换的便利性 / 编排工具。调试时,仍然可以直接调用 `convert_dataset_format`。 + +# P1:Eval 输入与错误分析工具 + +这些工具支撑任务 1:利用现有 eval errors 规划并生成针对性数据。 + +## `load_eval_results` + +用途:从 CSV、JSONL 或类 spreadsheet 导出文件中加载 eval result,并转换成标准内部表。 + +建议输入: + +```json +{ + "input_path": "context/eval_errors.csv", + "output_path": "artifacts/eval_results.normalized.jsonl", + "field_mapping": { + "query": "query", + "expected_label": "correct_label", + "predicted_label": "model_label" + } +} +``` + +说明: + +* 应支持显式 field mapping。 +* 可以支持 `field_mapping="auto"`,但应返回低置信度 mapping warning,而不是静默猜测。 + +## `profile_error_cases` + +用途:从 eval errors 中产出结构化统计和切片分析。 + +这是比全自动聚类更安全的第一版。 + +建议输出: + +```json +{ + "total_errors": 328, + "by_expected_label": {}, + "by_predicted_label": {}, + "confusion_pairs": [], + "top_terms": [], + "sample_records": [] +} +``` + +说明: + +* 这个工具给 Agent 提供分析证据。 +* 随后 Agent 可以写 `artifacts/error_analysis.md`。 + +## `cluster_error_cases` + +用途:把相似错误样本分组,便于分析。 + +处置:`P1`,但应被视为辅助聚类,而不是最终事实。 + +难点: + +* 输入文件格式不一致。 +* 好的 cluster 经常需要人工解释。 +* 之前的聚类是交互式的,这一点应该保留。 + +建议设计: + +```json +{ + "input_path": "artifacts/eval_results.normalized.jsonl", + "output_path": "artifacts/error_clusters.json", + "features": ["query", "expected_label", "predicted_label"], + "method": "heuristic", + "max_clusters": 20, + "include_examples": 10 +} +``` + +推荐 MVP: + +* 从 `profile_error_cases` 开始,再基于 confusion pair、label、keywords 或显式字段做简单分组。 +* 由 Agent 在 markdown 中产出 cluster 名称和假设。 +* 由人工评审、合并、拆分 clusters。 + +## `summarize_error_clusters` + +用途:把 cluster 数据转换成人类可评审的报告。 + +与 `cluster_error_cases` 的关系: + +* `cluster_error_cases` 创建结构化分组和代表样例。 +* `summarize_error_clusters` 把这些分组转换成报告:cluster 标题、疑似根因、示例、建议的数据生成方向。 + +建议输入: + +```json +{ + "clusters_path": "artifacts/error_clusters.json", + "output_path": "artifacts/error_cluster_summary.md" +} +``` + +说明: + +* 这个工具可能部分由 Agent 编写。工具可以渲染基础确定性报告;Agent 可以在其上补充推理。 + +## `create_review_packet` + +用途:把分析结果打包成一个紧凑的人类评审 artifact。 + +建议输入: + +```json +{ + "inputs": [ + "artifacts/error_analysis.md", + "artifacts/error_cluster_summary.md" + ], + "output_path": "artifacts/review_packet.md", + "questions_path": "memory/open_questions.md" +} +``` + +说明: + +* 在调用 `ask_user_question` 前有用。 +* 可以让交互式评审始终基于文件内容展开。 + +# P1:数据规划与生成工具 + +这些工具支撑任务 1 和任务 2。 + +## `generate_data_plan` + +用途:基于已评审的错误 clusters 或标签定义创建结构化生成计划。 + +建议输入: + +```json +{ + "analysis_path": "artifacts/error_cluster_summary.md", + "constraints_path": "context/data_requirements.md", + "output_path": "artifacts/generation_plan.json" +} +``` + +建议输出形态: + +```json +{ + "batches": [ + { + "name": "hard_negative_fast_direct", + "target_label": "SLOW_FILTER_RANK", + "count": 50, + "scenario": "...", + "requirements": [], + "negative_constraints": [] + } + ] +} +``` + +说明: + +* 可以由 Agent 生成,再由工具校验。 +* 短期内,这个工具可以先负责校验和规范化计划,而不是完整生成计划。 + +## `validate_data_plan` + +用途:校验 generation plan 是否完整、可执行。 + +说明: + +* 检查数量、标签、必填字段、schema names 和不支持的指令。 +* 有助于保持交互式 plan review 稳定。 + +## `generate_dataset_records` + +用途:根据已校验的 plan 和 data schema 生成 records。 + +建议输入: + +```json +{ + "plan_path": "artifacts/generation_plan.json", + "output_path": "artifacts/generated_candidates.jsonl", + "schema_name": "router_conversation_v1", + "generation_mode": "llm_assisted" +} +``` + +说明: + +* 如果内部使用 LLM,工具输出仍然需要校验。 +* 更简单的 MVP 是:Agent 生成候选 JSONL,然后调用 `validate_dataset_records`。 +* 长期看,这个工具可以负责生成,减少格式漂移。 + +## `validate_label_coverage` + +用途:检查生成数据是否覆盖了计划要求的 labels、scenarios 和 boundary types。 + +建议输入: + +```json +{ + "dataset_path": "artifacts/generated_candidates.jsonl", + "plan_path": "artifacts/generation_plan.json", + "output_path": "artifacts/coverage_report.md" +} +``` + +说明: + +* 这和 schema validation 不同。 +* Schema validation 问的是:“record 结构是否合法?” +* Coverage validation 问的是:“是否生成了计划中想要的数据?” + +# P1:产品或标签定义工具 + +这些工具支撑任务 2。 + +## `load_definition_document` + +用途:把产品或标签定义文档加载为标准 markdown / text。 + +建议输入: + +```json +{ + "input_path": "context/product_definition.md", + "output_path": "artifacts/definition.normalized.md" +} +``` + +说明: + +* 可以支持 markdown、txt、csv,之后按需支持 docx / pdf。 + +## `extract_label_definition` + +用途:抽取候选标签定义、正例、反例和模糊边界。 + +建议输出: + +```json +{ + "labels": [], + "positive_rules": [], + "negative_rules": [], + "ambiguous_boundaries": [], + "examples": [] +} +``` + +说明: + +* 可以由 LLM 辅助,但应输出结构化 JSON 和 markdown summary。 + +## `render_label_boundary_summary` + +用途:基于抽取出的定义创建可评审的标签边界摘要。 + +建议输出: + +```text +artifacts/label_boundary_summary.md +``` + +说明: + +* 应接入 `ask_user_question` 或人工评审流程。 + +# P2:在线挖掘工具 + +这些工具支撑任务 3。应在 schema / generation 工具稳定后再添加。 + +## `analyze_badcases` + +用途:总结输入 badcases 的共性特征。 + +建议输出: + +* 常见 query patterns +* 涉及 labels +* 候选过滤条件 +* 高风险 / 模糊字段 +* 代表样例 + +## `build_mining_strategy` + +用途:把 badcase 分析转换成结构化在线数据搜索策略。 + +建议输出形态: + +```json +{ + "filters": { + "date_range": {}, + "device": [], + "agent_type": [], + "keywords": [], + "regex": [], + "domain": [] + }, + "sampling": { + "limit": 100, + "method": "diverse" + } +} +``` + +说明: + +* 大规模查询前,应支持人工评审。 + +## `search_online_sessions` + +用途:通过受控、可审计的 filters 查询线上 sessions。 + +说明: + +* Agent 不应该写原始 SQL。 +* 工具 schema 应只暴露被批准的字段和过滤操作符。 +* 敏感字段应在工具边界完成脱敏。 + +## `sample_online_candidates` + +用途:从检索到的线上 sessions 中采样候选样本,供评审使用。 + +说明: + +* 支持“先标注 100 条”的循环。 +* 应包含确定性 sampling metadata,保证可复现。 + +## `create_annotation_batch` + +用途:从采样候选中创建 review / annotation packet。 + +说明: + +* MVP 可以是本地 markdown / CSV。 +* 之后可以接入标注系统。 + +## `read_annotation_result` + +用途:把人工评审结果读回 task workspace。 + +说明: + +* 应规范化 review labels 和 comments。 + +## `evaluate_mining_precision` + +用途:评估当前 mining strategy 的精度是否足够。 + +建议输出: + +* 已评审数量 +* 正样本数量 +* Precision +* 主要误召 pattern +* 推荐的下一步过滤条件调整 + +## `refine_mining_strategy` + +用途:根据评估结果生成修订版 mining strategy。 + +说明: + +* 初期可以保留为 Agent 编写;工具负责校验 strategy JSON。 + +## `export_mined_dataset` + +用途:把挖掘数据导出成 badcase set、eval set 或 training set 格式。 + +说明: + +* 应调用或复用 validation、dedup、conversion 和 export 逻辑。 + +# 持久化与 Git 工具 + +## `persist_dataset_artifact` + +用途:持久化已批准的数据集导出,并写入 manifest。 + +建议输入: + +```json +{ + "artifact_paths": [ + "artifacts/export/train.jsonl", + "artifacts/export/eval.csv" + ], + "manifest_path": "artifacts/export/manifest.json", + "destination": "workspace" +} +``` + +说明: + +* `destination="workspace"` 可作为 MVP。 +* 之后 destination 可以包括对象存储、dataset registry 或内部平台。 + +## `prepare_dataset_git_commit` + +用途:stage 已批准的数据集 artifacts,并生成 commit summary。 + +处置:`P1/P2`。 + +说明: + +* 实际 git push 应需要人工确认。 +* 这个工具应和 dataset export 分开。Export 负责创建文件;git persistence 负责发布或版本化。 + +# 建议的首批实现集合 + +当前短期工作建议先实现这些: + +```text +validate_dataset_records +normalize_dataset_records +generate_record_ids +convert_dataset_format +render_dataset_preview +dedup_dataset_records +export_dataset +load_eval_results +profile_error_cases +validate_data_plan +persist_dataset_artifact +``` + +然后再添加: + +```text +cluster_error_cases +summarize_error_clusters +generate_data_plan +generate_dataset_records +validate_label_coverage +load_definition_document +extract_label_definition +render_label_boundary_summary +``` + +最后添加在线挖掘工具: + +```text +analyze_badcases +build_mining_strategy +search_online_sessions +sample_online_candidates +create_annotation_batch +read_annotation_result +evaluate_mining_precision +refine_mining_strategy +export_mined_dataset +``` + +# 开放问题 + +* 对话 records 的 canonical schema name 和 version 是什么? +* 确切接受哪些 export formats?需要哪些必填列? +* 生成 records 中的 `tts` 应该是必填、可选,还是派生字段? +* 哪些 labels 是合法的?label registry 存在哪里? +* 在线挖掘时,哪些字段允许用于过滤和导出? +* 哪些动作在持久化或 git push 前需要人工确认? \ No newline at end of file diff --git a/docs/data_agent_skill_map.md b/docs/data_agent_skill_map.md new file mode 100644 index 0000000..b08481f --- /dev/null +++ b/docs/data_agent_skill_map.md @@ -0,0 +1,31 @@ +# 数据 Agent Skill 地图 + +这是一份第一批 data-agent 目录化 skill 的工作索引。 + +## Skill 列表 + +| Skill | 主要用途 | 当前状态 | +|---|---|---| +| `data-record-format` | 讨论和起草 canonical record 规则、样例记录、格式相关 open questions | 草案;canonical record v1 仍待确定 | +| `eval-error-data-generation` | 分析评测错误,并规划针对性训练/评测数据生成 | 草案;数据工具仍待实现 | +| `product-definition-data-generation` | 从产品或标签定义文档生成边界感知的数据计划和候选记录 | 草案;定义抽取工具仍待实现 | +| `online-badcase-mining` | 分析 badcase,并起草可迭代的线上挖掘策略 | 草案;线上挖掘工具仍待实现 | +| `tool-smoke-test` | 验证目录化 skill 加载和基础工具 trace | 可用 smoke test | + +## 示例触发 Query + +```text +Use the data-record-format skill. 帮我 review 这个样例文件:test_data/data_agent/sample.jsonl +``` + +```text +Use the eval-error-data-generation skill. 输入文件:tasks/debug/context/eval_errors.csv。请先起草错误分析和数据生成计划。 +``` + +```text +Use the product-definition-data-generation skill. 输入文件:tasks/debug/context/product_definition.md。请总结标签边界并起草数据生成计划。 +``` + +```text +Use the online-badcase-mining skill. 输入文件:tasks/debug/context/badcases.jsonl。请分析共性模式并起草挖掘策略。 +``` diff --git a/docs/data_agent_tool_inventory.md b/docs/data_agent_tool_inventory.md new file mode 100644 index 0000000..697db35 --- /dev/null +++ b/docs/data_agent_tool_inventory.md @@ -0,0 +1,189 @@ +# Data Agent 工具清单 + +本文档是当前 `claw-code-agent` 工具能力面的工作清单,用于未来的数据 Agent 改造。 + +范围: + +* 源注册表:`src.agent_tools.default_tool_registry()` +* 工具形态:`AgentTool(name, description, parameters, handler)` +* 运行时执行:模型输出 `tool_calls`,随后 `LocalCodingAgent` 执行匹配的 handler,并把结果作为 tool message 写回。 + +## 当前工具生命周期 + +1. `default_tool_registry()` 构建基础注册表。 +2. 在 `LocalCodingAgent.__post_init__` 阶段,可能会把插件别名和虚拟工具合并进注册表。 +3. 每个 `AgentTool` 会通过 `to_openai_tool()` 转换成 OpenAI 兼容的 function schema。 +4. 模型接收 `messages` 和 `tool_specs`。 +5. 如果模型返回 `tool_calls`,运行时会执行每个指定名称的工具。 +6. 工具 handler 接收 `(arguments, ToolExecutionContext)`。 +7. handler 返回字符串,或返回 `(content, metadata)`。 +8. 运行时序列化结果,并把它作为 `role="tool"` 的消息追加进去,供下一轮模型调用使用。 + +## Data-Agent 处置标记说明 + +* `Keep`:适合首版 data-agent MVP 使用。 +* `Candidate`:可能有用,但应只在具体数据工作流需要时启用。 +* `Wrap`:能力有用,但应该通过数据专用工具名或更窄的 schema 暴露。 +* `Defer`:首版 data-agent MVP 暂不需要。 +* `Disable`:对首版 data-agent MVP 来说太宽泛,或过于面向代码。 +* `Special`:由 agent loop 特殊处理,不是普通业务工具。 + +## 工作区与文件工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| --------------- | ------------------------------------------------------- | ------------- | ----------------------------------------------- | +| `list_dir` | 列出 workspace 路径下的文件和目录。 | Keep | 用于任务工作区检查。 | +| `read_file` | 读取 workspace 内 UTF-8 文本文件内容。 | Keep | Markdown 优先流程和 artifact 读取的核心工具。 | +| `write_file` | 在 workspace 内完整写入文件;必要时创建父目录。 | Keep | 用于 `goal.md`、memory 文件、报告、JSONL artifact。需要写权限。 | +| `edit_file` | 使用精确字符串匹配替换 workspace 文件中的文本。 | Keep | 适合增量更新 memory / artifact。 | +| `notebook_edit` | 通过替换或追加 source 来编辑 `.ipynb` 文件中的 Jupyter notebook cell。 | Defer | data-agent MVP 应优先使用 Markdown、JSONL、CSV 和报告。 | +| `glob_search` | 在 workspace 内查找匹配 glob pattern 的文件。 | Keep | 用于发现任务文件和 skill 文件。 | +| `grep_search` | 在 workspace 文件中搜索字符串或正则表达式。 | Keep | 用于本地标签定义、历史笔记和 skill 查找。 | + +## Shell 与代码智能工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| ------ | ------------------------- | ------------- | --------------------------------------- | +| `bash` | 在 workspace 内运行 shell 命令。 | Disable | 对 data-agent MVP 来说过于宽泛。优先使用专用数据工具和校验器。 | +| `LSP` | 使用本地 LSP 风格的代码智能能力。 | Disable | 面向代码的能力;数据任务不需要。 | + +## Web 与搜索工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| -------------------------- | ------------------------------- | ------------- | ---------------------------------------- | +| `web_fetch` | 从 HTTP、HTTPS 或 file URL 获取文本资源。 | Defer | 可能用于文档读取,但不是核心数据工作流。 | +| `search_status` | 显示本地搜索运行时摘要。 | Candidate | 仅当在线 session / 搜索 provider 被建模为搜索运行时时有用。 | +| `search_list_providers` | 列出已配置的本地搜索 provider。 | Candidate | 同上。 | +| `search_activate_provider` | 设置当前激活的本地搜索 provider。 | Defer | 属于配置动作,不应是普通 agent 任务行为。 | +| `web_search` | 通过已配置后端执行真实 Web 搜索。 | Defer | 外部 Web 搜索不同于内部数据挖掘。 | +| `tool_search` | 搜索当前激活的工具注册表。 | Keep | 在工具面持续演进时有用。之后进入严格生产模式可以移除。 | +| `sleep` | 暂停执行一小段时间。 | Defer | 适合轮询,但在存在异步任务之前不需要。 | + +## 人工评审与决策工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| ------------------- | ---------------------------- | ------------- | -------------- | +| `ask_user_question` | 向本地 ask-user runtime 请求用户回答。 | Keep | 对边界澄清和人工裁决很重要。 | + +## 账号与配置工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| ----------------------- | ------------------------------------ | ------------- | --------------------------------- | +| `account_status` | 显示本地账号运行时摘要。 | Candidate | 如果数据系统需要账号 profile,会有用。 | +| `account_list_profiles` | 列出已配置的本地账号 profile。 | Candidate | 用于调试访问配置。 | +| `account_login` | 激活本地账号 profile 或临时身份。 | Defer | 不应出现在常规自主数据任务循环里。 | +| `account_logout` | 清空当前激活账号 session 状态。 | Defer | 运维动作。 | +| `config_list` | 列出合并后的或特定来源的 workspace 配置 key。 | Candidate | 用于检查 data-agent 配置。 | +| `config_get` | 通过 dotted key path 读取 workspace 配置值。 | Candidate | 用于 data-agent policy / config 查询。 | +| `config_set` | 写入 workspace 配置值。 | Disable | 配置变更应由人控制。 | + +## MCP 工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| -------------------- | ------------------------------- | ------------- | ----------------------------------------------------------------------- | +| `mcp_list_resources` | 列出本地 MCP resources。 | Candidate | 适合 skill / resource 发现或内部文档读取。 | +| `mcp_read_resource` | 通过 URI 读取本地 MCP resource。 | Candidate | 如果标签、策略或历史决策以 resource 形式暴露,会有用。 | +| `mcp_list_tools` | 列出已配置 MCP server 暴露的 MCP tools。 | Candidate | 用于发现数据系统工具。 | +| `mcp_call_tool` | 调用已配置 MCP server 暴露的 MCP tool。 | Wrap | 这是强大的通用桥接能力。常规 agent 使用时,应优先使用 `search_online_sessions` 这类数据专用 wrapper。 | + +## Remote、Worktree、Workflow 与 Trigger 工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| ---------------------- | -------------------------------- | ------------- | --------------------------------- | +| `remote_status` | 显示本地 remote 运行时摘要。 | Defer | 运维相关。 | +| `remote_list_profiles` | 列出已配置的本地 remote profile。 | Defer | 运维相关。 | +| `remote_connect` | 激活本地 remote target / profile。 | Disable | 对 data-agent 自主模式来说过于宽泛。 | +| `remote_disconnect` | 清空当前激活 remote connection。 | Disable | 运维变更。 | +| `worktree_status` | 显示当前受管 git worktree session 状态。 | Disable | 面向代码。 | +| `worktree_enter` | 创建并进入隔离 git worktree。 | Disable | 面向代码。 | +| `worktree_exit` | 离开当前受管 worktree session。 | Disable | 面向代码。 | +| `workflow_list` | 列出本地 workflow 定义。 | Candidate | 如果数据 pipeline 以 workflow 表示,可能有用。 | +| `workflow_get` | 显示某个本地 workflow 定义。 | Candidate | 同上。 | +| `workflow_run` | 记录并渲染一次 workflow 执行请求。 | Wrap | 应变成明确的数据动作,而不是任意 workflow 执行。 | +| `remote_trigger` | 列出、查看、创建、更新或运行本地 remote trigger。 | Defer | 更偏自动化,首版 data-agent MVP 暂不需要。 | + +## Planning、Task、Team 与后台工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| --------------- | ----------------------------- | ------------- | -------------------------------------- | +| `plan_get` | 显示当前本地运行时 plan。 | Candidate | 对结构化长周期数据任务有用。 | +| `update_plan` | 替换当前运行时 plan。 | Candidate | 有用,但不能替代任务 workspace memory。 | +| `plan_clear` | 清空当前运行时 plan。 | Defer | 运维动作。 | +| `task_next` | 显示下一批可执行 runtime task。 | Candidate | 之后可映射到数据任务 backlog。 | +| `task_list` | 列出本地存储的 runtime task。 | Candidate | 可支持数据任务状态。 | +| `task_get` | 通过 id 显示本地存储的 runtime task。 | Candidate | 同上。 | +| `task_create` | 创建本地存储的 runtime task。 | Defer | 优先使用 task workspace。 | +| `task_update` | 更新本地存储的 runtime task。 | Defer | 优先使用 task workspace。 | +| `task_start` | 标记任务为进行中。 | Defer | 优先使用 task workspace。 | +| `task_complete` | 标记任务为完成。 | Defer | 优先使用 task workspace。 | +| `task_block` | 标记任务被阻塞。 | Defer | 优先使用 `open_questions.md`。 | +| `task_cancel` | 标记任务被取消。 | Defer | 运维动作。 | +| `team_list` | 列出本地配置的协作团队。 | Defer | 非核心 MVP。 | +| `team_get` | 显示某个本地配置的协作团队。 | Defer | 非核心 MVP。 | +| `team_create` | 创建本地存储的协作团队。 | Disable | 非核心 MVP;属于变更动作。 | +| `team_delete` | 删除本地存储的协作团队。 | Disable | 破坏性运维动作。 | +| `send_message` | 发送本地协作消息。 | Defer | 之后在评审路由中可能有用。 | +| `team_messages` | 显示已记录的协作消息。 | Defer | 之后在评审路由中可能有用。 | +| `todo_write` | 用结构化 todo list 替换当前本地运行时任务列表。 | Candidate | 可用于 agent 自组织;但 task workspace 仍应是事实源。 | +| `TaskOutput` | 通过 ID 获取后台任务输出。 | Defer | 等异步数据任务存在后有用。 | +| `TaskStop` | 通过 ID 停止运行中的后台任务。 | Defer | 等异步数据任务存在后有用。 | + +## Loop 特殊工具 + +| 工具 | 当前描述 | Data-Agent 处置 | 备注 | +| ---------------- | ------------------- | ------------- | ----------------------------------------- | +| `EnterPlanMode` | 进入 plan mode。 | Special | coding-agent 行为;可能会被 data-agent 任务规划指导替代。 | +| `ExitPlanMode` | 退出 plan mode。 | Special | 同上。 | +| `Agent` | 为复杂任务启动一个新 agent。 | Defer | 之后可用于并行挖掘 / 评审,但首版 MVP 应保持单 agent loop。 | +| `delegate_agent` | 旧版:把子任务委托给嵌套 agent。 | Disable | 如果之后需要委托,优先使用 `Agent`。 | +| `Skill` | 在主对话中执行 skill。 | Special | 作为概念入口保留,但基于目录的数据 skills 需要单独设计。 | + +## 建议的 MVP Data Tool Registry + +首版 data-agent 实验建议从这个最小注册表开始: + +```text +list_dir +read_file +write_file +edit_file +glob_search +grep_search +ask_user_question +tool_search +``` + +原型阶段可选 MCP 桥接: + +```text +mcp_list_resources +mcp_read_resource +mcp_list_tools +mcp_call_tool +``` + +当内部数据工具准备好后,应优先添加窄口径的数据专用工具,而不是暴露宽泛工具: + +```text +search_online_sessions +sample_top_queries +find_similar_cases +mine_by_pattern +mine_by_model_disagreement +cluster_and_dedup +generate_eval_candidates +validate_eval_jsonl +run_router_eval +run_online_replay +create_annotation_queue +read_human_review_result +persist_dataset_artifact +``` + +## Data-Agent 改造实现说明 + +* 新增 `default_data_tool_registry()`,不要直接修改 `default_tool_registry()`。 +* 第一阶段保留文件工具、人工评审工具,以及可选的 MCP discovery。 +* 内部数据系统应通过稳定、可审计的数据工具暴露,而不是通过 `bash`、原始 SQL 或通用 `mcp_call_tool` 暴露。 +* 将 `mcp_call_tool` 视为开发桥接;生产 data-agent 流程应使用面向目的构建的 wrapper tools。 +* 工具权限应与数据治理动作对齐,而不只是与底层执行机制对齐。 \ No newline at end of file diff --git a/docs/task-001-directory-skills.md b/docs/task-001-directory-skills.md new file mode 100644 index 0000000..275cb42 --- /dev/null +++ b/docs/task-001-directory-skills.md @@ -0,0 +1,43 @@ +# Task 001:目录化 Skills + +## 目标 + +把 skill 内容从 Python 内置字符串迁移到文件目录中,让 data-agent 工作流可以用文件形式开发、review 和维护。 + +## 范围 + +- 保持当前 `Skill` 工具和 agent loop 不变。 +- 支持从 `src/skills/bundled/*/SKILL.md` 加载内置目录化 skill。 +- 保持 Python 动态 skill 可用。 +- 使用已有内置 skill 作为迁移样例。 + +## 第一批切片 + +- `simplify` 和 `debug` 继续保留 Python 实现,因为它们需要读取动态运行时数据。 +- `verify` 和 `update-config` 迁移到目录化 skill。 +- `SKILL.md` 支持简单 front matter: + +```text +--- +name: verify +description: Verify a code change works by running the app and tests. +when_to_use: When the user asks to verify, test, or check that recent changes work. +allowed_tools: read_file, bash, grep_search, glob_search +--- + +Skill prompt body... +``` + +## 成功标准 + +- `/skills` 仍然能列出迁移后的 skills。 +- Web UI `/api/skills` 仍然能返回迁移后的 skills。 +- `Skill({"skill": "verify"})` 返回 `SKILL.md` 中的 prompt 正文。 +- 现有 Python 动态 skills 仍然可用。 + +## 当前状态 + +- 目录化 skill loader 已在 `src/bundled_skills.py` 实现。 +- `verify` 已迁移到 `src/skills/bundled/verify/SKILL.md`。 +- `update-config` 已迁移到 `src/skills/bundled/update-config/SKILL.md`。 +- 动态 skills `simplify` 和 `debug` 继续保持 Python 实现。 diff --git a/docs/task-002-project-skills.md b/docs/task-002-project-skills.md new file mode 100644 index 0000000..723fdd1 --- /dev/null +++ b/docs/task-002-project-skills.md @@ -0,0 +1,33 @@ +# Task 002:项目级 Skills + +## 目标 + +允许项目维护者直接在 workspace 根目录添加 skills: + +```text +skills/ + my-skill/ + SKILL.md +``` + +## 查找顺序 + +当存在 workspace `cwd` 时: + +1. `cwd/skills/*/SKILL.md` +2. `src/skills/bundled/*/SKILL.md` +3. Python 动态 skills + +项目级 skills 优先于同名的内置目录 skill 或 Python 动态 skill。 + +## 接入点 + +- `Skill` 工具执行时,会通过当前 runtime `cwd` 解析项目级 skills。 +- `/skills` 会列出当前 workspace 下的项目级 skills。 +- Web UI `/api/skills` 会列出当前 Working dir 下的项目级 skills。 + +## 说明 + +- 项目级 skill 与内置目录化 skill 共用同一套 `SKILL.md` front matter 格式。 +- `name`、`aliases`、`allowed_tools` 保持面向机器的英文字段。 +- skill 正文可以使用中文或英文维护。 diff --git a/pyproject.toml b/pyproject.toml index bfc275d..0b6ddf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,4 +57,5 @@ include = ["src*"] src = [ "reference_data/*.json", "gui/static/*", + "skills/bundled/*/SKILL.md", ] diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 60c6ec3..1d1a904 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -2082,7 +2082,7 @@ class LocalCodingAgent: args = str(args) if args is not None else '' # 1. Check bundled skills first - bundled = find_bundled_skill(skill_name) + bundled = find_bundled_skill(skill_name, cwd=self.runtime_config.cwd) if bundled is not None: prompt = bundled.get_prompt(self, args.strip()) return ToolExecutionResult( @@ -2092,7 +2092,8 @@ class LocalCodingAgent: metadata={ 'action': 'skill', 'skill_name': skill_name, - 'source': 'bundled', + 'source': bundled.source, + 'skill_path': bundled.path, 'should_query': True, }, ) @@ -2105,7 +2106,7 @@ class LocalCodingAgent: for s in get_slash_command_specs() for name in s.names ) - available_skills = [sk.name for sk in get_bundled_skills()] + available_skills = [sk.name for sk in get_bundled_skills(self.runtime_config.cwd)] all_available = sorted(set(available_cmds + available_skills)) return ToolExecutionResult( name='Skill', diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index e917076..4f169ce 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -1671,7 +1671,7 @@ def _handle_skills(agent: 'LocalCodingAgent', _args: str, input_text: str) -> Sl from .bundled_skills import get_bundled_skills lines = ['## Available Skills', ''] - for skill in get_bundled_skills(): + for skill in get_bundled_skills(agent.runtime_config.cwd): if not skill.user_invocable: continue header = f'- `{skill.name}`' diff --git a/src/bundled_skills.py b/src/bundled_skills.py index 89c5536..d9ea509 100644 --- a/src/bundled_skills.py +++ b/src/bundled_skills.py @@ -13,6 +13,7 @@ from __future__ import annotations import subprocess from dataclasses import dataclass, field +from pathlib import Path from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: @@ -29,6 +30,8 @@ class BundledSkill: aliases: tuple[str, ...] = () allowed_tools: tuple[str, ...] = () user_invocable: bool = True + source: str = 'python' + path: str | None = None get_prompt: Callable[['LocalCodingAgent', str], str] = lambda _a, _args: '' @@ -98,33 +101,6 @@ def _simplify_prompt(agent: 'LocalCodingAgent', args: str) -> str: {f'Additional context: {args}' if args.strip() else ''}""" -def _verify_prompt(agent: 'LocalCodingAgent', args: str) -> str: - """Generate the verify prompt.""" - return f"""Verify that the recent code changes work correctly. - -## Instructions - -1. Identify what was changed (check `git diff` and `git status`) -2. Determine the appropriate verification strategy: - - **Unit tests**: Run existing tests, check for failures - - **Integration tests**: Run broader test suites if available - - **Manual verification**: Start the app/server and test the feature -3. Run the verification -4. Report the result clearly: - - PASS: All checks passed, feature works as expected - - FAIL: Describe what failed and why - - PARTIAL: Some checks passed, some need attention - -## Verification Strategy - -- For CLI tools: Run the command with test inputs -- For servers: Start the server, make test requests -- For libraries: Run the test suite -- For config changes: Validate the config loads correctly - -{f'Specific focus: {args}' if args.strip() else 'Verify the most recent changes.'}""" - - def _debug_prompt(agent: 'LocalCodingAgent', args: str) -> str: """Generate the debug prompt.""" import os @@ -154,71 +130,94 @@ def _debug_prompt(agent: 'LocalCodingAgent', args: str) -> str: return '\n'.join(lines) -def _update_config_prompt(agent: 'LocalCodingAgent', args: str) -> str: - """Generate the update-config prompt.""" - return f"""Help configure the agent settings. +def _directory_skill_prompt(body: str) -> Callable[['LocalCodingAgent', str], str]: + def _prompt(_agent: 'LocalCodingAgent', args: str) -> str: + if args.strip(): + return f'{body.rstrip()}\n\n## Invocation Arguments\n\n{args.strip()}' + return body -## Settings File Locations + return _prompt -- **Global**: `~/.claude/settings.json` — applies to all projects -- **Project**: `.claude/settings.json` — project-specific, committed to git -- **Local**: `.claude/settings.local.json` — project-specific, gitignored -## Configurable Settings +def _parse_front_matter(text: str) -> tuple[dict[str, str], str]: + if not text.startswith('---\n'): + return {}, text + end_marker = text.find('\n---\n', 4) + if end_marker == -1: + return {}, text + raw_meta = text[4:end_marker] + body = text[end_marker + len('\n---\n') :] + metadata: dict[str, str] = {} + for line in raw_meta.splitlines(): + if not line.strip() or line.lstrip().startswith('#') or ':' not in line: + continue + key, value = line.split(':', 1) + metadata[key.strip()] = value.strip() + return metadata, body -### Hooks -Event-driven shell commands that run on tool use or lifecycle events: -- `PreToolUse` — runs before a tool executes (can block with exit code 2) -- `PostToolUse` — runs after a tool completes -- `PreCompact` — runs before conversation compaction -Hook format: -```json -{{ - "hooks": {{ - "PreToolUse": [ - {{ - "matcher": "Bash", - "hooks": [ - {{ - "type": "command", - "command": "echo 'tool: $TOOL_NAME'" - }} - ] - }} - ] - }} -}} -``` +def _split_csv(value: str | None) -> tuple[str, ...]: + if not value: + return () + return tuple(item.strip() for item in value.split(',') if item.strip()) -### Permissions -Tool permission rules: -```json -{{ - "permissions": {{ - "allow": ["Read", "Grep", "Glob"], - "deny": ["Bash(rm:*)"] - }} -}} -``` -### Environment Variables -```json -{{ - "env": {{ - "MY_VAR": "value" - }} -}} -``` +def _parse_bool(value: str | None, *, default: bool = True) -> bool: + if value is None or not value.strip(): + return default + return value.strip().lower() not in {'0', 'false', 'no', 'off'} -{f'User request: {args}' if args.strip() else 'What would you like to configure?'}""" + +def _load_directory_skill(path: Path, *, source: str = 'directory') -> BundledSkill | None: + try: + text = path.read_text(encoding='utf-8') + except OSError: + return None + metadata, body = _parse_front_matter(text) + name = metadata.get('name') or path.parent.name + description = metadata.get('description', '').strip() + if not name.strip() or not description: + return None + return BundledSkill( + name=name.strip(), + description=description, + when_to_use=metadata.get('when_to_use', '').strip(), + aliases=_split_csv(metadata.get('aliases')), + allowed_tools=_split_csv(metadata.get('allowed_tools')), + user_invocable=_parse_bool(metadata.get('user_invocable'), default=True), + source=source, + path=str(path), + get_prompt=_directory_skill_prompt(body.strip()), + ) + + +def load_directory_skills( + root: Path | None = None, + *, + source: str = 'directory', +) -> tuple[BundledSkill, ...]: + skill_root = root or (Path(__file__).resolve().parent / 'skills' / 'bundled') + if not skill_root.exists() or not skill_root.is_dir(): + return () + skills: list[BundledSkill] = [] + for path in sorted(skill_root.glob('*/SKILL.md')): + skill = _load_directory_skill(path, source=source) + if skill is not None: + skills.append(skill) + return tuple(skills) + + +def load_project_skills(cwd: Path | str | None) -> tuple[BundledSkill, ...]: + if cwd is None: + return () + return load_directory_skills(Path(cwd).resolve() / 'skills', source='project') # --------------------------------------------------------------------------- # Skill registry # --------------------------------------------------------------------------- -BUNDLED_SKILLS: tuple[BundledSkill, ...] = ( +PYTHON_BUNDLED_SKILLS: tuple[BundledSkill, ...] = ( BundledSkill( name='simplify', description='Review changed code for reuse, quality, and efficiency, then fix issues.', @@ -226,13 +225,6 @@ BUNDLED_SKILLS: tuple[BundledSkill, ...] = ( allowed_tools=('read_file', 'edit_file', 'write_file', 'bash', 'grep_search', 'glob_search'), get_prompt=_simplify_prompt, ), - BundledSkill( - name='verify', - description='Verify a code change works by running the app and tests.', - when_to_use='When the user asks to verify, test, or check that recent changes work.', - allowed_tools=('read_file', 'bash', 'grep_search', 'glob_search'), - get_prompt=_verify_prompt, - ), BundledSkill( name='debug', description='Debug the current session — show diagnostics, token usage, and config.', @@ -240,32 +232,40 @@ BUNDLED_SKILLS: tuple[BundledSkill, ...] = ( user_invocable=True, get_prompt=_debug_prompt, ), - BundledSkill( - name='update-config', - description='Configure settings via settings.json — hooks, permissions, env vars.', - when_to_use='When the user wants to configure hooks, permissions, or settings.', - aliases=('config-help',), - allowed_tools=('read_file',), - get_prompt=_update_config_prompt, - ), ) -def get_bundled_skills() -> tuple[BundledSkill, ...]: - """Return all registered bundled skills.""" - return BUNDLED_SKILLS +def get_bundled_skills(cwd: Path | str | None = None) -> tuple[BundledSkill, ...]: + """Return registered skills, with project skills taking precedence.""" + skills = [ + *load_project_skills(cwd), + *load_directory_skills(), + *PYTHON_BUNDLED_SKILLS, + ] + seen: set[str] = set() + deduped: list[BundledSkill] = [] + for skill in skills: + lowered = skill.name.lower() + if lowered in seen: + continue + seen.add(lowered) + deduped.append(skill) + return tuple(deduped) -def find_bundled_skill(name: str) -> BundledSkill | None: +def find_bundled_skill(name: str, cwd: Path | str | None = None) -> BundledSkill | None: """Look up a bundled skill by name or alias.""" lowered = name.lower() - for skill in BUNDLED_SKILLS: + for skill in get_bundled_skills(cwd): if lowered == skill.name or lowered in skill.aliases: return skill return None -def format_skills_for_system_prompt(max_chars: int = 8000) -> str: +def format_skills_for_system_prompt( + max_chars: int = 8000, + cwd: Path | str | None = None, +) -> str: """Format bundled skills for inclusion in system-reminder messages. The model discovers available skills through this listing. @@ -273,7 +273,7 @@ def format_skills_for_system_prompt(max_chars: int = 8000) -> str: lines = ['Available skills (invoke via Skill tool):'] char_count = len(lines[0]) - for skill in BUNDLED_SKILLS: + for skill in get_bundled_skills(cwd): if not skill.user_invocable: continue entry = f'- {skill.name}: {skill.description}' diff --git a/src/gui/server.py b/src/gui/server.py index 8894d20..2745435 100644 --- a/src/gui/server.py +++ b/src/gui/server.py @@ -204,7 +204,7 @@ def create_app(state: AgentState) -> FastAPI: 'aliases': list(skill.aliases), 'allowed_tools': list(skill.allowed_tools), } - for skill in get_bundled_skills() + for skill in get_bundled_skills(state.cwd) if skill.user_invocable ] diff --git a/src/gui/static/app.js b/src/gui/static/app.js index 115c664..cb47e59 100644 --- a/src/gui/static/app.js +++ b/src/gui/static/app.js @@ -232,6 +232,61 @@ function appendToolCall({ name, args, result, isError }) { els.chat.scrollTop = els.chat.scrollHeight; } +function parseAssistantToolCalls(entry) { + if (!Array.isArray(entry?.tool_calls)) return []; + return entry.tool_calls.map((tc) => { + const fn = tc?.function || {}; + let args = fn.arguments ?? tc?.arguments ?? ""; + try { + if (typeof args === "string") args = JSON.parse(args); + } catch {} + return { + id: tc?.id || "", + name: fn.name || tc?.name || "tool", + args, + }; + }); +} + +function renderTranscriptEntries(entries) { + const pendingToolCalls = new Map(); + for (const entry of entries || []) { + if (!entry || entry.role === "system") continue; + if (entry.role === "assistant") { + if (entry.content && entry.content.trim()) { + appendMessage({ role: "assistant", content: entry.content }); + } + for (const call of parseAssistantToolCalls(entry)) { + if (call.id) pendingToolCalls.set(call.id, call); + } + continue; + } + if (entry.role === "tool") { + const call = pendingToolCalls.get(entry.tool_call_id) || {}; + let parsedResult = null; + try { + parsedResult = JSON.parse(entry.content || "{}"); + } catch {} + appendToolCall({ + name: call.name || entry.name || parsedResult?.tool || "tool", + args: call.args ?? "", + result: entry.content || "", + isError: parsedResult?.ok === false || entry.metadata?.error_kind, + }); + if (entry.tool_call_id) pendingToolCalls.delete(entry.tool_call_id); + continue; + } + renderTranscriptEntry(entry); + } + for (const call of pendingToolCalls.values()) { + appendToolCall({ + name: call.name, + args: call.args, + result: "(pending — no tool result message found)", + }); + } +} + // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- @@ -314,9 +369,7 @@ async function openSession(sessionId) { const session = await apiGet(`/api/sessions/${encodeURIComponent(sessionId)}`); State.activeSessionId = sessionId; clearChat(); - for (const msg of session.messages) { - renderTranscriptEntry(msg); - } + renderTranscriptEntries(session.messages); renderSessions(); setStatus("ready", `Session ${sessionId.slice(0, 8)}…`); } catch (e) { @@ -494,16 +547,7 @@ function renderRunResult(data) { } } const tail = transcript.slice(lastUserIndex + 1); - for (const entry of tail) { - if (entry.role === "assistant") { - // Only render if it has visible content; tool_calls already rendered. - if (Array.isArray(entry.tool_calls) && entry.tool_calls.length) { - // Skip — corresponding tool messages will follow. - continue; - } - } - renderTranscriptEntry(entry); - } + renderTranscriptEntries(tail); // Always show the canonical final output if it isn't already present. const lastMsg = els.chat.querySelector(".message.assistant:last-of-type .body"); if (!lastMsg || lastMsg.textContent.trim() !== (data.final_output || "").trim()) { diff --git a/src/skills/bundled/data-record-format/SKILL.md b/src/skills/bundled/data-record-format/SKILL.md new file mode 100644 index 0000000..ef8f5af --- /dev/null +++ b/src/skills/bundled/data-record-format/SKILL.md @@ -0,0 +1,66 @@ +--- +name: data-record-format +description: 定义、检查、校验并准备标准数据记录,作为导出前的统一中间格式。 +when_to_use: 当用户想讨论、创建、校验、归一化或转换标准数据记录格式时使用。 +aliases: canonical-record, record-format +allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question +--- + +使用这个 skill 处理共享的数据格式层。目标是让生成、挖掘、历史导入的数据都先进入同一个 canonical record 格式,再导出为训练、评测、prompt 或展示格式。 + +## 当前 Canonical Record 状态 + +TODO:canonical record v1 还没有最终确定。 + +在最终确定前,先把下面结构当作工作草案: + +```json +{ + "record_id": "optional-stable-id", + "conversation": [ + { + "query": "...", + "tts": "...", + "metadata": {} + } + ], + "label": { + "type": "function_or_agent", + "name": "...", + "arguments": {} + }, + "source": { + "type": "generated|mined|eval_error|manual", + "task_id": "...", + "notes": "..." + }, + "metadata": {} +} +``` + +## 必要工作循环 + +1. 先判断用户讨论的是 canonical record 层,还是下游导出格式层。 +2. 如果用户提供了文件,先检查文件结构,再提出转换或校验方案。 +3. 明确记录必填字段中的不确定点,尤其是 `tts`、标签类型、标签名称、多轮结构和来源元数据。 +4. 如果当前 schema 信息不足以继续,提出简短问题,或把问题写入任务笔记。 +5. 在 canonical records 存在之前,不要直接创建最终训练、评测或展示格式。 + +## 工具使用建议 + +- 用 `read_file` 读取用户提供的样例或 schema 笔记。 +- 用 `write_file` 或 `edit_file` 起草 schema 笔记、样例记录或 open questions。 +- 用 `grep_search` 和 `glob_search` 查找已有 data-agent 文档和样例。 +- TODO:工具实现后,使用 `validate_dataset_records`、`normalize_dataset_records`、`convert_dataset_format` 和 `export_dataset`。 + +## 推荐产物 + +优先把过程沉淀到这些路径: + +```text +tasks/{task_id}/memory/open_questions.md +tasks/{task_id}/artifacts/canonical_record_notes.md +tasks/{task_id}/artifacts/sample_records.jsonl +``` + +最终回复保持简短,并引用创建或更新过的文件路径。 diff --git a/src/skills/bundled/eval-error-data-generation/SKILL.md b/src/skills/bundled/eval-error-data-generation/SKILL.md new file mode 100644 index 0000000..b73ba42 --- /dev/null +++ b/src/skills/bundled/eval-error-data-generation/SKILL.md @@ -0,0 +1,71 @@ +--- +name: eval-error-data-generation +description: 分析评测错误,并为针对性训练集/评测集补充生成可 review 的数据计划。 +when_to_use: 当用户提供评测结果、模型判错样本或带标签错误 case,并希望补充训练/评测数据时使用。 +aliases: eval-error-generation, error-driven-data +allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question +--- + +使用这个 skill 处理“已有评测错误 -> 错误分析 -> 人工 review 问题类别 -> 数据生成计划 -> canonical records -> 导出”的工作流。 + +## 输入假设 + +用户可能会提供一个或多个文件,里面包含: + +- query 或多轮对话 +- 期望/正确 label +- 模型预测 label +- 可选的 tts +- 可选的原因、垂域、模型版本、设备或其他元数据 + +输入格式可能每次不同。不要在检查文件前假设列名。 + +## 必要工作流 + +1. 读取用户提供的评测/错误文件;如果没有路径,先询问。 +2. 识别已有字段,以及缺失的 canonical record 必填字段。 +3. 生成错误分析笔记,至少包含: + - top 混淆对 + - 反复出现的 query 模式 + - 代表性样例 + - 缺失或不明确的字段 +4. 提出可供人工 review 的问题类别。 +5. 如果类别边界不清,先请用户确认,再制定生成计划。 +6. 创建生成计划,说明: + - 目标问题类别 + - 目标 label + - 计划生成数量 + - 要生成的正例、反例、边界例 + - 必要元数据 +7. 只有在计划清楚后,才生成或请求 canonical records。 +8. TODO:工具实现后,在预览或导出前运行 `validate_dataset_records`。 +9. TODO:工具实现后,运行 `render_dataset_preview` 和 `export_dataset`。 + +## 当前工具状态 + +当前先使用已有工具完成文件检查和产物写入: + +- `read_file` +- `write_file` +- `edit_file` +- `grep_search` +- `glob_search` +- `ask_user_question` + +规划中的数据工具不一定已经实现。除非用户确认当前分支已经实现,否则不要直接调用不存在的工具。 + +## 推荐产物 + +```text +tasks/{task_id}/context/eval_errors.* +tasks/{task_id}/artifacts/error_analysis.md +tasks/{task_id}/artifacts/generation_plan.md +tasks/{task_id}/artifacts/generated_candidates.jsonl +tasks/{task_id}/memory/open_questions.md +``` + +## 约束 + +- 在 canonical records 达成一致并通过校验前,不要直接导出最终训练/评测格式。 +- 不要把自动聚类当成最终事实,聚类和类别命名必须可 review。 +- 面向人的总结要简洁,并尽量用样例支撑。 diff --git a/src/skills/bundled/online-badcase-mining/SKILL.md b/src/skills/bundled/online-badcase-mining/SKILL.md new file mode 100644 index 0000000..4f8c11e --- /dev/null +++ b/src/skills/bundled/online-badcase-mining/SKILL.md @@ -0,0 +1,88 @@ +--- +name: online-badcase-mining +description: 分析 badcase,并通过可 review 的策略迭代挖掘线上相似 case。 +when_to_use: 当用户提供线上 badcase 或产品/标签定义,并希望查找相似线上问题或构建专项集时使用。 +aliases: badcase-mining, online-mining +allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question, mcp_list_tools, mcp_call_tool +--- + +使用这个 skill 处理“badcase 或标签定义 -> 挖掘策略 -> 候选召回 -> 抽样 review -> 策略迭代 -> mined dataset”的工作流。 + +## 输入假设 + +用户可能会提供: + +- 产品或标签定义 +- 线上 badcase 样例 +- 期望/正确 label +- 线上预测 label +- query、tts、agent type、function name、垂域、设备、日期、模型版本或其他元数据 + +线上字段和允许使用的筛选条件还没有最终确定。 + +## 必要工作流 + +1. 读取用户提供的 badcase 或定义文件。 +2. 分析 badcase 共性: + - query 模式 + - label 混淆 + - agent/function 类型 + - 垂域 + - 如存在,分析设备/日期/模型等元数据 +3. 起草带明确筛选条件的挖掘策略。 +4. 当筛选条件不清或影响较大时,先请用户 review,再做宽泛线上召回。 +5. 只使用批准的工具检索或请求候选数据。 +6. 先抽取小批 review 样本,通常约 100 条。 +7. 总结样本命中率和主要 false positive 模式。 +8. 根据结果调整策略,并按需重复。 +9. 最终产出以下一种或多种: + - 线上问题评估报告 + - 专项评测集候选 + - 专项 badcase 集合 + - 训练候选数据 + +## 当前工具状态 + +当前先使用已有工具完成本地分析和策略起草: + +- `read_file` +- `write_file` +- `edit_file` +- `grep_search` +- `glob_search` +- `ask_user_question` + +如果线上挖掘工具通过 MCP 暴露,先用 `mcp_list_tools` 查看可用工具,再决定是否调用 `mcp_call_tool`。 + +TODO:规划中的专用工具: + +- `analyze_badcases` +- `build_mining_strategy` +- `search_online_sessions` +- `sample_online_candidates` +- `create_annotation_batch` +- `read_annotation_result` +- `evaluate_mining_precision` +- `refine_mining_strategy` +- `export_mined_dataset` + +不要手写 raw SQL,也不要用宽泛 shell 命令进行数据检索。 + +## 推荐产物 + +```text +tasks/{task_id}/context/badcases.* +tasks/{task_id}/artifacts/badcase_analysis.md +tasks/{task_id}/artifacts/mining_strategy.md +tasks/{task_id}/artifacts/candidate_sample.jsonl +tasks/{task_id}/artifacts/review_report.md +tasks/{task_id}/artifacts/mined_candidates.jsonl +tasks/{task_id}/memory/open_questions.md +tasks/{task_id}/memory/failed_attempts.md +``` + +## 约束 + +- 除非批准的工具输出已经脱敏,否则不要导出敏感线上原始字段。 +- 不要把第一版挖掘策略当成最终策略。 +- 始终让筛选条件和抽样决策可 review。 diff --git a/src/skills/bundled/product-definition-data-generation/SKILL.md b/src/skills/bundled/product-definition-data-generation/SKILL.md new file mode 100644 index 0000000..82f26c8 --- /dev/null +++ b/src/skills/bundled/product-definition-data-generation/SKILL.md @@ -0,0 +1,70 @@ +--- +name: product-definition-data-generation +description: 从产品或标签定义文档中提取边界,并生成边界感知的 canonical data records。 +when_to_use: 当用户提供产品定义、标签规则或路由边界文档,并希望生成训练/评测数据时使用。 +aliases: definition-data-generation, label-definition-generation +allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question +--- + +使用这个 skill 处理“产品/标签定义 -> 边界总结 -> review 后的数据生成计划 -> canonical records -> 导出”的工作流。 + +## 输入假设 + +用户可能会提供: + +- 产品定义文档 +- 标签定义文档 +- 正例和反例 +- 路由、agent 或 function 选择规则 +- 已知边界冲突 + +文档可能不完整或存在歧义。保留不确定性,不要自行发明隐藏规则。 + +## 必要工作流 + +1. 读取用户提供的定义文档;如果没有路径,先询问。 +2. 总结目标标签/类型及其预期边界。 +3. 提取: + - 正向规则 + - 负向规则 + - 模糊边界 + - 示例和反例 + - 缺失假设 +4. 写出可 review 的边界总结。 +5. 对重要的模糊边界,先请用户确认,再进行大规模生成。 +6. 创建生成计划,覆盖: + - 直接正例 + - hard negative + - 边界样例 + - 必要时包含单轮和多轮样例 +7. 只有在边界总结和计划清楚后,才生成 canonical records。 +8. TODO:工具实现后,校验 records、生成预览并导出目标格式。 + +## 当前工具状态 + +当前先使用已有工具完成文件检查和产物写入: + +- `read_file` +- `write_file` +- `edit_file` +- `grep_search` +- `glob_search` +- `ask_user_question` + +规划中的定义类工具,例如 `extract_label_definition` 和 `validate_label_coverage`,不一定已经实现。 + +## 推荐产物 + +```text +tasks/{task_id}/context/product_definition.* +tasks/{task_id}/artifacts/label_boundary_summary.md +tasks/{task_id}/artifacts/generation_plan.md +tasks/{task_id}/artifacts/generated_candidates.jsonl +tasks/{task_id}/memory/open_questions.md +``` + +## 约束 + +- 不要静默解决产品或标签歧义。 +- canonical records 通过校验前,不要生成最终导出格式。 +- 除非用户明确要求,否则不要把“修改标签定义”和“生成数据”混在一起做。 diff --git a/src/skills/bundled/tool-smoke-test/SKILL.md b/src/skills/bundled/tool-smoke-test/SKILL.md new file mode 100644 index 0000000..9c28516 --- /dev/null +++ b/src/skills/bundled/tool-smoke-test/SKILL.md @@ -0,0 +1,24 @@ +--- +name: tool-smoke-test +description: Exercise a small existing-tool chain for skill/tool debugging. +when_to_use: When the user wants to verify directory skills and tool-call tracing in the Web UI. +aliases: smoke-tools +allowed_tools: list_dir, write_file, read_file, grep_search +--- + +Use this skill to verify that directory-based skills and basic tool chaining work. + +## Required Flow + +1. Briefly state that this is a tool smoke test. +2. Call `list_dir` on `.` with a small `max_entries` value. +3. Call `write_file` to create `tasks/task-001-smoke/artifacts/smoke.md` with a short markdown note that includes the exact phrase `tool-smoke-ok`. +4. Call `read_file` on `tasks/task-001-smoke/artifacts/smoke.md`. +5. Call `grep_search` for `tool-smoke-ok` under `tasks/task-001-smoke`. +6. Summarize whether all tool calls succeeded and include the created file path. + +## Constraints + +- Do not use `bash`. +- Do not edit unrelated files. +- Keep the final response short. diff --git a/src/skills/bundled/update-config/SKILL.md b/src/skills/bundled/update-config/SKILL.md new file mode 100644 index 0000000..15f7d3e --- /dev/null +++ b/src/skills/bundled/update-config/SKILL.md @@ -0,0 +1,68 @@ +--- +name: update-config +description: Configure settings via settings.json - hooks, permissions, env vars. +when_to_use: When the user wants to configure hooks, permissions, or settings. +aliases: config-help +allowed_tools: read_file +--- + +Help configure the agent settings. + +## Settings File Locations + +- Global: `~/.claude/settings.json` applies to all projects. +- Project: `.claude/settings.json` is project-specific and committed to git. +- Local: `.claude/settings.local.json` is project-specific and gitignored. + +## Configurable Settings + +### Hooks + +Event-driven shell commands that run on tool use or lifecycle events: + +- `PreToolUse` runs before a tool executes and can block with exit code 2. +- `PostToolUse` runs after a tool completes. +- `PreCompact` runs before conversation compaction. + +Hook format: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo 'tool: $TOOL_NAME'" + } + ] + } + ] + } +} +``` + +### Permissions + +Tool permission rules: + +```json +{ + "permissions": { + "allow": ["Read", "Grep", "Glob"], + "deny": ["Bash(rm:*)"] + } +} +``` + +### Environment Variables + +```json +{ + "env": { + "MY_VAR": "value" + } +} +``` diff --git a/src/skills/bundled/verify/SKILL.md b/src/skills/bundled/verify/SKILL.md new file mode 100644 index 0000000..e2fcbcb --- /dev/null +++ b/src/skills/bundled/verify/SKILL.md @@ -0,0 +1,28 @@ +--- +name: verify +description: Verify a code change works by running the app and tests. +when_to_use: When the user asks to verify, test, or check that recent changes work. +allowed_tools: read_file, bash, grep_search, glob_search +--- + +Verify that the recent code changes work correctly. + +## Instructions + +1. Identify what was changed by checking `git diff` and `git status`. +2. Determine the appropriate verification strategy: + - Unit tests: run existing tests and check for failures. + - Integration tests: run broader test suites if available. + - Manual verification: start the app/server and test the feature when needed. +3. Run the verification. +4. Report the result clearly: + - PASS: all checks passed and the feature works as expected. + - FAIL: describe what failed and why. + - PARTIAL: some checks passed and some still need attention. + +## Verification Strategy + +- For CLI tools: run the command with test inputs. +- For servers: start the server and make test requests. +- For libraries: run the test suite. +- For config changes: validate that the config loads correctly. diff --git a/tests/test_bundled_skills.py b/tests/test_bundled_skills.py new file mode 100644 index 0000000..0367ac5 --- /dev/null +++ b/tests/test_bundled_skills.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from src.bundled_skills import ( + find_bundled_skill, + format_skills_for_system_prompt, + get_bundled_skills, + load_directory_skills, + load_project_skills, +) +from src.agent_runtime import LocalCodingAgent +from src.agent_types import AgentRuntimeConfig, ModelConfig + + +class BundledSkillsTests(unittest.TestCase): + def test_directory_skills_are_loaded_with_metadata(self) -> None: + skills = {skill.name: skill for skill in get_bundled_skills()} + self.assertIn('verify', skills) + self.assertEqual(skills['verify'].source, 'directory') + self.assertIn('bash', skills['verify'].allowed_tools) + + def test_directory_skill_aliases_are_resolved(self) -> None: + skill = find_bundled_skill('config-help') + self.assertIsNotNone(skill) + assert skill is not None + self.assertEqual(skill.name, 'update-config') + self.assertEqual(skill.source, 'directory') + + def test_directory_skill_prompt_appends_invocation_args(self) -> None: + skill = find_bundled_skill('verify') + self.assertIsNotNone(skill) + assert skill is not None + prompt = skill.get_prompt(None, 'focus: tests') # type: ignore[arg-type] + self.assertIn('Verify that the recent code changes work correctly.', prompt) + self.assertIn('## Invocation Arguments', prompt) + self.assertIn('focus: tests', prompt) + + def test_custom_directory_skill_loader(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + skill_dir = root / 'sample' + skill_dir.mkdir() + (skill_dir / 'SKILL.md').write_text( + ( + '---\n' + 'name: sample\n' + 'description: Sample directory skill.\n' + 'aliases: s1, s2\n' + 'allowed_tools: read_file, write_file\n' + '---\n' + 'Use sample instructions.\n' + ), + encoding='utf-8', + ) + skills = load_directory_skills(root) + self.assertEqual(len(skills), 1) + self.assertEqual(skills[0].name, 'sample') + self.assertEqual(skills[0].aliases, ('s1', 's2')) + self.assertEqual(skills[0].allowed_tools, ('read_file', 'write_file')) + + def test_skills_prompt_includes_directory_skill(self) -> None: + rendered = format_skills_for_system_prompt() + self.assertIn('verify', rendered) + self.assertIn('update-config', rendered) + + def test_project_skills_are_loaded_from_workspace_skills_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + skill_dir = workspace / 'skills' / 'project-skill' + skill_dir.mkdir(parents=True) + (skill_dir / 'SKILL.md').write_text( + ( + '---\n' + 'name: project-skill\n' + 'description: Project-maintained skill.\n' + 'aliases: local-skill\n' + 'allowed_tools: read_file\n' + '---\n' + 'Project skill body.\n' + ), + encoding='utf-8', + ) + skills = get_bundled_skills(workspace) + resolved = find_bundled_skill('local-skill', cwd=workspace) + by_name = {skill.name: skill for skill in skills} + self.assertIn('project-skill', by_name) + self.assertEqual(by_name['project-skill'].source, 'project') + self.assertIsNotNone(resolved) + assert resolved is not None + self.assertEqual(resolved.name, 'project-skill') + + def test_project_skills_override_bundled_directory_skills(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + skill_dir = workspace / 'skills' / 'verify' + skill_dir.mkdir(parents=True) + (skill_dir / 'SKILL.md').write_text( + ( + '---\n' + 'name: verify\n' + 'description: Project override for verify.\n' + '---\n' + 'Project verify body.\n' + ), + encoding='utf-8', + ) + skill = find_bundled_skill('verify', cwd=workspace) + self.assertIsNotNone(skill) + assert skill is not None + self.assertEqual(skill.source, 'project') + self.assertIn('Project verify body.', skill.get_prompt(None, '')) # type: ignore[arg-type] + + def test_load_project_skills_returns_empty_without_skills_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + self.assertEqual(load_project_skills(Path(tmp_dir)), ()) + + def test_agent_skill_tool_executes_project_skill_from_cwd(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + skill_dir = workspace / 'skills' / 'project-run' + skill_dir.mkdir(parents=True) + (skill_dir / 'SKILL.md').write_text( + ( + '---\n' + 'name: project-run\n' + 'description: Project runnable skill.\n' + '---\n' + 'Run project skill body.\n' + ), + encoding='utf-8', + ) + agent = LocalCodingAgent( + model_config=ModelConfig(model='test'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + result = agent._execute_skill({'skill': 'project-run', 'args': 'demo'}) + self.assertTrue(result.ok) + self.assertIn('Run project skill body.', result.content) + self.assertEqual(result.metadata.get('source'), 'project') + self.assertIn('SKILL.md', str(result.metadata.get('skill_path'))) + + +if __name__ == '__main__': + unittest.main()