@cubicecho/agent-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/catalog.d.ts +17 -0
- package/dist/catalog.js +9 -0
- package/dist/client.d.ts +34 -0
- package/dist/client.js +111 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.js +14 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +8 -0
- package/dist/events.d.ts +87 -0
- package/dist/events.js +133 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/retry.d.ts +57 -0
- package/dist/retry.js +91 -0
- package/dist/schema-compat.d.ts +17 -0
- package/dist/schema-compat.js +178 -0
- package/dist/side-task.d.ts +37 -0
- package/dist/side-task.js +129 -0
- package/dist/tool-loading.d.ts +85 -0
- package/dist/tool-loading.js +185 -0
- package/package.json +64 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand tool loading.
|
|
3
|
+
*
|
|
4
|
+
* A full tool definition is mostly JSON Schema, and it is sent on every request of every
|
|
5
|
+
* iteration whether or not the model wants it — a couple of connected servers can cost more
|
|
6
|
+
* tokens per request than the task's own prompt. So in on-demand mode the run starts with a
|
|
7
|
+
* bare *catalogue*: tool names only, appended to the system prompt, plus this one meta-tool.
|
|
8
|
+
* The model calls `load_tools` with what it needs, and the next round trip carries those real
|
|
9
|
+
* definitions.
|
|
10
|
+
*
|
|
11
|
+
* Names alone cost roughly a fortieth of what the schemas cost, so a run that needs no tools
|
|
12
|
+
* pays almost nothing, and a run that needs three pays for three.
|
|
13
|
+
*/
|
|
14
|
+
export const LOAD_TOOLS = "load_tools";
|
|
15
|
+
/** One object for the life of the process — the agent loop asks for it on every iteration. */
|
|
16
|
+
export const LOAD_TOOLS_DEFINITION = {
|
|
17
|
+
type: "function",
|
|
18
|
+
function: {
|
|
19
|
+
name: LOAD_TOOLS,
|
|
20
|
+
description: "Load the full definitions of tools listed in the tool catalogue so you can call them. " +
|
|
21
|
+
"Pass the exact names you need, or a trailing wildcard like `server__group__*` for a " +
|
|
22
|
+
"whole group. The tools become callable on your next step — load them, then call them. " +
|
|
23
|
+
"Load only what the task actually needs.",
|
|
24
|
+
parameters: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
names: {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: { type: "string" },
|
|
30
|
+
description: "Tool names from the catalogue. Wildcards may end with `*`.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
required: ["names"],
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
/** The catalogue as a plain grouped listing of names, loaded ones marked. */
|
|
39
|
+
export function catalogList(catalog, loaded) {
|
|
40
|
+
return catalog
|
|
41
|
+
.map((server) => {
|
|
42
|
+
const names = server.tools.map((tool) => ` ${tool.name}${loaded?.has(tool.name) ? " (loaded)" : ""}`);
|
|
43
|
+
return `${server.label}:\n${names.join("\n")}`;
|
|
44
|
+
})
|
|
45
|
+
.join("\n");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The catalogue block appended to the system prompt. Names only — descriptions arrive on load.
|
|
49
|
+
*
|
|
50
|
+
* Loaded tools stay in the list, marked. Removing them reads as the tool having vanished the
|
|
51
|
+
* moment it was loaded, and the model loads again to get it back; hoisting them into a separate
|
|
52
|
+
* "already loaded" section splits a server's tools apart, and the model picks a sibling from
|
|
53
|
+
* the longer list instead.
|
|
54
|
+
*/
|
|
55
|
+
export function catalogPrompt(catalog, loaded) {
|
|
56
|
+
if (!catalog.length)
|
|
57
|
+
return "";
|
|
58
|
+
return [
|
|
59
|
+
"# Tool catalogue",
|
|
60
|
+
"",
|
|
61
|
+
"These tools exist but are not loaded. Call `load_tools` with the names you need, then call",
|
|
62
|
+
"them on the step after. Names are descriptive; load a tool to see its parameters. A name",
|
|
63
|
+
"marked `(loaded)` is already in your tool list — call it directly, do not load it again. Do",
|
|
64
|
+
"not load tools the task does not need, and do not mention this mechanism in your answer.",
|
|
65
|
+
"",
|
|
66
|
+
catalogList(catalog, loaded),
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
const flatten = (catalog) => catalog.flatMap((server) => server.tools);
|
|
70
|
+
/**
|
|
71
|
+
* The most a single `load_tools` call may pull in.
|
|
72
|
+
*
|
|
73
|
+
* A wildcard like `gmail__*` matches 33 tools, and loading them all puts the model right back
|
|
74
|
+
* in the position on-demand loading exists to avoid — a tool array too large to choose from.
|
|
75
|
+
* Over-broad requests are refused with the matching names listed, so the next call can be
|
|
76
|
+
* precise.
|
|
77
|
+
*/
|
|
78
|
+
export const MAX_PER_LOAD = 12;
|
|
79
|
+
/**
|
|
80
|
+
* The most a conversation carries between turns. Bounds the tool array no matter how long the
|
|
81
|
+
* conversation runs; least-recently-used names fall off the front.
|
|
82
|
+
*
|
|
83
|
+
* Only a multi-turn caller needs this — a run that starts from nothing each time has nothing to
|
|
84
|
+
* carry. See `carryOver`.
|
|
85
|
+
*/
|
|
86
|
+
export const MAX_CARRIED = 16;
|
|
87
|
+
/** The tools to start the next turn with: recently used, newest last, capped. */
|
|
88
|
+
export const carryOver = (previous, used) => [...previous.filter((name) => !used.has(name)), ...used].slice(-MAX_CARRIED);
|
|
89
|
+
/**
|
|
90
|
+
* Resolves requested names against the catalogue, expanding trailing `*` wildcards.
|
|
91
|
+
*
|
|
92
|
+
* Names are matched leniently. Catalogue entries are slug-qualified (`nas_fs__read_file`) and
|
|
93
|
+
* models routinely ask for the bare tool name, so an exact miss falls back to a suffix match
|
|
94
|
+
* on the `__` boundary — accepted only when it is unambiguous. Rejecting those outright just
|
|
95
|
+
* buys a wasted round trip while the model guesses the prefix, and pushes it toward
|
|
96
|
+
* shotgunning wildcards.
|
|
97
|
+
*/
|
|
98
|
+
export function expandNames(requested, catalog) {
|
|
99
|
+
const all = flatten(catalog);
|
|
100
|
+
const matched = new Set();
|
|
101
|
+
const unknown = [];
|
|
102
|
+
const overBroad = [];
|
|
103
|
+
const known = new Set(all.map((tool) => tool.name));
|
|
104
|
+
const resolve = (name) => {
|
|
105
|
+
if (name.endsWith("*")) {
|
|
106
|
+
const stem = name.slice(0, -1);
|
|
107
|
+
const direct = all.filter((tool) => tool.name.startsWith(stem));
|
|
108
|
+
if (direct.length)
|
|
109
|
+
return direct.map((tool) => tool.name);
|
|
110
|
+
return all.filter((tool) => tool.name.includes(`__${stem}`)).map((tool) => tool.name);
|
|
111
|
+
}
|
|
112
|
+
if (known.has(name))
|
|
113
|
+
return [name];
|
|
114
|
+
const suffix = all.filter((tool) => tool.name.endsWith(`__${name}`));
|
|
115
|
+
return suffix.length === 1 ? [suffix[0].name] : [];
|
|
116
|
+
};
|
|
117
|
+
for (const raw of requested) {
|
|
118
|
+
const name = raw.trim();
|
|
119
|
+
if (!name)
|
|
120
|
+
continue;
|
|
121
|
+
const hits = resolve(name);
|
|
122
|
+
if (!hits.length)
|
|
123
|
+
unknown.push(name);
|
|
124
|
+
else if (hits.length > MAX_PER_LOAD)
|
|
125
|
+
overBroad.push({ name, hits });
|
|
126
|
+
else
|
|
127
|
+
for (const hit of hits)
|
|
128
|
+
matched.add(hit);
|
|
129
|
+
}
|
|
130
|
+
return { matched: [...matched], unknown, overBroad };
|
|
131
|
+
}
|
|
132
|
+
/** What `load_tools` reports back: the descriptions, now that they are worth their tokens. */
|
|
133
|
+
export function loadResult({ matched, unknown, overBroad }, catalog) {
|
|
134
|
+
const byName = new Map(flatten(catalog).map((tool) => [tool.name, tool.description]));
|
|
135
|
+
const lines = [];
|
|
136
|
+
if (matched.length) {
|
|
137
|
+
lines.push(`Loaded ${matched.length} tool(s); they are callable on your next step.`, "");
|
|
138
|
+
for (const name of matched)
|
|
139
|
+
lines.push(`${name}: ${byName.get(name) ?? ""}`.trim());
|
|
140
|
+
}
|
|
141
|
+
for (const { name, hits } of overBroad) {
|
|
142
|
+
if (lines.length)
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push(`\`${name}\` matches ${hits.length} tools, more than the ${MAX_PER_LOAD} one call may load.`, "Name the ones you need from:", ...hits.map((hit) => ` ${hit}`));
|
|
145
|
+
}
|
|
146
|
+
if (unknown.length) {
|
|
147
|
+
if (lines.length)
|
|
148
|
+
lines.push("");
|
|
149
|
+
lines.push(`Not in the catalogue: ${unknown.join(", ")}. Check the names and try again.`);
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n") || "No tool names were given.";
|
|
152
|
+
}
|
|
153
|
+
export const inCatalog = (catalog, name) => catalog.some((server) => server.tools.some((tool) => tool.name === name));
|
|
154
|
+
/** `load_tools` arguments, defensively — a model may send a bare string or a nested object. */
|
|
155
|
+
export function requestedNames(args) {
|
|
156
|
+
const value = args.names ?? args.tools ?? args.name;
|
|
157
|
+
if (typeof value === "string")
|
|
158
|
+
return [value];
|
|
159
|
+
if (Array.isArray(value))
|
|
160
|
+
return value.filter((item) => typeof item === "string");
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Tool preselection.
|
|
165
|
+
*
|
|
166
|
+
* On-demand loading otherwise costs a round trip every run: the model reads the catalogue,
|
|
167
|
+
* calls `load_tools`, and only then can do the work. A small model reading the same catalogue
|
|
168
|
+
* usually names the right tools outright, so the task model finds them already loaded and
|
|
169
|
+
* starts working on its first step.
|
|
170
|
+
*
|
|
171
|
+
* A wrong guess is cheap — an unused definition is a few hundred tokens for one run — but a
|
|
172
|
+
* broad guess is not, so the same `MAX_PER_LOAD` cap applies here as to a `load_tools` call.
|
|
173
|
+
*/
|
|
174
|
+
export const PRESELECT_SYSTEM = "You choose tools. Below is a catalogue of tool names, then a request. Reply with a JSON " +
|
|
175
|
+
"array of the names the request is likely to need — exact names from the catalogue, at most " +
|
|
176
|
+
`${MAX_PER_LOAD}, and as few as could do the job. Reply with \`[]\` if the request can be ` +
|
|
177
|
+
"answered without tools. Reply with the array alone — no prose, no explanation.";
|
|
178
|
+
export const preselectInput = (catalog, prompt) => `# Tool catalogue\n\n${catalogList(catalog)}\n\n# Request\n\n${prompt.slice(0, 2000)}`;
|
|
179
|
+
/** Resolves a preselection against the catalogue: unknown names dropped, count capped. */
|
|
180
|
+
export function preselection(names, catalog) {
|
|
181
|
+
if (!Array.isArray(names))
|
|
182
|
+
return [];
|
|
183
|
+
const wanted = names.filter((name) => typeof name === "string");
|
|
184
|
+
return expandNames(wanted, catalog).matched.slice(0, MAX_PER_LOAD);
|
|
185
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cubicecho/agent-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"openai",
|
|
7
|
+
"agent",
|
|
8
|
+
"mcp",
|
|
9
|
+
"tool-calling",
|
|
10
|
+
"json-schema",
|
|
11
|
+
"llama.cpp",
|
|
12
|
+
"vllm",
|
|
13
|
+
"ollama",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Benjamin Van Treese <vantreeseba@gmail.com>",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/cubicecho/agent-core.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/cubicecho/agent-core#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/cubicecho/agent-core/issues"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json",
|
|
44
|
+
"prepare": "npm run build",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:watch": "vitest",
|
|
48
|
+
"lint": "biome check .",
|
|
49
|
+
"format": "biome check --write ."
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"openai": ">=6"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@biomejs/biome": "^2.5.11",
|
|
56
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
57
|
+
"@semantic-release/git": "^11.0.1",
|
|
58
|
+
"@types/node": "^26.4.0",
|
|
59
|
+
"openai": "^7.8.0",
|
|
60
|
+
"semantic-release": "^25.0.9",
|
|
61
|
+
"typescript": "^7.0.2",
|
|
62
|
+
"vitest": "^4.1.11"
|
|
63
|
+
}
|
|
64
|
+
}
|