新增工具: - 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
parent
549cc79dc6
commit
e09c3de91e
@ -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,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; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue