@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,74 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {ReadableStreamDefaultReader<Uint8Array>} reader
|
|
5
|
+
*/
|
|
6
|
+
export async function* readBedrockStreamEvents(reader) {
|
|
7
|
+
let buffer = new Uint8Array();
|
|
8
|
+
|
|
9
|
+
while (true) {
|
|
10
|
+
const { done, value } = await reader.read();
|
|
11
|
+
if (done) {
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const nextBuffer = new Uint8Array(buffer.length + value.length);
|
|
16
|
+
nextBuffer.set(buffer);
|
|
17
|
+
nextBuffer.set(value, buffer.length);
|
|
18
|
+
buffer = nextBuffer;
|
|
19
|
+
|
|
20
|
+
// AWS event stream format
|
|
21
|
+
// https://github.com/awslabs/aws-c-event-stream/blob/main/docs/images/encoding.png
|
|
22
|
+
while (buffer.length >= 12) {
|
|
23
|
+
const view = new DataView(
|
|
24
|
+
buffer.buffer,
|
|
25
|
+
buffer.byteOffset,
|
|
26
|
+
buffer.byteLength,
|
|
27
|
+
);
|
|
28
|
+
const totalLength = view.getUint32(0);
|
|
29
|
+
const headersLength = view.getUint32(4);
|
|
30
|
+
|
|
31
|
+
if (buffer.length < totalLength) {
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const payloadOffset = 12 + headersLength;
|
|
36
|
+
// prelude 12 bytes + CRC 4 bytes = 16
|
|
37
|
+
const payloadLength = totalLength - headersLength - 16;
|
|
38
|
+
const payloadRaw = buffer.slice(
|
|
39
|
+
payloadOffset,
|
|
40
|
+
payloadOffset + payloadLength,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const payloadDecoded = new TextDecoder().decode(payloadRaw);
|
|
44
|
+
try {
|
|
45
|
+
const payloadParsed = JSON.parse(payloadDecoded);
|
|
46
|
+
if (payloadParsed.bytes) {
|
|
47
|
+
const event = Buffer.from(payloadParsed.bytes, "base64").toString(
|
|
48
|
+
"utf-8",
|
|
49
|
+
);
|
|
50
|
+
const eventParsed = JSON.parse(event);
|
|
51
|
+
yield eventParsed;
|
|
52
|
+
} else if (payloadParsed.message) {
|
|
53
|
+
console.error(
|
|
54
|
+
styleText(
|
|
55
|
+
"yellow",
|
|
56
|
+
`Bedrock message received: ${JSON.stringify(payloadParsed.message)}`,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err instanceof Error) {
|
|
62
|
+
console.error(
|
|
63
|
+
styleText(
|
|
64
|
+
"red",
|
|
65
|
+
`Error decoding payload: ${err.message}\nPayload: ${payloadDecoded}`,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
buffer = buffer.slice(totalLength);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string=} account
|
|
5
|
+
* @returns {Promise<string>}
|
|
6
|
+
*/
|
|
7
|
+
export async function getGoogleCloudAccessToken(account) {
|
|
8
|
+
const accountOption = account?.endsWith("iam.gserviceaccount.com")
|
|
9
|
+
? ["--impersonate-service-account", account]
|
|
10
|
+
: account
|
|
11
|
+
? [account]
|
|
12
|
+
: [];
|
|
13
|
+
|
|
14
|
+
/** @type {string} */
|
|
15
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
16
|
+
execFile(
|
|
17
|
+
"gcloud",
|
|
18
|
+
["auth", "print-access-token", ...accountOption],
|
|
19
|
+
{
|
|
20
|
+
shell: false,
|
|
21
|
+
timeout: 10 * 1000,
|
|
22
|
+
},
|
|
23
|
+
(error, stdout, _stderr) => {
|
|
24
|
+
if (error) {
|
|
25
|
+
reject(error);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
resolve(stdout.trim());
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return stdout;
|
|
34
|
+
}
|
package/src/subagent.mjs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Message, MessageContentToolResult, MessageContentToolUse } from "./model"
|
|
3
|
+
* @import { ReportAsSubagentInput } from "./tools/reportAsSubagent"
|
|
4
|
+
* @import { AgentRole } from "./context/loadAgentRoles.mjs"
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { AGENT_PROJECT_METADATA_DIR } from "./env.mjs";
|
|
10
|
+
import { reportAsSubagentToolName } from "./tools/reportAsSubagent.mjs";
|
|
11
|
+
|
|
12
|
+
/** @typedef {ReturnType<typeof createSubagentManager>} SubagentManager */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} SubagentStateEventHandlers
|
|
16
|
+
* @property {(subagent: {name:string} | null) => void} onSubagentSwitched
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates a manager for subagent lifecycle and state.
|
|
21
|
+
* @param {Map<string, AgentRole>} agentRoles
|
|
22
|
+
* @param {SubagentStateEventHandlers} handlers
|
|
23
|
+
*/
|
|
24
|
+
export function createSubagentManager(agentRoles, handlers) {
|
|
25
|
+
/** @type {{name: string; goal: string; delegationMessageIndex: number}[]} */
|
|
26
|
+
const subagents = [];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {DelegateSuccess | DelegateFailure} DelegateResult
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} DelegateSuccess
|
|
34
|
+
* @property {true} success
|
|
35
|
+
* @property {string} value
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} DelegateFailure
|
|
40
|
+
* @property {false} success
|
|
41
|
+
* @property {string} error
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Delegate a task to a subagent.
|
|
46
|
+
* @param {string} name
|
|
47
|
+
* @param {string} goal
|
|
48
|
+
* @param {number} delegationMessageIndex
|
|
49
|
+
* @returns {DelegateResult}
|
|
50
|
+
*/
|
|
51
|
+
function delegateToSubagent(name, goal, delegationMessageIndex) {
|
|
52
|
+
if (subagents.length > 0) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
error:
|
|
56
|
+
"Cannot call delegate_to_subagent while already acting as a subagent.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isCustomRole = name.startsWith("custom:");
|
|
61
|
+
const actualName = isCustomRole ? name.substring(7) : name;
|
|
62
|
+
|
|
63
|
+
let roleContent = "";
|
|
64
|
+
if (!isCustomRole) {
|
|
65
|
+
const role = agentRoles.get(name);
|
|
66
|
+
if (!role) {
|
|
67
|
+
const availableRoles = Array.from(agentRoles.keys())
|
|
68
|
+
.sort()
|
|
69
|
+
.map((id) => ` - ${id}`)
|
|
70
|
+
.join("\n");
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
error: `Agent role "${name}" not found. Available agent roles:\n${availableRoles}\n\nTo use an ad-hoc role, prefix the name with "custom:" (e.g., "custom:researcher").`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
roleContent = role.content;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
subagents.push({
|
|
80
|
+
name: actualName,
|
|
81
|
+
goal,
|
|
82
|
+
delegationMessageIndex,
|
|
83
|
+
});
|
|
84
|
+
handlers.onSubagentSwitched({ name: actualName });
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
success: true,
|
|
88
|
+
value: [
|
|
89
|
+
`✓ Delegation successful. You are now the subagent "${actualName}".`,
|
|
90
|
+
`Your goal: ${goal}`,
|
|
91
|
+
`Role: ${actualName}\n---\n${roleContent}\n---`,
|
|
92
|
+
`Memory file path format: ${AGENT_PROJECT_METADATA_DIR}/memory/<session-id>--${actualName}--<kebab-case-title>.md (Replace <kebab-case-title> to match the parent task)`,
|
|
93
|
+
`Start working on this goal now. When finished, call "report_as_subagent" with the memory file path.`,
|
|
94
|
+
].join("\n\n"),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {ReportSuccess | ReportFailure} ReportResult
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {Object} ReportSuccess
|
|
104
|
+
* @property {true} success
|
|
105
|
+
* @property {string} memoryContent
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @typedef {Object} ReportFailure
|
|
110
|
+
* @property {false} success
|
|
111
|
+
* @property {string} error
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Report as a subagent and read the memory file.
|
|
116
|
+
* @param {string} memoryPath
|
|
117
|
+
* @returns {Promise<ReportResult>}
|
|
118
|
+
*/
|
|
119
|
+
async function reportAsSubagent(memoryPath) {
|
|
120
|
+
if (subagents.length === 0) {
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
error: "Cannot call report_as_subagent from the main agent.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const absolutePath = path.resolve(memoryPath);
|
|
128
|
+
const memoryDir = path.resolve(AGENT_PROJECT_METADATA_DIR, "memory");
|
|
129
|
+
const relativePath = path.relative(memoryDir, absolutePath);
|
|
130
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
131
|
+
return {
|
|
132
|
+
success: false,
|
|
133
|
+
error: `Access denied: memoryPath must be within ${AGENT_PROJECT_METADATA_DIR}/memory`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const memoryContent = await fs.readFile(absolutePath, {
|
|
139
|
+
encoding: "utf-8",
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
success: true,
|
|
143
|
+
memoryContent: memoryContent,
|
|
144
|
+
};
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return {
|
|
147
|
+
success: false,
|
|
148
|
+
error: `Failed to read memory file: ${error instanceof Error ? error.message : String(error)}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Process tool results and update state based on special tools.
|
|
155
|
+
* Returns the truncated message history and a new message to add.
|
|
156
|
+
* @param {MessageContentToolUse[]} toolUseParts
|
|
157
|
+
* @param {MessageContentToolResult[]} toolResults
|
|
158
|
+
* @param {Message[]} messages
|
|
159
|
+
* @returns {{ messages: Message[], newMessage: Message | null }}
|
|
160
|
+
* - messages: The potentially truncated message history (new array)
|
|
161
|
+
* - newMessage: The user message to add, or null if tool results should be added directly
|
|
162
|
+
*/
|
|
163
|
+
function processToolResults(toolUseParts, toolResults, messages) {
|
|
164
|
+
const reportSubagentToolUse = toolUseParts.find(
|
|
165
|
+
(toolUse) => toolUse.toolName === reportAsSubagentToolName,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (reportSubagentToolUse) {
|
|
169
|
+
const reportResult = toolResults.find(
|
|
170
|
+
(res) => res.toolUseId === reportSubagentToolUse.toolUseId,
|
|
171
|
+
);
|
|
172
|
+
if (!reportResult) {
|
|
173
|
+
return { messages, newMessage: null };
|
|
174
|
+
}
|
|
175
|
+
return handleSubagentReport(
|
|
176
|
+
reportSubagentToolUse,
|
|
177
|
+
reportResult,
|
|
178
|
+
messages,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { messages, newMessage: null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Handle the result of a subagent reporting back.
|
|
187
|
+
* On success, truncates conversation history back to the delegation point
|
|
188
|
+
* and converts the report into a standard user message.
|
|
189
|
+
* @param {MessageContentToolUse} reportToolUse
|
|
190
|
+
* @param {MessageContentToolResult} reportResult
|
|
191
|
+
* @param {Message[]} messages
|
|
192
|
+
* @returns {{ messages: Message[], newMessage: Message | null }}
|
|
193
|
+
* - messages: The truncated message history (new array)
|
|
194
|
+
* - newMessage: The user message to add, or null if not handled
|
|
195
|
+
*/
|
|
196
|
+
function handleSubagentReport(reportToolUse, reportResult, messages) {
|
|
197
|
+
if (reportResult.isError) {
|
|
198
|
+
return { messages, newMessage: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const currentSubagent = subagents.pop();
|
|
202
|
+
if (!currentSubagent) {
|
|
203
|
+
return { messages, newMessage: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
handlers.onSubagentSwitched(subagents.at(-1) ?? null);
|
|
207
|
+
|
|
208
|
+
// Truncate history back to the delegation point
|
|
209
|
+
const truncatedMessages = messages.slice(
|
|
210
|
+
0,
|
|
211
|
+
currentSubagent.delegationMessageIndex,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Convert the tool result into a standard user message
|
|
215
|
+
const resultText = reportResult.content
|
|
216
|
+
.map((c) => (c.type === "text" ? c.text : ""))
|
|
217
|
+
.join("\n\n");
|
|
218
|
+
|
|
219
|
+
const reportInput = /** @type {ReportAsSubagentInput} */ (
|
|
220
|
+
reportToolUse.input
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
/** @type {import('./model').UserMessage} */
|
|
224
|
+
const newMessage = {
|
|
225
|
+
role: "user",
|
|
226
|
+
content: [
|
|
227
|
+
{
|
|
228
|
+
type: "text",
|
|
229
|
+
text: [
|
|
230
|
+
`The subagent "${currentSubagent.name}" has completed the task.`,
|
|
231
|
+
`Goal: ${currentSubagent.goal}`,
|
|
232
|
+
`Memory file: ${reportInput.memoryPath}`,
|
|
233
|
+
`Result:\n${resultText}`,
|
|
234
|
+
].join("\n\n"),
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
return { messages: truncatedMessages, newMessage };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
delegateToSubagent,
|
|
244
|
+
reportAsSubagent,
|
|
245
|
+
processToolResults,
|
|
246
|
+
};
|
|
247
|
+
}
|
package/src/tmpfile.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AGENT_TMP_DIR } from "./env.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Write content to a temporary file and return the file path
|
|
7
|
+
* @param {string} content - Content to write
|
|
8
|
+
* @param {string} name - File name (e.g., "read_web_page")
|
|
9
|
+
* @param {string} extension - File extension (e.g., "md", "txt")
|
|
10
|
+
* @returns {Promise<string>} Path to the created temporary file
|
|
11
|
+
*/
|
|
12
|
+
export async function writeTmpFile(content, name, extension = "txt") {
|
|
13
|
+
const timestamp = new Date()
|
|
14
|
+
.toISOString()
|
|
15
|
+
.slice(0, 19)
|
|
16
|
+
.replace("T", "-")
|
|
17
|
+
.replace(/:/g, "");
|
|
18
|
+
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
19
|
+
|
|
20
|
+
const fileName = `${timestamp}-${randomSuffix}--${name}.${extension}`;
|
|
21
|
+
const filePath = path.join(AGENT_TMP_DIR, fileName);
|
|
22
|
+
|
|
23
|
+
await fs.mkdir(AGENT_TMP_DIR, { recursive: true });
|
|
24
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
25
|
+
|
|
26
|
+
return filePath;
|
|
27
|
+
}
|
package/src/tool.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { MessageContentToolUse } from "./model";
|
|
2
|
+
|
|
3
|
+
export type Tool = {
|
|
4
|
+
def: ToolDefinition;
|
|
5
|
+
impl: ToolImplementation;
|
|
6
|
+
validateInput?: (input: Record<string, unknown>) => Error | undefined;
|
|
7
|
+
maskApprovalInput?: (
|
|
8
|
+
input: Record<string, unknown>,
|
|
9
|
+
) => Record<string, unknown>;
|
|
10
|
+
injectImpl?: (impl: ToolImplementation) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ToolDefinition = {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
inputSchema: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ToolImplementation = (
|
|
20
|
+
input: Record,
|
|
21
|
+
) => Promise<string | StructuredToolResultContent[] | Error>;
|
|
22
|
+
|
|
23
|
+
export type StructuredToolResultContent =
|
|
24
|
+
| {
|
|
25
|
+
type: "text";
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
type: "image";
|
|
30
|
+
// base64 encoded
|
|
31
|
+
data: string;
|
|
32
|
+
// e.g., image/jpeg
|
|
33
|
+
mimeType: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ToolUseApproverConfig = {
|
|
37
|
+
patterns: ToolUsePattern[];
|
|
38
|
+
maxApprovals: number;
|
|
39
|
+
defaultAction: "deny" | "ask";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Mask the input before auto-approval checks and recording.
|
|
43
|
+
* Return a redacted object (e.g., keep only necessary fields) that will be used for:
|
|
44
|
+
* - safety validation via isSafeToolInput
|
|
45
|
+
* - storing per-session allowed tool-use patterns
|
|
46
|
+
*/
|
|
47
|
+
maskApprovalInput: (
|
|
48
|
+
toolName: string,
|
|
49
|
+
input: Record<string, unknown>,
|
|
50
|
+
) => Record<string, unknown>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ToolUseDecision = {
|
|
54
|
+
action: "allow" | "deny" | "ask";
|
|
55
|
+
reason?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type ToolUseApprover = {
|
|
59
|
+
isAllowedToolUse: (toolUse: MessageContentToolUse) => ToolUseDecision;
|
|
60
|
+
allowToolUse: (toolUse: MessageContentToolUse) => void;
|
|
61
|
+
resetApprovalCount: () => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type ToolUsePattern = {
|
|
65
|
+
toolName: ValuePattern;
|
|
66
|
+
input?: ObjectPattern;
|
|
67
|
+
action?: "allow" | "deny" | "ask";
|
|
68
|
+
reason?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type ToolUse = {
|
|
72
|
+
toolName: string;
|
|
73
|
+
input: Record<string, unknown>;
|
|
74
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { MessageContentToolResult, MessageContentToolUse } from "./model"
|
|
3
|
+
* @import { Tool } from "./tool"
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {ReturnType<typeof createToolExecutor>} ToolExecutor
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} ToolExecutorOptions
|
|
12
|
+
* @property {string[]} [exclusiveToolNames] - Tool names that must be called exclusively
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a tool executor that handles tool validation, execution, and error handling
|
|
17
|
+
* @param {Map<string, Tool>} toolByName - Map of tool names to tool implementations
|
|
18
|
+
* @param {ToolExecutorOptions} [options] - Configuration options
|
|
19
|
+
*/
|
|
20
|
+
export function createToolExecutor(toolByName, options = {}) {
|
|
21
|
+
const { exclusiveToolNames = [] } = options;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {ValidationSuccess | ValidationFailure} ValidationResult
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} ValidationSuccess
|
|
29
|
+
* @property {true} isValid
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} ValidationFailure
|
|
34
|
+
* @property {false} isValid
|
|
35
|
+
* @property {string} errorMessage
|
|
36
|
+
* @property {MessageContentToolResult[]} toolResults
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate all tool uses (tool existence, input validation, exclusive tool check)
|
|
41
|
+
* @param {MessageContentToolUse[]} toolUseParts - Tool uses to validate
|
|
42
|
+
* @returns {ValidationResult}
|
|
43
|
+
*/
|
|
44
|
+
function validateBatch(toolUseParts) {
|
|
45
|
+
// Tool existence + Input validation
|
|
46
|
+
/** @type {{index: number, message: string}[]} */
|
|
47
|
+
const errors = [];
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < toolUseParts.length; i++) {
|
|
50
|
+
const toolUse = toolUseParts[i];
|
|
51
|
+
const tool = toolByName.get(toolUse.toolName);
|
|
52
|
+
if (!tool) {
|
|
53
|
+
errors.push({
|
|
54
|
+
index: i,
|
|
55
|
+
message: `Tool not found: ${toolUse.toolName}`,
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (tool.validateInput) {
|
|
61
|
+
const result = tool.validateInput(toolUse.input);
|
|
62
|
+
if (result instanceof Error) {
|
|
63
|
+
errors.push({ index: i, message: result.message });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (errors.length > 0) {
|
|
69
|
+
return {
|
|
70
|
+
isValid: false,
|
|
71
|
+
errorMessage: errors.map((e) => e.message).join("; "),
|
|
72
|
+
toolResults: toolUseParts.map((toolUse, index) => {
|
|
73
|
+
const error = errors.find((e) => e.index === index);
|
|
74
|
+
return {
|
|
75
|
+
type: "tool_result",
|
|
76
|
+
toolUseId: toolUse.toolUseId,
|
|
77
|
+
toolName: toolUse.toolName,
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: error
|
|
82
|
+
? error.message
|
|
83
|
+
: "Tool call rejected due to other tool validation error",
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
isError: true,
|
|
87
|
+
};
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Exclusive tool validation
|
|
93
|
+
const exclusiveResult = validateExclusiveTools(toolUseParts);
|
|
94
|
+
if (!exclusiveResult.isValid) {
|
|
95
|
+
return {
|
|
96
|
+
isValid: false,
|
|
97
|
+
errorMessage: exclusiveResult.errorMessage,
|
|
98
|
+
toolResults: toolUseParts.map((t) => ({
|
|
99
|
+
type: "tool_result",
|
|
100
|
+
toolUseId: t.toolUseId,
|
|
101
|
+
toolName: t.toolName,
|
|
102
|
+
content: [{ type: "text", text: exclusiveResult.errorMessage }],
|
|
103
|
+
isError: true,
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { isValid: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {ExecuteBatchSuccess | ExecuteBatchFailure} ExecuteBatchResult
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @typedef {Object} ExecuteBatchSuccess
|
|
117
|
+
* @property {true} success - Execution succeeded
|
|
118
|
+
* @property {MessageContentToolResult[]} results - Tool results on success
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @typedef {Object} ExecuteBatchFailure
|
|
123
|
+
* @property {false} success - Execution failed
|
|
124
|
+
* @property {MessageContentToolResult[]} errors - Error tool results on validation failure
|
|
125
|
+
* @property {string} errorMessage - Error message on validation failure
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validate and execute multiple tools
|
|
130
|
+
* @param {MessageContentToolUse[]} toolUseParts
|
|
131
|
+
* @returns {Promise<ExecuteBatchResult>}
|
|
132
|
+
*/
|
|
133
|
+
async function executeBatch(toolUseParts) {
|
|
134
|
+
const validation = validateBatch(toolUseParts);
|
|
135
|
+
|
|
136
|
+
if (!validation.isValid) {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
errors: /** @type {MessageContentToolResult[]} */ (
|
|
140
|
+
validation.toolResults
|
|
141
|
+
),
|
|
142
|
+
errorMessage: /** @type {string} */ (validation.errorMessage),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const results = [];
|
|
147
|
+
for (const toolUse of toolUseParts) {
|
|
148
|
+
results.push(await execute(toolUse));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
success: true,
|
|
153
|
+
results,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate exclusive tool constraints
|
|
159
|
+
* @param {MessageContentToolUse[]} toolUseParts
|
|
160
|
+
* @returns {{isValid: true} | {isValid: false, errorMessage: string}}
|
|
161
|
+
*/
|
|
162
|
+
function validateExclusiveTools(toolUseParts) {
|
|
163
|
+
const exclusiveTools = toolUseParts.filter((t) =>
|
|
164
|
+
exclusiveToolNames.includes(t.toolName),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (exclusiveTools.length > 1) {
|
|
168
|
+
const toolNames = exclusiveTools.map((t) => t.toolName).join(", ");
|
|
169
|
+
return {
|
|
170
|
+
isValid: false,
|
|
171
|
+
errorMessage: `System: ${toolNames} cannot be called together. Only one of these tools can be called at a time.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (exclusiveTools.length === 1 && toolUseParts.length > 1) {
|
|
176
|
+
return {
|
|
177
|
+
isValid: false,
|
|
178
|
+
errorMessage: `System: ${exclusiveTools[0].toolName} cannot be called with other tools. It must be called alone.`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { isValid: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Execute a tool use and return the result
|
|
187
|
+
* @param {MessageContentToolUse} toolUse
|
|
188
|
+
* @returns {Promise<MessageContentToolResult>}
|
|
189
|
+
*/
|
|
190
|
+
async function execute(toolUse) {
|
|
191
|
+
const tool = toolByName.get(toolUse.toolName);
|
|
192
|
+
if (!tool) {
|
|
193
|
+
return {
|
|
194
|
+
type: "tool_result",
|
|
195
|
+
toolUseId: toolUse.toolUseId,
|
|
196
|
+
toolName: toolUse.toolName,
|
|
197
|
+
content: [
|
|
198
|
+
{ type: "text", text: `Tool not found: ${toolUse.toolName}` },
|
|
199
|
+
],
|
|
200
|
+
isError: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const result = await tool.impl(toolUse.input);
|
|
205
|
+
if (result instanceof Error) {
|
|
206
|
+
return {
|
|
207
|
+
type: "tool_result",
|
|
208
|
+
toolUseId: toolUse.toolUseId,
|
|
209
|
+
toolName: toolUse.toolName,
|
|
210
|
+
content: [{ type: "text", text: result.message }],
|
|
211
|
+
isError: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (typeof result === "string") {
|
|
216
|
+
return {
|
|
217
|
+
type: "tool_result",
|
|
218
|
+
toolUseId: toolUse.toolUseId,
|
|
219
|
+
toolName: toolUse.toolName,
|
|
220
|
+
content: [{ type: "text", text: result }],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
type: "tool_result",
|
|
226
|
+
toolUseId: toolUse.toolUseId,
|
|
227
|
+
toolName: toolUse.toolName,
|
|
228
|
+
content: result,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
executeBatch,
|
|
234
|
+
validateBatch,
|
|
235
|
+
};
|
|
236
|
+
}
|