toolnexus 0.15.0 → 0.16.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/dist/client.d.ts CHANGED
@@ -333,6 +333,51 @@ export declare class Client {
333
333
  private streamAnthropic;
334
334
  }
335
335
  export declare function createClient(opts: ClientOptions): Client;
336
+ /** One tool call an in-process model asks for. Flat on purpose: the nested
337
+ * `function: {}` wrapper is a wire detail, not something a model author should type.
338
+ * `arguments` may be a structured value (encoded for you) or an encoded string. */
339
+ export interface InProcessToolCall {
340
+ id?: string;
341
+ name: string;
342
+ arguments?: unknown;
343
+ }
344
+ /** What an in-process model is handed: the assembled request for THIS call. */
345
+ export interface InProcessRequest {
346
+ messages: any[];
347
+ tools?: any[];
348
+ model: string;
349
+ /** Every other key the client assembled, for a model that wants them. */
350
+ body: Record<string, any>;
351
+ }
352
+ /** What an in-process model returns: exactly one assistant message. Return `content`
353
+ * to finish, or `toolCalls` to ask for tools — never both. `usage` is optional; when
354
+ * omitted the run reports zero tokens rather than failing. */
355
+ export interface InProcessResponse {
356
+ content?: string;
357
+ toolCalls?: InProcessToolCall[];
358
+ usage?: {
359
+ promptTokens?: number;
360
+ completionTokens?: number;
361
+ totalTokens?: number;
362
+ };
363
+ }
364
+ /** Options for {@link createInProcessClient}: every client option EXCEPT the three
365
+ * that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
366
+ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
367
+ /** Your model. Receives the assembled request, returns one assistant message. */
368
+ generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>;
369
+ };
370
+ /**
371
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
372
+ * HTTP types to construct.
373
+ *
374
+ * This is a second constructor, not a second seam: it builds on the same injectable
375
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
376
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
377
+ *
378
+ * Streaming is refused rather than faked — see the thrown error.
379
+ */
380
+ export declare function createInProcessClient(opts: InProcessOptions): Client;
336
381
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
337
382
  export declare class Conversation {
338
383
  private readonly client;
package/dist/client.js CHANGED
@@ -950,6 +950,84 @@ export class Client {
950
950
  export function createClient(opts) {
951
951
  return new Client(opts);
952
952
  }
953
+ /** The sentinel base URL. Never dialled — the transport below answers every request
954
+ * before the network is reached — but the client builds a URL string internally, so
955
+ * it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
956
+ * name can never resolve. */
957
+ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1";
958
+ /**
959
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
960
+ * HTTP types to construct.
961
+ *
962
+ * This is a second constructor, not a second seam: it builds on the same injectable
963
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
964
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
965
+ *
966
+ * Streaming is refused rather than faked — see the thrown error.
967
+ */
968
+ export function createInProcessClient(opts) {
969
+ const { generate, ...rest } = opts;
970
+ if (typeof generate !== "function") {
971
+ throw new Error("toolnexus: createInProcessClient requires a `generate` function");
972
+ }
973
+ const inProcessFetch = async (_url, init) => {
974
+ const body = JSON.parse(String(init?.body ?? "{}"));
975
+ // A generate() returns a whole answer, so a stream would be one chunk pretending
976
+ // to be many. Content and delta-count assertions cannot tell that from a real
977
+ // stream, so refuse instead of degrading silently.
978
+ if (body.stream) {
979
+ throw new Error("toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
980
+ "complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.");
981
+ }
982
+ const answer = await generate({
983
+ messages: body.messages ?? [],
984
+ tools: body.tools,
985
+ model: body.model,
986
+ body,
987
+ });
988
+ const message = { role: "assistant" };
989
+ if (answer.toolCalls && answer.toolCalls.length > 0) {
990
+ message.tool_calls = answer.toolCalls.map((c, i) => ({
991
+ id: c.id ?? `call_${i}`,
992
+ type: "function",
993
+ function: {
994
+ name: c.name,
995
+ // A string is already encoded; anything else is encoded here so a model
996
+ // author never has to think about the wire.
997
+ arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
998
+ },
999
+ }));
1000
+ }
1001
+ else {
1002
+ message.content = answer.content ?? "";
1003
+ }
1004
+ const u = answer.usage ?? {};
1005
+ const prompt = u.promptTokens ?? 0;
1006
+ const completion = u.completionTokens ?? 0;
1007
+ return new Response(JSON.stringify({
1008
+ choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
1009
+ usage: {
1010
+ prompt_tokens: prompt,
1011
+ completion_tokens: completion,
1012
+ total_tokens: u.totalTokens ?? prompt + completion,
1013
+ },
1014
+ }), { status: 200, headers: { "content-type": "application/json" } });
1015
+ };
1016
+ return new Client({
1017
+ // Retries default to ZERO here, unlike a network client. There is no wire, so
1018
+ // there is no transient failure to ride out: whatever `generate` throws will
1019
+ // throw again. Retrying it only buys backoff before the user sees their own bug —
1020
+ // measured at 3.7s for the streaming refusal before this default. A host whose
1021
+ // model really is flaky (a GPU that OOMs, say) can still set `retries`.
1022
+ retries: 0,
1023
+ ...rest,
1024
+ baseUrl: IN_PROCESS_BASE_URL,
1025
+ style: "openai",
1026
+ // An in-process model has no endpoint to authenticate to, so the host must never need a key — but the client resolves one from the environment and fails when it finds none. A sentinel keeps that resolution from ever running. Caught by CI, which has no OPENROUTER_API_KEY; every local run passed because a developer shell has one.
1027
+ apiKey: "in-process",
1028
+ fetch: inProcessFetch,
1029
+ });
1030
+ }
953
1031
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
954
1032
  export class Conversation {
955
1033
  client;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
- "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts",
20
+ "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts",
21
21
  "example": "node --experimental-strip-types examples/basic.ts"
22
22
  },
23
23
  "keywords": [
package/src/client.ts CHANGED
@@ -1097,6 +1097,135 @@ export function createClient(opts: ClientOptions): Client {
1097
1097
  return new Client(opts)
1098
1098
  }
1099
1099
 
1100
+
1101
+ // --------------------------------------------------------------------------- //
1102
+ // In-process models (SPEC §8 Gap 2, semantic form).
1103
+ // --------------------------------------------------------------------------- //
1104
+
1105
+ /** One tool call an in-process model asks for. Flat on purpose: the nested
1106
+ * `function: {}` wrapper is a wire detail, not something a model author should type.
1107
+ * `arguments` may be a structured value (encoded for you) or an encoded string. */
1108
+ export interface InProcessToolCall {
1109
+ id?: string
1110
+ name: string
1111
+ arguments?: unknown
1112
+ }
1113
+
1114
+ /** What an in-process model is handed: the assembled request for THIS call. */
1115
+ export interface InProcessRequest {
1116
+ messages: any[]
1117
+ tools?: any[]
1118
+ model: string
1119
+ /** Every other key the client assembled, for a model that wants them. */
1120
+ body: Record<string, any>
1121
+ }
1122
+
1123
+ /** What an in-process model returns: exactly one assistant message. Return `content`
1124
+ * to finish, or `toolCalls` to ask for tools — never both. `usage` is optional; when
1125
+ * omitted the run reports zero tokens rather than failing. */
1126
+ export interface InProcessResponse {
1127
+ content?: string
1128
+ toolCalls?: InProcessToolCall[]
1129
+ usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }
1130
+ }
1131
+
1132
+ /** Options for {@link createInProcessClient}: every client option EXCEPT the three
1133
+ * that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
1134
+ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
1135
+ /** Your model. Receives the assembled request, returns one assistant message. */
1136
+ generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
1137
+ }
1138
+
1139
+ /** The sentinel base URL. Never dialled — the transport below answers every request
1140
+ * before the network is reached — but the client builds a URL string internally, so
1141
+ * it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
1142
+ * name can never resolve. */
1143
+ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
1144
+
1145
+ /**
1146
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1147
+ * HTTP types to construct.
1148
+ *
1149
+ * This is a second constructor, not a second seam: it builds on the same injectable
1150
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1151
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1152
+ *
1153
+ * Streaming is refused rather than faked — see the thrown error.
1154
+ */
1155
+ export function createInProcessClient(opts: InProcessOptions): Client {
1156
+ const { generate, ...rest } = opts
1157
+ if (typeof generate !== "function") {
1158
+ throw new Error("toolnexus: createInProcessClient requires a `generate` function")
1159
+ }
1160
+
1161
+ const inProcessFetch: typeof fetch = async (_url, init) => {
1162
+ const body = JSON.parse(String((init as any)?.body ?? "{}"))
1163
+
1164
+ // A generate() returns a whole answer, so a stream would be one chunk pretending
1165
+ // to be many. Content and delta-count assertions cannot tell that from a real
1166
+ // stream, so refuse instead of degrading silently.
1167
+ if (body.stream) {
1168
+ throw new Error(
1169
+ "toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
1170
+ "complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.",
1171
+ )
1172
+ }
1173
+
1174
+ const answer = await generate({
1175
+ messages: body.messages ?? [],
1176
+ tools: body.tools,
1177
+ model: body.model,
1178
+ body,
1179
+ })
1180
+
1181
+ const message: Record<string, any> = { role: "assistant" }
1182
+ if (answer.toolCalls && answer.toolCalls.length > 0) {
1183
+ message.tool_calls = answer.toolCalls.map((c, i) => ({
1184
+ id: c.id ?? `call_${i}`,
1185
+ type: "function",
1186
+ function: {
1187
+ name: c.name,
1188
+ // A string is already encoded; anything else is encoded here so a model
1189
+ // author never has to think about the wire.
1190
+ arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
1191
+ },
1192
+ }))
1193
+ } else {
1194
+ message.content = answer.content ?? ""
1195
+ }
1196
+
1197
+ const u = answer.usage ?? {}
1198
+ const prompt = u.promptTokens ?? 0
1199
+ const completion = u.completionTokens ?? 0
1200
+ return new Response(
1201
+ JSON.stringify({
1202
+ choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
1203
+ usage: {
1204
+ prompt_tokens: prompt,
1205
+ completion_tokens: completion,
1206
+ total_tokens: u.totalTokens ?? prompt + completion,
1207
+ },
1208
+ }),
1209
+ { status: 200, headers: { "content-type": "application/json" } },
1210
+ )
1211
+ }
1212
+
1213
+ return new Client({
1214
+ // Retries default to ZERO here, unlike a network client. There is no wire, so
1215
+ // there is no transient failure to ride out: whatever `generate` throws will
1216
+ // throw again. Retrying it only buys backoff before the user sees their own bug —
1217
+ // measured at 3.7s for the streaming refusal before this default. A host whose
1218
+ // model really is flaky (a GPU that OOMs, say) can still set `retries`.
1219
+ retries: 0,
1220
+ ...(rest as ClientOptions),
1221
+ baseUrl: IN_PROCESS_BASE_URL,
1222
+ style: "openai",
1223
+ // An in-process model has no endpoint to authenticate to, so the host must never need a key — but the client resolves one from the environment and fails when it finds none. A sentinel keeps that resolution from ever running. Caught by CI, which has no OPENROUTER_API_KEY; every local run passed because a developer shell has one.
1224
+ apiKey: "in-process",
1225
+ fetch: inProcessFetch,
1226
+ })
1227
+ }
1228
+
1100
1229
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
1101
1230
  export class Conversation {
1102
1231
  /** Full running transcript (system + user + assistant + tool messages). */