sideagent 0.1.0 → 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 +45 -1
- package/dist/index.js +18 -1
- package/dist/server/index.d.ts +19 -6
- package/dist/server/index.js +85 -38
- package/dist/workers-ai/index.d.ts +56 -0
- package/dist/workers-ai/index.js +241 -0
- package/package.json +16 -3
- package/skills/sideagent/SKILL.md +60 -0
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Embed a chat agent in your app. You give it an OpenAPI spec and an OpenAI-compat
|
|
|
6
6
|
npm i sideagent
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
Docs: https://egig.github.io/sideagent/
|
|
10
|
+
|
|
9
11
|
## Server (`sideagent/server`)
|
|
10
12
|
|
|
11
13
|
```ts
|
|
@@ -96,7 +98,7 @@ Neutral by default (grayscale, host font, 10px radius). `theme="auto"` follows t
|
|
|
96
98
|
|
|
97
99
|
## Results and icons
|
|
98
100
|
|
|
99
|
-
Tool results render as readable views: arrays of objects become tables (up to 50 rows and 8 columns, numbers right-aligned, dates and booleans formatted), objects become labelled fields, errors and rejections get their own state. Raw JSON stays under "Details".
|
|
101
|
+
Tool results render as readable views: arrays of objects become tables (up to 50 rows and 8 columns, numbers right-aligned, dates and booleans formatted), objects become labelled fields, errors and rejections get their own state. Raw JSON stays under "Details". Tool results are plain text, never HTML. Assistant replies render as Markdown (GFM); raw HTML is dropped. Override per operation:
|
|
100
102
|
|
|
101
103
|
```tsx
|
|
102
104
|
<AgentChat resultRenderers={{ listInvoices: ({ result }) => <MyInvoiceTable rows={result as any[]} /> }} />
|
|
@@ -130,3 +132,45 @@ createAgent({
|
|
|
130
132
|
The agent gets a `search_knowledge` tool; answers show a "Sources" card (links only for your configured URLs). Passages reach the model as labelled untrusted data and never reach the browser. URLs come only from config, must be public http(s) (private ranges need `allowPrivateNetworks`, metadata addresses never), follow only same-host redirects, and are capped by `maxSourceBytes`. URL sources refresh in the background after `ttlMs` (default 1h) or on `agent.knowledge.refresh()`. A source that fails to load is skipped with a warning and shown in `doctor()`.
|
|
131
133
|
|
|
132
134
|
With the default in-memory store, every start re-embeds all sources (embedding cost). Use a persistent `vectorStore` for large knowledge.
|
|
135
|
+
|
|
136
|
+
## Cloudflare Workers
|
|
137
|
+
|
|
138
|
+
`sideagent/server` runs on Workers. Use `nodejs_compat` (or just `nodejs_als`) in `wrangler.jsonc`. LangGraph's approval pause (`interrupt()`) needs `AsyncLocalStorage`, and the Workers build of LangGraph does not register it. sideagent registers it for you when the runtime has it. Without the flag, reads work and the first approval fails (startup logs a warning).
|
|
139
|
+
|
|
140
|
+
```jsonc
|
|
141
|
+
{ "compatibility_date": "2025-09-01", "compatibility_flags": ["nodejs_compat"] }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Export conditions.** `sideagent/server` resolves to the real build under `workerd` and `worker`, listed before `browser` (which stays a throwing stub for real browser bundles). Wrangler (esbuild) and `@cloudflare/vite-plugin` need no alias. Both also set `browser`, so the order in `exports` matters.
|
|
145
|
+
|
|
146
|
+
**Workers AI as the LLM.** `env.AI` is a binding, not an HTTP endpoint, so use the adapter from `sideagent/workers-ai` instead of `llm`:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { createAgent } from "sideagent/server";
|
|
150
|
+
import { workersAiChatModel, workersAiEmbeddings } from "sideagent/workers-ai";
|
|
151
|
+
|
|
152
|
+
export default {
|
|
153
|
+
async fetch(request: Request, env: Env) {
|
|
154
|
+
agent ??= await createAgent({
|
|
155
|
+
spec, baseUrl: env.API_URL, allow: ["listInvoices", "createInvoice"],
|
|
156
|
+
allowedOrigins: ["https://app.example.com"], approvalSecret: env.APPROVAL_SECRET,
|
|
157
|
+
getAuth,
|
|
158
|
+
model: workersAiChatModel(env.AI, "@cf/openai/gpt-oss-120b"),
|
|
159
|
+
knowledge: { sources }, // optional
|
|
160
|
+
embeddings: workersAiEmbeddings(env.AI, "@cf/baai/bge-m3"), // replaces knowledge.embeddings
|
|
161
|
+
});
|
|
162
|
+
return agent.handler(request);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`model` and `llm` are mutually exclusive; so are the `embeddings` option and `knowledge.embeddings`. `model` can be any LangChain chat model that supports `bindTools()`. `doctor()` checks an injected model with one `invoke`, not an HTTP call. The adapter takes the binding structurally (no `@cloudflare/workers-types` needed) and reads gpt-oss Responses-style output, chat-style `choices[0].message` and `{ response }`.
|
|
169
|
+
|
|
170
|
+
A custom `checkpointer` (D1, Durable Object storage) is a `BaseCheckpointSaver` with four methods: `getTuple`, `list`, `put`, `putWrites`. sideagent also calls `deleteThread` when a user deletes a thread.
|
|
171
|
+
|
|
172
|
+
## For AI agents
|
|
173
|
+
|
|
174
|
+
- Skill: `npx skills add egig/sideagent`, then ask your coding agent to "add an AI assistant to this app". See [Quickstart](https://egig.github.io/sideagent/quickstart).
|
|
175
|
+
- [`AGENTS.md`](AGENTS.md): commands, layout and rules for coding agents working on this repo.
|
|
176
|
+
- [`llms.txt`](https://egig.github.io/sideagent/llms.txt) and [`llms-full.txt`](https://egig.github.io/sideagent/llms-full.txt): the docs in LLM-friendly form.
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
ViewVerticalIcon
|
|
26
26
|
} from "@radix-ui/react-icons";
|
|
27
27
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
28
|
+
import Markdown from "react-markdown";
|
|
29
|
+
import remarkGfm from "remark-gfm";
|
|
28
30
|
|
|
29
31
|
// src/client/hooks.ts
|
|
30
32
|
function safely(fn) {
|
|
@@ -166,6 +168,18 @@ var CSS = `
|
|
|
166
168
|
.sa-msg{max-width:90%;white-space:pre-wrap;word-break:break-word}
|
|
167
169
|
.sa-user{align-self:flex-end;background:var(--sa-surface);padding:7px 12px;border-radius:calc(var(--sa-radius) + 4px)}
|
|
168
170
|
.sa-assistant{align-self:flex-start}
|
|
171
|
+
.sa-md{white-space:normal;min-width:0}
|
|
172
|
+
.sa-md>:first-child{margin-top:0}.sa-md>:last-child{margin-bottom:0}
|
|
173
|
+
.sa-md :is(p,ul,ol,pre,table,blockquote){margin:0 0 .6em}
|
|
174
|
+
.sa-md :is(ul,ol){padding-left:1.4em}
|
|
175
|
+
.sa-md :is(h1,h2,h3,h4){font-size:1em;font-weight:600;margin:.8em 0 .3em}
|
|
176
|
+
.sa-md a{color:inherit;text-decoration:underline}
|
|
177
|
+
.sa-md code{font-family:ui-monospace,monospace;font-size:.9em;background:var(--sa-surface);padding:1px 4px;border-radius:4px}
|
|
178
|
+
.sa-md pre{background:var(--sa-surface);padding:8px 10px;border-radius:calc(var(--sa-radius) - 2px);overflow-x:auto}
|
|
179
|
+
.sa-md pre code{background:none;padding:0}
|
|
180
|
+
.sa-md blockquote{border-left:3px solid var(--sa-border);padding-left:10px;color:var(--sa-muted);margin-left:0}
|
|
181
|
+
.sa-md table{border-collapse:collapse;display:block;overflow-x:auto}
|
|
182
|
+
.sa-md :is(th,td){border:1px solid var(--sa-border);padding:3px 8px;text-align:left}
|
|
169
183
|
.sa-icon-btn{display:inline-grid;place-items:center;min-width:28px;padding:4px}
|
|
170
184
|
.sa-icon-btn svg{width:15px;height:15px}
|
|
171
185
|
.sa-tool{align-self:stretch;border:1px solid var(--sa-border);border-radius:var(--sa-radius);padding:8px 10px;font-size:13px;min-width:0}
|
|
@@ -402,12 +416,15 @@ function ToolCard(props) {
|
|
|
402
416
|
] })
|
|
403
417
|
] });
|
|
404
418
|
}
|
|
419
|
+
var mdComponents = {
|
|
420
|
+
a: ({ node: _n, ...p }) => /* @__PURE__ */ jsx2("a", { ...p, target: "_blank", rel: "noopener noreferrer nofollow" })
|
|
421
|
+
};
|
|
405
422
|
var UserMessage = () => /* @__PURE__ */ jsx2(MessagePrimitive.Root, { className: "sa-msg sa-user", children: /* @__PURE__ */ jsx2(MessagePrimitive.Parts, { components: { Text } }) });
|
|
406
423
|
var AssistantMessage = () => /* @__PURE__ */ jsx2(MessagePrimitive.Root, { style: { display: "contents" }, children: /* @__PURE__ */ jsx2(
|
|
407
424
|
MessagePrimitive.Parts,
|
|
408
425
|
{
|
|
409
426
|
components: {
|
|
410
|
-
Text: ({ text }) => text ? /* @__PURE__ */ jsx2("div", { className: "sa-msg sa-assistant", children: text }) : null,
|
|
427
|
+
Text: ({ text }) => text ? /* @__PURE__ */ jsx2("div", { className: "sa-msg sa-assistant sa-md", children: /* @__PURE__ */ jsx2(Markdown, { remarkPlugins: [remarkGfm], components: mdComponents, children: text }) }) : null,
|
|
411
428
|
tools: { Fallback: ToolCard }
|
|
412
429
|
}
|
|
413
430
|
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -2,16 +2,17 @@ import { ThreadMeta } from '../protocol.js';
|
|
|
2
2
|
export { AgentEvent, ChatRequest } from '../protocol.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { BaseCheckpointSaver } from '@langchain/langgraph';
|
|
5
|
+
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
5
6
|
|
|
6
7
|
declare const configSchema: z.ZodObject<{
|
|
7
8
|
spec: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>]>;
|
|
8
9
|
baseUrl: z.ZodString;
|
|
9
|
-
llm: z.ZodObject<{
|
|
10
|
+
llm: z.ZodOptional<z.ZodObject<{
|
|
10
11
|
baseUrl: z.ZodString;
|
|
11
12
|
apiKey: z.ZodString;
|
|
12
13
|
model: z.ZodString;
|
|
13
14
|
temperature: z.ZodOptional<z.ZodNumber>;
|
|
14
|
-
}, z.core.$strip
|
|
15
|
+
}, z.core.$strip>>;
|
|
15
16
|
allow: z.ZodArray<z.ZodString>;
|
|
16
17
|
allowedOrigins: z.ZodArray<z.ZodString>;
|
|
17
18
|
anonymous: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -28,11 +29,11 @@ declare const configSchema: z.ZodObject<{
|
|
|
28
29
|
url: z.ZodString;
|
|
29
30
|
title: z.ZodOptional<z.ZodString>;
|
|
30
31
|
}, z.core.$strip>]>>>;
|
|
31
|
-
embeddings: z.ZodObject<{
|
|
32
|
+
embeddings: z.ZodOptional<z.ZodObject<{
|
|
32
33
|
model: z.ZodString;
|
|
33
34
|
baseUrl: z.ZodOptional<z.ZodString>;
|
|
34
35
|
apiKey: z.ZodOptional<z.ZodString>;
|
|
35
|
-
}, z.core.$strip
|
|
36
|
+
}, z.core.$strip>>;
|
|
36
37
|
topK: z.ZodDefault<z.ZodNumber>;
|
|
37
38
|
ttlMs: z.ZodDefault<z.ZodNumber>;
|
|
38
39
|
maxSourceBytes: z.ZodDefault<z.ZodNumber>;
|
|
@@ -51,7 +52,10 @@ declare const configSchema: z.ZodObject<{
|
|
|
51
52
|
type AgentConfigInput = z.input<typeof configSchema>;
|
|
52
53
|
type AgentConfig = z.output<typeof configSchema>;
|
|
53
54
|
/** Validates config; also requires getAuth or anonymous:true (checked by caller with the function in hand). */
|
|
54
|
-
declare function parseConfig(input: unknown, hasGetAuth: boolean
|
|
55
|
+
declare function parseConfig(input: unknown, hasGetAuth: boolean, injected?: {
|
|
56
|
+
model?: boolean;
|
|
57
|
+
embeddings?: boolean;
|
|
58
|
+
}): AgentConfig;
|
|
55
59
|
|
|
56
60
|
interface UrlGuardOptions {
|
|
57
61
|
/** Pinned origin from config. Requests may only go here. */
|
|
@@ -187,7 +191,16 @@ interface AuthResult {
|
|
|
187
191
|
/** Headers forwarded to your API (e.g. Authorization, Cookie). Never shown to the LLM. */
|
|
188
192
|
headers?: Record<string, string>;
|
|
189
193
|
}
|
|
194
|
+
/** Same shape as LangChain's Embeddings, minimal so any adapter fits. */
|
|
195
|
+
interface EmbeddingsLike {
|
|
196
|
+
embedDocuments(texts: string[]): Promise<number[][]>;
|
|
197
|
+
embedQuery(text: string): Promise<number[]>;
|
|
198
|
+
}
|
|
190
199
|
interface CreateAgentOptions extends AgentConfigInput {
|
|
200
|
+
/** Your own chat model (e.g. workersAiChatModel from sideagent/workers-ai). Use instead of `llm`, never with it. It must support bindTools(). */
|
|
201
|
+
model?: BaseChatModel;
|
|
202
|
+
/** Your own embeddings, used instead of knowledge.embeddings. */
|
|
203
|
+
embeddings?: EmbeddingsLike;
|
|
191
204
|
getAuth?: (request: Request) => AuthResult | Promise<AuthResult>;
|
|
192
205
|
/** Throw to reject a turn (rate limiting, quota). */
|
|
193
206
|
beforeTurn?: (ctx: {
|
|
@@ -244,4 +257,4 @@ declare function honoHandler(agent: Agent): (c: {
|
|
|
244
257
|
/** Express (use express.json() before it): `app.post("/agent", expressHandler(agent))` and `app.options("/agent", expressHandler(agent))` */
|
|
245
258
|
declare function expressHandler(agent: Agent): (req: any, res: any) => Promise<any>;
|
|
246
259
|
|
|
247
|
-
export { type Agent, type AgentConfig, type AgentConfigInput, type AuditEvent, type AuthResult, type CreateAgentOptions, type DoctorReport, KnowledgeBase, type KnowledgeSourceInput, MemoryThreadStore, MemoryVectorStore, type ThreadStore, type VectorStore, assertAllowedUrl, chunkText, configSchema, createAgent, expressHandler, honoHandler, htmlToText, nextRoute, parseConfig };
|
|
260
|
+
export { type Agent, type AgentConfig, type AgentConfigInput, type AuditEvent, type AuthResult, type CreateAgentOptions, type DoctorReport, type EmbeddingsLike, KnowledgeBase, type KnowledgeSourceInput, MemoryThreadStore, MemoryVectorStore, type ThreadStore, type VectorStore, assertAllowedUrl, chunkText, configSchema, createAgent, expressHandler, honoHandler, htmlToText, nextRoute, parseConfig };
|
package/dist/server/index.js
CHANGED
|
@@ -6,8 +6,8 @@ var knowledgeSchema = z.object({
|
|
|
6
6
|
sources: z.array(z.object({ id: z.string().min(1).max(64), title: z.string().min(1).max(200), content: z.string() })).default([]),
|
|
7
7
|
/** Pages the server fetches itself (never model-chosen). Public hosts only unless allowPrivateNetworks. */
|
|
8
8
|
urls: z.array(z.union([url, z.object({ url, title: z.string().max(200).optional() })])).default([]),
|
|
9
|
-
/** OpenAI-compatible /embeddings endpoint. baseUrl and apiKey default to the llm ones. */
|
|
10
|
-
embeddings: z.object({ model: z.string().min(1), baseUrl: url.optional(), apiKey: z.string().min(1).optional() }),
|
|
9
|
+
/** OpenAI-compatible /embeddings endpoint. baseUrl and apiKey default to the llm ones. Omit when you pass the `embeddings` option to createAgent. */
|
|
10
|
+
embeddings: z.object({ model: z.string().min(1), baseUrl: url.optional(), apiKey: z.string().min(1).optional() }).optional(),
|
|
11
11
|
topK: z.number().int().min(1).max(12).default(4),
|
|
12
12
|
ttlMs: z.number().int().positive().default(36e5),
|
|
13
13
|
maxSourceBytes: z.number().int().positive().default(2e5),
|
|
@@ -17,12 +17,13 @@ var knowledgeSchema = z.object({
|
|
|
17
17
|
var configSchema = z.object({
|
|
18
18
|
spec: z.union([z.string(), z.record(z.string(), z.unknown())]),
|
|
19
19
|
baseUrl: url,
|
|
20
|
+
/** OpenAI-compatible endpoint. Give either this or the `model` option of createAgent, never both. */
|
|
20
21
|
llm: z.object({
|
|
21
22
|
baseUrl: url,
|
|
22
23
|
apiKey: z.string().min(1, "llm.apiKey is empty; read it from an env var on the server"),
|
|
23
24
|
model: z.string().min(1),
|
|
24
25
|
temperature: z.number().min(0).max(2).optional()
|
|
25
|
-
}),
|
|
26
|
+
}).optional(),
|
|
26
27
|
allow: z.array(z.string()).min(1, "allow is empty; list operationIds, tags or globs to expose"),
|
|
27
28
|
allowedOrigins: z.array(z.string().url()).min(1, "allowedOrigins is required"),
|
|
28
29
|
anonymous: z.boolean().optional(),
|
|
@@ -47,7 +48,7 @@ var configSchema = z.object({
|
|
|
47
48
|
maxTools: 40
|
|
48
49
|
})
|
|
49
50
|
}).strict();
|
|
50
|
-
function parseConfig(input, hasGetAuth) {
|
|
51
|
+
function parseConfig(input, hasGetAuth, injected = {}) {
|
|
51
52
|
const result = configSchema.safeParse(input);
|
|
52
53
|
if (!result.success) {
|
|
53
54
|
const lines = result.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`);
|
|
@@ -57,6 +58,18 @@ ${lines.join("\n")}`);
|
|
|
57
58
|
if (!hasGetAuth && !result.data.anonymous) {
|
|
58
59
|
throw new Error("Invalid agent config:\n - getAuth: provide getAuth(request) or set anonymous: true explicitly");
|
|
59
60
|
}
|
|
61
|
+
const { llm, knowledge } = result.data;
|
|
62
|
+
const fail = (msg) => {
|
|
63
|
+
throw new Error(`Invalid agent config:
|
|
64
|
+
- ${msg}`);
|
|
65
|
+
};
|
|
66
|
+
if (llm && injected.model) fail("llm and model are mutually exclusive: pass llm for an OpenAI-compatible endpoint, or model for your own chat model");
|
|
67
|
+
if (!llm && !injected.model) fail("no model: pass llm ({ baseUrl, apiKey, model }) or model (a LangChain BaseChatModel)");
|
|
68
|
+
if (knowledge?.embeddings && injected.embeddings) fail("knowledge.embeddings and the embeddings option are mutually exclusive");
|
|
69
|
+
if (knowledge && !knowledge.embeddings && !injected.embeddings) fail("knowledge needs knowledge.embeddings (HTTP endpoint) or the embeddings option");
|
|
70
|
+
if (knowledge?.embeddings && !llm && !(knowledge.embeddings.baseUrl && knowledge.embeddings.apiKey)) {
|
|
71
|
+
fail("knowledge.embeddings needs baseUrl and apiKey when llm is not set");
|
|
72
|
+
}
|
|
60
73
|
return result.data;
|
|
61
74
|
}
|
|
62
75
|
|
|
@@ -112,7 +125,7 @@ function assertPublicUrl(url2, allowPrivateNetworks) {
|
|
|
112
125
|
// src/server/create-agent.ts
|
|
113
126
|
import { HumanMessage } from "@langchain/core/messages";
|
|
114
127
|
import { Command as Command2 } from "@langchain/langgraph";
|
|
115
|
-
import { OpenAIEmbeddings } from "@langchain/openai";
|
|
128
|
+
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
|
|
116
129
|
import { z as z2 } from "zod";
|
|
117
130
|
|
|
118
131
|
// src/server/approval.ts
|
|
@@ -152,7 +165,6 @@ async function verifyApproval(secret, token, claims) {
|
|
|
152
165
|
|
|
153
166
|
// src/server/graph.ts
|
|
154
167
|
import { SystemMessage, ToolMessage } from "@langchain/core/messages";
|
|
155
|
-
import { ChatOpenAI } from "@langchain/openai";
|
|
156
168
|
import {
|
|
157
169
|
Command,
|
|
158
170
|
END,
|
|
@@ -195,8 +207,8 @@ async function readCapped(res, max) {
|
|
|
195
207
|
let size = 0;
|
|
196
208
|
let truncated = false;
|
|
197
209
|
for (; ; ) {
|
|
198
|
-
const { done, value } = await reader.read();
|
|
199
|
-
if (
|
|
210
|
+
const { done: done2, value } = await reader.read();
|
|
211
|
+
if (done2) break;
|
|
200
212
|
size += value.byteLength;
|
|
201
213
|
if (size > max) {
|
|
202
214
|
truncated = true;
|
|
@@ -606,7 +618,7 @@ function redact(value) {
|
|
|
606
618
|
|
|
607
619
|
// src/server/graph.ts
|
|
608
620
|
var APPROVAL_TTL_MS = 10 * 6e4;
|
|
609
|
-
function buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge }) {
|
|
621
|
+
function buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge, model: baseModel, bindOptions }) {
|
|
610
622
|
const byName = new Map(ops.map((o) => [o.operationId, o]));
|
|
611
623
|
const toolDefs = ops.map((o) => ({
|
|
612
624
|
type: "function",
|
|
@@ -623,13 +635,8 @@ function buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge }) {
|
|
|
623
635
|
}
|
|
624
636
|
});
|
|
625
637
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
apiKey: config.llm.apiKey,
|
|
629
|
-
temperature: config.llm.temperature,
|
|
630
|
-
streaming: true,
|
|
631
|
-
configuration: { baseURL: config.llm.baseUrl }
|
|
632
|
-
}).bindTools(toolDefs, { parallel_tool_calls: false });
|
|
638
|
+
if (!baseModel.bindTools) throw new Error("The chat model does not support bindTools(), which sideagent needs for tool calling.");
|
|
639
|
+
const model = baseModel.bindTools(toolDefs, bindOptions);
|
|
633
640
|
const audit = (e) => {
|
|
634
641
|
try {
|
|
635
642
|
void Promise.resolve(onAudit?.({ ...e, args: redact(e.args) })).catch(() => {
|
|
@@ -768,6 +775,31 @@ function buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge }) {
|
|
|
768
775
|
return new StateGraph(MessagesAnnotation).addNode("agent", agentNode).addNode("tools", toolsNode).addEdge(START, "agent").addConditionalEdges("agent", route, ["tools", END]).addEdge("tools", "agent").compile({ checkpointer: checkpointer ?? new MemorySaver() });
|
|
769
776
|
}
|
|
770
777
|
|
|
778
|
+
// src/server/als.ts
|
|
779
|
+
import { AsyncLocalStorageProviderSingleton } from "@langchain/core/singletons";
|
|
780
|
+
var done;
|
|
781
|
+
function ensureAsyncLocalStorage() {
|
|
782
|
+
done ??= (async () => {
|
|
783
|
+
const spec = "node:async_hooks";
|
|
784
|
+
let ALS = globalThis.AsyncLocalStorage;
|
|
785
|
+
if (!ALS) {
|
|
786
|
+
try {
|
|
787
|
+
ALS = (await import(
|
|
788
|
+
/* @vite-ignore */
|
|
789
|
+
spec
|
|
790
|
+
)).AsyncLocalStorage;
|
|
791
|
+
} catch {
|
|
792
|
+
console.warn(
|
|
793
|
+
'[sideagent] AsyncLocalStorage is unavailable, so approval pauses will fail. On Cloudflare Workers add "nodejs_compat" (or "nodejs_als") to compatibility_flags.'
|
|
794
|
+
);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
AsyncLocalStorageProviderSingleton.initializeGlobalInstance(new ALS());
|
|
799
|
+
})();
|
|
800
|
+
return done;
|
|
801
|
+
}
|
|
802
|
+
|
|
771
803
|
// src/server/history.ts
|
|
772
804
|
function textOf(content) {
|
|
773
805
|
if (typeof content === "string") return content;
|
|
@@ -844,13 +876,15 @@ var chatBody = z2.union([
|
|
|
844
876
|
})
|
|
845
877
|
]);
|
|
846
878
|
var enc2 = encodeURIComponent;
|
|
879
|
+
var PING_TOOL = { type: "function", function: { name: "ping", description: "ping", parameters: { type: "object", properties: {} } } };
|
|
847
880
|
var MAX_BODY = 256 * 1024;
|
|
848
881
|
function json(status, body, extra = {}) {
|
|
849
882
|
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...extra } });
|
|
850
883
|
}
|
|
851
884
|
async function createAgent(options) {
|
|
852
|
-
const { getAuth, beforeTurn, onAudit, checkpointer, threadStore: givenStore, vectorStore, ...data } = options;
|
|
853
|
-
|
|
885
|
+
const { getAuth, beforeTurn, onAudit, checkpointer, threadStore: givenStore, vectorStore, model: givenModel, embeddings: givenEmbeddings, ...data } = options;
|
|
886
|
+
await ensureAsyncLocalStorage();
|
|
887
|
+
const config = parseConfig(data, !!getAuth, { model: !!givenModel, embeddings: !!givenEmbeddings });
|
|
854
888
|
const ops = await loadOperations(config.spec, config.allow, config.limits.maxTools);
|
|
855
889
|
const secret = config.approvalSecret ?? crypto.randomUUID() + crypto.randomUUID();
|
|
856
890
|
if (!config.approvalSecret) {
|
|
@@ -869,7 +903,7 @@ async function createAgent(options) {
|
|
|
869
903
|
throw new Error(`An allowlisted operation is named "${SEARCH_TOOL}", which is reserved for knowledge search`);
|
|
870
904
|
}
|
|
871
905
|
const k = config.knowledge;
|
|
872
|
-
const embedder = new OpenAIEmbeddings({
|
|
906
|
+
const embedder = givenEmbeddings ?? new OpenAIEmbeddings({
|
|
873
907
|
model: k.embeddings.model,
|
|
874
908
|
apiKey: k.embeddings.apiKey ?? config.llm.apiKey,
|
|
875
909
|
configuration: { baseURL: k.embeddings.baseUrl ?? config.llm.baseUrl }
|
|
@@ -884,7 +918,9 @@ async function createAgent(options) {
|
|
|
884
918
|
const timedOut = await Promise.race([loading.then(() => false), new Promise((r) => setTimeout(() => r(true), 6e4))]);
|
|
885
919
|
if (timedOut) console.warn("[sideagent] knowledge is still loading after 60s; search results may be incomplete until it finishes.");
|
|
886
920
|
}
|
|
887
|
-
const
|
|
921
|
+
const llm = config.llm;
|
|
922
|
+
const model = givenModel ?? new ChatOpenAI({ model: llm.model, apiKey: llm.apiKey, temperature: llm.temperature, streaming: true, configuration: { baseURL: llm.baseUrl } });
|
|
923
|
+
const graph = buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge, model, bindOptions: givenModel ? void 0 : { parallel_tool_calls: false } });
|
|
888
924
|
const active = /* @__PURE__ */ new Map();
|
|
889
925
|
async function handler(request) {
|
|
890
926
|
const origin = request.headers.get("origin");
|
|
@@ -1016,31 +1052,42 @@ async function createAgent(options) {
|
|
|
1016
1052
|
headers: { ...cors, "content-type": "text/event-stream", "cache-control": "no-store" }
|
|
1017
1053
|
});
|
|
1018
1054
|
}
|
|
1019
|
-
async function
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1055
|
+
async function checkInjectedModel(m) {
|
|
1056
|
+
try {
|
|
1057
|
+
if (!m.bindTools) return { name: "llm tool calling", ok: false, detail: "model has no bindTools()" };
|
|
1058
|
+
const reply = await m.bindTools([PING_TOOL]).invoke("Call the ping tool.");
|
|
1059
|
+
const called = (reply.tool_calls?.length ?? 0) > 0;
|
|
1060
|
+
return { name: "llm tool calling", ok: called, detail: called ? "supported" : "model did not return a tool call" };
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
return { name: "llm", ok: false, detail: e.message };
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
async function checkHttpLlm(cfg) {
|
|
1024
1066
|
try {
|
|
1025
|
-
const res = await fetch(`${
|
|
1067
|
+
const res = await fetch(`${cfg.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
1026
1068
|
method: "POST",
|
|
1027
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${
|
|
1069
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
|
|
1028
1070
|
body: JSON.stringify({
|
|
1029
|
-
model:
|
|
1071
|
+
model: cfg.model,
|
|
1030
1072
|
messages: [{ role: "user", content: "Call the ping tool." }],
|
|
1031
|
-
tools: [
|
|
1073
|
+
tools: [PING_TOOL],
|
|
1032
1074
|
tool_choice: { type: "function", function: { name: "ping" } }
|
|
1033
1075
|
})
|
|
1034
1076
|
});
|
|
1035
|
-
if (!res.ok)
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
checks.push({ name: "llm tool calling", ok: called, detail: called ? "supported" : "model did not return a tool call" });
|
|
1040
|
-
}
|
|
1077
|
+
if (!res.ok) return { name: "llm", ok: false, detail: `HTTP ${res.status}` };
|
|
1078
|
+
const j = await res.json();
|
|
1079
|
+
const called = j?.choices?.[0]?.message?.tool_calls?.length > 0;
|
|
1080
|
+
return { name: "llm tool calling", ok: called, detail: called ? "supported" : "model did not return a tool call" };
|
|
1041
1081
|
} catch (e) {
|
|
1042
|
-
|
|
1082
|
+
return { name: "llm", ok: false, detail: e.message };
|
|
1043
1083
|
}
|
|
1084
|
+
}
|
|
1085
|
+
async function doctor() {
|
|
1086
|
+
const checks = [];
|
|
1087
|
+
checks.push({ name: "spec", ok: true, detail: `${ops.length} operation(s) exposed` });
|
|
1088
|
+
if (secret === config.approvalSecret) checks.push({ name: "approvalSecret", ok: true, detail: "set" });
|
|
1089
|
+
else checks.push({ name: "approvalSecret", ok: false, detail: "not set; random per instance" });
|
|
1090
|
+
checks.push(await (givenModel ? checkInjectedModel(givenModel) : checkHttpLlm(config.llm)));
|
|
1044
1091
|
if (knowledge) {
|
|
1045
1092
|
const st = knowledge.status();
|
|
1046
1093
|
const bad = st.filter((x) => x.error);
|
|
@@ -1090,8 +1137,8 @@ function expressHandler(agent) {
|
|
|
1090
1137
|
if (!r.body) return res.end();
|
|
1091
1138
|
const reader = r.body.getReader();
|
|
1092
1139
|
for (; ; ) {
|
|
1093
|
-
const { done, value } = await reader.read();
|
|
1094
|
-
if (
|
|
1140
|
+
const { done: done2, value } = await reader.read();
|
|
1141
|
+
if (done2) break;
|
|
1095
1142
|
res.write(value);
|
|
1096
1143
|
}
|
|
1097
1144
|
res.end();
|
|
@@ -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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sideagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,9 +10,17 @@
|
|
|
10
10
|
},
|
|
11
11
|
"./server": {
|
|
12
12
|
"types": "./dist/server/index.d.ts",
|
|
13
|
+
"workerd": "./dist/server/index.js",
|
|
14
|
+
"worker": "./dist/server/index.js",
|
|
13
15
|
"browser": "./dist/server/browser.js",
|
|
14
16
|
"import": "./dist/server/index.js"
|
|
15
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
|
+
},
|
|
16
24
|
"./protocol": {
|
|
17
25
|
"types": "./dist/protocol.d.ts",
|
|
18
26
|
"import": "./dist/protocol.js"
|
|
@@ -23,14 +31,17 @@
|
|
|
23
31
|
}
|
|
24
32
|
},
|
|
25
33
|
"files": [
|
|
26
|
-
"dist"
|
|
34
|
+
"dist",
|
|
35
|
+
"skills",
|
|
36
|
+
"!skills/**/evals"
|
|
27
37
|
],
|
|
28
38
|
"scripts": {
|
|
29
39
|
"build": "tsup",
|
|
30
40
|
"test": "vitest run",
|
|
31
41
|
"typecheck": "tsc --noEmit",
|
|
32
42
|
"demo:api": "tsx demo/server.ts",
|
|
33
|
-
"demo": "tsx demo/server.ts & vite demo"
|
|
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"
|
|
34
45
|
},
|
|
35
46
|
"dependencies": {
|
|
36
47
|
"@assistant-ui/react": "^0.15.31",
|
|
@@ -39,6 +50,8 @@
|
|
|
39
50
|
"@langchain/openai": "^1.7.0",
|
|
40
51
|
"@radix-ui/react-icons": "^1.3.2",
|
|
41
52
|
"@scalar/openapi-parser": "^0.29.16",
|
|
53
|
+
"react-markdown": "^10.1.0",
|
|
54
|
+
"remark-gfm": "^4.0.1",
|
|
42
55
|
"zod": "^4.0.0"
|
|
43
56
|
},
|
|
44
57
|
"peerDependencies": {
|
|
@@ -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.
|