feat: Phase3 新增5个工具 (ListFiles, WebFetch, TodoWrite, Agent, NotebookEdit)
新增工具: - ListFilesTool: 目录列表,支持递归深度、隐藏文件过滤、文件大小显示 - WebFetchTool: HTTP内容获取,HTML→文本转换,大小限制100KB,超时30s - TodoWriteTool: 待办任务管理(add/update/complete/delete/list),内存存储 - AgentTool: 子Agent调用,通过ToolContext工厂方法创建独立AgentLoop - NotebookEditTool: Jupyter notebook编辑(read/insert/replace/delete/move) 配置更新: - AppConfig注册11个工具(原6+新5) - AgentLoop工厂方法注入ToolContext,支持AgentTool创建子Agent 工具总数: 6 → 11 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -43,7 +43,12 @@ public class AppConfig {
|
||||
new FileWriteTool(),
|
||||
new FileEditTool(),
|
||||
new GlobTool(),
|
||||
new GrepTool()
|
||||
new GrepTool(),
|
||||
new ListFilesTool(),
|
||||
new WebFetchTool(),
|
||||
new TodoWriteTool(),
|
||||
new AgentTool(),
|
||||
new NotebookEditTool()
|
||||
);
|
||||
return registry;
|
||||
}
|
||||
@@ -76,7 +81,16 @@ public class AppConfig {
|
||||
@Bean
|
||||
public AgentLoop agentLoop(@Qualifier("anthropicChatModel") ChatModel chatModel, ToolRegistry toolRegistry,
|
||||
ToolContext toolContext, String systemPrompt) {
|
||||
return new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt);
|
||||
AgentLoop mainLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt);
|
||||
|
||||
// 注册子 Agent 工厂到 ToolContext,使 AgentTool 能创建独立的 AgentLoop
|
||||
toolContext.set(AgentTool.AGENT_FACTORY_KEY,
|
||||
(java.util.function.Function<String, String>) prompt -> {
|
||||
AgentLoop subLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt);
|
||||
return subLoop.run(prompt);
|
||||
});
|
||||
|
||||
return mainLoop;
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.claudecode.tool.impl;
|
||||
|
||||
import com.claudecode.tool.Tool;
|
||||
import com.claudecode.tool.ToolContext;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 子 Agent 工具 —— 对应 claude-code/src/tools/agent/AgentTool.ts。
|
||||
* <p>
|
||||
* 创建一个独立的子 Agent 来处理复杂的子任务。子 Agent 拥有独立的消息历史,
|
||||
* 但共享工具集和上下文环境。适用于:
|
||||
* <ul>
|
||||
* <li>需要独立上下文的子任务(如分析另一个文件)</li>
|
||||
* <li>并行处理多个任务</li>
|
||||
* <li>隔离风险操作</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 注意:子 Agent 使用主 Agent 的 ChatModel 和工具集,
|
||||
* 通过 ToolContext 中的 "agentLoop.factory" 获取 AgentLoop 工厂方法。
|
||||
*/
|
||||
public class AgentTool implements Tool {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentTool.class);
|
||||
|
||||
/** ToolContext 中存储 AgentLoop 工厂的键名 */
|
||||
public static final String AGENT_FACTORY_KEY = "__agent_factory__";
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "Agent";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return """
|
||||
Launch a sub-agent to handle a complex task independently. \
|
||||
The sub-agent has its own conversation context but shares tools \
|
||||
and environment. Use this for tasks that require focused attention \
|
||||
or when you want to isolate a subtask. \
|
||||
The sub-agent will execute the given prompt and return its final response.""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String inputSchema() {
|
||||
return """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The task description / prompt for the sub-agent"
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Additional context or instructions (optional)"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(Map<String, Object> input, ToolContext context) {
|
||||
String prompt = (String) input.get("prompt");
|
||||
String additionalContext = (String) input.getOrDefault("context", "");
|
||||
|
||||
if (prompt == null || prompt.isBlank()) {
|
||||
return "Error: 'prompt' is required";
|
||||
}
|
||||
|
||||
// 从 ToolContext 获取 AgentLoop 工厂方法
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.function.Function<String, String> agentFactory =
|
||||
context.getOrDefault(AGENT_FACTORY_KEY, null);
|
||||
|
||||
if (agentFactory == null) {
|
||||
log.warn("AgentTool: 未配置 Agent 工厂,无法创建子 Agent");
|
||||
return "Error: Sub-agent capability is not configured. "
|
||||
+ "The Agent tool requires an agent factory to be registered in the ToolContext.";
|
||||
}
|
||||
|
||||
// 构建完整的子 Agent 提示
|
||||
String fullPrompt = buildSubAgentPrompt(prompt, additionalContext);
|
||||
|
||||
log.info("启动子 Agent,任务: {}", truncate(prompt, 80));
|
||||
|
||||
try {
|
||||
String result = agentFactory.apply(fullPrompt);
|
||||
log.info("子 Agent 完成,结果长度: {} chars", result.length());
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("子 Agent 执行失败", e);
|
||||
return "Error: Sub-agent failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建子 Agent 的完整提示词
|
||||
*/
|
||||
private String buildSubAgentPrompt(String prompt, String additionalContext) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("You are a sub-agent tasked with a specific job. ");
|
||||
sb.append("Complete the following task thoroughly and return your findings/results:\n\n");
|
||||
sb.append("## Task\n");
|
||||
sb.append(prompt);
|
||||
|
||||
if (additionalContext != null && !additionalContext.isBlank()) {
|
||||
sb.append("\n\n## Additional Context\n");
|
||||
sb.append(additionalContext);
|
||||
}
|
||||
|
||||
sb.append("\n\n## Instructions\n");
|
||||
sb.append("- Focus only on the given task\n");
|
||||
sb.append("- Use available tools as needed\n");
|
||||
sb.append("- Provide a clear, concise result\n");
|
||||
sb.append("- If the task cannot be completed, explain why\n");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLen) {
|
||||
if (text.length() <= maxLen) return text;
|
||||
return text.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String activityDescription(Map<String, Object> input) {
|
||||
String prompt = (String) input.getOrDefault("prompt", "");
|
||||
if (prompt.length() > 40) {
|
||||
prompt = prompt.substring(0, 37) + "...";
|
||||
}
|
||||
return "🤖 Sub-agent: " + prompt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.claudecode.tool.impl;
|
||||
|
||||
import com.claudecode.tool.Tool;
|
||||
import com.claudecode.tool.ToolContext;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 目录列表工具 —— 对应 claude-code/src/tools/ls/LsTool.ts。
|
||||
* <p>
|
||||
* 列出指定目录的文件和子目录,支持递归深度控制。
|
||||
* 类似 Unix 的 ls / Windows 的 dir。
|
||||
*/
|
||||
public class ListFilesTool implements Tool {
|
||||
|
||||
private static final int DEFAULT_DEPTH = 1;
|
||||
private static final int MAX_DEPTH = 5;
|
||||
private static final int MAX_ENTRIES = 500;
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "ListFiles";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return """
|
||||
List files and directories in a given path. Returns a structured listing \
|
||||
with file types and sizes. Useful for exploring project structure. \
|
||||
By default lists the immediate contents (depth=1).""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String inputSchema() {
|
||||
return """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to list (default: working directory)"
|
||||
},
|
||||
"depth": {
|
||||
"type": "integer",
|
||||
"description": "Recursion depth (default: 1, max: 5)"
|
||||
},
|
||||
"includeHidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include hidden files/dirs (default: false)"
|
||||
}
|
||||
}
|
||||
}""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(Map<String, Object> input, ToolContext context) {
|
||||
String pathStr = (String) input.getOrDefault("path", ".");
|
||||
int depth = Math.min(
|
||||
input.containsKey("depth") ? ((Number) input.get("depth")).intValue() : DEFAULT_DEPTH,
|
||||
MAX_DEPTH);
|
||||
boolean includeHidden = Boolean.TRUE.equals(input.get("includeHidden"));
|
||||
|
||||
Path baseDir = context.getWorkDir().resolve(pathStr).normalize();
|
||||
|
||||
if (!Files.isDirectory(baseDir)) {
|
||||
return "Error: Not a directory: " + baseDir;
|
||||
}
|
||||
|
||||
try {
|
||||
List<String> entries = new ArrayList<>();
|
||||
listRecursive(baseDir, baseDir, depth, includeHidden, entries);
|
||||
|
||||
if (entries.isEmpty()) {
|
||||
return "Directory is empty: " + pathStr;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Directory: ").append(baseDir).append("\n");
|
||||
sb.append("Entries: ").append(entries.size());
|
||||
if (entries.size() >= MAX_ENTRIES) {
|
||||
sb.append(" (truncated)");
|
||||
}
|
||||
sb.append("\n\n");
|
||||
|
||||
for (String entry : entries) {
|
||||
sb.append(entry).append("\n");
|
||||
}
|
||||
|
||||
return sb.toString().stripTrailing();
|
||||
|
||||
} catch (IOException e) {
|
||||
return "Error listing directory: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归列出目录内容,带缩进和类型标记
|
||||
*/
|
||||
private void listRecursive(Path baseDir, Path currentDir, int remainingDepth,
|
||||
boolean includeHidden, List<String> entries) throws IOException {
|
||||
if (remainingDepth < 0 || entries.size() >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Stream<Path> stream = Files.list(currentDir).sorted()) {
|
||||
List<Path> paths = stream.toList();
|
||||
for (Path path : paths) {
|
||||
if (entries.size() >= MAX_ENTRIES) break;
|
||||
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
// 跳过隐藏文件
|
||||
if (!includeHidden && fileName.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过常见忽略目录
|
||||
if (Files.isDirectory(path) && isIgnoredDir(fileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Path relative = baseDir.relativize(path);
|
||||
String relStr = relative.toString().replace('\\', '/');
|
||||
boolean isDir = Files.isDirectory(path);
|
||||
|
||||
if (isDir) {
|
||||
entries.add("📁 " + relStr + "/");
|
||||
// 递归进入子目录
|
||||
if (remainingDepth > 1) {
|
||||
listRecursive(baseDir, path, remainingDepth - 1, includeHidden, entries);
|
||||
}
|
||||
} else {
|
||||
long size = Files.size(path);
|
||||
entries.add("📄 " + relStr + " (" + formatSize(size) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否为应该忽略的目录 */
|
||||
private boolean isIgnoredDir(String name) {
|
||||
return name.equals("node_modules") || name.equals("target")
|
||||
|| name.equals("build") || name.equals(".git")
|
||||
|| name.equals("__pycache__") || name.equals(".idea")
|
||||
|| name.equals(".vscode");
|
||||
}
|
||||
|
||||
/** 友好的文件大小格式化 */
|
||||
private String formatSize(long bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
|
||||
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
|
||||
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String activityDescription(Map<String, Object> input) {
|
||||
return "📁 Listing " + input.getOrDefault("path", ".");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.claudecode.tool.impl;
|
||||
|
||||
import com.claudecode.tool.Tool;
|
||||
import com.claudecode.tool.ToolContext;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Jupyter Notebook 编辑工具 —— 对应 claude-code/src/tools/NotebookEditTool.ts。
|
||||
* <p>
|
||||
* 操作 .ipynb 文件中的单元格(cell),支持:
|
||||
* <ul>
|
||||
* <li>插入新单元格(code / markdown)</li>
|
||||
* <li>替换已有单元格内容</li>
|
||||
* <li>删除单元格</li>
|
||||
* <li>移动单元格位置</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Notebook 格式遵循 nbformat 4.x 规范。
|
||||
*/
|
||||
public class NotebookEditTool implements Tool {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "NotebookEdit";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return """
|
||||
Edit Jupyter Notebook (.ipynb) cells. Supports insert, replace, delete, \
|
||||
and move operations on notebook cells. Works with nbformat 4.x notebooks. \
|
||||
Use this to modify code cells, add markdown documentation, or restructure notebooks.""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String inputSchema() {
|
||||
return """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the .ipynb notebook file"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["insert", "replace", "delete", "move", "read"],
|
||||
"description": "The operation to perform on the notebook"
|
||||
},
|
||||
"cellIndex": {
|
||||
"type": "integer",
|
||||
"description": "Target cell index (0-based)"
|
||||
},
|
||||
"cellType": {
|
||||
"type": "string",
|
||||
"enum": ["code", "markdown", "raw"],
|
||||
"description": "Type of cell (for insert/replace, default: code)"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Cell content/source code (for insert/replace)"
|
||||
},
|
||||
"targetIndex": {
|
||||
"type": "integer",
|
||||
"description": "Target position for move operation"
|
||||
}
|
||||
},
|
||||
"required": ["path", "operation"]
|
||||
}""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(Map<String, Object> input, ToolContext context) {
|
||||
String pathStr = (String) input.get("path");
|
||||
String operation = (String) input.get("operation");
|
||||
|
||||
if (pathStr == null || operation == null) {
|
||||
return "Error: 'path' and 'operation' are required";
|
||||
}
|
||||
|
||||
Path filePath = context.getWorkDir().resolve(pathStr).normalize();
|
||||
|
||||
return switch (operation) {
|
||||
case "read" -> readNotebook(filePath);
|
||||
case "insert" -> insertCell(filePath, input);
|
||||
case "replace" -> replaceCell(filePath, input);
|
||||
case "delete" -> deleteCell(filePath, input);
|
||||
case "move" -> moveCell(filePath, input);
|
||||
default -> "Error: Unknown operation '" + operation + "'. Use: read, insert, replace, delete, move";
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取 notebook 结构概览 */
|
||||
private String readNotebook(Path filePath) {
|
||||
try {
|
||||
JsonNode root = readNotebookJson(filePath);
|
||||
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||
if (cells == null) {
|
||||
return "Error: Invalid notebook format (no 'cells' array)";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("📓 Notebook: ").append(filePath.getFileName()).append("\n");
|
||||
sb.append("Cells: ").append(cells.size()).append("\n\n");
|
||||
|
||||
for (int i = 0; i < cells.size(); i++) {
|
||||
JsonNode cell = cells.get(i);
|
||||
String cellType = cell.has("cell_type") ? cell.get("cell_type").asText() : "unknown";
|
||||
String source = extractSource(cell);
|
||||
|
||||
sb.append("--- Cell ").append(i).append(" [").append(cellType).append("] ---\n");
|
||||
// 截断过长的内容
|
||||
if (source.length() > 200) {
|
||||
sb.append(source, 0, 200).append("...(truncated)\n");
|
||||
} else {
|
||||
sb.append(source).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString().stripTrailing();
|
||||
|
||||
} catch (IOException e) {
|
||||
return "Error reading notebook: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/** 在指定位置插入新单元格 */
|
||||
private String insertCell(Path filePath, Map<String, Object> input) {
|
||||
int cellIndex = input.containsKey("cellIndex") ? ((Number) input.get("cellIndex")).intValue() : -1;
|
||||
String cellType = (String) input.getOrDefault("cellType", "code");
|
||||
String source = (String) input.get("source");
|
||||
|
||||
if (source == null) {
|
||||
return "Error: 'source' is required for insert operation";
|
||||
}
|
||||
|
||||
try {
|
||||
ObjectNode root = (ObjectNode) readNotebookJson(filePath);
|
||||
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||
if (cells == null) {
|
||||
return "Error: Invalid notebook format";
|
||||
}
|
||||
|
||||
// 创建新单元格
|
||||
ObjectNode newCell = createCell(cellType, source);
|
||||
|
||||
// 插入位置:-1 或超出范围则追加到末尾
|
||||
if (cellIndex < 0 || cellIndex >= cells.size()) {
|
||||
cells.add(newCell);
|
||||
cellIndex = cells.size() - 1;
|
||||
} else {
|
||||
cells.insert(cellIndex, newCell);
|
||||
}
|
||||
|
||||
writeNotebookJson(filePath, root);
|
||||
|
||||
return "✅ Inserted " + cellType + " cell at index " + cellIndex;
|
||||
|
||||
} catch (IOException e) {
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/** 替换指定单元格内容 */
|
||||
private String replaceCell(Path filePath, Map<String, Object> input) {
|
||||
if (!input.containsKey("cellIndex")) {
|
||||
return "Error: 'cellIndex' is required for replace operation";
|
||||
}
|
||||
int cellIndex = ((Number) input.get("cellIndex")).intValue();
|
||||
String cellType = (String) input.get("cellType"); // null 表示保持原类型
|
||||
String source = (String) input.get("source");
|
||||
|
||||
if (source == null) {
|
||||
return "Error: 'source' is required for replace operation";
|
||||
}
|
||||
|
||||
try {
|
||||
ObjectNode root = (ObjectNode) readNotebookJson(filePath);
|
||||
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||
|
||||
if (cellIndex < 0 || cellIndex >= cells.size()) {
|
||||
return "Error: Cell index " + cellIndex + " out of range (0-" + (cells.size() - 1) + ")";
|
||||
}
|
||||
|
||||
if (cellType == null) {
|
||||
cellType = cells.get(cellIndex).get("cell_type").asText();
|
||||
}
|
||||
|
||||
ObjectNode newCell = createCell(cellType, source);
|
||||
cells.set(cellIndex, newCell);
|
||||
|
||||
writeNotebookJson(filePath, root);
|
||||
|
||||
return "✅ Replaced cell " + cellIndex + " [" + cellType + "]";
|
||||
|
||||
} catch (IOException e) {
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除指定单元格 */
|
||||
private String deleteCell(Path filePath, Map<String, Object> input) {
|
||||
if (!input.containsKey("cellIndex")) {
|
||||
return "Error: 'cellIndex' is required for delete operation";
|
||||
}
|
||||
int cellIndex = ((Number) input.get("cellIndex")).intValue();
|
||||
|
||||
try {
|
||||
ObjectNode root = (ObjectNode) readNotebookJson(filePath);
|
||||
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||
|
||||
if (cellIndex < 0 || cellIndex >= cells.size()) {
|
||||
return "Error: Cell index " + cellIndex + " out of range (0-" + (cells.size() - 1) + ")";
|
||||
}
|
||||
|
||||
String cellType = cells.get(cellIndex).get("cell_type").asText();
|
||||
cells.remove(cellIndex);
|
||||
|
||||
writeNotebookJson(filePath, root);
|
||||
|
||||
return "🗑️ Deleted cell " + cellIndex + " [" + cellType + "]. Remaining: " + cells.size() + " cells";
|
||||
|
||||
} catch (IOException e) {
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/** 移动单元格到新位置 */
|
||||
private String moveCell(Path filePath, Map<String, Object> input) {
|
||||
if (!input.containsKey("cellIndex") || !input.containsKey("targetIndex")) {
|
||||
return "Error: 'cellIndex' and 'targetIndex' are required for move operation";
|
||||
}
|
||||
int fromIndex = ((Number) input.get("cellIndex")).intValue();
|
||||
int toIndex = ((Number) input.get("targetIndex")).intValue();
|
||||
|
||||
try {
|
||||
ObjectNode root = (ObjectNode) readNotebookJson(filePath);
|
||||
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||
int size = cells.size();
|
||||
|
||||
if (fromIndex < 0 || fromIndex >= size) {
|
||||
return "Error: Source index " + fromIndex + " out of range";
|
||||
}
|
||||
if (toIndex < 0 || toIndex >= size) {
|
||||
return "Error: Target index " + toIndex + " out of range";
|
||||
}
|
||||
if (fromIndex == toIndex) {
|
||||
return "Cell is already at index " + toIndex;
|
||||
}
|
||||
|
||||
// 取出单元格
|
||||
JsonNode cell = cells.remove(fromIndex);
|
||||
// 插入到新位置
|
||||
cells.insert(toIndex, cell);
|
||||
|
||||
writeNotebookJson(filePath, root);
|
||||
|
||||
return "↕️ Moved cell from " + fromIndex + " to " + toIndex;
|
||||
|
||||
} catch (IOException e) {
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/** 读取 notebook JSON */
|
||||
private JsonNode readNotebookJson(Path filePath) throws IOException {
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found: " + filePath);
|
||||
}
|
||||
String content = Files.readString(filePath, StandardCharsets.UTF_8);
|
||||
return MAPPER.readTree(content);
|
||||
}
|
||||
|
||||
/** 写入 notebook JSON(保持格式化) */
|
||||
private void writeNotebookJson(Path filePath, JsonNode root) throws IOException {
|
||||
String json = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(root);
|
||||
Files.writeString(filePath, json, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** 创建 nbformat 4.x 单元格节点 */
|
||||
private ObjectNode createCell(String cellType, String source) {
|
||||
ObjectNode cell = MAPPER.createObjectNode();
|
||||
cell.put("cell_type", cellType);
|
||||
|
||||
// source 是行数组
|
||||
ArrayNode sourceArray = MAPPER.createArrayNode();
|
||||
String[] lines = source.split("\n", -1);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (i < lines.length - 1) {
|
||||
sourceArray.add(lines[i] + "\n");
|
||||
} else {
|
||||
sourceArray.add(lines[i]);
|
||||
}
|
||||
}
|
||||
cell.set("source", sourceArray);
|
||||
|
||||
// metadata 空对象
|
||||
cell.set("metadata", MAPPER.createObjectNode());
|
||||
|
||||
// code 单元格需要额外字段
|
||||
if ("code".equals(cellType)) {
|
||||
cell.putNull("execution_count");
|
||||
cell.set("outputs", MAPPER.createArrayNode());
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** 从单元格节点提取 source 文本 */
|
||||
private String extractSource(JsonNode cell) {
|
||||
JsonNode source = cell.get("source");
|
||||
if (source == null) return "";
|
||||
|
||||
if (source.isArray()) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (JsonNode line : source) {
|
||||
sb.append(line.asText());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
return source.asText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String activityDescription(Map<String, Object> input) {
|
||||
String op = (String) input.getOrDefault("operation", "editing");
|
||||
String path = (String) input.getOrDefault("path", "notebook");
|
||||
return "📓 Notebook " + op + ": " + path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.claudecode.tool.impl;
|
||||
|
||||
import com.claudecode.tool.Tool;
|
||||
import com.claudecode.tool.ToolContext;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 待办任务工具 —— 对应 claude-code/src/tools/TodoWriteTool。
|
||||
* <p>
|
||||
* 管理 AI 工作过程中的待办事项列表,支持创建、更新、完成和删除任务。
|
||||
* 任务存储在内存中(ToolContext 的共享状态中),生命周期与会话一致。
|
||||
*/
|
||||
public class TodoWriteTool implements Tool {
|
||||
|
||||
private static final String TODOS_KEY = "__todos__";
|
||||
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "TodoWrite";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return """
|
||||
Manage a todo list for tracking tasks during the conversation. \
|
||||
Supports operations: add, update, complete, delete, list. \
|
||||
Use this to track multi-step tasks, record progress, and organize work.""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String inputSchema() {
|
||||
return """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["add", "update", "complete", "delete", "list"],
|
||||
"description": "The operation to perform"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Task ID (required for update/complete/delete)"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Task title (required for add)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Task description (optional)"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "done", "blocked"],
|
||||
"description": "Task status (for update)"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"],
|
||||
"description": "Task priority (default: medium)"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}""";
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public String execute(Map<String, Object> input, ToolContext context) {
|
||||
String operation = (String) input.get("operation");
|
||||
if (operation == null) {
|
||||
return "Error: 'operation' is required";
|
||||
}
|
||||
|
||||
// 从 ToolContext 获取或初始化 todo 列表
|
||||
Map<String, TodoItem> todos = context.getOrDefault(TODOS_KEY, null);
|
||||
if (todos == null) {
|
||||
todos = new ConcurrentHashMap<>();
|
||||
context.set(TODOS_KEY, todos);
|
||||
}
|
||||
|
||||
return switch (operation) {
|
||||
case "add" -> addTodo(input, todos);
|
||||
case "update" -> updateTodo(input, todos);
|
||||
case "complete" -> completeTodo(input, todos);
|
||||
case "delete" -> deleteTodo(input, todos);
|
||||
case "list" -> listTodos(todos);
|
||||
default -> "Error: Unknown operation '" + operation + "'. Use: add, update, complete, delete, list";
|
||||
};
|
||||
}
|
||||
|
||||
private String addTodo(Map<String, Object> input, Map<String, TodoItem> todos) {
|
||||
String title = (String) input.get("title");
|
||||
if (title == null || title.isBlank()) {
|
||||
return "Error: 'title' is required for add operation";
|
||||
}
|
||||
|
||||
String id = generateId();
|
||||
String description = (String) input.getOrDefault("description", "");
|
||||
String priority = (String) input.getOrDefault("priority", "medium");
|
||||
|
||||
TodoItem item = new TodoItem(id, title, description, "pending", priority, LocalDateTime.now());
|
||||
todos.put(id, item);
|
||||
|
||||
return "✅ Task added:\n" + formatItem(item);
|
||||
}
|
||||
|
||||
private String updateTodo(Map<String, Object> input, Map<String, TodoItem> todos) {
|
||||
String id = (String) input.get("id");
|
||||
if (id == null) {
|
||||
return "Error: 'id' is required for update operation";
|
||||
}
|
||||
|
||||
TodoItem item = todos.get(id);
|
||||
if (item == null) {
|
||||
return "Error: Task not found: " + id;
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
String title = (String) input.getOrDefault("title", item.title());
|
||||
String description = (String) input.getOrDefault("description", item.description());
|
||||
String status = (String) input.getOrDefault("status", item.status());
|
||||
String priority = (String) input.getOrDefault("priority", item.priority());
|
||||
|
||||
TodoItem updated = new TodoItem(id, title, description, status, priority, item.createdAt());
|
||||
todos.put(id, updated);
|
||||
|
||||
return "✏️ Task updated:\n" + formatItem(updated);
|
||||
}
|
||||
|
||||
private String completeTodo(Map<String, Object> input, Map<String, TodoItem> todos) {
|
||||
String id = (String) input.get("id");
|
||||
if (id == null) {
|
||||
return "Error: 'id' is required for complete operation";
|
||||
}
|
||||
|
||||
TodoItem item = todos.get(id);
|
||||
if (item == null) {
|
||||
return "Error: Task not found: " + id;
|
||||
}
|
||||
|
||||
TodoItem completed = new TodoItem(id, item.title(), item.description(), "done", item.priority(), item.createdAt());
|
||||
todos.put(id, completed);
|
||||
|
||||
return "✅ Task completed: " + item.title();
|
||||
}
|
||||
|
||||
private String deleteTodo(Map<String, Object> input, Map<String, TodoItem> todos) {
|
||||
String id = (String) input.get("id");
|
||||
if (id == null) {
|
||||
return "Error: 'id' is required for delete operation";
|
||||
}
|
||||
|
||||
TodoItem removed = todos.remove(id);
|
||||
if (removed == null) {
|
||||
return "Error: Task not found: " + id;
|
||||
}
|
||||
|
||||
return "🗑️ Task deleted: " + removed.title();
|
||||
}
|
||||
|
||||
private String listTodos(Map<String, TodoItem> todos) {
|
||||
if (todos.isEmpty()) {
|
||||
return "📋 No tasks. Use 'add' operation to create one.";
|
||||
}
|
||||
|
||||
// 按状态分组,优先级排序
|
||||
Map<String, List<TodoItem>> byStatus = todos.values().stream()
|
||||
.sorted(Comparator.comparingInt(this::priorityOrder))
|
||||
.collect(Collectors.groupingBy(TodoItem::status, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("📋 Task List (").append(todos.size()).append(" tasks)\n");
|
||||
sb.append("━".repeat(40)).append("\n");
|
||||
|
||||
for (Map.Entry<String, List<TodoItem>> entry : byStatus.entrySet()) {
|
||||
String statusIcon = statusIcon(entry.getKey());
|
||||
sb.append("\n").append(statusIcon).append(" ").append(entry.getKey().toUpperCase()).append(":\n");
|
||||
for (TodoItem item : entry.getValue()) {
|
||||
sb.append(formatItem(item)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString().stripTrailing();
|
||||
}
|
||||
|
||||
private String formatItem(TodoItem item) {
|
||||
String priorityIcon = switch (item.priority()) {
|
||||
case "high" -> "🔴";
|
||||
case "medium" -> "🟡";
|
||||
case "low" -> "🟢";
|
||||
default -> "⚪";
|
||||
};
|
||||
return String.format(" %s [%s] %s - %s (%s)",
|
||||
priorityIcon, item.id(), item.title(),
|
||||
item.description().isEmpty() ? "(no description)" : item.description(),
|
||||
item.createdAt().format(FMT));
|
||||
}
|
||||
|
||||
private int priorityOrder(TodoItem item) {
|
||||
return switch (item.priority()) {
|
||||
case "high" -> 0;
|
||||
case "medium" -> 1;
|
||||
case "low" -> 2;
|
||||
default -> 3;
|
||||
};
|
||||
}
|
||||
|
||||
private String statusIcon(String status) {
|
||||
return switch (status) {
|
||||
case "pending" -> "⏳";
|
||||
case "in_progress" -> "🔄";
|
||||
case "done" -> "✅";
|
||||
case "blocked" -> "🚫";
|
||||
default -> "❓";
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成短 ID(4 位十六进制) */
|
||||
private String generateId() {
|
||||
return UUID.randomUUID().toString().substring(0, 8);
|
||||
}
|
||||
|
||||
/** 不可变任务数据记录 */
|
||||
record TodoItem(String id, String title, String description, String status,
|
||||
String priority, LocalDateTime createdAt) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String activityDescription(Map<String, Object> input) {
|
||||
String op = (String) input.getOrDefault("operation", "managing");
|
||||
return "📋 Todo: " + op;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.claudecode.tool.impl;
|
||||
|
||||
import com.claudecode.tool.Tool;
|
||||
import com.claudecode.tool.ToolContext;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 网页获取工具 —— 对应 claude-code/src/tools/WebFetchTool。
|
||||
* <p>
|
||||
* 使用 HTTP GET 获取指定 URL 的内容,自动将 HTML 简化为纯文本。
|
||||
* 支持大小限制、超时控制和基本的 HTML→文本转换。
|
||||
*/
|
||||
public class WebFetchTool implements Tool {
|
||||
|
||||
/** 最大响应体大小:100KB */
|
||||
private static final int MAX_BODY_SIZE = 100 * 1024;
|
||||
|
||||
/** HTTP 请求超时 */
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(30);
|
||||
|
||||
/** User-Agent 标识 */
|
||||
private static final String USER_AGENT = "ClaudeCode-Java/0.1 (WebFetchTool)";
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "WebFetch";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return """
|
||||
Fetch the content of a URL. Returns the page content as text. \
|
||||
HTML pages are automatically simplified to readable text. \
|
||||
Useful for reading documentation, API responses, or web pages. \
|
||||
Has a 100KB size limit and 30s timeout.""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String inputSchema() {
|
||||
return """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to fetch (must start with http:// or https://)"
|
||||
},
|
||||
"maxLength": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of characters to return (default: 50000)"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}""";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(Map<String, Object> input, ToolContext context) {
|
||||
String url = (String) input.get("url");
|
||||
int maxLength = input.containsKey("maxLength")
|
||||
? ((Number) input.get("maxLength")).intValue()
|
||||
: 50000;
|
||||
|
||||
// URL 校验
|
||||
if (url == null || url.isBlank()) {
|
||||
return "Error: URL is required";
|
||||
}
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return "Error: URL must start with http:// or https://";
|
||||
}
|
||||
|
||||
try {
|
||||
URI uri = URI.create(url);
|
||||
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(TIMEOUT)
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/json,text/plain,*/*")
|
||||
.timeout(TIMEOUT)
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
int statusCode = response.statusCode();
|
||||
String body = response.body();
|
||||
|
||||
if (statusCode >= 400) {
|
||||
return "Error: HTTP " + statusCode + "\n" + truncate(body, 2000);
|
||||
}
|
||||
|
||||
// 检查大小限制
|
||||
if (body.length() > MAX_BODY_SIZE) {
|
||||
body = body.substring(0, MAX_BODY_SIZE);
|
||||
}
|
||||
|
||||
// 根据内容类型处理
|
||||
String contentType = response.headers().firstValue("Content-Type").orElse("text/plain");
|
||||
|
||||
String result;
|
||||
if (contentType.contains("text/html") || contentType.contains("application/xhtml")) {
|
||||
result = htmlToText(body);
|
||||
} else {
|
||||
result = body;
|
||||
}
|
||||
|
||||
// 截断到最大长度
|
||||
result = truncate(result, maxLength);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("URL: ").append(url).append("\n");
|
||||
sb.append("Status: ").append(statusCode).append("\n");
|
||||
sb.append("Content-Type: ").append(contentType).append("\n");
|
||||
sb.append("---\n");
|
||||
sb.append(result);
|
||||
|
||||
return sb.toString();
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "Error: Invalid URL: " + e.getMessage();
|
||||
} catch (java.net.http.HttpTimeoutException e) {
|
||||
return "Error: Request timed out after " + TIMEOUT.toSeconds() + " seconds";
|
||||
} catch (Exception e) {
|
||||
return "Error fetching URL: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 HTML → 纯文本转换。
|
||||
* 移除脚本/样式块,转换常见标签为文本格式。
|
||||
*/
|
||||
private String htmlToText(String html) {
|
||||
// 移除 script 和 style 块
|
||||
String text = html.replaceAll("(?is)<script[^>]*>.*?</script>", "");
|
||||
text = text.replaceAll("(?is)<style[^>]*>.*?</style>", "");
|
||||
|
||||
// 移除 HTML 注释
|
||||
text = text.replaceAll("(?s)<!--.*?-->", "");
|
||||
|
||||
// 将块级元素转为换行
|
||||
text = text.replaceAll("(?i)<br\\s*/?>", "\n");
|
||||
text = text.replaceAll("(?i)</(p|div|h[1-6]|li|tr|blockquote|pre)>", "\n");
|
||||
text = text.replaceAll("(?i)<(p|div|h[1-6]|li|tr|blockquote|pre)[^>]*>", "\n");
|
||||
|
||||
// 将链接转为 [text](url) 格式
|
||||
Pattern linkPattern = Pattern.compile("<a[^>]*href=[\"']([^\"']*)[\"'][^>]*>(.*?)</a>", Pattern.CASE_INSENSITIVE);
|
||||
Matcher linkMatcher = linkPattern.matcher(text);
|
||||
text = linkMatcher.replaceAll("[$2]($1)");
|
||||
|
||||
// 移除所有剩余 HTML 标签
|
||||
text = text.replaceAll("<[^>]+>", "");
|
||||
|
||||
// 解码常见 HTML 实体
|
||||
text = text.replace("&", "&");
|
||||
text = text.replace("<", "<");
|
||||
text = text.replace(">", ">");
|
||||
text = text.replace(""", "\"");
|
||||
text = text.replace("'", "'");
|
||||
text = text.replace(" ", " ");
|
||||
// 数字实体
|
||||
java.util.regex.Pattern numEntity = java.util.regex.Pattern.compile("&#(\\d+);");
|
||||
java.util.regex.Matcher numMatcher = numEntity.matcher(text);
|
||||
text = numMatcher.replaceAll(mr -> {
|
||||
try {
|
||||
return String.valueOf((char) Integer.parseInt(mr.group(1)));
|
||||
} catch (Exception e) {
|
||||
return mr.group();
|
||||
}
|
||||
});
|
||||
|
||||
// 压缩多余空行(3个以上连续空行压缩为2个)
|
||||
text = text.replaceAll("\\n{3,}", "\n\n");
|
||||
// 压缩行内多余空格
|
||||
text = text.replaceAll("[ \\t]+", " ");
|
||||
|
||||
return text.strip();
|
||||
}
|
||||
|
||||
/** 截断文本到指定长度 */
|
||||
private String truncate(String text, int maxLength) {
|
||||
if (text.length() <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "\n...[truncated at " + maxLength + " chars]";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String activityDescription(Map<String, Object> input) {
|
||||
String url = (String) input.getOrDefault("url", "");
|
||||
// 截断过长的 URL
|
||||
if (url.length() > 50) {
|
||||
url = url.substring(0, 47) + "...";
|
||||
}
|
||||
return "🌐 Fetching " + url;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user