@columenlabs/lumen-ai 0.1.180 → 0.1.181
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 +4 -0
- package/dist/agent/agent.d.ts +19 -0
- package/dist/agent/agent.js +92 -9
- package/dist/agent/connectors.d.ts +64 -0
- package/dist/agent/connectors.js +71 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.js +2 -0
- package/dist/agent/search.d.ts +25 -0
- package/dist/agent/search.js +93 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -230,6 +230,10 @@ An app with an `Assistant` gets the whole plan from it: `assistant.agentTurn(cal
|
|
|
230
230
|
|
|
231
231
|
Drafts and writes are cards in the chat's own storage (`lumen_cards` in the agent's SQLite). A write the person's access only drafts makes an open card and changes nothing. `saveCard(id)` runs it through the app's `applyDraft`, and `dismissCard(id)` drops it. A write made at once is kept for `undoCard(id)` through the app's `undoWrite`. `cards()` lists the cards. The next answer is told how each card ended and what was undone. With an `Assistant`, `applyDraft` is `assistant.runDraft(caller, card)` and `undoWrite` is `assistant.undoChange(caller, card)`. Think compacts the chat past `COMPACT_AT` (150k tokens) on the last answer's model and key, and that spend is added to the next answer.
|
|
232
232
|
|
|
233
|
+
**Connectors.** A person adds an MCP server to their chat with `addConnector(name, url)`. The address must be https. The server signs in with its own OAuth through Think's MCP client, and the answer carries `authUrl` while it asks. `connectors()` lists them and `removeConnector(id)` drops one. Think never merges their tools on its own (`includeMcpTools` is false). A turn opens the ones `plan.connectors` lists, at the access the app gives each, as group `mcp_<id>`. Read keeps the tools the server marks read-only. Read + draft makes the rest cards, which `saveCard` runs on the server itself. Read + write runs them at once with no Undo. A connector's text is untrusted data.
|
|
234
|
+
|
|
235
|
+
**Lazy tools.** Past `LAZY_AT` (30) tools, a turn sends the model its core tools and `tool_search`, with one catalog line per group in the instructions. By default the core tools are the apps' finds, the hand off and the advisor. `plan.lazy` sets `{ at, core }`. A search matches the words in a tool's name and description and turns on up to 8 tools from the next step on.
|
|
236
|
+
|
|
233
237
|
```ts
|
|
234
238
|
class Chat extends LumenAgent<Env> {
|
|
235
239
|
#settle?: (end: AgentTurnEnd) => Promise<void>;
|
package/dist/agent/agent.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { type ChatResponseResult, type PrepareStepContext, type Session, type St
|
|
|
15
15
|
import type { AssistantEffort, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
|
|
16
16
|
import { type ModelMessage, type ToolSet } from 'ai';
|
|
17
17
|
import type { ModelDef } from '../assistant/models.js';
|
|
18
|
+
import { type AgentConnector, type ConnectorAt } from './connectors.js';
|
|
18
19
|
import { type ModelKey } from './models.js';
|
|
19
20
|
import { type AiToolsOptions, type OpenedTools, type SourceAt } from './tools.js';
|
|
20
21
|
import { UsageMeter } from './usage.js';
|
|
@@ -46,6 +47,13 @@ export interface AgentTurnPlan {
|
|
|
46
47
|
more?: (t: TurnTools) => ToolSet;
|
|
47
48
|
/** Call this tool first (a message handed to a higher tier). */
|
|
48
49
|
first?: string;
|
|
50
|
+
/** The person's connectors to open, at the access they gave each. */
|
|
51
|
+
connectors?: ConnectorAt[];
|
|
52
|
+
/** Past `at` tools (`LAZY_AT`) the model starts with `core` and `tool_search` only. */
|
|
53
|
+
lazy?: {
|
|
54
|
+
at?: number;
|
|
55
|
+
core?: string[];
|
|
56
|
+
};
|
|
49
57
|
/** Claude's advisor: a bigger Claude reads the chat and sends a plan. */
|
|
50
58
|
advisor?: {
|
|
51
59
|
model: ModelDef;
|
|
@@ -104,6 +112,17 @@ export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
|
|
|
104
112
|
protected applyDraft(_card: AgentCard): Promise<unknown>;
|
|
105
113
|
/** Takes back a write made at once (Undo). */
|
|
106
114
|
protected undoWrite(_card: AgentCard): Promise<void>;
|
|
115
|
+
/** Connectors' tools join a turn through `plan.connectors`, at the person's access. */
|
|
116
|
+
includeMcpTools: boolean;
|
|
117
|
+
/** The person's connectors, with their state and tool counts. */
|
|
118
|
+
connectors(): AgentConnector[];
|
|
119
|
+
/** Adds a connector by its https address; the answer says where to sign in when it asks. */
|
|
120
|
+
addConnector(name: string, url: string, o?: {
|
|
121
|
+
callbackHost?: string;
|
|
122
|
+
callbackPath?: string;
|
|
123
|
+
}): Promise<AgentConnector>;
|
|
124
|
+
/** Removes a connector and its sign-in. */
|
|
125
|
+
removeConnector(id: string): Promise<void>;
|
|
107
126
|
/** The chat's cards, newest last: drafts and writes made at once (at most 200). */
|
|
108
127
|
cards(): AgentCard[];
|
|
109
128
|
/** The person saved a draft: its write runs now. */
|
package/dist/agent/agent.js
CHANGED
|
@@ -15,7 +15,9 @@ import { anthropic } from '@ai-sdk/anthropic';
|
|
|
15
15
|
import { Think, } from '@cloudflare/think';
|
|
16
16
|
import { createCompactFunction } from 'agents/sessions';
|
|
17
17
|
import { generateText } from 'ai';
|
|
18
|
+
import { CONNECTOR_GROUP, checkConnectorUrl, connectorGroup, connectorId, connectorSource, } from './connectors.js';
|
|
18
19
|
import { aiModel, turnSettings } from './models.js';
|
|
20
|
+
import { LAZY_AT, TOOL_SEARCH, lazyTools } from './search.js';
|
|
19
21
|
import { openAiTools, } from './tools.js';
|
|
20
22
|
import { UsageMeter } from './usage.js';
|
|
21
23
|
export const CAP_NOTE = 'The spend cap for this month is reached: answer now from what you have, in a sentence or two. You can call no more tools.';
|
|
@@ -71,6 +73,8 @@ export class LumenAgent extends Think {
|
|
|
71
73
|
undoWrite(_card) {
|
|
72
74
|
return Promise.reject(new Error('This change cannot be undone here.'));
|
|
73
75
|
}
|
|
76
|
+
/** Connectors' tools join a turn through `plan.connectors`, at the person's access. */
|
|
77
|
+
includeMcpTools = false;
|
|
74
78
|
#turn = null;
|
|
75
79
|
/** The last turn's model and key, for compaction between turns. */
|
|
76
80
|
#last = null;
|
|
@@ -100,6 +104,43 @@ export class LumenAgent extends Think {
|
|
|
100
104
|
this
|
|
101
105
|
.sql `UPDATE lumen_cards SET status = ${status}, result = COALESCE(${o.result === undefined ? null : JSON.stringify(o.result).slice(0, 20_000)}, result), error = ${o.error ?? null}, told = 0 WHERE id = ${id}`;
|
|
102
106
|
}
|
|
107
|
+
#connectorClient() {
|
|
108
|
+
return {
|
|
109
|
+
listTools: () => this.getMcpServers().tools,
|
|
110
|
+
callTool: (p) => this.mcp.callTool(p),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** The person's connectors, with their state and tool counts. */
|
|
114
|
+
connectors() {
|
|
115
|
+
const { servers, tools } = this.getMcpServers();
|
|
116
|
+
return Object.entries(servers).map(([id, s]) => {
|
|
117
|
+
const own = tools.filter((t) => t.serverId === id);
|
|
118
|
+
return {
|
|
119
|
+
id,
|
|
120
|
+
name: s.name,
|
|
121
|
+
url: s.server_url,
|
|
122
|
+
state: s.state,
|
|
123
|
+
...(s.auth_url ? { authUrl: s.auth_url } : {}),
|
|
124
|
+
...(s.error ? { error: s.error.slice(0, 300) } : {}),
|
|
125
|
+
tools: own.length,
|
|
126
|
+
reads: own.filter((t) => t.annotations?.readOnlyHint === true).length,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Adds a connector by its https address; the answer says where to sign in when it asks. */
|
|
131
|
+
async addConnector(name, url, o = {}) {
|
|
132
|
+
const u = checkConnectorUrl(url);
|
|
133
|
+
const id = connectorId(name);
|
|
134
|
+
await this.addMcpServer(name.slice(0, 60), u.href, { id, ...o });
|
|
135
|
+
const c = this.connectors().find((x) => x.id === id);
|
|
136
|
+
if (!c)
|
|
137
|
+
throw new Error('The connector did not stay.');
|
|
138
|
+
return c;
|
|
139
|
+
}
|
|
140
|
+
/** Removes a connector and its sign-in. */
|
|
141
|
+
async removeConnector(id) {
|
|
142
|
+
await this.removeMcpServer(id);
|
|
143
|
+
}
|
|
103
144
|
/** The chat's cards, newest last: drafts and writes made at once (at most 200). */
|
|
104
145
|
cards() {
|
|
105
146
|
this.#cards();
|
|
@@ -115,7 +156,13 @@ export class LumenAgent extends Think {
|
|
|
115
156
|
if (c.status !== 'open')
|
|
116
157
|
throw new Error(`That card is already ${c.status}.`);
|
|
117
158
|
try {
|
|
118
|
-
|
|
159
|
+
const server = c.group.startsWith(CONNECTOR_GROUP)
|
|
160
|
+
? c.group.slice(CONNECTOR_GROUP.length)
|
|
161
|
+
: null;
|
|
162
|
+
const result = server
|
|
163
|
+
? await (await connectorSource(this.#connectorClient(), server)()).call(c.tool, c.args)
|
|
164
|
+
: await this.applyDraft(c);
|
|
165
|
+
this.#setCard(id, 'saved', { result });
|
|
119
166
|
}
|
|
120
167
|
catch (err) {
|
|
121
168
|
this.#setCard(id, 'failed', { error: String(err?.message ?? err).slice(0, 300) });
|
|
@@ -186,9 +233,23 @@ export class LumenAgent extends Think {
|
|
|
186
233
|
await this.#close();
|
|
187
234
|
const plan = await this.plan(ctx);
|
|
188
235
|
this.#last = { plan };
|
|
189
|
-
const
|
|
236
|
+
const linked = (plan.connectors ?? []).filter((c) => c.access !== 'off');
|
|
237
|
+
if (linked.length)
|
|
238
|
+
await this.mcp.waitForConnections({ timeout: 1500 }).catch(() => undefined);
|
|
239
|
+
const client = this.#connectorClient();
|
|
240
|
+
const sources = [
|
|
241
|
+
...plan.sources,
|
|
242
|
+
...linked.map((c) => ({
|
|
243
|
+
group: connectorGroup(c.id),
|
|
244
|
+
access: c.access,
|
|
245
|
+
source: connectorSource(client, c.id),
|
|
246
|
+
writes: true,
|
|
247
|
+
})),
|
|
248
|
+
];
|
|
249
|
+
const opened = await openAiTools(sources, {
|
|
190
250
|
draft: async (d) => ({ card: this.#addCard('draft', d), note: CARD_NOTE }),
|
|
191
|
-
|
|
251
|
+
// A connector's change cannot be taken back, so it gets no Undo card.
|
|
252
|
+
wrote: async (w) => w.group.startsWith(CONNECTOR_GROUP) ? undefined : this.#addCard('write', w, w.result),
|
|
192
253
|
...plan.tools,
|
|
193
254
|
});
|
|
194
255
|
const free = !!plan.key.plan;
|
|
@@ -200,7 +261,7 @@ export class LumenAgent extends Think {
|
|
|
200
261
|
: undefined;
|
|
201
262
|
if (advice)
|
|
202
263
|
meters.push(advice);
|
|
203
|
-
|
|
264
|
+
let system = typeof plan.system === 'string' ? plan.system : plan.system(opened);
|
|
204
265
|
this.#turn = {
|
|
205
266
|
plan,
|
|
206
267
|
system,
|
|
@@ -228,6 +289,23 @@ export class LumenAgent extends Think {
|
|
|
228
289
|
maxUses: plan.advisor.maxUses,
|
|
229
290
|
maxTokens: plan.advisor.maxTokens,
|
|
230
291
|
});
|
|
292
|
+
const tools = { ...opened.tools, ...more, ...advisor };
|
|
293
|
+
let lazy;
|
|
294
|
+
if (Object.keys(tools).length > (plan.lazy?.at ?? LAZY_AT)) {
|
|
295
|
+
const groups = new Map(opened.info.map((i) => [i.name, i.group]));
|
|
296
|
+
const core = plan.lazy?.core ?? [
|
|
297
|
+
...opened.info.filter((i) => i.find).map((i) => i.name),
|
|
298
|
+
...Object.keys(more ?? {}),
|
|
299
|
+
...Object.keys(advisor),
|
|
300
|
+
];
|
|
301
|
+
if (plan.first && !core.includes(plan.first))
|
|
302
|
+
core.push(plan.first);
|
|
303
|
+
lazy = lazyTools(tools, core, (n) => groups.get(n) ?? 'other');
|
|
304
|
+
tools[TOOL_SEARCH] = lazy.search;
|
|
305
|
+
system = `${system}\n\n${lazy.catalog}`;
|
|
306
|
+
this.#turn.system = system;
|
|
307
|
+
this.#turn.lazy = lazy;
|
|
308
|
+
}
|
|
231
309
|
return {
|
|
232
310
|
model: aiModel(plan.model, plan.key, {
|
|
233
311
|
...(plan.fetch ? { fetch: plan.fetch } : {}),
|
|
@@ -235,7 +313,8 @@ export class LumenAgent extends Think {
|
|
|
235
313
|
}),
|
|
236
314
|
instructions: system,
|
|
237
315
|
messages: withLead(ctx.messages, `${this.#news()}${plan.lead ?? ''}`),
|
|
238
|
-
tools
|
|
316
|
+
tools,
|
|
317
|
+
...(lazy ? { activeTools: lazy.active() } : {}),
|
|
239
318
|
...(plan.first ? { toolChoice: { type: 'tool', toolName: plan.first } } : {}),
|
|
240
319
|
maxSteps: plan.maxSteps ?? 8,
|
|
241
320
|
...turnSettings(plan.model, {
|
|
@@ -254,10 +333,14 @@ export class LumenAgent extends Think {
|
|
|
254
333
|
t.capped = true;
|
|
255
334
|
return { activeTools: [], instructions: `${t.system}\n\n${CAP_NOTE}` };
|
|
256
335
|
}
|
|
257
|
-
// A forced first call is for the first step only.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
336
|
+
// A forced first call is for the first step only; tools a search found join each step.
|
|
337
|
+
const auto = t.plan.first && ctx.stepNumber > 0;
|
|
338
|
+
if (!auto && !t.lazy)
|
|
339
|
+
return undefined;
|
|
340
|
+
return {
|
|
341
|
+
...(auto ? { toolChoice: 'auto' } : {}),
|
|
342
|
+
...(t.lazy ? { activeTools: t.lazy.active() } : {}),
|
|
343
|
+
};
|
|
261
344
|
}
|
|
262
345
|
onStepFinish(ctx) {
|
|
263
346
|
const t = this.#turn;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connectors: MCP servers a person adds to their chat (a URL, signed in with the server's own
|
|
3
|
+
* OAuth through Think's MCP client). Each is a tool group like the app's own, at the access the
|
|
4
|
+
* app gives it: Read keeps the tools the server marks read-only, Read + draft turns the rest into
|
|
5
|
+
* cards, Read + write runs them at once. A server's text is untrusted data, never instructions.
|
|
6
|
+
*/
|
|
7
|
+
import type { AssistantAccess } from '@columenlabs/lumen-ui-types/assistant';
|
|
8
|
+
import { type ToolSource } from '../assistant/tools.js';
|
|
9
|
+
/** A connector's tool group: `mcp_` and its id. */
|
|
10
|
+
export declare const CONNECTOR_GROUP = "mcp_";
|
|
11
|
+
export declare const connectorGroup: (id: string) => string;
|
|
12
|
+
/** One connector the app opens for a turn, at the access the person gave it. */
|
|
13
|
+
export interface ConnectorAt {
|
|
14
|
+
id: string;
|
|
15
|
+
access: AssistantAccess;
|
|
16
|
+
}
|
|
17
|
+
/** A connector as the app's settings list it. */
|
|
18
|
+
export interface AgentConnector {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
url: string;
|
|
22
|
+
state: string;
|
|
23
|
+
/** Where the person signs in, while the server asks for it. */
|
|
24
|
+
authUrl?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
/** Its tools, and how many are marked read-only. */
|
|
27
|
+
tools: number;
|
|
28
|
+
reads: number;
|
|
29
|
+
}
|
|
30
|
+
interface McpTool {
|
|
31
|
+
name: string;
|
|
32
|
+
serverId: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
inputSchema: unknown;
|
|
36
|
+
annotations?: {
|
|
37
|
+
readOnlyHint?: boolean;
|
|
38
|
+
title?: string;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** The part of Think's MCP client a connector needs. */
|
|
42
|
+
export interface ConnectorClient {
|
|
43
|
+
listTools(): McpTool[];
|
|
44
|
+
callTool(p: {
|
|
45
|
+
serverId: string;
|
|
46
|
+
name: string;
|
|
47
|
+
arguments: Record<string, unknown>;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
content?: unknown;
|
|
50
|
+
isError?: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
/** What a connector's call answered: its text as JSON when it parses, else the text. */
|
|
54
|
+
export declare function connectorResult(r: {
|
|
55
|
+
content?: unknown;
|
|
56
|
+
isError?: boolean;
|
|
57
|
+
}, name: string): unknown;
|
|
58
|
+
/** One connector's tools as a source: listed from the client's catalog, called through it. */
|
|
59
|
+
export declare function connectorSource(mcp: ConnectorClient, serverId: string): ToolSource;
|
|
60
|
+
/** A connector's id from its name: a letter first, then letters, digits and `_`, at most 24. */
|
|
61
|
+
export declare function connectorId(name: string): string;
|
|
62
|
+
/** A connector's address must be https (localhost only while developing). */
|
|
63
|
+
export declare function checkConnectorUrl(url: string): URL;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ToolError, withDeadline } from '../assistant/tools.js';
|
|
2
|
+
/** A connector's tool group: `mcp_` and its id. */
|
|
3
|
+
export const CONNECTOR_GROUP = 'mcp_';
|
|
4
|
+
export const connectorGroup = (id) => `${CONNECTOR_GROUP}${id}`;
|
|
5
|
+
/** What a connector's call answered: its text as JSON when it parses, else the text. */
|
|
6
|
+
export function connectorResult(r, name) {
|
|
7
|
+
const parts = r.content ?? [];
|
|
8
|
+
const text = parts
|
|
9
|
+
.filter((c) => c.type === 'text' && c.text)
|
|
10
|
+
.map((c) => c.text)
|
|
11
|
+
.join('\n');
|
|
12
|
+
if (r.isError)
|
|
13
|
+
throw new ToolError(text.slice(0, 500) || `${name} failed`);
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** One connector's tools as a source: listed from the client's catalog, called through it. */
|
|
22
|
+
export function connectorSource(mcp, serverId) {
|
|
23
|
+
return async () => ({
|
|
24
|
+
tools: mcp
|
|
25
|
+
.listTools()
|
|
26
|
+
.filter((t) => t.serverId === serverId)
|
|
27
|
+
.map((t) => {
|
|
28
|
+
const title = t.title ?? t.annotations?.title;
|
|
29
|
+
return {
|
|
30
|
+
name: t.name,
|
|
31
|
+
...(title ? { title } : {}),
|
|
32
|
+
...(t.description ? { description: t.description } : {}),
|
|
33
|
+
inputSchema: (t.inputSchema ?? { type: 'object' }),
|
|
34
|
+
read: t.annotations?.readOnlyHint === true,
|
|
35
|
+
};
|
|
36
|
+
}),
|
|
37
|
+
call: async (name, args) => connectorResult(await withDeadline(`${name}`, 60_000, mcp.callTool({
|
|
38
|
+
serverId,
|
|
39
|
+
name,
|
|
40
|
+
arguments: (args ?? {}),
|
|
41
|
+
})), name),
|
|
42
|
+
close: async () => undefined,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** A connector's id from its name: a letter first, then letters, digits and `_`, at most 24. */
|
|
46
|
+
export function connectorId(name) {
|
|
47
|
+
const id = name
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
50
|
+
.replace(/^_+|_+$/g, '')
|
|
51
|
+
.slice(0, 24);
|
|
52
|
+
if (!id)
|
|
53
|
+
throw new Error('Give the connector a name.');
|
|
54
|
+
return /^[a-z]/.test(id) ? id : `c_${id}`.slice(0, 24);
|
|
55
|
+
}
|
|
56
|
+
/** A connector's address must be https (localhost only while developing). */
|
|
57
|
+
export function checkConnectorUrl(url) {
|
|
58
|
+
let u;
|
|
59
|
+
try {
|
|
60
|
+
u = new URL(url);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error('That is not a web address.');
|
|
64
|
+
}
|
|
65
|
+
const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
|
|
66
|
+
if (u.protocol !== 'https:' && !(local && u.protocol === 'http:'))
|
|
67
|
+
throw new Error('A connector needs an https address.');
|
|
68
|
+
if (u.username || u.password)
|
|
69
|
+
throw new Error('Leave sign-in details out of the address.');
|
|
70
|
+
return u;
|
|
71
|
+
}
|
package/dist/agent/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* payer rules; Think keeps the loop, the messages, streaming, resume and compaction.
|
|
5
5
|
*/
|
|
6
6
|
export * from './agent.js';
|
|
7
|
+
export * from './connectors.js';
|
|
7
8
|
export * from './models.js';
|
|
9
|
+
export * from './search.js';
|
|
8
10
|
export * from './tools.js';
|
|
9
11
|
export * from './usage.js';
|
package/dist/agent/index.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* payer rules; Think keeps the loop, the messages, streaming, resume and compaction.
|
|
5
5
|
*/
|
|
6
6
|
export * from './agent.js';
|
|
7
|
+
export * from './connectors.js';
|
|
7
8
|
export * from './models.js';
|
|
9
|
+
export * from './search.js';
|
|
8
10
|
export * from './tools.js';
|
|
9
11
|
export * from './usage.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy tools: past `LAZY_AT` tools a turn sends the model only its core tools and `tool_search`,
|
|
3
|
+
* with one catalog line per group in the instructions. A search finds tools by their name and
|
|
4
|
+
* description and turns them on from the next step, so a big set (many connectors) costs a short
|
|
5
|
+
* prompt instead of every schema on every call.
|
|
6
|
+
*/
|
|
7
|
+
import { type Tool, type ToolSet } from 'ai';
|
|
8
|
+
/** Past this many tools a turn starts with `tool_search` and its core tools only. */
|
|
9
|
+
export declare const LAZY_AT = 30;
|
|
10
|
+
export declare const TOOL_SEARCH = "tool_search";
|
|
11
|
+
export interface LazyTools {
|
|
12
|
+
/** The search tool, to add to the turn's tools. */
|
|
13
|
+
search: Tool;
|
|
14
|
+
/** One line per group, for the instructions. */
|
|
15
|
+
catalog: string;
|
|
16
|
+
/** The names to keep active now: the core tools, the search and what it found. */
|
|
17
|
+
active(): string[];
|
|
18
|
+
}
|
|
19
|
+
/** How well a tool's name and description match the query's words. */
|
|
20
|
+
export declare function toolScore(query: string, name: string, description?: string): number;
|
|
21
|
+
/**
|
|
22
|
+
* A search over `tools` whose finds stay on for the rest of the turn. `core` names are always on;
|
|
23
|
+
* `groupOf` names each tool's group for the catalog.
|
|
24
|
+
*/
|
|
25
|
+
export declare function lazyTools(tools: ToolSet, core: string[], groupOf: (name: string) => string): LazyTools;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy tools: past `LAZY_AT` tools a turn sends the model only its core tools and `tool_search`,
|
|
3
|
+
* with one catalog line per group in the instructions. A search finds tools by their name and
|
|
4
|
+
* description and turns them on from the next step, so a big set (many connectors) costs a short
|
|
5
|
+
* prompt instead of every schema on every call.
|
|
6
|
+
*/
|
|
7
|
+
import { jsonSchema, tool } from 'ai';
|
|
8
|
+
/** Past this many tools a turn starts with `tool_search` and its core tools only. */
|
|
9
|
+
export const LAZY_AT = 30;
|
|
10
|
+
export const TOOL_SEARCH = 'tool_search';
|
|
11
|
+
/** Most tools one search turns on. */
|
|
12
|
+
const FOUND_MAX = 8;
|
|
13
|
+
const words = (s) => s
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.split(/[^a-z0-9]+/)
|
|
16
|
+
.filter((w) => w.length > 1);
|
|
17
|
+
/** How well a tool's name and description match the query's words. */
|
|
18
|
+
export function toolScore(query, name, description = '') {
|
|
19
|
+
const q = words(query);
|
|
20
|
+
if (!q.length)
|
|
21
|
+
return 0;
|
|
22
|
+
const n = new Set(words(name));
|
|
23
|
+
const d = new Set(words(description));
|
|
24
|
+
let score = 0;
|
|
25
|
+
for (const w of q) {
|
|
26
|
+
if (n.has(w))
|
|
27
|
+
score += 3;
|
|
28
|
+
else if ([...n].some((x) => x.startsWith(w) || w.startsWith(x)))
|
|
29
|
+
score += 2;
|
|
30
|
+
if (d.has(w))
|
|
31
|
+
score += 1;
|
|
32
|
+
}
|
|
33
|
+
return score;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A search over `tools` whose finds stay on for the rest of the turn. `core` names are always on;
|
|
37
|
+
* `groupOf` names each tool's group for the catalog.
|
|
38
|
+
*/
|
|
39
|
+
export function lazyTools(tools, core, groupOf) {
|
|
40
|
+
const found = new Set();
|
|
41
|
+
const about = (n) => {
|
|
42
|
+
const d = tools[n]?.description;
|
|
43
|
+
return typeof d === 'string' ? d : '';
|
|
44
|
+
};
|
|
45
|
+
const names = Object.keys(tools);
|
|
46
|
+
const groups = new Map();
|
|
47
|
+
for (const n of names) {
|
|
48
|
+
if (core.includes(n))
|
|
49
|
+
continue;
|
|
50
|
+
const g = groupOf(n);
|
|
51
|
+
groups.set(g, (groups.get(g) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
const catalog = `More tools are off until you find them: call ${TOOL_SEARCH} with a few words of what you need, then call what it finds. Groups: ${[
|
|
54
|
+
...groups,
|
|
55
|
+
]
|
|
56
|
+
.map(([g, n]) => `${g} (${n})`)
|
|
57
|
+
.join(', ')}.`;
|
|
58
|
+
const search = tool({
|
|
59
|
+
description: 'Finds tools by what they do and turns them on for your next call. Give a few words (e.g. "create calendar event"), and a group to look in only it.',
|
|
60
|
+
inputSchema: jsonSchema({
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
query: { type: 'string', description: 'A few words of what the tool does.' },
|
|
64
|
+
group: { type: 'string', description: 'Look in this group only.' },
|
|
65
|
+
},
|
|
66
|
+
required: ['query'],
|
|
67
|
+
}),
|
|
68
|
+
execute: async ({ query, group }) => {
|
|
69
|
+
const hits = names
|
|
70
|
+
.filter((n) => !core.includes(n) && (!group || groupOf(n) === group))
|
|
71
|
+
.map((n) => ({ n, s: toolScore(query, n, about(n)) }))
|
|
72
|
+
.filter((h) => h.s > 0)
|
|
73
|
+
.sort((a, b) => b.s - a.s)
|
|
74
|
+
.slice(0, FOUND_MAX);
|
|
75
|
+
for (const h of hits)
|
|
76
|
+
found.add(h.n);
|
|
77
|
+
if (!hits.length)
|
|
78
|
+
return { found: [], note: 'Nothing matched. Try other words or a group.' };
|
|
79
|
+
return {
|
|
80
|
+
found: hits.map((h) => ({
|
|
81
|
+
name: h.n,
|
|
82
|
+
description: about(h.n).slice(0, 300),
|
|
83
|
+
})),
|
|
84
|
+
note: 'These are on from your next call.',
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
search,
|
|
90
|
+
catalog,
|
|
91
|
+
active: () => [...core, TOOL_SEARCH, ...found],
|
|
92
|
+
};
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.181",
|
|
4
4
|
"description": "Lumen's assistant and agent parts: a chat assistant's server (Claude tiers, tools over MCP, the web, hand off, spend caps), serving an MCP server from a Worker, read-only tool sets and metered tool calls.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@ai-sdk/openai": "4.0.108",
|
|
49
49
|
"@anthropic-ai/sdk": "0.133.0",
|
|
50
50
|
"@cloudflare/think": "0.23.0",
|
|
51
|
-
"@columenlabs/lumen-ops": "0.1.
|
|
52
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
51
|
+
"@columenlabs/lumen-ops": "0.1.181",
|
|
52
|
+
"@columenlabs/lumen-ui-types": "0.1.181",
|
|
53
53
|
"agents": "0.32.0",
|
|
54
54
|
"ai": "7.0.162",
|
|
55
55
|
"just-bash": "3.6.0",
|