@iinm/plain-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.config/agents.library/code-simplifier.md +5 -0
- package/.config/agents.library/qa-engineer.md +74 -0
- package/.config/agents.library/software-architect.md +278 -0
- package/.config/agents.predefined/worker.md +3 -0
- package/.config/config.predefined.json +825 -0
- package/.config/prompts.library/code-review.md +8 -0
- package/.config/prompts.library/feature-dev.md +6 -0
- package/.config/prompts.predefined/shortcuts/commit-by-user.md +9 -0
- package/.config/prompts.predefined/shortcuts/commit.md +10 -0
- package/.config/prompts.predefined/shortcuts/general-question.md +6 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/bin/plain +3 -0
- package/bin/plain-interrupt +6 -0
- package/bin/plain-notify-desktop +19 -0
- package/bin/plain-notify-terminal-bell +3 -0
- package/package.json +57 -0
- package/sandbox/bin/plain-sandbox +972 -0
- package/src/agent.d.ts +48 -0
- package/src/agent.mjs +159 -0
- package/src/agentLoop.mjs +369 -0
- package/src/agentState.mjs +41 -0
- package/src/cliArgs.mjs +45 -0
- package/src/cliFormatter.mjs +217 -0
- package/src/cliInteractive.mjs +739 -0
- package/src/config.d.ts +48 -0
- package/src/config.mjs +168 -0
- package/src/context/consumeInterruptMessage.mjs +30 -0
- package/src/context/loadAgentRoles.mjs +272 -0
- package/src/context/loadPrompts.mjs +312 -0
- package/src/context/loadUserMessageContext.mjs +147 -0
- package/src/env.mjs +46 -0
- package/src/main.mjs +202 -0
- package/src/mcp.mjs +202 -0
- package/src/model.d.ts +109 -0
- package/src/modelCaller.mjs +29 -0
- package/src/modelDefinition.d.ts +73 -0
- package/src/prompt.mjs +128 -0
- package/src/providers/anthropic.d.ts +248 -0
- package/src/providers/anthropic.mjs +596 -0
- package/src/providers/gemini.d.ts +208 -0
- package/src/providers/gemini.mjs +752 -0
- package/src/providers/openai.d.ts +281 -0
- package/src/providers/openai.mjs +551 -0
- package/src/providers/openaiCompatible.d.ts +147 -0
- package/src/providers/openaiCompatible.mjs +658 -0
- package/src/providers/platform/azure.mjs +42 -0
- package/src/providers/platform/bedrock.mjs +74 -0
- package/src/providers/platform/googleCloud.mjs +34 -0
- package/src/subagent.mjs +247 -0
- package/src/tmpfile.mjs +27 -0
- package/src/tool.d.ts +74 -0
- package/src/toolExecutor.mjs +236 -0
- package/src/toolInputValidator.mjs +183 -0
- package/src/toolUseApprover.mjs +98 -0
- package/src/tools/askGoogle.mjs +135 -0
- package/src/tools/delegateToSubagent.d.ts +4 -0
- package/src/tools/delegateToSubagent.mjs +48 -0
- package/src/tools/execCommand.d.ts +22 -0
- package/src/tools/execCommand.mjs +200 -0
- package/src/tools/fetchWebPage.mjs +96 -0
- package/src/tools/patchFile.d.ts +4 -0
- package/src/tools/patchFile.mjs +96 -0
- package/src/tools/reportAsSubagent.d.ts +3 -0
- package/src/tools/reportAsSubagent.mjs +44 -0
- package/src/tools/tavilySearch.d.ts +6 -0
- package/src/tools/tavilySearch.mjs +57 -0
- package/src/tools/tmuxCommand.d.ts +14 -0
- package/src/tools/tmuxCommand.mjs +194 -0
- package/src/tools/writeFile.d.ts +4 -0
- package/src/tools/writeFile.mjs +56 -0
- package/src/utils/evalJSONConfig.mjs +48 -0
- package/src/utils/matchValue.d.ts +6 -0
- package/src/utils/matchValue.mjs +40 -0
- package/src/utils/noThrow.mjs +31 -0
- package/src/utils/notify.mjs +28 -0
- package/src/utils/parseFileRange.mjs +18 -0
- package/src/utils/readFileRange.mjs +33 -0
- package/src/utils/retryOnError.mjs +41 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
AGENT_MEMORY_DIR,
|
|
6
|
+
AGENT_TMP_DIR,
|
|
7
|
+
CLAUDE_CODE_PLUGIN_DIR,
|
|
8
|
+
} from "./env.mjs";
|
|
9
|
+
import { noThrowSync } from "./utils/noThrow.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {unknown} input
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isSafeToolInput(input) {
|
|
16
|
+
if (["number", "boolean", "undefined"].includes(typeof input)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (typeof input === "string") {
|
|
21
|
+
return isSafeToolInputItem(input);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (Array.isArray(input)) {
|
|
25
|
+
return input.every((item) => isSafeToolInput(item));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (typeof input === "object") {
|
|
29
|
+
if (input === null) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return Object.values(input).every((value) => isSafeToolInput(value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} arg
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isSafeToolInputItem(arg) {
|
|
43
|
+
const workingDir = process.cwd();
|
|
44
|
+
|
|
45
|
+
// Note: An argument can be a command option (e.g., '-l').
|
|
46
|
+
// It will then create an absolute path like `/path/to/project/-l`.
|
|
47
|
+
const absPath = path.resolve(arg);
|
|
48
|
+
|
|
49
|
+
const realPath = resolveRealPath(absPath, workingDir);
|
|
50
|
+
if (!realPath) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Disallow paths outside the working directory (WITHOUT EXCEPTION)
|
|
55
|
+
if (!isInsideWorkingDirectory(realPath, workingDir)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Disallow any input that contains ".." as a path segment (directory traversal)
|
|
60
|
+
// Example:
|
|
61
|
+
// - When write_file is allowed for ^safe-dir/.+
|
|
62
|
+
// - "safe-dir/../unsafe-path" should be disallowed
|
|
63
|
+
if (arg.split(path.sep).includes("..")) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Allow safe path even if git-ignored.
|
|
68
|
+
if (isSafePath(realPath)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Deny git ignored files (which may contain sensitive information or should not be accessed)
|
|
73
|
+
return !isGitIgnored(realPath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} absPath
|
|
78
|
+
* @param {string} workingDir
|
|
79
|
+
* @returns {string | null}
|
|
80
|
+
*/
|
|
81
|
+
function resolveRealPath(absPath, workingDir) {
|
|
82
|
+
const realPathResult = noThrowSync(() => fs.realpathSync(absPath));
|
|
83
|
+
if (!(realPathResult instanceof Error)) {
|
|
84
|
+
return realPathResult;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// realpathSync can fail if the path (or its target) doesn't exist.
|
|
88
|
+
// Manually follow symlink chain for broken links to ensure they don't point outside.
|
|
89
|
+
let currentPath = absPath;
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const MAX_SYMLINK_DEPTH = 10;
|
|
92
|
+
|
|
93
|
+
for (let depth = 0; depth < MAX_SYMLINK_DEPTH; depth++) {
|
|
94
|
+
if (seen.has(currentPath)) {
|
|
95
|
+
return null; // Circular link
|
|
96
|
+
}
|
|
97
|
+
seen.add(currentPath);
|
|
98
|
+
|
|
99
|
+
// Check if the current path is a symbolic link.
|
|
100
|
+
const lstats = noThrowSync(() => fs.lstatSync(currentPath));
|
|
101
|
+
if (lstats instanceof Error || !lstats.isSymbolicLink()) {
|
|
102
|
+
break; // Not a symlink or doesn't exist; stop traversal.
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Read the target path the symlink points to.
|
|
106
|
+
const target = noThrowSync(() => fs.readlinkSync(currentPath));
|
|
107
|
+
if (typeof target !== "string") {
|
|
108
|
+
break; // Failed to read the link; stop traversal.
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
currentPath = path.resolve(path.dirname(currentPath), target);
|
|
112
|
+
|
|
113
|
+
// If at any point it goes outside, we stop and use this path for the check.
|
|
114
|
+
if (!isInsideWorkingDirectory(currentPath, workingDir)) {
|
|
115
|
+
return currentPath;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (seen.size >= MAX_SYMLINK_DEPTH) {
|
|
120
|
+
return null; // Too deep
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return currentPath;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} targetPath
|
|
128
|
+
* @param {string} workingDir
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
function isInsideWorkingDirectory(targetPath, workingDir) {
|
|
132
|
+
return (
|
|
133
|
+
targetPath === workingDir ||
|
|
134
|
+
targetPath.startsWith(`${workingDir}${path.sep}`)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} targetPath
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
function isSafePath(targetPath) {
|
|
143
|
+
const safePaths = [AGENT_MEMORY_DIR, AGENT_TMP_DIR, CLAUDE_CODE_PLUGIN_DIR];
|
|
144
|
+
|
|
145
|
+
for (const safePath of safePaths) {
|
|
146
|
+
const safeAbsPath = path.resolve(safePath);
|
|
147
|
+
if (
|
|
148
|
+
targetPath === safeAbsPath ||
|
|
149
|
+
targetPath.startsWith(`${safeAbsPath}${path.sep}`)
|
|
150
|
+
) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} absPath
|
|
160
|
+
* @returns {boolean}
|
|
161
|
+
*/
|
|
162
|
+
function isGitIgnored(absPath) {
|
|
163
|
+
try {
|
|
164
|
+
execFileSync("git", ["check-ignore", "--no-index", "-q", absPath], {
|
|
165
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
166
|
+
});
|
|
167
|
+
// The path is ignored (exit code 0)
|
|
168
|
+
return true;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (
|
|
171
|
+
error instanceof Error &&
|
|
172
|
+
"status" in error &&
|
|
173
|
+
typeof error.status === "number" &&
|
|
174
|
+
error.status === 1
|
|
175
|
+
) {
|
|
176
|
+
// Path is not ignored
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
// Other errors (e.g., status 128 if not a git repo or other git error)
|
|
180
|
+
// We treat this as "effectively ignored" to be safe.
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { ToolUseApprover, ToolUseApproverConfig, ToolUseDecision, ToolUsePattern } from './tool'
|
|
3
|
+
* @import { MessageContentToolUse } from './model'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isSafeToolInput } from "./toolInputValidator.mjs";
|
|
7
|
+
import { matchValue } from "./utils/matchValue.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {ToolUseApproverConfig} config
|
|
11
|
+
* @returns {ToolUseApprover}
|
|
12
|
+
*/
|
|
13
|
+
export function createToolUseApprover({
|
|
14
|
+
patterns,
|
|
15
|
+
maxApprovals: max,
|
|
16
|
+
defaultAction,
|
|
17
|
+
maskApprovalInput,
|
|
18
|
+
}) {
|
|
19
|
+
const state = {
|
|
20
|
+
approvalCount: 0,
|
|
21
|
+
/** @type {ToolUsePattern[]} */
|
|
22
|
+
allowedToolUseInSession: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** @returns {void} */
|
|
26
|
+
function resetApprovalCount() {
|
|
27
|
+
state.approvalCount = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {MessageContentToolUse} toolUse
|
|
32
|
+
* @returns {ToolUseDecision}
|
|
33
|
+
*/
|
|
34
|
+
function isAllowedToolUse(toolUse) {
|
|
35
|
+
const toolUseToMatch = {
|
|
36
|
+
toolName: toolUse.toolName,
|
|
37
|
+
input: toolUse.input,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const pattern of [...patterns, ...state.allowedToolUseInSession]) {
|
|
41
|
+
const patternToMatch = {
|
|
42
|
+
toolName: pattern.toolName,
|
|
43
|
+
...(pattern.input !== undefined && { input: pattern.input }),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (!matchValue(toolUseToMatch, patternToMatch)) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const action = pattern.action ?? defaultAction;
|
|
51
|
+
|
|
52
|
+
if (!["allow", "deny", "ask"].includes(action)) {
|
|
53
|
+
return {
|
|
54
|
+
action: "ask",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (action === "deny") {
|
|
59
|
+
return {
|
|
60
|
+
action: "deny",
|
|
61
|
+
reason: pattern.reason,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (action === "allow") {
|
|
66
|
+
const maskedInput = maskApprovalInput(toolUse.toolName, toolUse.input);
|
|
67
|
+
if (isSafeToolInput(maskedInput)) {
|
|
68
|
+
state.approvalCount += 1;
|
|
69
|
+
return state.approvalCount <= max
|
|
70
|
+
? { action: "allow" }
|
|
71
|
+
: { action: "ask" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { action };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { action: defaultAction };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {MessageContentToolUse} toolUse
|
|
83
|
+
* @returns {void}
|
|
84
|
+
*/
|
|
85
|
+
function allowToolUse(toolUse) {
|
|
86
|
+
state.allowedToolUseInSession.push({
|
|
87
|
+
toolName: toolUse.toolName,
|
|
88
|
+
input: maskApprovalInput(toolUse.toolName, toolUse.input),
|
|
89
|
+
action: "allow",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
isAllowedToolUse,
|
|
95
|
+
allowToolUse,
|
|
96
|
+
resetApprovalCount,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { getGoogleCloudAccessToken } from "../providers/platform/googleCloud.mjs";
|
|
7
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} AskGoogleToolOptions
|
|
11
|
+
* @property {"vertex-ai"=} platform
|
|
12
|
+
* @property {string=} baseURL
|
|
13
|
+
* @property {string=} apiKey - API key for Google AI Studio
|
|
14
|
+
* @property {string=} account - The Google Cloud account to use for Vertex AI
|
|
15
|
+
* @property {string=} model
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} AskGoogleInput
|
|
20
|
+
* @property {string} question
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {AskGoogleToolOptions} config
|
|
25
|
+
* @returns {Tool}
|
|
26
|
+
*/
|
|
27
|
+
export function createAskGoogleTool(config) {
|
|
28
|
+
/**
|
|
29
|
+
* @param {AskGoogleInput} input
|
|
30
|
+
* @param {number} retryCount
|
|
31
|
+
* @returns {Promise<string | Error>}
|
|
32
|
+
*/
|
|
33
|
+
async function askGoogle(input, retryCount = 0) {
|
|
34
|
+
const model = config.model ?? "gemini-3-flash-preview";
|
|
35
|
+
const url =
|
|
36
|
+
config.platform === "vertex-ai" && config.baseURL
|
|
37
|
+
? `${config.baseURL}/publishers/google/models/${model}:generateContent`
|
|
38
|
+
: config.baseURL
|
|
39
|
+
? `${config.baseURL}/models/${model}:generateContent`
|
|
40
|
+
: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
41
|
+
|
|
42
|
+
/** @type {Record<string,string>} */
|
|
43
|
+
const authHeader =
|
|
44
|
+
config.platform === "vertex-ai"
|
|
45
|
+
? {
|
|
46
|
+
Authorization: `Bearer ${await getGoogleCloudAccessToken(config.account)}`,
|
|
47
|
+
}
|
|
48
|
+
: {
|
|
49
|
+
"x-goog-api-key": config.apiKey ?? "",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const data = {
|
|
53
|
+
contents: [
|
|
54
|
+
{
|
|
55
|
+
role: "user",
|
|
56
|
+
parts: [
|
|
57
|
+
{
|
|
58
|
+
text: `I need a comprehensive answer to this question. Please note that I don't have access to external URLs, so include all relevant facts, data, or explanations directly in your response. Avoid referencing links I can't open.
|
|
59
|
+
|
|
60
|
+
Question: ${input.question}`,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
tools: [
|
|
66
|
+
{
|
|
67
|
+
google_search: {},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const response = await fetch(url, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
...authHeader,
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(data),
|
|
79
|
+
signal: AbortSignal.timeout(120 * 1000),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (response.status === 429 || response.status >= 500) {
|
|
83
|
+
const interval = Math.min(2 * 2 ** retryCount, 16);
|
|
84
|
+
console.error(
|
|
85
|
+
styleText(
|
|
86
|
+
"yellow",
|
|
87
|
+
`Google API returned ${response.status}. Retrying in ${interval} seconds...`,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
91
|
+
return askGoogle(input, retryCount + 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
return new Error(
|
|
96
|
+
`Failed to ask Google: status=${response.status}, body=${await response.text()}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const body = await response.json();
|
|
101
|
+
|
|
102
|
+
const answer = body.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
103
|
+
|
|
104
|
+
if (typeof answer !== "string") {
|
|
105
|
+
return new Error(
|
|
106
|
+
`Unexpected response format from Google: ${JSON.stringify(body)}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return answer;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
def: {
|
|
115
|
+
name: "ask_google",
|
|
116
|
+
description: "Ask Google a question using natural language",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: {
|
|
120
|
+
question: {
|
|
121
|
+
type: "string",
|
|
122
|
+
description: "The question to ask Google",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
required: ["question"],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {AskGoogleInput} input
|
|
131
|
+
* @returns {Promise<string | Error>}
|
|
132
|
+
*/
|
|
133
|
+
impl: async (input) => await noThrow(async () => askGoogle(input, 0)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool, ToolImplementation } from '../tool'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const delegateToSubagentToolName = "delegate_to_subagent";
|
|
6
|
+
|
|
7
|
+
/** @returns {Tool} */
|
|
8
|
+
export function createDelegateToSubagentTool() {
|
|
9
|
+
/** @type {ToolImplementation} */
|
|
10
|
+
let impl = async () => {
|
|
11
|
+
throw new Error("Not implemented");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** @type {Tool} */
|
|
15
|
+
const tool = {
|
|
16
|
+
def: {
|
|
17
|
+
name: delegateToSubagentToolName,
|
|
18
|
+
description:
|
|
19
|
+
"Delegate a subtask to a subagent. You inherit the current context and work on the delegated goal.",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
name: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description:
|
|
26
|
+
"Role or name of the subagent. Use 'custom:' prefix for ad-hoc roles.",
|
|
27
|
+
},
|
|
28
|
+
goal: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "The goal or task for the subagent to achieve.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
required: ["name", "goal"],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Implementation will be injected by the agent to access its state
|
|
38
|
+
get impl() {
|
|
39
|
+
return impl;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
injectImpl(fn) {
|
|
43
|
+
impl = fn;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return tool;
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ExecCommandInput = {
|
|
2
|
+
command: string;
|
|
3
|
+
args?: string[];
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type ExecCommandConfig = {
|
|
7
|
+
sandbox?: ExecCommandSanboxConfig;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ExecCommandSanboxConfig = {
|
|
11
|
+
command: string;
|
|
12
|
+
args?: string[];
|
|
13
|
+
separator?: string;
|
|
14
|
+
rules?: {
|
|
15
|
+
pattern: {
|
|
16
|
+
command: string;
|
|
17
|
+
args?: string[];
|
|
18
|
+
};
|
|
19
|
+
mode: "sandbox" | "unsandboxed";
|
|
20
|
+
additionalArgs?: string[];
|
|
21
|
+
}[];
|
|
22
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
* @import { ExecCommandConfig, ExecCommandInput, ExecCommandSanboxConfig } from './execCommand'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { writeTmpFile } from "../tmpfile.mjs";
|
|
8
|
+
import { matchValue } from "../utils/matchValue.mjs";
|
|
9
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
10
|
+
|
|
11
|
+
const OUTPUT_MAX_LENGTH = 1024 * 8;
|
|
12
|
+
const OUTPUT_TRUNCATED_LENGTH = 1024 * 2;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {ExecCommandConfig=} config
|
|
16
|
+
* @returns {Tool}
|
|
17
|
+
*/
|
|
18
|
+
export function createExecCommandTool(config) {
|
|
19
|
+
/** @type {Tool} */
|
|
20
|
+
return {
|
|
21
|
+
def: {
|
|
22
|
+
name: "exec_command",
|
|
23
|
+
description: "Run a command without shell interpretation.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
command: {
|
|
28
|
+
description: "The executable name or path. e.g., rg",
|
|
29
|
+
type: "string",
|
|
30
|
+
},
|
|
31
|
+
args: {
|
|
32
|
+
// Gemini 3 flashが command: rg, args: [rg, ...] のようにargsにコマンドを含めることがある
|
|
33
|
+
description:
|
|
34
|
+
"Array of arguments to pass to the command. Do not include the command name itself in this array.",
|
|
35
|
+
type: "array",
|
|
36
|
+
items: {
|
|
37
|
+
type: "string",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
required: ["command"],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
validateInput: (input) => {
|
|
46
|
+
if (typeof input.command !== "string") {
|
|
47
|
+
return new Error("command must be a string");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Example: fd<arg_key>args</arg_key><arg_value>[... (GLM-5)
|
|
51
|
+
if (input.command.match(/[<>]/)) {
|
|
52
|
+
return new Error(
|
|
53
|
+
`invalid tool use format: command=${JSON.stringify(input.command)}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (input.command.startsWith("-")) {
|
|
58
|
+
return new Error("command must not start with '-'");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (input.args && !Array.isArray(input.args)) {
|
|
62
|
+
return new Error("args must be an array of strings");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {ExecCommandInput} input
|
|
70
|
+
* @returns {Promise<string | Error>}
|
|
71
|
+
*/
|
|
72
|
+
impl: async (input) =>
|
|
73
|
+
await noThrow(async () => {
|
|
74
|
+
const { command, args } = config?.sandbox
|
|
75
|
+
? rewriteInputForSandbox(input, config.sandbox)
|
|
76
|
+
: input;
|
|
77
|
+
return new Promise((resolve, _reject) => {
|
|
78
|
+
const child = execFile(
|
|
79
|
+
command,
|
|
80
|
+
args,
|
|
81
|
+
{
|
|
82
|
+
shell: false,
|
|
83
|
+
env: {
|
|
84
|
+
PWD: process.env.PWD,
|
|
85
|
+
PATH: process.env.PATH,
|
|
86
|
+
HOME: process.env.HOME,
|
|
87
|
+
},
|
|
88
|
+
timeout: 5 * 60 * 1000,
|
|
89
|
+
},
|
|
90
|
+
async (err, stdout, stderr) => {
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} content
|
|
93
|
+
* @param {string} type
|
|
94
|
+
* @returns {Promise<string>}
|
|
95
|
+
*/
|
|
96
|
+
const formatOutput = async (content, type) => {
|
|
97
|
+
if (content.length <= OUTPUT_MAX_LENGTH) {
|
|
98
|
+
return content;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let fileExtension = "txt";
|
|
102
|
+
try {
|
|
103
|
+
JSON.parse(content);
|
|
104
|
+
fileExtension = "json";
|
|
105
|
+
} catch {
|
|
106
|
+
// not JSON
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const prefix = `exec_command-${type}`;
|
|
110
|
+
const filePath = await writeTmpFile(
|
|
111
|
+
content,
|
|
112
|
+
prefix,
|
|
113
|
+
fileExtension,
|
|
114
|
+
);
|
|
115
|
+
const lineCount = content.split("\n").length;
|
|
116
|
+
|
|
117
|
+
const head = content.slice(0, OUTPUT_TRUNCATED_LENGTH);
|
|
118
|
+
const tail = content.slice(
|
|
119
|
+
Math.max(content.length - OUTPUT_TRUNCATED_LENGTH, 0),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
`Content is too large (${content.length} characters, ${lineCount} lines). Saved to ${filePath}.`,
|
|
124
|
+
`<truncated_output part="start" length="${OUTPUT_TRUNCATED_LENGTH}" total_length="${content.length}">\n${head}\n</truncated_output>`,
|
|
125
|
+
`<truncated_output part="end" length="${OUTPUT_TRUNCATED_LENGTH}" total_length="${content.length}">\n${tail}</truncated_output>\n`,
|
|
126
|
+
].join("\n\n");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const stdoutOrMessage = await formatOutput(stdout, "stdout");
|
|
130
|
+
const stderrOrMessage = await formatOutput(stderr, "stderr");
|
|
131
|
+
|
|
132
|
+
const result = [
|
|
133
|
+
stdoutOrMessage
|
|
134
|
+
? `<stdout>\n${stdoutOrMessage}</stdout>`
|
|
135
|
+
: "<stdout></stdout>",
|
|
136
|
+
"",
|
|
137
|
+
stderrOrMessage
|
|
138
|
+
? `<stderr>\n${stderrOrMessage}</stderr>`
|
|
139
|
+
: "<stderr></stderr>",
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
if (err) {
|
|
143
|
+
// rg: 何もマッチしない場合は exit status != 0 になるので無視
|
|
144
|
+
const ignoreError = [command, ...(args || [])].includes("rg");
|
|
145
|
+
if (!ignoreError) {
|
|
146
|
+
// err.message が長過ぎる場合は先頭を表示
|
|
147
|
+
const errMessageTruncated = err.message.slice(
|
|
148
|
+
0,
|
|
149
|
+
OUTPUT_TRUNCATED_LENGTH,
|
|
150
|
+
);
|
|
151
|
+
const isErrMessageTruncated =
|
|
152
|
+
err.message.length > OUTPUT_MAX_LENGTH;
|
|
153
|
+
result.push(
|
|
154
|
+
`\n<error>\n${err.name}: ${errMessageTruncated}${isErrMessageTruncated ? "... (Message truncated)" : ""}</error>`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return resolve(result.join("\n"));
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
child.stdin?.end();
|
|
162
|
+
});
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {ExecCommandInput} input
|
|
169
|
+
* @param {ExecCommandSanboxConfig} sandbox
|
|
170
|
+
* @returns {ExecCommandInput}
|
|
171
|
+
*/
|
|
172
|
+
function rewriteInputForSandbox(input, sandbox) {
|
|
173
|
+
const matchedRule = (sandbox.rules || []).find((rule) =>
|
|
174
|
+
matchValue(input, rule.pattern),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (matchedRule?.mode === "unsandboxed") {
|
|
178
|
+
return input;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const args = [
|
|
182
|
+
...(sandbox.args || []),
|
|
183
|
+
...(matchedRule?.additionalArgs || []),
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
if (sandbox.separator) {
|
|
187
|
+
args.push(sandbox.separator);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
args.push(input.command);
|
|
191
|
+
|
|
192
|
+
if (input.args) {
|
|
193
|
+
args.push(...input.args);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
command: sandbox.command,
|
|
198
|
+
args,
|
|
199
|
+
};
|
|
200
|
+
}
|