react-observer-agent 0.2.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/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/index.cjs +990 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +171 -0
- package/dist/index.d.ts +171 -0
- package/dist/index.js +955 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
// src/tools/registerTool.ts
|
|
2
|
+
function registerTool(name, handler, options) {
|
|
3
|
+
return {
|
|
4
|
+
name,
|
|
5
|
+
handler,
|
|
6
|
+
description: options?.description,
|
|
7
|
+
parameters: options?.parameters,
|
|
8
|
+
confirm: options?.confirm ?? false
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/tools/validateToolNames.ts
|
|
13
|
+
var RESERVED_PREFIX = "__";
|
|
14
|
+
function validateToolNames(tools) {
|
|
15
|
+
const seen = /* @__PURE__ */ new Set();
|
|
16
|
+
for (const tool of tools) {
|
|
17
|
+
if (tool.name.startsWith(RESERVED_PREFIX)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Tool name "${tool.name}" uses the reserved "${RESERVED_PREFIX}" prefix. Names beginning with "${RESERVED_PREFIX}" are reserved for internal tools.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (seen.has(tool.name)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Duplicate tool name "${tool.name}". All tools passed to a single provider must have unique names.`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
seen.add(tool.name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/provider/AIAgentProvider.tsx
|
|
32
|
+
import { createContext, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
33
|
+
|
|
34
|
+
// src/state/resolveState.ts
|
|
35
|
+
function resolveState(state) {
|
|
36
|
+
const resolved = typeof state === "function" ? state() : state;
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/state/createStateSnapshot.ts
|
|
41
|
+
function stripNonSerializable(obj, debug = false) {
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
44
|
+
try {
|
|
45
|
+
JSON.stringify(value);
|
|
46
|
+
result[key] = value;
|
|
47
|
+
} catch {
|
|
48
|
+
if (debug) {
|
|
49
|
+
console.warn(
|
|
50
|
+
`[react-observer-agent] Non-serializable value stripped from state key "${key}"`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
function isSerializableValue(value) {
|
|
58
|
+
if (value === null || value === void 0) return true;
|
|
59
|
+
if (typeof value === "function" || typeof value === "symbol") return false;
|
|
60
|
+
if (typeof value === "bigint") return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function createStateSnapshot(state, canAccess, debug = false) {
|
|
64
|
+
const resolved = resolveState(state);
|
|
65
|
+
const filtered = {};
|
|
66
|
+
for (const key of canAccess) {
|
|
67
|
+
if (key in resolved) {
|
|
68
|
+
const value = resolved[key];
|
|
69
|
+
if (!isSerializableValue(value)) {
|
|
70
|
+
if (debug) {
|
|
71
|
+
console.warn(
|
|
72
|
+
`[react-observer-agent] Non-serializable value stripped from state key "${key}"`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
filtered[key] = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return stripNonSerializable(filtered, debug);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/permissions/filterTools.ts
|
|
84
|
+
function filterTools(tools, canExecute) {
|
|
85
|
+
const allowed = new Set(canExecute);
|
|
86
|
+
return tools.filter((tool) => allowed.has(tool.name));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/permissions/validateToolCall.ts
|
|
90
|
+
function validateToolCall(name, canExecute) {
|
|
91
|
+
return canExecute.includes(name);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/tools/validateArgs.ts
|
|
95
|
+
function validateArgs(args, schema) {
|
|
96
|
+
const errors = [];
|
|
97
|
+
validateValue(args, schema, "", errors);
|
|
98
|
+
return { valid: errors.length === 0, errors };
|
|
99
|
+
}
|
|
100
|
+
function describeType(value) {
|
|
101
|
+
if (value === null) return "null";
|
|
102
|
+
if (value === void 0) return "undefined";
|
|
103
|
+
if (Array.isArray(value)) return "array";
|
|
104
|
+
if (typeof value === "number") {
|
|
105
|
+
return Number.isInteger(value) ? "integer" : "number";
|
|
106
|
+
}
|
|
107
|
+
return typeof value;
|
|
108
|
+
}
|
|
109
|
+
function matchesType(value, expected) {
|
|
110
|
+
const actual = describeType(value);
|
|
111
|
+
if (expected === "number") return actual === "number" || actual === "integer";
|
|
112
|
+
return actual === expected;
|
|
113
|
+
}
|
|
114
|
+
function sameValue(a, b) {
|
|
115
|
+
if (a === b) return true;
|
|
116
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
120
|
+
}
|
|
121
|
+
function label(path) {
|
|
122
|
+
return path === "" ? "value" : path;
|
|
123
|
+
}
|
|
124
|
+
function validateValue(value, schema, path, errors) {
|
|
125
|
+
if (!schema || typeof schema !== "object") return;
|
|
126
|
+
const expectedType = schema.type;
|
|
127
|
+
if (typeof expectedType === "string" && !matchesType(value, expectedType)) {
|
|
128
|
+
errors.push(
|
|
129
|
+
`${label(path)} should be ${expectedType}, got ${describeType(value)}`
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((option) => sameValue(option, value))) {
|
|
134
|
+
errors.push(
|
|
135
|
+
`${label(path)} should be one of ${JSON.stringify(schema.enum)}, got ${JSON.stringify(value)}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (isPlainObject(value)) {
|
|
139
|
+
validateObject(value, schema, path, errors);
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(value) && isPlainObject(schema.items)) {
|
|
142
|
+
const itemSchema = schema.items;
|
|
143
|
+
value.forEach((item, index) => {
|
|
144
|
+
validateValue(item, itemSchema, `${label(path)}[${index}]`, errors);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function validateObject(value, schema, path, errors) {
|
|
149
|
+
if (Array.isArray(schema.required)) {
|
|
150
|
+
for (const key of schema.required) {
|
|
151
|
+
if (typeof key === "string" && value[key] === void 0) {
|
|
152
|
+
errors.push(`${path === "" ? "" : `${path}.`}${key} is required`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (isPlainObject(schema.properties)) {
|
|
157
|
+
const properties = schema.properties;
|
|
158
|
+
for (const [key, propertySchema] of Object.entries(properties)) {
|
|
159
|
+
if (value[key] !== void 0 && isPlainObject(propertySchema)) {
|
|
160
|
+
validateValue(
|
|
161
|
+
value[key],
|
|
162
|
+
propertySchema,
|
|
163
|
+
path === "" ? key : `${path}.${key}`,
|
|
164
|
+
errors
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function isPlainObject(value) {
|
|
171
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/provider/executeAgentLoop.ts
|
|
175
|
+
var DEFAULT_MAX_TURNS = 5;
|
|
176
|
+
var READ_STATE_TOOL_NAME = "__readState";
|
|
177
|
+
var EMPTY_OBJECT_SCHEMA = { type: "object", properties: {} };
|
|
178
|
+
var UsageTotal = class {
|
|
179
|
+
constructor() {
|
|
180
|
+
this.promptTokens = 0;
|
|
181
|
+
this.completionTokens = 0;
|
|
182
|
+
this.reported = false;
|
|
183
|
+
}
|
|
184
|
+
add(usage) {
|
|
185
|
+
if (!usage) return;
|
|
186
|
+
this.reported = true;
|
|
187
|
+
this.promptTokens += usage.promptTokens ?? 0;
|
|
188
|
+
this.completionTokens += usage.completionTokens ?? 0;
|
|
189
|
+
}
|
|
190
|
+
/** Undefined when no adapter response carried usage, rather than a false zero. */
|
|
191
|
+
total() {
|
|
192
|
+
if (!this.reported) return void 0;
|
|
193
|
+
return {
|
|
194
|
+
promptTokens: this.promptTokens,
|
|
195
|
+
completionTokens: this.completionTokens
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
function isAbortError(error) {
|
|
200
|
+
return error instanceof Error && error.name === "AbortError";
|
|
201
|
+
}
|
|
202
|
+
function buildStateManifest(canAccess, descriptions) {
|
|
203
|
+
return canAccess.map((key) => ({
|
|
204
|
+
key,
|
|
205
|
+
description: descriptions?.[key] ?? key
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
function buildStateManifestPrompt(manifest) {
|
|
209
|
+
if (manifest.length === 0) return "";
|
|
210
|
+
const lines = manifest.map((m) => `- ${m.key}: ${m.description}`);
|
|
211
|
+
return [
|
|
212
|
+
"Available application state (use the __readState tool to access specific keys when needed):",
|
|
213
|
+
...lines,
|
|
214
|
+
"",
|
|
215
|
+
"Only request state keys relevant to the user's question. Do not read all keys at once unless necessary."
|
|
216
|
+
].join("\n");
|
|
217
|
+
}
|
|
218
|
+
function buildReadStateToolDef() {
|
|
219
|
+
return {
|
|
220
|
+
name: READ_STATE_TOOL_NAME,
|
|
221
|
+
description: "Read specific keys from the application state. Only request keys you need.",
|
|
222
|
+
parameters: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {
|
|
225
|
+
keys: {
|
|
226
|
+
type: "array",
|
|
227
|
+
items: { type: "string" },
|
|
228
|
+
description: "State keys to read"
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
required: ["keys"]
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async function executeAgentLoop(message, ctx) {
|
|
236
|
+
const { model, state, tools, permissions, options, signal } = ctx;
|
|
237
|
+
const debug = options?.debug ?? false;
|
|
238
|
+
const maxTurns = options?.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
239
|
+
const stateManifest = buildStateManifest(
|
|
240
|
+
permissions.canAccess,
|
|
241
|
+
permissions.stateDescriptions
|
|
242
|
+
);
|
|
243
|
+
if (debug) {
|
|
244
|
+
console.log("[react-observer-agent] State manifest:", stateManifest.map((m) => m.key));
|
|
245
|
+
}
|
|
246
|
+
const allowedTools = filterTools(tools, permissions.canExecute);
|
|
247
|
+
const llmTools = [];
|
|
248
|
+
for (const tool of allowedTools) {
|
|
249
|
+
if (!tool.description) {
|
|
250
|
+
if (debug) {
|
|
251
|
+
console.warn(
|
|
252
|
+
`[react-observer-agent] Tool "${tool.name}" has no description and is hidden from the LLM.`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
llmTools.push({
|
|
258
|
+
name: tool.name,
|
|
259
|
+
description: tool.description,
|
|
260
|
+
parameters: tool.parameters ?? EMPTY_OBJECT_SCHEMA
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (stateManifest.length > 0) {
|
|
264
|
+
llmTools.push(buildReadStateToolDef());
|
|
265
|
+
}
|
|
266
|
+
if (debug) {
|
|
267
|
+
console.log("[react-observer-agent] Available tools:", llmTools.map((t) => t.name));
|
|
268
|
+
}
|
|
269
|
+
const toolMap = new Map(allowedTools.map((t) => [t.name, t]));
|
|
270
|
+
const messages = [
|
|
271
|
+
...ctx.conversationHistory,
|
|
272
|
+
{ role: "user", content: message }
|
|
273
|
+
];
|
|
274
|
+
const manifestPrompt = buildStateManifestPrompt(stateManifest);
|
|
275
|
+
const systemPrompt = [options?.systemPrompt, manifestPrompt].filter(Boolean).join("\n\n") || void 0;
|
|
276
|
+
const allToolCalls = [];
|
|
277
|
+
const usage = new UsageTotal();
|
|
278
|
+
let turns = 0;
|
|
279
|
+
let finalMessage = "";
|
|
280
|
+
let completed = false;
|
|
281
|
+
const abortedResult = () => ({
|
|
282
|
+
response: {
|
|
283
|
+
message: "",
|
|
284
|
+
toolCalls: allToolCalls,
|
|
285
|
+
error: { message: "Interaction aborted", code: "ABORTED" },
|
|
286
|
+
usage: usage.total()
|
|
287
|
+
},
|
|
288
|
+
messages
|
|
289
|
+
});
|
|
290
|
+
while (turns < maxTurns) {
|
|
291
|
+
if (signal?.aborted) return abortedResult();
|
|
292
|
+
turns++;
|
|
293
|
+
if (debug) {
|
|
294
|
+
console.log(`[react-observer-agent] Turn ${turns}/${maxTurns}`);
|
|
295
|
+
}
|
|
296
|
+
const modelRequest = {
|
|
297
|
+
// A snapshot, since the loop keeps appending to `messages` after this
|
|
298
|
+
// call and an adapter that reads it asynchronously would see the churn.
|
|
299
|
+
messages: [...messages],
|
|
300
|
+
tools: llmTools,
|
|
301
|
+
state: {},
|
|
302
|
+
systemPrompt,
|
|
303
|
+
stateManifest,
|
|
304
|
+
signal
|
|
305
|
+
};
|
|
306
|
+
if (debug) {
|
|
307
|
+
console.log("[react-observer-agent] LLM request:", {
|
|
308
|
+
messageCount: modelRequest.messages.length,
|
|
309
|
+
toolCount: modelRequest.tools.length,
|
|
310
|
+
hasSystemPrompt: !!modelRequest.systemPrompt
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
let modelResponse;
|
|
314
|
+
try {
|
|
315
|
+
modelResponse = await model.sendMessage(modelRequest);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (isAbortError(error) || signal?.aborted) {
|
|
318
|
+
return abortedResult();
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
usage.add(modelResponse.usage);
|
|
323
|
+
if (signal?.aborted) return abortedResult();
|
|
324
|
+
if (debug) {
|
|
325
|
+
console.log("[react-observer-agent] LLM response:", {
|
|
326
|
+
content: modelResponse.content?.slice(0, 200),
|
|
327
|
+
toolCalls: modelResponse.toolCalls?.map((tc) => tc.name)
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (!modelResponse.toolCalls || modelResponse.toolCalls.length === 0) {
|
|
331
|
+
finalMessage = modelResponse.content ?? "";
|
|
332
|
+
completed = true;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
messages.push({
|
|
336
|
+
role: "assistant",
|
|
337
|
+
content: modelResponse.content ?? "",
|
|
338
|
+
toolCalls: modelResponse.toolCalls
|
|
339
|
+
});
|
|
340
|
+
for (const llmCall of modelResponse.toolCalls) {
|
|
341
|
+
if (signal?.aborted) return abortedResult();
|
|
342
|
+
if (llmCall.name === READ_STATE_TOOL_NAME) {
|
|
343
|
+
const args = llmCall.arguments;
|
|
344
|
+
const requestedKeys = args?.keys ?? [];
|
|
345
|
+
const allowedKeys = requestedKeys.filter((k) => permissions.canAccess.includes(k));
|
|
346
|
+
const snapshot = createStateSnapshot(state, allowedKeys, debug);
|
|
347
|
+
if (debug) {
|
|
348
|
+
console.log("[react-observer-agent] readState requested:", requestedKeys);
|
|
349
|
+
console.log("[react-observer-agent] readState allowed:", allowedKeys);
|
|
350
|
+
console.log("[react-observer-agent] readState result:", snapshot);
|
|
351
|
+
}
|
|
352
|
+
messages.push({
|
|
353
|
+
role: "tool",
|
|
354
|
+
content: JSON.stringify(snapshot),
|
|
355
|
+
toolCallId: llmCall.id
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (!validateToolCall(llmCall.name, permissions.canExecute)) {
|
|
360
|
+
const deniedResult = {
|
|
361
|
+
toolName: llmCall.name,
|
|
362
|
+
args: llmCall.arguments,
|
|
363
|
+
result: `Tool "${llmCall.name}" is not permitted`,
|
|
364
|
+
status: "denied"
|
|
365
|
+
};
|
|
366
|
+
allToolCalls.push(deniedResult);
|
|
367
|
+
options?.onToolCall?.({
|
|
368
|
+
toolName: llmCall.name,
|
|
369
|
+
args: llmCall.arguments,
|
|
370
|
+
result: deniedResult.result,
|
|
371
|
+
status: "denied"
|
|
372
|
+
});
|
|
373
|
+
messages.push({
|
|
374
|
+
role: "tool",
|
|
375
|
+
content: JSON.stringify({ error: deniedResult.result }),
|
|
376
|
+
toolCallId: llmCall.id
|
|
377
|
+
});
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const toolDef = toolMap.get(llmCall.name);
|
|
381
|
+
if (!toolDef) {
|
|
382
|
+
const deniedResult = {
|
|
383
|
+
toolName: llmCall.name,
|
|
384
|
+
args: llmCall.arguments,
|
|
385
|
+
result: `Tool "${llmCall.name}" not found`,
|
|
386
|
+
status: "denied"
|
|
387
|
+
};
|
|
388
|
+
allToolCalls.push(deniedResult);
|
|
389
|
+
messages.push({
|
|
390
|
+
role: "tool",
|
|
391
|
+
content: JSON.stringify({ error: deniedResult.result }),
|
|
392
|
+
toolCallId: llmCall.id
|
|
393
|
+
});
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (toolDef.parameters) {
|
|
397
|
+
const validation = validateArgs(llmCall.arguments, toolDef.parameters);
|
|
398
|
+
if (!validation.valid) {
|
|
399
|
+
const errorMessage = `Invalid arguments for tool "${llmCall.name}": ${validation.errors.join("; ")}`;
|
|
400
|
+
if (debug) {
|
|
401
|
+
console.warn(`[react-observer-agent] ${errorMessage}`);
|
|
402
|
+
}
|
|
403
|
+
const invalidResult = {
|
|
404
|
+
toolName: llmCall.name,
|
|
405
|
+
args: llmCall.arguments,
|
|
406
|
+
result: errorMessage,
|
|
407
|
+
status: "error"
|
|
408
|
+
};
|
|
409
|
+
allToolCalls.push(invalidResult);
|
|
410
|
+
options?.onToolCall?.({
|
|
411
|
+
toolName: llmCall.name,
|
|
412
|
+
args: llmCall.arguments,
|
|
413
|
+
result: errorMessage,
|
|
414
|
+
status: "error"
|
|
415
|
+
});
|
|
416
|
+
messages.push({
|
|
417
|
+
role: "tool",
|
|
418
|
+
content: JSON.stringify({ error: errorMessage }),
|
|
419
|
+
toolCallId: llmCall.id
|
|
420
|
+
});
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (toolDef.confirm) {
|
|
425
|
+
if (!options?.onConfirm) {
|
|
426
|
+
if (debug) {
|
|
427
|
+
console.warn(
|
|
428
|
+
`[react-observer-agent] Tool "${llmCall.name}" requires confirmation but no onConfirm handler provided. Skipping.`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
const cancelledResult = {
|
|
432
|
+
toolName: llmCall.name,
|
|
433
|
+
args: llmCall.arguments,
|
|
434
|
+
result: "Tool execution cancelled: no confirmation handler provided",
|
|
435
|
+
status: "cancelled"
|
|
436
|
+
};
|
|
437
|
+
allToolCalls.push(cancelledResult);
|
|
438
|
+
options?.onToolCall?.({
|
|
439
|
+
toolName: llmCall.name,
|
|
440
|
+
args: llmCall.arguments,
|
|
441
|
+
result: cancelledResult.result,
|
|
442
|
+
status: "cancelled"
|
|
443
|
+
});
|
|
444
|
+
messages.push({
|
|
445
|
+
role: "tool",
|
|
446
|
+
content: JSON.stringify({ status: "cancelled", reason: "No confirmation handler" }),
|
|
447
|
+
toolCallId: llmCall.id
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const confirmed = await options.onConfirm({
|
|
452
|
+
toolName: llmCall.name,
|
|
453
|
+
args: llmCall.arguments,
|
|
454
|
+
description: toolDef.description
|
|
455
|
+
});
|
|
456
|
+
if (!confirmed) {
|
|
457
|
+
const cancelledResult = {
|
|
458
|
+
toolName: llmCall.name,
|
|
459
|
+
args: llmCall.arguments,
|
|
460
|
+
result: "Tool execution cancelled by user",
|
|
461
|
+
status: "cancelled"
|
|
462
|
+
};
|
|
463
|
+
allToolCalls.push(cancelledResult);
|
|
464
|
+
options?.onToolCall?.({
|
|
465
|
+
toolName: llmCall.name,
|
|
466
|
+
args: llmCall.arguments,
|
|
467
|
+
result: cancelledResult.result,
|
|
468
|
+
status: "cancelled"
|
|
469
|
+
});
|
|
470
|
+
messages.push({
|
|
471
|
+
role: "tool",
|
|
472
|
+
content: JSON.stringify({ status: "cancelled", reason: "User denied" }),
|
|
473
|
+
toolCallId: llmCall.id
|
|
474
|
+
});
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
const result = await toolDef.handler(llmCall.arguments);
|
|
480
|
+
const status = toolDef.confirm ? "confirmed" : "success";
|
|
481
|
+
if (debug) {
|
|
482
|
+
console.log(`[react-observer-agent] Tool "${llmCall.name}" executed:`, { status, result });
|
|
483
|
+
}
|
|
484
|
+
const toolResult = {
|
|
485
|
+
toolName: llmCall.name,
|
|
486
|
+
args: llmCall.arguments,
|
|
487
|
+
result,
|
|
488
|
+
status
|
|
489
|
+
};
|
|
490
|
+
allToolCalls.push(toolResult);
|
|
491
|
+
options?.onToolCall?.({
|
|
492
|
+
toolName: llmCall.name,
|
|
493
|
+
args: llmCall.arguments,
|
|
494
|
+
result,
|
|
495
|
+
status
|
|
496
|
+
});
|
|
497
|
+
messages.push({
|
|
498
|
+
role: "tool",
|
|
499
|
+
content: JSON.stringify({ result }),
|
|
500
|
+
toolCallId: llmCall.id
|
|
501
|
+
});
|
|
502
|
+
} catch (error) {
|
|
503
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
504
|
+
const errorResult = {
|
|
505
|
+
toolName: llmCall.name,
|
|
506
|
+
args: llmCall.arguments,
|
|
507
|
+
result: errorMessage,
|
|
508
|
+
status: "error"
|
|
509
|
+
};
|
|
510
|
+
allToolCalls.push(errorResult);
|
|
511
|
+
options?.onToolCall?.({
|
|
512
|
+
toolName: llmCall.name,
|
|
513
|
+
args: llmCall.arguments,
|
|
514
|
+
result: errorMessage,
|
|
515
|
+
status: "error"
|
|
516
|
+
});
|
|
517
|
+
messages.push({
|
|
518
|
+
role: "tool",
|
|
519
|
+
content: JSON.stringify({ error: errorMessage }),
|
|
520
|
+
toolCallId: llmCall.id
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (!completed) {
|
|
526
|
+
if (debug) {
|
|
527
|
+
console.warn(`[react-observer-agent] Max turns (${maxTurns}) reached`);
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
response: {
|
|
531
|
+
message: "",
|
|
532
|
+
toolCalls: allToolCalls,
|
|
533
|
+
error: {
|
|
534
|
+
message: `Agent did not produce a final response within ${maxTurns} turns`,
|
|
535
|
+
code: "MAX_TURNS"
|
|
536
|
+
},
|
|
537
|
+
usage: usage.total()
|
|
538
|
+
},
|
|
539
|
+
messages
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
messages.push({ role: "assistant", content: finalMessage });
|
|
543
|
+
return {
|
|
544
|
+
response: {
|
|
545
|
+
message: finalMessage,
|
|
546
|
+
toolCalls: allToolCalls,
|
|
547
|
+
usage: usage.total()
|
|
548
|
+
},
|
|
549
|
+
messages
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/provider/AIAgentProvider.tsx
|
|
554
|
+
import { jsx } from "react/jsx-runtime";
|
|
555
|
+
var AgentContextValue = createContext(null);
|
|
556
|
+
function AIAgentProvider({
|
|
557
|
+
model,
|
|
558
|
+
state,
|
|
559
|
+
tools,
|
|
560
|
+
permissions,
|
|
561
|
+
options,
|
|
562
|
+
children
|
|
563
|
+
}) {
|
|
564
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
565
|
+
const [history, setHistory] = useState([]);
|
|
566
|
+
const [lastResponse, setLastResponse] = useState(null);
|
|
567
|
+
const modelRef = useRef(model);
|
|
568
|
+
const stateRef = useRef(state);
|
|
569
|
+
const toolsRef = useRef(tools);
|
|
570
|
+
const permissionsRef = useRef(permissions);
|
|
571
|
+
const optionsRef = useRef(options);
|
|
572
|
+
modelRef.current = model;
|
|
573
|
+
stateRef.current = state;
|
|
574
|
+
toolsRef.current = tools;
|
|
575
|
+
permissionsRef.current = permissions;
|
|
576
|
+
optionsRef.current = options;
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
validateToolNames(tools);
|
|
579
|
+
}, [tools]);
|
|
580
|
+
const transcriptRef = useRef([]);
|
|
581
|
+
const clearHistory = useCallback(() => {
|
|
582
|
+
setHistory([]);
|
|
583
|
+
setLastResponse(null);
|
|
584
|
+
transcriptRef.current = [];
|
|
585
|
+
}, []);
|
|
586
|
+
const send = useCallback(async (message, sendOptions) => {
|
|
587
|
+
setIsProcessing(true);
|
|
588
|
+
const userEntry = {
|
|
589
|
+
role: "user",
|
|
590
|
+
content: message,
|
|
591
|
+
timestamp: Date.now()
|
|
592
|
+
};
|
|
593
|
+
setHistory((prev) => [...prev, userEntry]);
|
|
594
|
+
try {
|
|
595
|
+
const { response, messages } = await executeAgentLoop(message, {
|
|
596
|
+
model: modelRef.current,
|
|
597
|
+
state: stateRef.current,
|
|
598
|
+
tools: toolsRef.current,
|
|
599
|
+
permissions: permissionsRef.current,
|
|
600
|
+
options: optionsRef.current,
|
|
601
|
+
conversationHistory: transcriptRef.current,
|
|
602
|
+
signal: sendOptions?.signal
|
|
603
|
+
});
|
|
604
|
+
if (response.error?.code !== "ABORTED") {
|
|
605
|
+
transcriptRef.current = messages;
|
|
606
|
+
}
|
|
607
|
+
const assistantEntry = {
|
|
608
|
+
role: "assistant",
|
|
609
|
+
content: response.message,
|
|
610
|
+
toolCalls: response.toolCalls,
|
|
611
|
+
timestamp: Date.now()
|
|
612
|
+
};
|
|
613
|
+
setHistory((prev) => [...prev, assistantEntry]);
|
|
614
|
+
setLastResponse(response);
|
|
615
|
+
if (response.error && response.error.code !== "ABORTED") {
|
|
616
|
+
optionsRef.current?.onError?.(response.error);
|
|
617
|
+
}
|
|
618
|
+
return response;
|
|
619
|
+
} catch (error) {
|
|
620
|
+
const agentError = {
|
|
621
|
+
message: error instanceof Error ? error.message : "Unknown error",
|
|
622
|
+
cause: error
|
|
623
|
+
};
|
|
624
|
+
const errorResponse = {
|
|
625
|
+
message: "",
|
|
626
|
+
toolCalls: [],
|
|
627
|
+
error: agentError
|
|
628
|
+
};
|
|
629
|
+
setLastResponse(errorResponse);
|
|
630
|
+
optionsRef.current?.onError?.(agentError);
|
|
631
|
+
return errorResponse;
|
|
632
|
+
} finally {
|
|
633
|
+
setIsProcessing(false);
|
|
634
|
+
}
|
|
635
|
+
}, []);
|
|
636
|
+
const contextValue = useMemo(
|
|
637
|
+
() => ({
|
|
638
|
+
send,
|
|
639
|
+
isProcessing,
|
|
640
|
+
history,
|
|
641
|
+
clearHistory,
|
|
642
|
+
lastResponse
|
|
643
|
+
}),
|
|
644
|
+
[send, isProcessing, history, clearHistory, lastResponse]
|
|
645
|
+
);
|
|
646
|
+
return /* @__PURE__ */ jsx(AgentContextValue.Provider, { value: contextValue, children });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/provider/useAgent.ts
|
|
650
|
+
import { useContext } from "react";
|
|
651
|
+
function useAgent() {
|
|
652
|
+
const context = useContext(AgentContextValue);
|
|
653
|
+
if (!context) {
|
|
654
|
+
throw new Error(
|
|
655
|
+
"useAgent() must be used within an <AIAgentProvider>. Wrap your component tree with <AIAgentProvider> to use this hook."
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
return context;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/permissions/filterState.ts
|
|
662
|
+
function filterState(state, canAccess) {
|
|
663
|
+
const filtered = {};
|
|
664
|
+
for (const key of canAccess) {
|
|
665
|
+
if (key in state) {
|
|
666
|
+
filtered[key] = state[key];
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return filtered;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/adapters/openai.ts
|
|
673
|
+
var DEFAULT_MODEL = "gpt-4o";
|
|
674
|
+
var DEFAULT_TEMPERATURE = 0.2;
|
|
675
|
+
var OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
676
|
+
function openAIAdapter(config) {
|
|
677
|
+
if (!config.apiKey && !config.baseURL) {
|
|
678
|
+
throw new Error(
|
|
679
|
+
'openAIAdapter requires either "apiKey" or "baseURL". Provide an API key for direct access, or a baseURL to route through your backend proxy.'
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
const baseURL = config.baseURL ? config.baseURL.replace(/\/+$/, "") : OPENAI_BASE_URL;
|
|
683
|
+
const model = config.model ?? DEFAULT_MODEL;
|
|
684
|
+
const temperature = config.temperature ?? DEFAULT_TEMPERATURE;
|
|
685
|
+
return {
|
|
686
|
+
async sendMessage(request) {
|
|
687
|
+
const headers = {
|
|
688
|
+
"Content-Type": "application/json",
|
|
689
|
+
...config.headers
|
|
690
|
+
};
|
|
691
|
+
if (config.apiKey) {
|
|
692
|
+
headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
693
|
+
}
|
|
694
|
+
const messages = request.systemPrompt ? [
|
|
695
|
+
{ role: "system", content: request.systemPrompt },
|
|
696
|
+
...request.messages.map(formatMessage)
|
|
697
|
+
] : request.messages.map(formatMessage);
|
|
698
|
+
const tools = request.tools.length > 0 ? request.tools.map((t) => ({
|
|
699
|
+
type: "function",
|
|
700
|
+
function: {
|
|
701
|
+
name: t.name,
|
|
702
|
+
description: t.description,
|
|
703
|
+
parameters: t.parameters
|
|
704
|
+
}
|
|
705
|
+
})) : void 0;
|
|
706
|
+
const body = {
|
|
707
|
+
model,
|
|
708
|
+
messages,
|
|
709
|
+
temperature
|
|
710
|
+
};
|
|
711
|
+
if (tools) {
|
|
712
|
+
body.tools = tools;
|
|
713
|
+
}
|
|
714
|
+
const url = baseURL.includes("/chat/completions") ? baseURL : `${baseURL}/chat/completions`;
|
|
715
|
+
let res;
|
|
716
|
+
try {
|
|
717
|
+
res = await fetch(url, {
|
|
718
|
+
method: "POST",
|
|
719
|
+
headers,
|
|
720
|
+
body: JSON.stringify(body),
|
|
721
|
+
signal: request.signal
|
|
722
|
+
});
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
725
|
+
throw new Error(
|
|
726
|
+
`Network error calling OpenAI API: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (!res.ok) {
|
|
730
|
+
const text = await res.text().catch(() => "");
|
|
731
|
+
throw new Error(
|
|
732
|
+
`OpenAI API error (${res.status}): ${text || res.statusText}`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
let data;
|
|
736
|
+
try {
|
|
737
|
+
data = await res.json();
|
|
738
|
+
} catch {
|
|
739
|
+
throw new Error("Failed to parse OpenAI API response as JSON");
|
|
740
|
+
}
|
|
741
|
+
return parseResponse(data);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
function formatMessage(msg) {
|
|
746
|
+
const formatted = {
|
|
747
|
+
role: msg.role,
|
|
748
|
+
content: msg.content
|
|
749
|
+
};
|
|
750
|
+
if (msg.toolCallId) {
|
|
751
|
+
formatted.tool_call_id = msg.toolCallId;
|
|
752
|
+
}
|
|
753
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
754
|
+
formatted.tool_calls = msg.toolCalls.map((tc) => ({
|
|
755
|
+
id: tc.id,
|
|
756
|
+
type: "function",
|
|
757
|
+
function: {
|
|
758
|
+
name: tc.name,
|
|
759
|
+
arguments: typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments ?? {})
|
|
760
|
+
}
|
|
761
|
+
}));
|
|
762
|
+
if (msg.content === "") {
|
|
763
|
+
formatted.content = null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return formatted;
|
|
767
|
+
}
|
|
768
|
+
function parseResponse(data) {
|
|
769
|
+
const obj = data;
|
|
770
|
+
const choices = obj.choices;
|
|
771
|
+
if (!choices || choices.length === 0) {
|
|
772
|
+
throw new Error("Malformed OpenAI response: no choices returned");
|
|
773
|
+
}
|
|
774
|
+
const message = choices[0].message;
|
|
775
|
+
if (!message) {
|
|
776
|
+
throw new Error("Malformed OpenAI response: no message in first choice");
|
|
777
|
+
}
|
|
778
|
+
const content = message.content ?? null;
|
|
779
|
+
const toolCalls = message.tool_calls;
|
|
780
|
+
const usage = obj.usage;
|
|
781
|
+
return {
|
|
782
|
+
content,
|
|
783
|
+
toolCalls: toolCalls?.map((tc) => ({
|
|
784
|
+
id: tc.id,
|
|
785
|
+
name: tc.function.name,
|
|
786
|
+
arguments: safeParseJSON(tc.function.arguments)
|
|
787
|
+
})),
|
|
788
|
+
usage: usage ? {
|
|
789
|
+
promptTokens: usage.prompt_tokens,
|
|
790
|
+
completionTokens: usage.completion_tokens
|
|
791
|
+
} : void 0
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function safeParseJSON(str) {
|
|
795
|
+
try {
|
|
796
|
+
return JSON.parse(str);
|
|
797
|
+
} catch {
|
|
798
|
+
return str;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/adapters/claude.ts
|
|
803
|
+
var DEFAULT_MODEL2 = "claude-opus-5";
|
|
804
|
+
var DEFAULT_MAX_TOKENS = 16e3;
|
|
805
|
+
var ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
806
|
+
var ANTHROPIC_VERSION = "2023-06-01";
|
|
807
|
+
function claudeAdapter(config) {
|
|
808
|
+
if (!config.apiKey && !config.baseURL) {
|
|
809
|
+
throw new Error(
|
|
810
|
+
'claudeAdapter requires either "apiKey" or "baseURL". Provide an API key for direct access, or a baseURL to route through your backend proxy.'
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const baseURL = config.baseURL ? config.baseURL.replace(/\/+$/, "") : ANTHROPIC_BASE_URL;
|
|
814
|
+
const model = config.model ?? DEFAULT_MODEL2;
|
|
815
|
+
const maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
816
|
+
return {
|
|
817
|
+
async sendMessage(request) {
|
|
818
|
+
const headers = {
|
|
819
|
+
"Content-Type": "application/json",
|
|
820
|
+
"anthropic-version": ANTHROPIC_VERSION
|
|
821
|
+
};
|
|
822
|
+
if (config.apiKey) {
|
|
823
|
+
headers["x-api-key"] = config.apiKey;
|
|
824
|
+
}
|
|
825
|
+
Object.assign(headers, config.headers);
|
|
826
|
+
const body = {
|
|
827
|
+
model,
|
|
828
|
+
max_tokens: maxTokens,
|
|
829
|
+
messages: toAnthropicMessages(request.messages)
|
|
830
|
+
};
|
|
831
|
+
if (request.systemPrompt) {
|
|
832
|
+
body.system = request.systemPrompt;
|
|
833
|
+
}
|
|
834
|
+
if (request.tools.length > 0) {
|
|
835
|
+
body.tools = request.tools.map((t) => ({
|
|
836
|
+
name: t.name,
|
|
837
|
+
description: t.description,
|
|
838
|
+
input_schema: t.parameters
|
|
839
|
+
}));
|
|
840
|
+
}
|
|
841
|
+
const url = baseURL.includes("/v1/messages") ? baseURL : `${baseURL}/v1/messages`;
|
|
842
|
+
let res;
|
|
843
|
+
try {
|
|
844
|
+
res = await fetch(url, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
headers,
|
|
847
|
+
body: JSON.stringify(body),
|
|
848
|
+
signal: request.signal
|
|
849
|
+
});
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
852
|
+
throw new Error(
|
|
853
|
+
`Network error calling Anthropic API: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
if (!res.ok) {
|
|
857
|
+
const text = await res.text().catch(() => "");
|
|
858
|
+
throw new Error(
|
|
859
|
+
`Anthropic API error (${res.status}): ${text || res.statusText}`
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
let data;
|
|
863
|
+
try {
|
|
864
|
+
data = await res.json();
|
|
865
|
+
} catch {
|
|
866
|
+
throw new Error("Failed to parse Anthropic API response as JSON");
|
|
867
|
+
}
|
|
868
|
+
return parseResponse2(data);
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
function toAnthropicMessages(messages) {
|
|
873
|
+
const result = [];
|
|
874
|
+
let pendingToolResults = [];
|
|
875
|
+
const flushToolResults = () => {
|
|
876
|
+
if (pendingToolResults.length > 0) {
|
|
877
|
+
result.push({ role: "user", content: pendingToolResults });
|
|
878
|
+
pendingToolResults = [];
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
for (const message of messages) {
|
|
882
|
+
if (message.role === "tool") {
|
|
883
|
+
pendingToolResults.push({
|
|
884
|
+
type: "tool_result",
|
|
885
|
+
tool_use_id: message.toolCallId ?? "",
|
|
886
|
+
content: message.content
|
|
887
|
+
});
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
flushToolResults();
|
|
891
|
+
if (message.role === "assistant" && message.toolCalls && message.toolCalls.length > 0) {
|
|
892
|
+
const blocks = [];
|
|
893
|
+
if (message.content) {
|
|
894
|
+
blocks.push({ type: "text", text: message.content });
|
|
895
|
+
}
|
|
896
|
+
for (const call of message.toolCalls) {
|
|
897
|
+
blocks.push({
|
|
898
|
+
type: "tool_use",
|
|
899
|
+
id: call.id,
|
|
900
|
+
name: call.name,
|
|
901
|
+
input: isPlainObject2(call.arguments) ? call.arguments : {}
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
result.push({ role: "assistant", content: blocks });
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
result.push({ role: message.role, content: message.content });
|
|
908
|
+
}
|
|
909
|
+
flushToolResults();
|
|
910
|
+
return result;
|
|
911
|
+
}
|
|
912
|
+
function parseResponse2(data) {
|
|
913
|
+
const obj = data;
|
|
914
|
+
const blocks = obj.content;
|
|
915
|
+
if (!Array.isArray(blocks)) {
|
|
916
|
+
throw new Error("Malformed Anthropic response: no content blocks returned");
|
|
917
|
+
}
|
|
918
|
+
const textParts = [];
|
|
919
|
+
const toolCalls = [];
|
|
920
|
+
for (const block of blocks) {
|
|
921
|
+
if (block?.type === "text") {
|
|
922
|
+
textParts.push(block.text);
|
|
923
|
+
} else if (block?.type === "tool_use") {
|
|
924
|
+
toolCalls.push({
|
|
925
|
+
id: block.id,
|
|
926
|
+
name: block.name,
|
|
927
|
+
arguments: block.input
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const usage = obj.usage;
|
|
932
|
+
return {
|
|
933
|
+
content: textParts.length > 0 ? textParts.join("") : null,
|
|
934
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
|
|
935
|
+
usage: usage ? {
|
|
936
|
+
promptTokens: usage.input_tokens,
|
|
937
|
+
completionTokens: usage.output_tokens
|
|
938
|
+
} : void 0
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
function isPlainObject2(value) {
|
|
942
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
943
|
+
}
|
|
944
|
+
export {
|
|
945
|
+
AIAgentProvider,
|
|
946
|
+
claudeAdapter,
|
|
947
|
+
filterState,
|
|
948
|
+
filterTools,
|
|
949
|
+
openAIAdapter,
|
|
950
|
+
registerTool,
|
|
951
|
+
useAgent,
|
|
952
|
+
validateToolCall,
|
|
953
|
+
validateToolNames
|
|
954
|
+
};
|
|
955
|
+
//# sourceMappingURL=index.js.map
|