mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
299 lines
14 KiB
Java
299 lines
14 KiB
Java
package vip.mate.tool.builtin;
|
|
|
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.ai.chat.model.ToolContext;
|
|
import org.springframework.ai.tool.annotation.Tool;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.context.annotation.Lazy;
|
|
import org.springframework.lang.Nullable;
|
|
import org.springframework.stereotype.Component;
|
|
import vip.mate.agent.context.AgentWorkspaceResolver;
|
|
import vip.mate.agent.context.ChatOrigin;
|
|
import vip.mate.llm.routing.AgentBindingResolver;
|
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
|
import vip.mate.skill.runtime.SkillScriptExecutionService;
|
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
|
import vip.mate.skill.secret.SkillSecretService;
|
|
import vip.mate.tool.document.GeneratedFileCache;
|
|
import vip.mate.tool.document.WorkspaceArtifactSurfacer;
|
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
|
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
|
|
/**
|
|
* Built-in tool: execute LLM-generated source code inline.
|
|
* <p>
|
|
* Unlike {@code runSkillScript}, which runs a pre-existing file under a skill's
|
|
* {@code scripts/} directory, this tool accepts the code as text and runs it on
|
|
* the fly. It lets a documentation-only skill (a SKILL.md with no {@code scripts:}
|
|
* entries) be acted on: the agent reads the instructions, writes the code those
|
|
* instructions describe, and runs it here.
|
|
*
|
|
* <p>Safety:
|
|
* <ul>
|
|
* <li>Dangerous patterns in the code trigger ToolGuard approval/blocking — the
|
|
* tool name is registered as a shell-equivalent guarded tool.</li>
|
|
* <li>The subprocess does not inherit the server's secret env vars; only a
|
|
* bound skill's own declared secrets are injected.</li>
|
|
* <li>When {@code skillName} is given, the calling agent must be bound to that
|
|
* skill, and execution is scoped to the skill directory.</li>
|
|
* <li>Timeout defaults to 30s, hard-capped at 300s; output is truncated.</li>
|
|
* </ul>
|
|
*/
|
|
@Slf4j
|
|
@Component
|
|
@RequiredArgsConstructor
|
|
public class CodeExecuteTool {
|
|
|
|
private final SkillRuntimeService runtimeService;
|
|
private final AgentWorkspaceResolver workspaceResolver;
|
|
private final SkillScriptExecutionService executionService;
|
|
private final SkillSecretService skillSecretService;
|
|
private final ObjectMapper objectMapper;
|
|
private final GeneratedFileCache generatedFileCache;
|
|
|
|
@Autowired
|
|
private vip.mate.tool.sandbox.MemberSandboxExecutor memberSandboxExecutor;
|
|
|
|
@Lazy
|
|
@Autowired
|
|
private AgentBindingResolver agentBindingResolver;
|
|
|
|
@vip.mate.tool.ConcurrencyUnsafe("code execution can have arbitrary side effects on the host process and filesystem")
|
|
@Tool(name = "execute_code", description = """
|
|
Execute a snippet of code you write, in python, bash, or node.
|
|
Use this to act on a skill whose SKILL.md describes steps but ships no runnable script:
|
|
read the instructions, write the code they describe, and run it here.
|
|
|
|
Parameters:
|
|
- language: one of "python", "bash", "node"
|
|
- code: the full source code to run
|
|
- skillName: optional. When set, the code runs inside that skill's directory
|
|
(so it can read the skill's reference/template files by relative path)
|
|
and the skill's stored secrets are injected as environment variables.
|
|
- args: optional positional arguments, given as ONE JSON-encoded string:
|
|
a JSON array for multiple args, or plain text for a single argument.
|
|
- timeoutSeconds: optional, default 30, max 300.
|
|
|
|
Returns: JSON with exitCode, stdout, stderr, and (when the run wrote files)
|
|
a generatedFiles array of [name](url) download links. When present, echo
|
|
those links in your reply so the user can download the files you produced.
|
|
|
|
Security: dangerous operations trigger security approval. The server's own
|
|
secret environment variables are not exposed to the code.
|
|
""")
|
|
public String execute_code(
|
|
@JsonProperty(required = true)
|
|
@JsonPropertyDescription("Language: python, bash, or node")
|
|
String language,
|
|
|
|
@JsonProperty(required = true)
|
|
@JsonPropertyDescription("The full source code to run")
|
|
String code,
|
|
|
|
@JsonProperty(required = false)
|
|
@JsonPropertyDescription("Optional skill name to scope execution to and inject secrets from")
|
|
String skillName,
|
|
|
|
@JsonProperty(required = false)
|
|
@JsonPropertyDescription("Optional positional arguments as ONE JSON-encoded string: a JSON array for multiple args, or plain text for one literal argument.")
|
|
String args,
|
|
|
|
@JsonProperty(required = false)
|
|
@JsonPropertyDescription("Timeout in seconds, default 30, max 300")
|
|
Integer timeoutSeconds,
|
|
|
|
@Nullable ToolContext ctx
|
|
) {
|
|
log.info("[CodeExecute] language={}, skill={}, codeChars={}",
|
|
language, skillName, code == null ? 0 : code.length());
|
|
|
|
if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) {
|
|
return executeIsolated(language, code, skillName, args, timeoutSeconds, ctx);
|
|
}
|
|
Path workingDir;
|
|
Map<String, String> envVars = Collections.emptyMap();
|
|
|
|
if (skillName != null && !skillName.isBlank()) {
|
|
// Skill-scoped run: validate binding + resolve the skill directory,
|
|
// scoped to the conversation's workspace (+ builtin/global).
|
|
ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx)));
|
|
if (skill == null) {
|
|
return formatError("Skill '" + skillName + "' not found or not enabled", ctx);
|
|
}
|
|
Long agentId = ChatOrigin.from(ctx).agentId();
|
|
if (agentId != null) {
|
|
Set<Long> boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId);
|
|
if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) {
|
|
return formatError("Skill '" + skillName + "' is not available for this agent.", ctx);
|
|
}
|
|
}
|
|
// Directory-backed skills run inside their own directory so the code
|
|
// can read the skill's reference/template files by relative path. A
|
|
// database-backed skill (no directory) is still runnable: fall through
|
|
// with a null working dir so executeCode uses a private scratch dir.
|
|
// Either way the skill's stored secrets are injected.
|
|
workingDir = skill.getSkillDir();
|
|
if (skill.getId() != null) {
|
|
envVars = skillSecretService.getDecrypted(skill.getId());
|
|
}
|
|
} else {
|
|
// Workspace-scoped run: prefer the agent's workspace base path so any
|
|
// files the code produces land where the user expects. When the agent
|
|
// has no workspace dir, pass null — executeCode then runs in a private
|
|
// temp scratch dir (same tolerance as the shell tool).
|
|
workingDir = WorkspacePathGuard.getWorkingDirectory(ctx);
|
|
if (workingDir != null && !Files.isDirectory(workingDir)) {
|
|
workingDir = null;
|
|
}
|
|
}
|
|
|
|
Long timeout = timeoutSeconds != null ? timeoutSeconds.longValue() : null;
|
|
List<String> argList = normalizeArgs(args);
|
|
|
|
long runStart = System.currentTimeMillis();
|
|
try {
|
|
SkillScriptExecutionService.ScriptResult result =
|
|
executionService.executeCode(language, code, workingDir, argList, envVars, timeout);
|
|
// Surface any files the run wrote as one-click downloads so the user can
|
|
// grab generated artifacts (xlsx / csv / images / …) without the model
|
|
// having to call send_file or echo a server path.
|
|
recordOutcome(ctx, result.getExitCode(), false);
|
|
List<String> fileLinks = WorkspaceArtifactSurfacer.collect(generatedFileCache, workingDir, runStart, ctx);
|
|
return formatResult(result, fileLinks);
|
|
} catch (Exception e) {
|
|
log.error("[CodeExecute] Execution failed: {}", e.getMessage());
|
|
return formatError("Execution failed: " + e.getMessage(), ctx);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode the JSON-encoded {@code args} string into a positional argument list,
|
|
* mirroring {@code SkillScriptTool.normalizeArgs}: a JSON array becomes one
|
|
* argument per element, anything else is forwarded verbatim as a single
|
|
* argument (so a bare date / version string is never mangled by JSON parsing).
|
|
*/
|
|
private String executeIsolated(String language, String code, String skillName, String args,
|
|
Integer timeoutSeconds, ToolContext ctx) {
|
|
if (skillName != null && !skillName.isBlank()) return formatError("Shared skill execution is unavailable in member isolation mode", ctx);
|
|
try {
|
|
var scoped = vip.mate.workspace.core.service.MemberFileIsolation.scope(ChatOrigin.from(ctx));
|
|
Path root = Path.of(scoped.workspaceBasePath());
|
|
long since = System.currentTimeMillis();
|
|
var output = memberSandboxExecutor.executeCode(language, code, root, normalizeArgs(args), Map.of(),
|
|
timeoutSeconds == null || timeoutSeconds <= 0 ? 30 : timeoutSeconds);
|
|
recordOutcome(ctx, output.exitCode(), output.timedOut());
|
|
var json = objectMapper.readTree(formatResult(new SkillScriptExecutionService.ScriptResult(
|
|
output.exitCode(), output.stdout(), output.stderr()),
|
|
WorkspaceArtifactSurfacer.collect(generatedFileCache, root, since, scoped.toToolContext())));
|
|
((com.fasterxml.jackson.databind.node.ObjectNode) json).put("timedOut", output.timedOut());
|
|
return objectMapper.writeValueAsString(json);
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
return formatError("Isolated execution cancelled", ctx);
|
|
} catch (Exception e) {
|
|
return formatError("Isolated execution failed: " + e.getMessage(), ctx);
|
|
}
|
|
}
|
|
|
|
List<String> normalizeArgs(String args) {
|
|
if (args == null) {
|
|
return null;
|
|
}
|
|
String trimmed = args.trim();
|
|
if (trimmed.isEmpty()) {
|
|
return null;
|
|
}
|
|
char lead = trimmed.charAt(0);
|
|
if (lead == '[') {
|
|
try {
|
|
JsonNode node = objectMapper.reader()
|
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
|
.readTree(trimmed);
|
|
if (node != null && node.isArray()) {
|
|
List<String> out = new ArrayList<>(node.size());
|
|
for (JsonNode el : node) {
|
|
out.add(el.isTextual() ? el.asText() : el.toString());
|
|
}
|
|
return out.isEmpty() ? null : out;
|
|
}
|
|
} catch (Exception e) {
|
|
log.debug("execute_code: args not valid JSON array, forwarding verbatim: {}", e.getMessage());
|
|
}
|
|
}
|
|
return List.of(trimmed);
|
|
}
|
|
|
|
String formatResult(SkillScriptExecutionService.ScriptResult result, List<String> fileLinks) {
|
|
// generatedFiles carries [name](url) markdown so the chat layer surfaces the
|
|
// artifacts as one-click downloads, and the model can echo them to the user.
|
|
// It's a single JSON *string* (links joined by newlines), not an array — a
|
|
// JSON array's own '[' sits adjacent to the markdown '[' and the link-
|
|
// extraction regex would then capture '"[name' as the filename.
|
|
String filesField = (fileLinks == null || fileLinks.isEmpty()) ? "" :
|
|
",\n \"generatedFiles\": " + jsonEscape(String.join("\n", fileLinks));
|
|
return String.format(
|
|
"{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s%s\n}",
|
|
result.getExitCode(),
|
|
jsonEscape(result.getStdout()),
|
|
jsonEscape(result.getStderr()),
|
|
filesField
|
|
);
|
|
}
|
|
|
|
private void recordOutcome(ToolContext context, int exitCode, boolean timedOut) {
|
|
if (Thread.currentThread().isInterrupted()) {
|
|
recordFailure(context, "Code execution cancelled");
|
|
return;
|
|
}
|
|
var sink = vip.mate.execution.evidence.service.ExecutionObservationSink.from(context);
|
|
if (sink != null) {
|
|
if (exitCode >= 0 || timedOut) sink.command(exitCode >= 0 ? exitCode : null, timedOut, false, false);
|
|
else sink.toolFailure(); // -1 can also represent validation/setup failure, not an observed process exit.
|
|
}
|
|
if (exitCode != 0 || timedOut)
|
|
recordFailure(context, timedOut ? "Code execution timed out" : "Code execution failed (exit code " + exitCode + ")");
|
|
}
|
|
|
|
private void recordFailure(ToolContext context, String message) {
|
|
var capture = vip.mate.tool.mcp.runtime.McpToolResultCapture.from(context);
|
|
if (capture != null) capture.recordFailure(message);
|
|
var sink = vip.mate.execution.evidence.service.ExecutionObservationSink.from(context);
|
|
if (sink != null) {
|
|
if (Thread.currentThread().isInterrupted()) sink.toolCancelled();
|
|
else sink.toolFailure();
|
|
}
|
|
}
|
|
|
|
private String formatError(String message, ToolContext context) {
|
|
recordFailure(context, message);
|
|
return String.format(
|
|
"{\n \"exitCode\": -1,\n \"stdout\": \"\",\n \"stderr\": %s\n}",
|
|
jsonEscape(message)
|
|
);
|
|
}
|
|
|
|
private String jsonEscape(String str) {
|
|
if (str == null || str.isEmpty()) {
|
|
return "\"\"";
|
|
}
|
|
return "\"" + str.replace("\\", "\\\\")
|
|
.replace("\"", "\\\"")
|
|
.replace("\n", "\\n")
|
|
.replace("\r", "\\r")
|
|
.replace("\t", "\\t") + "\"";
|
|
}
|
|
}
|