iris-chatbot 0.2.4
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/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/iris.mjs +267 -0
- package/package.json +61 -0
- package/template/LICENSE +21 -0
- package/template/README.md +49 -0
- package/template/eslint.config.mjs +18 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +9193 -0
- package/template/package.json +46 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/api/chat/route.ts +2445 -0
- package/template/src/app/api/connections/models/route.ts +255 -0
- package/template/src/app/api/connections/test/route.ts +124 -0
- package/template/src/app/api/local-sync/route.ts +74 -0
- package/template/src/app/api/tool-approval/route.ts +47 -0
- package/template/src/app/favicon.ico +0 -0
- package/template/src/app/globals.css +808 -0
- package/template/src/app/layout.tsx +74 -0
- package/template/src/app/page.tsx +444 -0
- package/template/src/components/ChatView.tsx +1537 -0
- package/template/src/components/Composer.tsx +160 -0
- package/template/src/components/MapView.tsx +244 -0
- package/template/src/components/MessageCard.tsx +955 -0
- package/template/src/components/SearchModal.tsx +72 -0
- package/template/src/components/SettingsModal.tsx +1257 -0
- package/template/src/components/Sidebar.tsx +153 -0
- package/template/src/components/TopBar.tsx +164 -0
- package/template/src/lib/connections.ts +275 -0
- package/template/src/lib/data.ts +324 -0
- package/template/src/lib/db.ts +49 -0
- package/template/src/lib/hooks.ts +76 -0
- package/template/src/lib/local-sync.ts +192 -0
- package/template/src/lib/memory.ts +695 -0
- package/template/src/lib/model-presets.ts +251 -0
- package/template/src/lib/store.ts +36 -0
- package/template/src/lib/tooling/approvals.ts +78 -0
- package/template/src/lib/tooling/providers/anthropic.ts +155 -0
- package/template/src/lib/tooling/providers/ollama.ts +73 -0
- package/template/src/lib/tooling/providers/openai.ts +267 -0
- package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
- package/template/src/lib/tooling/providers/types.ts +44 -0
- package/template/src/lib/tooling/registry.ts +103 -0
- package/template/src/lib/tooling/runtime.ts +189 -0
- package/template/src/lib/tooling/safety.ts +165 -0
- package/template/src/lib/tooling/tools/apps.ts +108 -0
- package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
- package/template/src/lib/tooling/tools/communication.ts +883 -0
- package/template/src/lib/tooling/tools/files.ts +395 -0
- package/template/src/lib/tooling/tools/music.ts +988 -0
- package/template/src/lib/tooling/tools/notes.ts +461 -0
- package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
- package/template/src/lib/tooling/tools/numbers.ts +175 -0
- package/template/src/lib/tooling/tools/schedule.ts +579 -0
- package/template/src/lib/tooling/tools/system.ts +142 -0
- package/template/src/lib/tooling/tools/web.ts +212 -0
- package/template/src/lib/tooling/tools/workflow.ts +218 -0
- package/template/src/lib/tooling/types.ts +27 -0
- package/template/src/lib/types.ts +309 -0
- package/template/src/lib/utils.ts +108 -0
- package/template/tsconfig.json +34 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import type { ProviderToolSchema } from "../registry";
|
|
3
|
+
import type {
|
|
4
|
+
CreateProviderSessionArgs,
|
|
5
|
+
ProviderCitationSource,
|
|
6
|
+
NormalizedToolCall,
|
|
7
|
+
ProviderStep,
|
|
8
|
+
ToolLoopMessage,
|
|
9
|
+
ToolProviderSession,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
type OpenAIResponseInputItem = OpenAI.Responses.ResponseInputItem;
|
|
13
|
+
|
|
14
|
+
function mapMessages(messages: ToolLoopMessage[]): OpenAIResponseInputItem[] {
|
|
15
|
+
const mapped: OpenAIResponseInputItem[] = [];
|
|
16
|
+
|
|
17
|
+
for (const message of messages) {
|
|
18
|
+
if (message.role === "system") {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (message.role === "tool") {
|
|
23
|
+
if (!message.toolCallId) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
mapped.push({
|
|
27
|
+
type: "function_call_output",
|
|
28
|
+
call_id: message.toolCallId,
|
|
29
|
+
output: message.content,
|
|
30
|
+
} as OpenAIResponseInputItem);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
mapped.push({
|
|
35
|
+
type: "message",
|
|
36
|
+
role: message.role,
|
|
37
|
+
content: message.content,
|
|
38
|
+
} as OpenAIResponseInputItem);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return mapped;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mapTools(tools: ProviderToolSchema[]): OpenAI.Responses.FunctionTool[] {
|
|
45
|
+
return tools.map((tool) => ({
|
|
46
|
+
type: "function" as const,
|
|
47
|
+
name: tool.name,
|
|
48
|
+
description: tool.description,
|
|
49
|
+
parameters: tool.input_schema,
|
|
50
|
+
strict: false,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeSource(
|
|
55
|
+
source: { url?: unknown; title?: unknown } | null | undefined,
|
|
56
|
+
): ProviderCitationSource | null {
|
|
57
|
+
if (!source || typeof source.url !== "string") {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const url = source.url.trim();
|
|
61
|
+
if (!url) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const title =
|
|
65
|
+
typeof source.title === "string" && source.title.trim()
|
|
66
|
+
? source.title.trim()
|
|
67
|
+
: undefined;
|
|
68
|
+
return {
|
|
69
|
+
url,
|
|
70
|
+
title,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function addSource(
|
|
75
|
+
uniqueSources: Map<string, ProviderCitationSource>,
|
|
76
|
+
source: ProviderCitationSource | null,
|
|
77
|
+
) {
|
|
78
|
+
if (!source) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const existing = uniqueSources.get(source.url);
|
|
82
|
+
if (!existing || (!existing.title && source.title)) {
|
|
83
|
+
uniqueSources.set(source.url, source);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function extractCitationSourcesFromResponse(response: {
|
|
88
|
+
output?: unknown;
|
|
89
|
+
}): ProviderCitationSource[] {
|
|
90
|
+
if (!Array.isArray(response.output)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const uniqueSources = new Map<string, ProviderCitationSource>();
|
|
95
|
+
|
|
96
|
+
for (const outputItem of response.output) {
|
|
97
|
+
if (!outputItem || typeof outputItem !== "object") {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const item = outputItem as {
|
|
101
|
+
type?: unknown;
|
|
102
|
+
action?: unknown;
|
|
103
|
+
content?: unknown;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (item.type === "web_search_call" && item.action && typeof item.action === "object") {
|
|
107
|
+
const action = item.action as { type?: unknown; sources?: unknown };
|
|
108
|
+
if (action.type === "search" && Array.isArray(action.sources)) {
|
|
109
|
+
for (const source of action.sources) {
|
|
110
|
+
if (!source || typeof source !== "object") {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
addSource(uniqueSources, normalizeSource(source as { url?: unknown; title?: unknown }));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (item.type !== "message" || !Array.isArray(item.content)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const contentPart of item.content) {
|
|
124
|
+
if (!contentPart || typeof contentPart !== "object") {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const part = contentPart as { type?: unknown; annotations?: unknown };
|
|
128
|
+
if (part.type !== "output_text" || !Array.isArray(part.annotations)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const annotation of part.annotations) {
|
|
133
|
+
if (!annotation || typeof annotation !== "object") {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const urlCitation = annotation as {
|
|
137
|
+
type?: unknown;
|
|
138
|
+
url?: unknown;
|
|
139
|
+
title?: unknown;
|
|
140
|
+
};
|
|
141
|
+
if (urlCitation.type !== "url_citation") {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
addSource(uniqueSources, normalizeSource(urlCitation));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return [...uniqueSources.values()];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseToolCalls(outputItems: unknown): NormalizedToolCall[] {
|
|
153
|
+
if (!Array.isArray(outputItems)) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const calls: NormalizedToolCall[] = [];
|
|
158
|
+
for (const rawCall of outputItems) {
|
|
159
|
+
if (!rawCall || typeof rawCall !== "object") {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const call = rawCall as {
|
|
164
|
+
type?: unknown;
|
|
165
|
+
call_id?: unknown;
|
|
166
|
+
id?: unknown;
|
|
167
|
+
name?: unknown;
|
|
168
|
+
arguments?: unknown;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (call.type !== "function_call") {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (
|
|
176
|
+
typeof call.call_id !== "string" ||
|
|
177
|
+
typeof call.name !== "string"
|
|
178
|
+
) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let parsedInput: Record<string, unknown> = {};
|
|
183
|
+
const rawArguments = call.arguments;
|
|
184
|
+
|
|
185
|
+
if (typeof rawArguments === "string" && rawArguments.trim()) {
|
|
186
|
+
try {
|
|
187
|
+
const maybeObj = JSON.parse(rawArguments);
|
|
188
|
+
if (maybeObj && typeof maybeObj === "object") {
|
|
189
|
+
parsedInput = maybeObj as Record<string, unknown>;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Model returned invalid JSON args for tool ${call.name}.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
calls.push({
|
|
199
|
+
id: call.call_id,
|
|
200
|
+
name: call.name,
|
|
201
|
+
input: parsedInput,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return calls;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function createOpenAISession(
|
|
209
|
+
args: CreateProviderSessionArgs & {
|
|
210
|
+
baseURL?: string;
|
|
211
|
+
defaultHeaders?: Record<string, string>;
|
|
212
|
+
},
|
|
213
|
+
): ToolProviderSession {
|
|
214
|
+
const client = new OpenAI({
|
|
215
|
+
apiKey: args.apiKey,
|
|
216
|
+
baseURL: args.baseURL,
|
|
217
|
+
defaultHeaders: args.defaultHeaders,
|
|
218
|
+
});
|
|
219
|
+
const initialInput = mapMessages(args.messages);
|
|
220
|
+
const pendingInput: OpenAIResponseInputItem[] = [...initialInput];
|
|
221
|
+
const functionTools = mapTools(args.tools);
|
|
222
|
+
const webSearchTools: OpenAI.Responses.WebSearchTool[] = args.enableWebSources
|
|
223
|
+
? [{ type: "web_search" }]
|
|
224
|
+
: [];
|
|
225
|
+
const tools: OpenAI.Responses.Tool[] = [...functionTools, ...webSearchTools];
|
|
226
|
+
const instructions = args.system?.trim() || undefined;
|
|
227
|
+
let previousResponseId: string | null = null;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
async step(signal?: AbortSignal): Promise<ProviderStep> {
|
|
231
|
+
const response = await client.responses.create({
|
|
232
|
+
model: args.model,
|
|
233
|
+
instructions,
|
|
234
|
+
input: [...pendingInput],
|
|
235
|
+
previous_response_id: previousResponseId,
|
|
236
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
237
|
+
tool_choice: tools.length > 0 ? "auto" : undefined,
|
|
238
|
+
include: args.enableWebSources ? ["web_search_call.action.sources"] : undefined,
|
|
239
|
+
}, { signal });
|
|
240
|
+
|
|
241
|
+
previousResponseId = response.id;
|
|
242
|
+
pendingInput.length = 0;
|
|
243
|
+
|
|
244
|
+
if (response.error?.message) {
|
|
245
|
+
throw new Error(response.error.message);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const text = response.output_text || "";
|
|
249
|
+
const toolCalls = parseToolCalls((response as { output?: unknown }).output);
|
|
250
|
+
const sources = extractCitationSourcesFromResponse(response as { output?: unknown });
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
text,
|
|
254
|
+
toolCalls,
|
|
255
|
+
sources,
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
addToolResult(params) {
|
|
260
|
+
pendingInput.push({
|
|
261
|
+
type: "function_call_output",
|
|
262
|
+
call_id: params.callId,
|
|
263
|
+
output: params.result,
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createOpenAISession } from "./openai";
|
|
2
|
+
import type { CreateProviderSessionArgs, ToolProviderSession } from "./types";
|
|
3
|
+
|
|
4
|
+
export function createOpenAICompatibleSession(
|
|
5
|
+
args: CreateProviderSessionArgs & {
|
|
6
|
+
baseURL: string;
|
|
7
|
+
defaultHeaders?: Record<string, string>;
|
|
8
|
+
},
|
|
9
|
+
): ToolProviderSession {
|
|
10
|
+
return createOpenAISession({
|
|
11
|
+
...args,
|
|
12
|
+
baseURL: args.baseURL,
|
|
13
|
+
defaultHeaders: args.defaultHeaders,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ProviderToolSchema } from "../registry";
|
|
2
|
+
|
|
3
|
+
export type ToolLoopMessage = {
|
|
4
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
5
|
+
content: string;
|
|
6
|
+
toolCallId?: string;
|
|
7
|
+
toolName?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type NormalizedToolCall = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
input: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ProviderCitationSource = {
|
|
17
|
+
url: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ProviderStep = {
|
|
22
|
+
text: string;
|
|
23
|
+
toolCalls: NormalizedToolCall[];
|
|
24
|
+
sources?: ProviderCitationSource[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type CreateProviderSessionArgs = {
|
|
28
|
+
model: string;
|
|
29
|
+
apiKey: string;
|
|
30
|
+
system?: string;
|
|
31
|
+
messages: ToolLoopMessage[];
|
|
32
|
+
tools: ProviderToolSchema[];
|
|
33
|
+
enableWebSources?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ToolProviderSession = {
|
|
37
|
+
step: (signal?: AbortSignal) => Promise<ProviderStep>;
|
|
38
|
+
addToolResult: (params: {
|
|
39
|
+
callId: string;
|
|
40
|
+
name: string;
|
|
41
|
+
result: string;
|
|
42
|
+
isError?: boolean;
|
|
43
|
+
}) => void;
|
|
44
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { LocalToolsSettings } from "../types";
|
|
2
|
+
import type { ToolDefinition } from "./types";
|
|
3
|
+
import { appTools } from "./tools/apps";
|
|
4
|
+
import { appPlusTools } from "./tools/apps_plus";
|
|
5
|
+
import { communicationTools } from "./tools/communication";
|
|
6
|
+
import { fileTools } from "./tools/files";
|
|
7
|
+
import { musicTools } from "./tools/music";
|
|
8
|
+
import { notesTools } from "./tools/notes";
|
|
9
|
+
import { notesPlusTools } from "./tools/notes_plus";
|
|
10
|
+
import { numbersTools } from "./tools/numbers";
|
|
11
|
+
import { scheduleTools } from "./tools/schedule";
|
|
12
|
+
import { systemTools } from "./tools/system";
|
|
13
|
+
import { webTools } from "./tools/web";
|
|
14
|
+
import { createWorkflowTool } from "./tools/workflow";
|
|
15
|
+
|
|
16
|
+
export type ProviderToolSchema = {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
input_schema: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const MAX_SERIALIZED_TOOL_OUTPUT_CHARS = 12_000;
|
|
23
|
+
|
|
24
|
+
export function createToolRegistry(localTools: LocalToolsSettings): ToolDefinition[] {
|
|
25
|
+
const tools: ToolDefinition[] = [...fileTools];
|
|
26
|
+
|
|
27
|
+
if (localTools.enableNotes) {
|
|
28
|
+
tools.push(...notesTools);
|
|
29
|
+
tools.push(...notesPlusTools);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (localTools.enableApps) {
|
|
33
|
+
tools.push(...appTools);
|
|
34
|
+
tools.push(...appPlusTools);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (localTools.enableNumbers) {
|
|
38
|
+
tools.push(...numbersTools);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (localTools.enableWeb) {
|
|
42
|
+
tools.push(...webTools);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (localTools.enableMusic) {
|
|
46
|
+
tools.push(...musicTools);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (localTools.enableCalendar) {
|
|
50
|
+
tools.push(...scheduleTools);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (localTools.enableMail) {
|
|
54
|
+
tools.push(...communicationTools);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (localTools.enableSystem) {
|
|
58
|
+
tools.push(...systemTools);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (localTools.enableWorkflow) {
|
|
62
|
+
const workflowTool = createWorkflowTool(() => tools);
|
|
63
|
+
tools.push(workflowTool);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return tools;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function toProviderToolSchemas(tools: ToolDefinition[]): ProviderToolSchema[] {
|
|
70
|
+
return tools.map((tool) => ({
|
|
71
|
+
name: tool.name,
|
|
72
|
+
description: tool.description,
|
|
73
|
+
input_schema: tool.inputSchema,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function findToolByName(
|
|
78
|
+
tools: ToolDefinition[],
|
|
79
|
+
name: string,
|
|
80
|
+
): ToolDefinition | null {
|
|
81
|
+
return tools.find((tool) => tool.name === name) ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function serializeToolOutput(output: unknown): string {
|
|
85
|
+
if (typeof output === "string") {
|
|
86
|
+
if (output.length <= MAX_SERIALIZED_TOOL_OUTPUT_CHARS) {
|
|
87
|
+
return output;
|
|
88
|
+
}
|
|
89
|
+
const remaining = output.length - MAX_SERIALIZED_TOOL_OUTPUT_CHARS;
|
|
90
|
+
return `${output.slice(0, MAX_SERIALIZED_TOOL_OUTPUT_CHARS)}\n...[truncated ${remaining} chars]`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const serialized = JSON.stringify(output, null, 2);
|
|
95
|
+
if (serialized.length <= MAX_SERIALIZED_TOOL_OUTPUT_CHARS) {
|
|
96
|
+
return serialized;
|
|
97
|
+
}
|
|
98
|
+
const remaining = serialized.length - MAX_SERIALIZED_TOOL_OUTPUT_CHARS;
|
|
99
|
+
return `${serialized.slice(0, MAX_SERIALIZED_TOOL_OUTPUT_CHARS)}\n...[truncated ${remaining} chars]`;
|
|
100
|
+
} catch {
|
|
101
|
+
return String(output);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type ToolErrorCode =
|
|
4
|
+
| "TIMEOUT"
|
|
5
|
+
| "VALIDATION"
|
|
6
|
+
| "PERMISSION"
|
|
7
|
+
| "NOT_FOUND"
|
|
8
|
+
| "TRANSIENT"
|
|
9
|
+
| "UNAVAILABLE";
|
|
10
|
+
|
|
11
|
+
export type NormalizedToolError = {
|
|
12
|
+
code: ToolErrorCode;
|
|
13
|
+
message: string;
|
|
14
|
+
retryable: boolean;
|
|
15
|
+
details?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type RunCommandSafeParams = {
|
|
19
|
+
command: string;
|
|
20
|
+
args: string[];
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
maxOutputChars?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type RunCommandSafeResult = {
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const idempotencyStore = new Map<string, unknown>();
|
|
32
|
+
|
|
33
|
+
function trimOutput(value: string, maxOutputChars: number): string {
|
|
34
|
+
if (value.length <= maxOutputChars) {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
const remaining = value.length - maxOutputChars;
|
|
38
|
+
return `${value.slice(0, maxOutputChars)}... (+${remaining} chars truncated)`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runCommandSafe(
|
|
42
|
+
params: RunCommandSafeParams,
|
|
43
|
+
): Promise<RunCommandSafeResult> {
|
|
44
|
+
const timeoutMs = params.timeoutMs ?? 25_000;
|
|
45
|
+
const maxOutputChars = params.maxOutputChars ?? 12_000;
|
|
46
|
+
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const child = spawn(params.command, params.args, {
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
signal: params.signal,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let settled = false;
|
|
54
|
+
let stdout = "";
|
|
55
|
+
let stderr = "";
|
|
56
|
+
|
|
57
|
+
let forceKillTimer: NodeJS.Timeout | null = null;
|
|
58
|
+
const finalize = (action: () => void) => {
|
|
59
|
+
if (settled) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
if (forceKillTimer) {
|
|
65
|
+
clearTimeout(forceKillTimer);
|
|
66
|
+
}
|
|
67
|
+
action();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
if (settled) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
child.kill("SIGTERM");
|
|
75
|
+
forceKillTimer = setTimeout(() => {
|
|
76
|
+
if (!settled) {
|
|
77
|
+
child.kill("SIGKILL");
|
|
78
|
+
}
|
|
79
|
+
}, 1_000);
|
|
80
|
+
finalize(() => reject(new Error(`Timed out after ${timeoutMs}ms`)));
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
|
|
83
|
+
child.stdout.on("data", (chunk) => {
|
|
84
|
+
stdout += String(chunk);
|
|
85
|
+
});
|
|
86
|
+
child.stderr.on("data", (chunk) => {
|
|
87
|
+
stderr += String(chunk);
|
|
88
|
+
});
|
|
89
|
+
child.on("error", (error) => {
|
|
90
|
+
finalize(() => reject(error));
|
|
91
|
+
});
|
|
92
|
+
child.on("close", (code) => {
|
|
93
|
+
if (code === 0) {
|
|
94
|
+
finalize(() => resolve({
|
|
95
|
+
stdout: trimOutput(stdout.trim(), maxOutputChars),
|
|
96
|
+
stderr: trimOutput(stderr.trim(), maxOutputChars),
|
|
97
|
+
}));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
finalize(() => reject(
|
|
102
|
+
new Error(
|
|
103
|
+
trimOutput(
|
|
104
|
+
stderr.trim() || `${params.command} exited with code ${code}`,
|
|
105
|
+
maxOutputChars,
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function normalizeToolError(error: unknown): NormalizedToolError {
|
|
114
|
+
const message =
|
|
115
|
+
error instanceof Error ? error.message : "Unexpected tool runtime error.";
|
|
116
|
+
const lower = message.toLowerCase();
|
|
117
|
+
|
|
118
|
+
if (lower.includes("timed out") || lower.includes("timeout")) {
|
|
119
|
+
return { code: "TIMEOUT", message, retryable: true };
|
|
120
|
+
}
|
|
121
|
+
if (lower.includes("outside allowed roots") || lower.includes("blocked")) {
|
|
122
|
+
return { code: "PERMISSION", message, retryable: false };
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
lower.includes("missing required") ||
|
|
126
|
+
lower.includes("must be") ||
|
|
127
|
+
lower.includes("invalid")
|
|
128
|
+
) {
|
|
129
|
+
return { code: "VALIDATION", message, retryable: false };
|
|
130
|
+
}
|
|
131
|
+
if (lower.includes("not found") || lower.includes("no such file")) {
|
|
132
|
+
return { code: "NOT_FOUND", message, retryable: false };
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
lower.includes("temporar") ||
|
|
136
|
+
lower.includes("econn") ||
|
|
137
|
+
lower.includes("enotfound")
|
|
138
|
+
) {
|
|
139
|
+
return { code: "TRANSIENT", message, retryable: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { code: "UNAVAILABLE", message, retryable: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sleep(ms: number): Promise<void> {
|
|
146
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function withRetry<T>(params: {
|
|
150
|
+
operation: () => Promise<T>;
|
|
151
|
+
maxRetries?: number;
|
|
152
|
+
baseDelayMs?: number;
|
|
153
|
+
}): Promise<T> {
|
|
154
|
+
const maxRetries = params.maxRetries ?? 2;
|
|
155
|
+
const baseDelayMs = params.baseDelayMs ?? 120;
|
|
156
|
+
|
|
157
|
+
let attempt = 0;
|
|
158
|
+
while (true) {
|
|
159
|
+
try {
|
|
160
|
+
return await params.operation();
|
|
161
|
+
} catch (error) {
|
|
162
|
+
const normalized = normalizeToolError(error);
|
|
163
|
+
if (!normalized.retryable || attempt >= maxRetries) {
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const jitter = Math.floor(Math.random() * 80);
|
|
167
|
+
const delay = baseDelayMs * 2 ** attempt + jitter;
|
|
168
|
+
attempt += 1;
|
|
169
|
+
await sleep(delay);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function withIdempotency<T>(params: {
|
|
175
|
+
key?: string;
|
|
176
|
+
operation: () => Promise<T>;
|
|
177
|
+
}): Promise<T> {
|
|
178
|
+
if (!params.key) {
|
|
179
|
+
return params.operation();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (idempotencyStore.has(params.key)) {
|
|
183
|
+
return idempotencyStore.get(params.key) as T;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const result = await params.operation();
|
|
187
|
+
idempotencyStore.set(params.key, result);
|
|
188
|
+
return result;
|
|
189
|
+
}
|