sideagent 0.0.0-stage → 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/README.md +175 -2
- package/dist/index.d.ts +97 -0
- package/dist/index.js +759 -0
- package/dist/protocol.d.ts +87 -0
- package/dist/protocol.js +0 -0
- package/dist/server/browser.d.ts +2 -0
- package/dist/server/browser.js +4 -0
- package/dist/server/index.d.ts +260 -0
- package/dist/server/index.js +1160 -0
- package/dist/testing/mock-llm.d.ts +7 -0
- package/dist/testing/mock-llm.js +84 -0
- package/dist/workers-ai/index.d.ts +56 -0
- package/dist/workers-ai/index.js +241 -0
- package/package.json +79 -4
- package/skills/sideagent/SKILL.md +60 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
declare function mockLlmHandler(request: Request): Promise<Response>;
|
|
2
|
+
/** Deterministic bag-of-words embedding: texts that share words get similar vectors. */
|
|
3
|
+
declare function mockEmbed(text: string): number[];
|
|
4
|
+
/** Scripted OpenAI-compatible /embeddings endpoint for tests and demos. */
|
|
5
|
+
declare function mockEmbeddingsHandler(request: Request): Promise<Response>;
|
|
6
|
+
|
|
7
|
+
export { mockEmbed, mockEmbeddingsHandler, mockLlmHandler };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// src/testing/mock-llm.ts
|
|
2
|
+
function script(messages, forced) {
|
|
3
|
+
if (forced) return { call: { name: forced, args: {} } };
|
|
4
|
+
const last = messages[messages.length - 1];
|
|
5
|
+
if (!last) return { text: "..." };
|
|
6
|
+
if (last.role === "tool") return { text: `Done. Result: ${String(last.content).slice(0, 120)}` };
|
|
7
|
+
const text = String(last.content ?? "");
|
|
8
|
+
const explicit = /CALL (\S+)\s*(\{.*\})?/s.exec(text);
|
|
9
|
+
if (explicit) return { call: { name: explicit[1], args: explicit[2] ? JSON.parse(explicit[2]) : {} } };
|
|
10
|
+
if (/\b(policy|refunds?|terms|docs|warranty)\b/i.test(text)) return { call: { name: "search_knowledge", args: { query: text } } };
|
|
11
|
+
if (/\blist\b/i.test(text)) return { call: { name: "listInvoices", args: {} } };
|
|
12
|
+
if (/\bcreate\b/i.test(text)) return { call: { name: "createInvoice", args: { body: { customer: "Acme", amount: 100 } } } };
|
|
13
|
+
if (/\bdelete\b/i.test(text)) return { call: { name: "deleteInvoice", args: { id: "1" } } };
|
|
14
|
+
return { text: "Hello from the mock model." };
|
|
15
|
+
}
|
|
16
|
+
async function mockLlmHandler(request) {
|
|
17
|
+
const body = await request.json();
|
|
18
|
+
const { text, call } = script(body.messages, body.tool_choice?.function?.name);
|
|
19
|
+
const id = `chatcmpl-mock-${crypto.randomUUID()}`;
|
|
20
|
+
const base = { id, object: "chat.completion.chunk", created: 0, model: "mock" };
|
|
21
|
+
if (!body.stream) {
|
|
22
|
+
return Response.json({
|
|
23
|
+
id,
|
|
24
|
+
object: "chat.completion",
|
|
25
|
+
created: 0,
|
|
26
|
+
model: "mock",
|
|
27
|
+
choices: [
|
|
28
|
+
{
|
|
29
|
+
index: 0,
|
|
30
|
+
finish_reason: call ? "tool_calls" : "stop",
|
|
31
|
+
message: call ? { role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: call.name, arguments: JSON.stringify(call.args) } }] } : { role: "assistant", content: text }
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const chunks = [{ ...base, choices: [{ index: 0, delta: { role: "assistant", content: "" } }] }];
|
|
37
|
+
if (call) {
|
|
38
|
+
chunks.push({
|
|
39
|
+
...base,
|
|
40
|
+
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: `call_${Math.random().toString(36).slice(2, 8)}`, type: "function", function: { name: call.name, arguments: JSON.stringify(call.args) } }] } }]
|
|
41
|
+
});
|
|
42
|
+
chunks.push({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] });
|
|
43
|
+
} else {
|
|
44
|
+
for (const w of (text ?? "").split(/(?<= )/)) chunks.push({ ...base, choices: [{ index: 0, delta: { content: w } }] });
|
|
45
|
+
chunks.push({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
|
46
|
+
}
|
|
47
|
+
const sse = chunks.map((c) => `data: ${JSON.stringify(c)}
|
|
48
|
+
|
|
49
|
+
`).join("") + "data: [DONE]\n\n";
|
|
50
|
+
return new Response(sse, { headers: { "content-type": "text/event-stream" } });
|
|
51
|
+
}
|
|
52
|
+
var DIM = 128;
|
|
53
|
+
function hashToken(t) {
|
|
54
|
+
let h = 2166136261;
|
|
55
|
+
for (let i = 0; i < t.length; i++) h = Math.imul(h ^ t.charCodeAt(i), 16777619);
|
|
56
|
+
return Math.abs(h) % DIM;
|
|
57
|
+
}
|
|
58
|
+
function mockEmbed(text) {
|
|
59
|
+
const v = new Array(DIM).fill(0);
|
|
60
|
+
for (const w of text.toLowerCase().match(/[a-z0-9]+/g) ?? []) v[hashToken(w)] += 1;
|
|
61
|
+
const n = Math.sqrt(v.reduce((a, b) => a + b * b, 0)) || 1;
|
|
62
|
+
return v.map((x) => x / n);
|
|
63
|
+
}
|
|
64
|
+
async function mockEmbeddingsHandler(request) {
|
|
65
|
+
const body = await request.json();
|
|
66
|
+
const inputs = Array.isArray(body.input) ? body.input : [body.input];
|
|
67
|
+
const toB64 = (v) => {
|
|
68
|
+
const bytes = new Uint8Array(new Float32Array(v).buffer);
|
|
69
|
+
let s = "";
|
|
70
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
71
|
+
return btoa(s);
|
|
72
|
+
};
|
|
73
|
+
return Response.json({
|
|
74
|
+
object: "list",
|
|
75
|
+
model: "mock-embedding",
|
|
76
|
+
data: inputs.map((t, index) => ({ object: "embedding", index, embedding: body.encoding_format === "base64" ? toB64(mockEmbed(t)) : mockEmbed(t) })),
|
|
77
|
+
usage: { prompt_tokens: 0, total_tokens: 0 }
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export {
|
|
81
|
+
mockEmbed,
|
|
82
|
+
mockEmbeddingsHandler,
|
|
83
|
+
mockLlmHandler
|
|
84
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { BaseChatModel, BaseChatModelCallOptions, BindToolsInput } from '@langchain/core/language_models/chat_models';
|
|
2
|
+
import { BaseLanguageModelInput } from '@langchain/core/language_models/base';
|
|
3
|
+
import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
|
4
|
+
import { AIMessageChunk, BaseMessage } from '@langchain/core/messages';
|
|
5
|
+
import { ChatResult, ChatGenerationChunk } from '@langchain/core/outputs';
|
|
6
|
+
import { Runnable } from '@langchain/core/runnables';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The part of the Workers AI binding (`env.AI`) used here. Structural on purpose:
|
|
10
|
+
* no dependency on @cloudflare/workers-types.
|
|
11
|
+
*/
|
|
12
|
+
interface WorkersAiBinding {
|
|
13
|
+
run(model: string, input: unknown): Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
interface WorkersAiChatOptions {
|
|
16
|
+
temperature?: number;
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
/** Ask the binding for a stream. Falls back to one chunk when it returns a plain object. Default true. */
|
|
19
|
+
streaming?: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface WorkersAiCallOptions extends BaseChatModelCallOptions {
|
|
22
|
+
tools?: unknown[];
|
|
23
|
+
}
|
|
24
|
+
interface ToolCall {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
args: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
interface Parsed {
|
|
30
|
+
text: string;
|
|
31
|
+
toolCalls: ToolCall[];
|
|
32
|
+
}
|
|
33
|
+
/** Reads every response shape the binding is known to return. Throws on anything else. */
|
|
34
|
+
declare function parseWorkersAiResponse(res: unknown): Parsed;
|
|
35
|
+
declare class ChatWorkersAI extends BaseChatModel<WorkersAiCallOptions> {
|
|
36
|
+
private readonly ai;
|
|
37
|
+
private readonly modelName;
|
|
38
|
+
private readonly opts;
|
|
39
|
+
constructor(ai: WorkersAiBinding, modelName: string, opts?: WorkersAiChatOptions);
|
|
40
|
+
_llmType(): string;
|
|
41
|
+
bindTools(tools: BindToolsInput[], kwargs?: Partial<WorkersAiCallOptions>): Runnable<BaseLanguageModelInput, AIMessageChunk, WorkersAiCallOptions>;
|
|
42
|
+
private input;
|
|
43
|
+
private run;
|
|
44
|
+
_generate(messages: BaseMessage[], options: this["ParsedCallOptions"]): Promise<ChatResult>;
|
|
45
|
+
_streamResponseChunks(messages: BaseMessage[], options: this["ParsedCallOptions"], runManager?: CallbackManagerForLLMRun): AsyncGenerator<ChatGenerationChunk>;
|
|
46
|
+
private emit;
|
|
47
|
+
}
|
|
48
|
+
/** A LangChain chat model backed by the Workers AI binding. Pass it to createAgent as `model`. */
|
|
49
|
+
declare function workersAiChatModel(ai: WorkersAiBinding, model: string, opts?: WorkersAiChatOptions): BaseChatModel;
|
|
50
|
+
/** Embeddings backed by the Workers AI binding. Pass them to createAgent as `embeddings`. */
|
|
51
|
+
declare function workersAiEmbeddings(ai: WorkersAiBinding, model: string): {
|
|
52
|
+
embedDocuments: (texts: string[]) => Promise<number[][]>;
|
|
53
|
+
embedQuery(text: string): Promise<number[]>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export { ChatWorkersAI, type WorkersAiBinding, type WorkersAiChatOptions, parseWorkersAiResponse, workersAiChatModel, workersAiEmbeddings };
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// src/workers-ai/index.ts
|
|
2
|
+
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
|
3
|
+
import { AIMessage, AIMessageChunk } from "@langchain/core/messages";
|
|
4
|
+
import { ChatGenerationChunk } from "@langchain/core/outputs";
|
|
5
|
+
import { convertToOpenAITool } from "@langchain/core/utils/function_calling";
|
|
6
|
+
var isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
7
|
+
function textOf(content) {
|
|
8
|
+
if (typeof content === "string") return content;
|
|
9
|
+
return content.map((p) => p.type === "text" && typeof p.text === "string" ? p.text : "").join("");
|
|
10
|
+
}
|
|
11
|
+
function toWire(messages) {
|
|
12
|
+
return messages.map((m) => {
|
|
13
|
+
const type = m.getType();
|
|
14
|
+
const content = textOf(m.content);
|
|
15
|
+
if (type === "system") return { role: "system", content };
|
|
16
|
+
if (type === "human") return { role: "user", content };
|
|
17
|
+
if (type === "tool") return { role: "tool", tool_call_id: m.tool_call_id, content };
|
|
18
|
+
const calls = m.tool_calls ?? [];
|
|
19
|
+
return {
|
|
20
|
+
role: "assistant",
|
|
21
|
+
content,
|
|
22
|
+
...calls.length ? { tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args ?? {}) } })) } : {}
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function parseArgs(raw) {
|
|
27
|
+
if (raw == null || raw === "") return {};
|
|
28
|
+
if (isObj(raw)) return raw;
|
|
29
|
+
if (typeof raw === "string") {
|
|
30
|
+
let v;
|
|
31
|
+
try {
|
|
32
|
+
v = JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error("Workers AI returned a tool call with malformed arguments");
|
|
35
|
+
}
|
|
36
|
+
if (isObj(v)) return v;
|
|
37
|
+
}
|
|
38
|
+
throw new Error("Workers AI returned a tool call with malformed arguments");
|
|
39
|
+
}
|
|
40
|
+
var newId = () => `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
41
|
+
function parseWorkersAiResponse(res) {
|
|
42
|
+
const r = isObj(res) && isObj(res.result) && !("response" in res) && !("choices" in res) && !("output" in res) ? res.result : res;
|
|
43
|
+
if (typeof r === "string") return { text: r, toolCalls: [] };
|
|
44
|
+
if (!isObj(r)) throw new Error("Workers AI returned an unrecognized response");
|
|
45
|
+
let text = "";
|
|
46
|
+
const toolCalls = [];
|
|
47
|
+
let known = false;
|
|
48
|
+
if (Array.isArray(r.output)) {
|
|
49
|
+
known = true;
|
|
50
|
+
for (const item of r.output) {
|
|
51
|
+
if (!isObj(item)) continue;
|
|
52
|
+
if (item.type === "function_call") {
|
|
53
|
+
toolCalls.push({ id: String(item.call_id ?? item.id ?? newId()), name: String(item.name), args: parseArgs(item.arguments) });
|
|
54
|
+
} else if (item.type === "message" || item.type === void 0 && Array.isArray(item.content)) {
|
|
55
|
+
for (const part of Array.isArray(item.content) ? item.content : []) {
|
|
56
|
+
if (isObj(part) && (part.type === "output_text" || part.type === "text") && typeof part.text === "string") text += part.text;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const msg = Array.isArray(r.choices) && isObj(r.choices[0]) ? r.choices[0].message : void 0;
|
|
62
|
+
if (isObj(msg)) {
|
|
63
|
+
known = true;
|
|
64
|
+
if (typeof msg.content === "string") text += msg.content;
|
|
65
|
+
for (const c of Array.isArray(msg.tool_calls) ? msg.tool_calls : []) {
|
|
66
|
+
const fn = isObj(c) ? c.function ?? c : void 0;
|
|
67
|
+
if (!isObj(fn) || typeof fn.name !== "string") throw new Error("Workers AI returned a malformed tool call");
|
|
68
|
+
toolCalls.push({ id: String(c.id ?? newId()), name: fn.name, args: parseArgs(fn.arguments) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (typeof r.response === "string") {
|
|
72
|
+
known = true;
|
|
73
|
+
text += r.response;
|
|
74
|
+
} else if (r.response === null) known = true;
|
|
75
|
+
if (Array.isArray(r.tool_calls)) {
|
|
76
|
+
known = true;
|
|
77
|
+
for (const c of r.tool_calls) {
|
|
78
|
+
const fn = isObj(c) ? c.function ?? c : void 0;
|
|
79
|
+
if (!isObj(fn) || typeof fn.name !== "string") throw new Error("Workers AI returned a malformed tool call");
|
|
80
|
+
toolCalls.push({ id: String(c.id ?? newId()), name: fn.name, args: parseArgs(fn.arguments) });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!known) throw new Error("Workers AI returned an unrecognized response");
|
|
84
|
+
return { text, toolCalls };
|
|
85
|
+
}
|
|
86
|
+
async function* sseEvents(stream) {
|
|
87
|
+
const dec = new TextDecoder();
|
|
88
|
+
let buf = "";
|
|
89
|
+
const source = Symbol.asyncIterator in stream ? stream : readerIterable(stream);
|
|
90
|
+
function* drain(final) {
|
|
91
|
+
const lines = buf.split("\n");
|
|
92
|
+
buf = final ? "" : lines.pop() ?? "";
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
const l = line.trim();
|
|
95
|
+
if (!l.startsWith("data:")) continue;
|
|
96
|
+
const data = l.slice(5).trim();
|
|
97
|
+
if (!data || data === "[DONE]") continue;
|
|
98
|
+
try {
|
|
99
|
+
yield JSON.parse(data);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error("Workers AI returned a malformed stream event");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for await (const chunk of source) {
|
|
106
|
+
buf += typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true });
|
|
107
|
+
yield* drain(false);
|
|
108
|
+
}
|
|
109
|
+
yield* drain(true);
|
|
110
|
+
}
|
|
111
|
+
async function* readerIterable(stream) {
|
|
112
|
+
const reader = stream.getReader();
|
|
113
|
+
try {
|
|
114
|
+
for (; ; ) {
|
|
115
|
+
const { done, value } = await reader.read();
|
|
116
|
+
if (done) return;
|
|
117
|
+
yield value;
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
reader.releaseLock();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
var isStream = (v) => !!v && typeof v === "object" && (typeof v.getReader === "function" || Symbol.asyncIterator in v);
|
|
124
|
+
var ChatWorkersAI = class extends BaseChatModel {
|
|
125
|
+
constructor(ai, modelName, opts = {}) {
|
|
126
|
+
super({});
|
|
127
|
+
this.ai = ai;
|
|
128
|
+
this.modelName = modelName;
|
|
129
|
+
this.opts = opts;
|
|
130
|
+
}
|
|
131
|
+
ai;
|
|
132
|
+
modelName;
|
|
133
|
+
opts;
|
|
134
|
+
_llmType() {
|
|
135
|
+
return "workers-ai";
|
|
136
|
+
}
|
|
137
|
+
bindTools(tools, kwargs) {
|
|
138
|
+
return this.withConfig({ ...kwargs, tools: tools.map((t) => convertToOpenAITool(t)) });
|
|
139
|
+
}
|
|
140
|
+
input(messages, options, stream) {
|
|
141
|
+
return {
|
|
142
|
+
messages: toWire(messages),
|
|
143
|
+
...options.tools?.length ? { tools: options.tools } : {},
|
|
144
|
+
...this.opts.temperature !== void 0 ? { temperature: this.opts.temperature } : {},
|
|
145
|
+
...this.opts.maxTokens !== void 0 ? { max_tokens: this.opts.maxTokens } : {},
|
|
146
|
+
...stream ? { stream: true } : {}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async run(input, signal) {
|
|
150
|
+
signal?.throwIfAborted();
|
|
151
|
+
return this.ai.run(this.modelName, input);
|
|
152
|
+
}
|
|
153
|
+
async _generate(messages, options) {
|
|
154
|
+
const { text, toolCalls } = parseWorkersAiResponse(await this.run(this.input(messages, options, false), options.signal));
|
|
155
|
+
const message = new AIMessage({ content: text, tool_calls: toolCalls.map((c) => ({ type: "tool_call", ...c })) });
|
|
156
|
+
return { generations: [{ text, message }] };
|
|
157
|
+
}
|
|
158
|
+
async *_streamResponseChunks(messages, options, runManager) {
|
|
159
|
+
const res = this.opts.streaming === false ? void 0 : await this.run(this.input(messages, options, true), options.signal);
|
|
160
|
+
if (!isStream(res)) {
|
|
161
|
+
const full = res === void 0 ? parseWorkersAiResponse(await this.run(this.input(messages, options, false), options.signal)) : parseWorkersAiResponse(res);
|
|
162
|
+
yield* this.emit(full, runManager);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const partial = /* @__PURE__ */ new Map();
|
|
166
|
+
const whole = [];
|
|
167
|
+
for await (const ev of sseEvents(res)) {
|
|
168
|
+
options.signal?.throwIfAborted();
|
|
169
|
+
if (!isObj(ev)) continue;
|
|
170
|
+
let delta = "";
|
|
171
|
+
if (typeof ev.response === "string") delta = ev.response;
|
|
172
|
+
const choice = Array.isArray(ev.choices) && isObj(ev.choices[0]) ? ev.choices[0] : void 0;
|
|
173
|
+
if (choice) {
|
|
174
|
+
const d = isObj(choice.delta) ? choice.delta : isObj(choice.message) ? choice.message : {};
|
|
175
|
+
if (typeof d.content === "string") delta += d.content;
|
|
176
|
+
for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) {
|
|
177
|
+
const i = typeof tc.index === "number" ? tc.index : partial.size;
|
|
178
|
+
const cur = partial.get(i) ?? { name: "", args: "" };
|
|
179
|
+
if (tc.id) cur.id = String(tc.id);
|
|
180
|
+
if (tc.function?.name) cur.name += tc.function.name;
|
|
181
|
+
if (typeof tc.function?.arguments === "string") cur.args += tc.function.arguments;
|
|
182
|
+
partial.set(i, cur);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (ev.type === "response.output_text.delta" && typeof ev.delta === "string") delta += ev.delta;
|
|
186
|
+
if (ev.type === "response.output_item.done" && isObj(ev.item) && ev.item.type === "function_call") {
|
|
187
|
+
whole.push({ id: String(ev.item.call_id ?? ev.item.id ?? newId()), name: String(ev.item.name), args: parseArgs(ev.item.arguments) });
|
|
188
|
+
}
|
|
189
|
+
if (Array.isArray(ev.tool_calls)) whole.push(...parseWorkersAiResponse({ tool_calls: ev.tool_calls }).toolCalls);
|
|
190
|
+
if (delta) yield* this.emit({ text: delta, toolCalls: [] }, runManager);
|
|
191
|
+
}
|
|
192
|
+
const toolCalls = [...whole];
|
|
193
|
+
for (const p of partial.values()) {
|
|
194
|
+
if (!p.name) throw new Error("Workers AI returned a malformed tool call");
|
|
195
|
+
toolCalls.push({ id: p.id ?? newId(), name: p.name, args: parseArgs(p.args) });
|
|
196
|
+
}
|
|
197
|
+
if (toolCalls.length) yield* this.emit({ text: "", toolCalls }, runManager);
|
|
198
|
+
}
|
|
199
|
+
async *emit({ text, toolCalls }, runManager) {
|
|
200
|
+
const chunk = new ChatGenerationChunk({
|
|
201
|
+
text,
|
|
202
|
+
message: new AIMessageChunk({
|
|
203
|
+
content: text,
|
|
204
|
+
tool_call_chunks: toolCalls.map((c, index) => ({ type: "tool_call_chunk", index, id: c.id, name: c.name, args: JSON.stringify(c.args) }))
|
|
205
|
+
})
|
|
206
|
+
});
|
|
207
|
+
yield chunk;
|
|
208
|
+
await runManager?.handleLLMNewToken(text, void 0, void 0, void 0, void 0, { chunk });
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
function workersAiChatModel(ai, model, opts) {
|
|
212
|
+
return new ChatWorkersAI(ai, model, opts);
|
|
213
|
+
}
|
|
214
|
+
var EMBED_BATCH = 100;
|
|
215
|
+
function workersAiEmbeddings(ai, model) {
|
|
216
|
+
async function embedDocuments(texts) {
|
|
217
|
+
const out = [];
|
|
218
|
+
for (let i = 0; i < texts.length; i += EMBED_BATCH) {
|
|
219
|
+
const batch = texts.slice(i, i + EMBED_BATCH);
|
|
220
|
+
const res = await ai.run(model, { text: batch });
|
|
221
|
+
const data = res?.data ?? res?.result?.data;
|
|
222
|
+
if (!Array.isArray(data) || data.length !== batch.length || !data.every((v) => Array.isArray(v))) {
|
|
223
|
+
throw new Error("Workers AI returned an unrecognized embeddings response");
|
|
224
|
+
}
|
|
225
|
+
out.push(...data);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
embedDocuments,
|
|
231
|
+
async embedQuery(text) {
|
|
232
|
+
return (await embedDocuments([text]))[0];
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
export {
|
|
237
|
+
ChatWorkersAI,
|
|
238
|
+
parseWorkersAiResponse,
|
|
239
|
+
workersAiChatModel,
|
|
240
|
+
workersAiEmbeddings
|
|
241
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sideagent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./server": {
|
|
12
|
+
"types": "./dist/server/index.d.ts",
|
|
13
|
+
"workerd": "./dist/server/index.js",
|
|
14
|
+
"worker": "./dist/server/index.js",
|
|
15
|
+
"browser": "./dist/server/browser.js",
|
|
16
|
+
"import": "./dist/server/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./workers-ai": {
|
|
19
|
+
"types": "./dist/workers-ai/index.d.ts",
|
|
20
|
+
"workerd": "./dist/workers-ai/index.js",
|
|
21
|
+
"worker": "./dist/workers-ai/index.js",
|
|
22
|
+
"import": "./dist/workers-ai/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./protocol": {
|
|
25
|
+
"types": "./dist/protocol.d.ts",
|
|
26
|
+
"import": "./dist/protocol.js"
|
|
27
|
+
},
|
|
28
|
+
"./testing": {
|
|
29
|
+
"types": "./dist/testing/mock-llm.d.ts",
|
|
30
|
+
"import": "./dist/testing/mock-llm.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"skills",
|
|
36
|
+
"!skills/**/evals"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"demo:api": "tsx demo/server.ts",
|
|
43
|
+
"demo": "tsx demo/server.ts & vite demo",
|
|
44
|
+
"eval:skill": "cd skills/sideagent && claude plugin eval . --scaffold --allow-tools Write Edit --trust-plugin --no-publish"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@assistant-ui/react": "^0.15.31",
|
|
48
|
+
"@langchain/core": "^1.0.0",
|
|
49
|
+
"@langchain/langgraph": "^1.5.2",
|
|
50
|
+
"@langchain/openai": "^1.7.0",
|
|
51
|
+
"@radix-ui/react-icons": "^1.3.2",
|
|
52
|
+
"@scalar/openapi-parser": "^0.29.16",
|
|
53
|
+
"react-markdown": "^10.1.0",
|
|
54
|
+
"remark-gfm": "^4.0.1",
|
|
55
|
+
"zod": "^4.0.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"react": ">=18"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"react": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@hono/node-server": "^1.14.0",
|
|
67
|
+
"@types/node": "^22.0.0",
|
|
68
|
+
"@types/react": "^19.0.0",
|
|
69
|
+
"@types/react-dom": "^19.0.0",
|
|
70
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
71
|
+
"hono": "^4.9.0",
|
|
72
|
+
"react": "^19.0.0",
|
|
73
|
+
"react-dom": "^19.0.0",
|
|
74
|
+
"tsup": "^8.5.0",
|
|
75
|
+
"tsx": "^4.20.0",
|
|
76
|
+
"typescript": "^5.9.0",
|
|
77
|
+
"vite": "^7.0.0",
|
|
78
|
+
"vitest": "^3.2.0"
|
|
79
|
+
},
|
|
80
|
+
"packageManager": "pnpm@10.30.2"
|
|
81
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sideagent
|
|
3
|
+
description: Add an AI agent, chat assistant or copilot to an existing app or SaaS that has an HTTP API, using the sideagent library. Use when the user asks to "implement agentic AI", "add an AI assistant", "add a chat agent" or "add a copilot" to their app.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add an agent to an app with sideagent
|
|
7
|
+
|
|
8
|
+
sideagent turns an OpenAPI spec plus an OpenAI-compatible LLM into a secure agent endpoint (`sideagent/server`) and a React chat UI (`sideagent`). Full docs: https://egig.github.io/sideagent/llms-full.txt
|
|
9
|
+
|
|
10
|
+
## Steps
|
|
11
|
+
|
|
12
|
+
Do them in order. Do not skip the review in step 7.
|
|
13
|
+
|
|
14
|
+
1. **Detect the stack.** Find the server framework (Next.js App Router, Hono, Express), the frontend (React?) and the OpenAPI spec. No spec: generate one from the existing routes, or ask the user where it is. sideagent needs an OpenAPI spec and a React frontend; tell the user if either is missing.
|
|
15
|
+
2. **Install.** `npm i sideagent` (use the project's package manager).
|
|
16
|
+
3. **Fix the spec.** For each operation the agent may use:
|
|
17
|
+
- Unique `operationId`, a clear `summary`, typed parameters.
|
|
18
|
+
- `x-agent` hints where useful: `{ approval, description, invalidates: ["tag"], responseFields: ["id"] }`. `approval` is the string `"required"` or `"auto"` (never a boolean). Non-GET defaults to `"required"`; leave that default. `"auto"` is rejected for DELETE. Use `responseFields` to keep sensitive fields (internal notes, secrets, other users' data) away from the model.
|
|
19
|
+
4. **Add the server route** at `/agent`:
|
|
20
|
+
```ts
|
|
21
|
+
import { createAgent, nextRoute /* or honoHandler, expressHandler */ } from "sideagent/server";
|
|
22
|
+
|
|
23
|
+
const agent = await createAgent({
|
|
24
|
+
spec, // URL, object or JSON string
|
|
25
|
+
baseUrl: process.env.API_BASE_URL!, // pinned; the spec's `servers` is ignored
|
|
26
|
+
llm: { baseUrl: process.env.LLM_BASE_URL ?? "https://api.openai.com/v1", apiKey: process.env.LLM_API_KEY!, model: process.env.LLM_MODEL ?? "gpt-4o-mini" },
|
|
27
|
+
allow: ["listInvoices"], // default-deny; start read-only
|
|
28
|
+
allowedOrigins: [process.env.APP_ORIGIN!],
|
|
29
|
+
approvalSecret: process.env.APPROVAL_SECRET, // 16+ chars; required on serverless / multi-instance
|
|
30
|
+
getAuth: async (req) => {
|
|
31
|
+
const session = await verifySession(req); // use the app's existing auth
|
|
32
|
+
if (!session) throw new Error("no session");
|
|
33
|
+
return { userId: session.userId, headers: { authorization: `Bearer ${session.apiToken}` } };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
export const { POST, OPTIONS } = nextRoute(agent); // Next.js; Hono: app.on(["POST","OPTIONS"], "/agent", honoHandler(agent))
|
|
37
|
+
```
|
|
38
|
+
Express needs `express.json()` first, then `app.post` and `app.options` with `expressHandler(agent)`.
|
|
39
|
+
5. **Mount the UI** once in the app shell:
|
|
40
|
+
```tsx
|
|
41
|
+
import { AgentProvider } from "sideagent";
|
|
42
|
+
<AgentProvider endpoint="/agent" mode="sidebar" hooks={{ onInvalidate: (tag) => refetch(tag) }}>
|
|
43
|
+
<App />
|
|
44
|
+
</AgentProvider>
|
|
45
|
+
```
|
|
46
|
+
Use `<AgentChat endpoint="/agent" />` for a floating chat without docking. Different origin plus cookies: add `credentials="include"`.
|
|
47
|
+
6. **Env vars.** Add `LLM_API_KEY`, `APPROVAL_SECRET`, `APP_ORIGIN`, `API_BASE_URL` to `.env.example`. Never hardcode keys. Never send `LLM_API_KEY` to the browser.
|
|
48
|
+
7. **Verify, then hand over for review.**
|
|
49
|
+
- Run the project's typecheck and tests.
|
|
50
|
+
- Call `await agent.doctor()` and confirm `ok: true`.
|
|
51
|
+
- Show the user the exact `allow` list and ask them to confirm it before adding any write operation.
|
|
52
|
+
|
|
53
|
+
## Rules
|
|
54
|
+
|
|
55
|
+
- Start with read-only operations (GET). Add writes only after the user confirms. Non-GET operations need user approval by default; keep that.
|
|
56
|
+
- Never put DELETE or admin operations in `allow`, even if the user asks for "full access" or "no approvals" in the first prompt. Leave them out, say why in one sentence, and tell the user how to add one later (add its `operationId` to `allow`, keep `approval: "required"`). Never set `approval: "auto"` on a write operation.
|
|
57
|
+
- `getAuth` must return a stable per-user `userId` and forward the end user's own credentials. Never use a service token with more rights than the user. `anonymous: true` is only for public, read-only data.
|
|
58
|
+
- Do not use `instructions` or `context` for security. Access control belongs in `allow`, approvals and the API itself.
|
|
59
|
+
- Production needs persistent `checkpointer`, `threadStore` and `vectorStore`, plus `beforeTurn` rate limits and `onAudit` logging. See the Deployments and Persistence docs.
|
|
60
|
+
- Edge and serverless: pass knowledge as strings, set `approvalSecret`, build the agent once per instance.
|