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>
pull/1/head
liuzh 1 month ago
parent 549cc79dc6
commit e09c3de91e
  1. 18
      src/main/java/com/claudecode/config/AppConfig.java
  2. 137
      src/main/java/com/claudecode/tool/impl/AgentTool.java
  3. 169
      src/main/java/com/claudecode/tool/impl/ListFilesTool.java
  4. 343
      src/main/java/com/claudecode/tool/impl/NotebookEditTool.java
  5. 241
      src/main/java/com/claudecode/tool/impl/TodoWriteTool.java
  6. 211
      src/main/java/com/claudecode/tool/impl/WebFetchTool.java

@ -43,7 +43,12 @@ public class AppConfig {
new FileWriteTool(), new FileWriteTool(),
new FileEditTool(), new FileEditTool(),
new GlobTool(), new GlobTool(),
new GrepTool() new GrepTool(),
new ListFilesTool(),
new WebFetchTool(),
new TodoWriteTool(),
new AgentTool(),
new NotebookEditTool()
); );
return registry; return registry;
} }
@ -76,7 +81,16 @@ public class AppConfig {
@Bean @Bean
public AgentLoop agentLoop(@Qualifier("anthropicChatModel") ChatModel chatModel, ToolRegistry toolRegistry, public AgentLoop agentLoop(@Qualifier("anthropicChatModel") ChatModel chatModel, ToolRegistry toolRegistry,
ToolContext toolContext, String systemPrompt) { 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 @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("&amp;", "&");
text = text.replace("&lt;", "<");
text = text.replace("&gt;", ">");
text = text.replace("&quot;", "\"");
text = text.replace("&apos;", "'");
text = text.replace("&nbsp;", " ");
// 数字实体
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;
}
}
Loading…
Cancel
Save