toolnexus 0.10.0 → 0.13.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.
@@ -22,6 +22,12 @@ export interface AgentSpec {
22
22
  waitFor?: (request: Request) => Promise<Answer>;
23
23
  onSpawn?: AgentDef["onSpawn"];
24
24
  onClose?: AgentDef["onClose"];
25
+ /** §8 lifecycle callbacks for this agent (e.g. a §7F `compactor`). Set ⇒ replaces
26
+ * the runtime-wide `hooks` for this agent; forwarded verbatim. */
27
+ hooks?: AgentDef["hooks"];
28
+ /** §8 observability sink for this agent. Set ⇒ replaces the runtime-wide
29
+ * `onMetric` for this agent; resolves independently of `hooks`. */
30
+ onMetric?: AgentDef["onMetric"];
25
31
  }
26
32
  /** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
27
33
  export type AgentRunOptions = Omit<RuntimeOptions, "registry">;
@@ -38,6 +38,8 @@ export class Agent {
38
38
  waitFor: this.spec.waitFor,
39
39
  onSpawn: this.spec.onSpawn,
40
40
  onClose: this.spec.onClose,
41
+ hooks: this.spec.hooks,
42
+ onMetric: this.spec.onMetric,
41
43
  };
42
44
  for (const t of this.spec.team ?? [])
43
45
  t.registry(acc);
@@ -14,7 +14,7 @@
14
14
  * the ConversationStore. SPEC pins transitions, never scheduling — conformance is
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
- import { type ClientStyle, type ConversationStore } from "../client.js";
17
+ import { type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js";
18
18
  import type { Answer, Request, Tool } from "../types.js";
19
19
  /** Injectable time source. All runtime timers, timeouts, and deadlines use it. */
20
20
  export interface Clock {
@@ -68,6 +68,14 @@ export interface AgentDef {
68
68
  onSpawn?: (h: Handle) => void | Promise<void>;
69
69
  /** Runs before the final checkpoint on close. */
70
70
  onClose?: (h: Handle, reason: CloseReason) => void | Promise<void>;
71
+ /** §8 lifecycle callbacks for THIS agent's client. Set ⇒ REPLACES the runtime-wide
72
+ * `hooks` for this agent (never merged — composing two transcript rewrites has no
73
+ * defined order). Forwarded verbatim; the runtime never reads or wraps it.
74
+ * This is how a §7F compactor attaches per agent. */
75
+ hooks?: Hooks;
76
+ /** §8 observability sink for THIS agent's client. Set ⇒ REPLACES the runtime-wide
77
+ * `onMetric` for this agent. Resolves independently of `hooks`. Forwarded verbatim. */
78
+ onMetric?: (ev: MetricEvent) => void;
71
79
  }
72
80
  /** An unsolicited inbox item. Delivered ONLY as part of a fresh turn's coalesced
73
81
  * drain, with provenance rendered per item (non-ancestor senders are untrusted). */
@@ -202,6 +210,13 @@ export interface RuntimeOptions {
202
210
  maxConcurrentTurns?: number;
203
211
  /** Graceful-close bound for a running turn before escalating to abort. Default 200. */
204
212
  shutdownMs?: number;
213
+ /** §8 lifecycle callbacks applied to EVERY agent's client, unless that agent's
214
+ * def sets its own `hooks` (def-over-runtime, replace never merge). Forwarded
215
+ * verbatim into `createClient` — never composed, wrapped, reordered or read. */
216
+ hooks?: Hooks;
217
+ /** §8 observability sink applied to EVERY agent's client, unless that agent's def
218
+ * sets its own `onMetric`. Resolves independently of `hooks`. Forwarded verbatim. */
219
+ onMetric?: (ev: MetricEvent) => void;
205
220
  }
206
221
  /**
207
222
  * The §7D agent runtime: six host verbs over a tree of Handles, three loud
@@ -579,6 +579,10 @@ export class AgentRuntime {
579
579
  fetch: this.gatedFetch(),
580
580
  store: this.store,
581
581
  waitFor: oneShotWaitFor ?? this.wrapWaitFor(h),
582
+ // The §8 seams: def-over-runtime, REPLACE never merge, each field resolved
583
+ // independently, forwarded verbatim (never composed, wrapped or read).
584
+ hooks: h.def.hooks ?? this.opts.hooks,
585
+ onMetric: h.def.onMetric ?? this.opts.onMetric,
582
586
  });
583
587
  const r = await client.ask(input, { toolkit, id: h.id, signal: h.abort.signal });
584
588
  h.turnsTotal += r.turns;
package/dist/client.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { Toolkit } from "./toolkit.js";
7
7
  import type { ToolResult, Request, Answer } from "./types.js";
8
+ import type { TranslateRequest, TranslateResult } from "./translate.js";
8
9
  export type ClientStyle = "openai" | "anthropic";
9
10
  export interface ClientOptions {
10
11
  baseUrl: string;
@@ -256,6 +257,28 @@ export declare class Client {
256
257
  signal?: AbortSignal;
257
258
  history?: any[];
258
259
  }): Promise<RunResult>;
260
+ /**
261
+ * Single-turn translation (§11): exactly ONE provider call, returned in OpenAI shape.
262
+ * No agent loop, no tool execution, no conversation state — every call is
263
+ * self-contained, so this may be run statelessly and concurrently.
264
+ *
265
+ * The inbound half of the adapters: `toAnthropic`/`toGemini` send declarations out,
266
+ * this reads the provider's tool calls back in. A `toolkit` passed in the request is
267
+ * DECLARED and never executed.
268
+ *
269
+ * Retries/backoff, requestParams merging and the `llm` metric are shared with the loop.
270
+ * `beforeLLM`/`afterLLM` fire once; tool hooks do not, because no tool runs.
271
+ */
272
+ translate(req: TranslateRequest): Promise<TranslateResult>;
273
+ /** OpenAI-style upstream: near-passthrough. */
274
+ private translateOpenAI;
275
+ /** Anthropic-style upstream: the real translation. */
276
+ private translateAnthropic;
277
+ /**
278
+ * Renders a translate result's tool calls as an OpenAI `tool_calls` array, ready to put
279
+ * on an assistant message. Convenience for assembling a response envelope.
280
+ */
281
+ static toolCallsJSON(res: TranslateResult): any[];
259
282
  /**
260
283
  * Stateful ask. With an `id`, the client's `store` remembers the conversation:
261
284
  * it loads that id's transcript, runs, saves the updated transcript, and returns
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { pendingOf } from "./types.js";
2
+ import { finishReasonFor, argsString, openAIMessagesToAnthropic, openAIToolsToAnthropic, openAIToolChoiceToAnthropic, hasSystemMessage, } from "./translate.js";
2
3
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
3
4
  export class InMemoryConversationStore {
4
5
  map = new Map();
@@ -228,6 +229,152 @@ export class Client {
228
229
  ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
229
230
  : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history);
230
231
  }
232
+ /**
233
+ * Single-turn translation (§11): exactly ONE provider call, returned in OpenAI shape.
234
+ * No agent loop, no tool execution, no conversation state — every call is
235
+ * self-contained, so this may be run statelessly and concurrently.
236
+ *
237
+ * The inbound half of the adapters: `toAnthropic`/`toGemini` send declarations out,
238
+ * this reads the provider's tool calls back in. A `toolkit` passed in the request is
239
+ * DECLARED and never executed.
240
+ *
241
+ * Retries/backoff, requestParams merging and the `llm` metric are shared with the loop.
242
+ * `beforeLLM`/`afterLLM` fire once; tool hooks do not, because no tool runs.
243
+ */
244
+ async translate(req) {
245
+ return this.opts.style === "anthropic" ? this.translateAnthropic(req) : this.translateOpenAI(req);
246
+ }
247
+ /** OpenAI-style upstream: near-passthrough. */
248
+ async translateOpenAI(req) {
249
+ const key = resolveKey(this.opts);
250
+ const declared = [...(req.toolkit ? req.toolkit.toOpenAI() : []), ...(req.tools ?? [])];
251
+ let messages = (req.messages ?? []).slice();
252
+ const sys = req.system ?? this.opts.systemPrompt ?? "";
253
+ if (sys && !hasSystemMessage(messages))
254
+ messages = [{ role: "system", content: sys }, ...messages];
255
+ let tools = declared;
256
+ if (this.opts.hooks?.beforeLLM) {
257
+ const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn: 0 });
258
+ if (ov?.messages)
259
+ messages = ov.messages;
260
+ if (ov?.tools)
261
+ tools = ov.tools;
262
+ }
263
+ const body = { model: this.opts.model, messages };
264
+ if (tools.length)
265
+ body.tools = tools;
266
+ if (req.toolChoice !== undefined)
267
+ body.tool_choice = req.toolChoice;
268
+ if (req.maxTokens)
269
+ body.max_tokens = req.maxTokens;
270
+ const signal = this.makeSignal(req.signal);
271
+ {
272
+ const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
273
+ method: "POST",
274
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
275
+ body: JSON.stringify(this.finalizeBody(body)),
276
+ }, signal, "openai");
277
+ if (this.opts.hooks?.afterLLM)
278
+ await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn: 0 });
279
+ const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
280
+ addUsage(usage, data.usage, "openai");
281
+ const choice = data.choices?.[0];
282
+ const toolCalls = (choice?.message?.tool_calls ?? []).map((tc) => ({
283
+ id: String(tc.id ?? ""),
284
+ name: String(tc.function?.name ?? ""),
285
+ arguments: argsString(tc.function?.arguments),
286
+ }));
287
+ return {
288
+ text: choice?.message?.content ?? "",
289
+ toolCalls,
290
+ finishReason: choice?.finish_reason ?? finishReasonFor(toolCalls.length > 0),
291
+ usage,
292
+ model: this.opts.model,
293
+ raw: data,
294
+ };
295
+ }
296
+ }
297
+ /** Anthropic-style upstream: the real translation. */
298
+ async translateAnthropic(req) {
299
+ const key = resolveKey(this.opts);
300
+ const base = this.opts.baseUrl.replace(/\/$/, "");
301
+ const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
302
+ const converted = openAIMessagesToAnthropic(req.messages ?? []);
303
+ let messages = converted.messages;
304
+ const system = req.system ?? this.opts.systemPrompt ?? converted.system;
305
+ let tools = [
306
+ ...(req.toolkit ? req.toolkit.toAnthropic() : []),
307
+ ...openAIToolsToAnthropic(req.tools),
308
+ ];
309
+ if (this.opts.hooks?.beforeLLM) {
310
+ const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn: 0 });
311
+ if (ov?.messages)
312
+ messages = ov.messages;
313
+ if (ov?.tools)
314
+ tools = ov.tools;
315
+ }
316
+ const body = {
317
+ model: this.opts.model,
318
+ max_tokens: req.maxTokens && req.maxTokens > 0 ? req.maxTokens : 4096,
319
+ messages,
320
+ };
321
+ if (system)
322
+ body.system = system;
323
+ if (tools.length)
324
+ body.tools = tools;
325
+ const tc = openAIToolChoiceToAnthropic(req.toolChoice);
326
+ if (tc)
327
+ body.tool_choice = tc;
328
+ const signal = this.makeSignal(req.signal);
329
+ {
330
+ const data = await this.llmCallJson(endpoint, {
331
+ method: "POST",
332
+ headers: {
333
+ "content-type": "application/json",
334
+ "x-api-key": key,
335
+ "anthropic-version": "2023-06-01",
336
+ ...this.opts.headers,
337
+ },
338
+ body: JSON.stringify(this.finalizeBody(body)),
339
+ }, signal, "anthropic");
340
+ if (this.opts.hooks?.afterLLM)
341
+ await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn: 0 });
342
+ const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
343
+ addUsage(usage, data.usage, "anthropic");
344
+ const text = [];
345
+ const toolCalls = [];
346
+ for (const b of data.content ?? []) {
347
+ if (b?.type === "text")
348
+ text.push(b.text ?? "");
349
+ else if (b?.type === "tool_use") {
350
+ toolCalls.push({
351
+ id: String(b.id ?? ""),
352
+ name: String(b.name ?? ""),
353
+ arguments: JSON.stringify(b.input ?? {}),
354
+ });
355
+ }
356
+ }
357
+ return {
358
+ text: text.join(""),
359
+ toolCalls,
360
+ finishReason: finishReasonFor(toolCalls.length > 0, data.stop_reason),
361
+ usage,
362
+ model: this.opts.model,
363
+ raw: data,
364
+ };
365
+ }
366
+ }
367
+ /**
368
+ * Renders a translate result's tool calls as an OpenAI `tool_calls` array, ready to put
369
+ * on an assistant message. Convenience for assembling a response envelope.
370
+ */
371
+ static toolCallsJSON(res) {
372
+ return res.toolCalls.map((tc) => ({
373
+ id: tc.id,
374
+ type: "function",
375
+ function: { name: tc.name, arguments: tc.arguments },
376
+ }));
377
+ }
231
378
  /**
232
379
  * Stateful ask. With an `id`, the client's `store` remembers the conversation:
233
380
  * it loads that id's transcript, runs, saves the updated transcript, and returns
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./a2a.js";
8
8
  export * from "./serve.js";
9
9
  export * from "./mcpserve.js";
10
10
  export * from "./adapters.js";
11
+ export * from "./translate.js";
11
12
  export * from "./toolkit.js";
12
13
  export * from "./client.js";
13
14
  export * as agents from "./agents/index.js";
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export * from "./a2a.js";
8
8
  export * from "./serve.js";
9
9
  export * from "./mcpserve.js";
10
10
  export * from "./adapters.js";
11
+ export * from "./translate.js";
11
12
  export * from "./toolkit.js";
12
13
  export * from "./client.js";
13
14
  export * as agents from "./agents/index.js";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Single-turn translation — the translator path (SPEC.md §11, ADR-0011).
3
+ *
4
+ * `client.translate(request)` is toolnexus used as a pure wire-format translator: OpenAI
5
+ * shapes in, exactly ONE provider call, OpenAI shapes out. No agent loop, no tool
6
+ * execution, no conversation state — so a caller can run it statelessly.
7
+ *
8
+ * It is the INBOUND half of the §5 adapters: `toOpenAI`/`toAnthropic`/`toGemini` send
9
+ * declarations out, this reads the provider's tool calls back in.
10
+ *
11
+ * Use it when the CALLER owns the conversation and executes tools itself (the standard
12
+ * OpenAI function-calling posture). When toolnexus owns the conversation, use the agent
13
+ * loop with relay tools + `runWithAnswer` (§10) instead.
14
+ */
15
+ import type { Toolkit } from "./toolkit.js";
16
+ import type { Usage } from "./client.js";
17
+ /** An OpenAI-shaped chat request, handed over verbatim (§11). */
18
+ export interface TranslateRequest {
19
+ /**
20
+ * The OpenAI `messages` array, verbatim — including assistant turns carrying
21
+ * `tool_calls` and `tool`-role results carrying `tool_call_id`.
22
+ */
23
+ messages: any[];
24
+ /**
25
+ * The OpenAI `tools` array, verbatim
26
+ * (`{type:"function",function:{name,description,parameters}}`). Declaration-only —
27
+ * nothing here is ever executed.
28
+ */
29
+ tools?: any[];
30
+ /**
31
+ * Declares an ordinary toolkit's tools to the provider WITHOUT executing any of them —
32
+ * MCP tools, skills, native functions, A2A agents, builtins. Use this when you have a
33
+ * toolkit and want the model's tool CALLS handed back to you to dispatch yourself.
34
+ * Composes with `tools`; toolkit declarations come first.
35
+ */
36
+ toolkit?: Toolkit;
37
+ /** The OpenAI `tool_choice`, verbatim. Omitted when absent. */
38
+ toolChoice?: any;
39
+ /** Overrides the system prompt. Absent uses any `system` message found in `messages`. */
40
+ system?: string;
41
+ /** Overrides the per-provider default max tokens. */
42
+ maxTokens?: number;
43
+ /** Aborts the in-flight provider call. */
44
+ signal?: AbortSignal;
45
+ }
46
+ /** One tool call the model asked for, in OpenAI shape (§11). */
47
+ export interface TranslatedToolCall {
48
+ /** The tool-call id the caller must echo on its `tool` result message. */
49
+ id: string;
50
+ /** The function name. */
51
+ name: string;
52
+ /** Arguments as a JSON **string** — the OpenAI wire form, echoable byte-for-byte. */
53
+ arguments: string;
54
+ }
55
+ /** An OpenAI-shaped single-turn result (§11). */
56
+ export interface TranslateResult {
57
+ /** Assistant text ("" when the model only called tools). */
58
+ text: string;
59
+ /** The tool calls the model emitted, in provider order. None is ever dropped. */
60
+ toolCalls: TranslatedToolCall[];
61
+ /** The OpenAI finish reason. A turn with any tool call is always `"tool_calls"`. */
62
+ finishReason: "stop" | "tool_calls" | "length" | "content_filter";
63
+ /** This single call's token usage. */
64
+ usage: Usage;
65
+ /** The model that answered. */
66
+ model: string;
67
+ /** The provider's decoded response, for fields this type does not model. */
68
+ raw?: any;
69
+ }
70
+ /**
71
+ * Maps a provider stop reason onto an OpenAI finish reason. Tool calls win: a turn that
72
+ * emitted any tool call is always `"tool_calls"` to a conforming client.
73
+ */
74
+ export declare function finishReasonFor(hasToolCalls: boolean, providerStop?: string): TranslateResult["finishReason"];
75
+ /** Flattens an OpenAI `content` value to text: the string form and the parts-array form. */
76
+ export declare function contentText(content: unknown): string;
77
+ /** Parses a tool-call arguments value into an object, tolerating both wire forms. */
78
+ export declare function argsObject(args: unknown): Record<string, unknown>;
79
+ /** Renders a tool-call arguments value as the JSON string the OpenAI wire format uses. */
80
+ export declare function argsString(args: unknown): string;
81
+ /** Reads an assistant message's OpenAI `tool_calls`. */
82
+ export declare function toolCallsOf(m: any): TranslatedToolCall[];
83
+ /**
84
+ * Converts an OpenAI `messages` array into Anthropic-native messages plus the extracted
85
+ * system prompt, preserving the tool structure a text flattening destroys (§11):
86
+ *
87
+ * - an assistant turn's `tool_calls` become `tool_use` blocks (arguments re-parsed to objects),
88
+ * - a `tool`-role result becomes a `tool_result` block keyed by `tool_call_id`, MERGED into a
89
+ * single user turn when consecutive (providers want one result-bearing turn per assistant turn),
90
+ * - `system`/`developer` messages are hoisted out, since Anthropic takes system separately.
91
+ */
92
+ export declare function openAIMessagesToAnthropic(messages: any[]): {
93
+ messages: any[];
94
+ system: string;
95
+ };
96
+ /**
97
+ * Converts an OpenAI `tools` array into Anthropic tool declarations. Entries that are
98
+ * already provider-native pass through; anything unrecognized is skipped.
99
+ */
100
+ export declare function openAIToolsToAnthropic(tools: any[] | undefined): any[];
101
+ /**
102
+ * Maps OpenAI `tool_choice` onto Anthropic's shape. Returns undefined for absent/"auto"
103
+ * (the provider default) and for anything unrecognized.
104
+ */
105
+ export declare function openAIToolChoiceToAnthropic(choice: unknown): any;
106
+ /** True when the message list already carries a system-ish message. */
107
+ export declare function hasSystemMessage(messages: any[]): boolean;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Single-turn translation — the translator path (SPEC.md §11, ADR-0011).
3
+ *
4
+ * `client.translate(request)` is toolnexus used as a pure wire-format translator: OpenAI
5
+ * shapes in, exactly ONE provider call, OpenAI shapes out. No agent loop, no tool
6
+ * execution, no conversation state — so a caller can run it statelessly.
7
+ *
8
+ * It is the INBOUND half of the §5 adapters: `toOpenAI`/`toAnthropic`/`toGemini` send
9
+ * declarations out, this reads the provider's tool calls back in.
10
+ *
11
+ * Use it when the CALLER owns the conversation and executes tools itself (the standard
12
+ * OpenAI function-calling posture). When toolnexus owns the conversation, use the agent
13
+ * loop with relay tools + `runWithAnswer` (§10) instead.
14
+ */
15
+ /**
16
+ * Maps a provider stop reason onto an OpenAI finish reason. Tool calls win: a turn that
17
+ * emitted any tool call is always `"tool_calls"` to a conforming client.
18
+ */
19
+ export function finishReasonFor(hasToolCalls, providerStop) {
20
+ if (hasToolCalls)
21
+ return "tool_calls";
22
+ switch (providerStop) {
23
+ case "max_tokens":
24
+ case "length":
25
+ return "length";
26
+ case "refusal":
27
+ case "content_filter":
28
+ return "content_filter";
29
+ default:
30
+ return "stop";
31
+ }
32
+ }
33
+ /** Flattens an OpenAI `content` value to text: the string form and the parts-array form. */
34
+ export function contentText(content) {
35
+ if (typeof content === "string")
36
+ return content;
37
+ if (Array.isArray(content)) {
38
+ return content
39
+ .map((p) => (p && typeof p === "object" && typeof p.text === "string" ? p.text : ""))
40
+ .join("");
41
+ }
42
+ return "";
43
+ }
44
+ /** Parses a tool-call arguments value into an object, tolerating both wire forms. */
45
+ export function argsObject(args) {
46
+ if (args && typeof args === "object")
47
+ return args;
48
+ if (typeof args === "string" && args.trim()) {
49
+ try {
50
+ const parsed = JSON.parse(args);
51
+ if (parsed && typeof parsed === "object")
52
+ return parsed;
53
+ }
54
+ catch {
55
+ /* fall through to {} — a malformed arguments string is not fatal */
56
+ }
57
+ }
58
+ return {};
59
+ }
60
+ /** Renders a tool-call arguments value as the JSON string the OpenAI wire format uses. */
61
+ export function argsString(args) {
62
+ if (typeof args === "string")
63
+ return args;
64
+ if (args && typeof args === "object")
65
+ return JSON.stringify(args);
66
+ return "{}";
67
+ }
68
+ /** Reads an assistant message's OpenAI `tool_calls`. */
69
+ export function toolCallsOf(m) {
70
+ const raw = m?.tool_calls;
71
+ if (!Array.isArray(raw))
72
+ return [];
73
+ const out = [];
74
+ for (const tc of raw) {
75
+ const fn = tc?.function;
76
+ if (!fn)
77
+ continue;
78
+ out.push({ id: String(tc.id ?? ""), name: String(fn.name ?? ""), arguments: argsString(fn.arguments) });
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * Converts an OpenAI `messages` array into Anthropic-native messages plus the extracted
84
+ * system prompt, preserving the tool structure a text flattening destroys (§11):
85
+ *
86
+ * - an assistant turn's `tool_calls` become `tool_use` blocks (arguments re-parsed to objects),
87
+ * - a `tool`-role result becomes a `tool_result` block keyed by `tool_call_id`, MERGED into a
88
+ * single user turn when consecutive (providers want one result-bearing turn per assistant turn),
89
+ * - `system`/`developer` messages are hoisted out, since Anthropic takes system separately.
90
+ */
91
+ export function openAIMessagesToAnthropic(messages) {
92
+ const out = [];
93
+ const systemParts = [];
94
+ let pendingResults = [];
95
+ const flush = () => {
96
+ if (pendingResults.length) {
97
+ out.push({ role: "user", content: pendingResults });
98
+ pendingResults = [];
99
+ }
100
+ };
101
+ for (const m of messages ?? []) {
102
+ if (!m || typeof m !== "object")
103
+ continue;
104
+ switch (m.role) {
105
+ case "system":
106
+ case "developer": {
107
+ flush();
108
+ const s = contentText(m.content);
109
+ if (s)
110
+ systemParts.push(s);
111
+ break;
112
+ }
113
+ case "tool":
114
+ case "function": {
115
+ const block = { type: "tool_result", content: contentText(m.content) };
116
+ if (m.tool_call_id)
117
+ block.tool_use_id = String(m.tool_call_id);
118
+ pendingResults.push(block);
119
+ break;
120
+ }
121
+ case "assistant": {
122
+ flush();
123
+ const blocks = [];
124
+ const s = contentText(m.content);
125
+ if (s)
126
+ blocks.push({ type: "text", text: s });
127
+ for (const tc of toolCallsOf(m)) {
128
+ blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: argsObject(tc.arguments) });
129
+ }
130
+ if (!blocks.length)
131
+ break; // an empty assistant turn would be rejected
132
+ out.push({ role: "assistant", content: blocks });
133
+ break;
134
+ }
135
+ default: {
136
+ flush();
137
+ const s = contentText(m.content);
138
+ if (s)
139
+ out.push({ role: "user", content: s });
140
+ else if (Array.isArray(m.content) && m.content.length)
141
+ out.push({ role: "user", content: m.content });
142
+ }
143
+ }
144
+ }
145
+ flush();
146
+ return { messages: out, system: systemParts.join("\n\n") };
147
+ }
148
+ /**
149
+ * Converts an OpenAI `tools` array into Anthropic tool declarations. Entries that are
150
+ * already provider-native pass through; anything unrecognized is skipped.
151
+ */
152
+ export function openAIToolsToAnthropic(tools) {
153
+ const out = [];
154
+ for (const t of tools ?? []) {
155
+ if (!t || typeof t !== "object")
156
+ continue;
157
+ const fn = t.function;
158
+ if (!fn) {
159
+ if (t.name)
160
+ out.push(t); // already native
161
+ continue;
162
+ }
163
+ if (!fn.name)
164
+ continue;
165
+ const decl = { name: fn.name };
166
+ if (fn.description)
167
+ decl.description = fn.description;
168
+ decl.input_schema = fn.parameters ?? { type: "object", properties: {} };
169
+ out.push(decl);
170
+ }
171
+ return out;
172
+ }
173
+ /**
174
+ * Maps OpenAI `tool_choice` onto Anthropic's shape. Returns undefined for absent/"auto"
175
+ * (the provider default) and for anything unrecognized.
176
+ */
177
+ export function openAIToolChoiceToAnthropic(choice) {
178
+ if (typeof choice === "string") {
179
+ if (choice === "required" || choice === "any")
180
+ return { type: "any" };
181
+ if (choice === "none")
182
+ return { type: "none" };
183
+ return undefined;
184
+ }
185
+ if (choice && typeof choice === "object") {
186
+ const name = choice.function?.name;
187
+ if (name)
188
+ return { type: "tool", name };
189
+ }
190
+ return undefined;
191
+ }
192
+ /** True when the message list already carries a system-ish message. */
193
+ export function hasSystemMessage(messages) {
194
+ return (messages ?? []).some((m) => m?.role === "system" || m?.role === "developer");
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.10.0",
3
+ "version": "0.13.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/home.test.ts test/compaction.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",
21
21
  "example": "node --experimental-strip-types examples/basic.ts"
22
22
  },
23
23
  "keywords": [
@@ -32,6 +32,12 @@ export interface AgentSpec {
32
32
  waitFor?: (request: Request) => Promise<Answer>
33
33
  onSpawn?: AgentDef["onSpawn"]
34
34
  onClose?: AgentDef["onClose"]
35
+ /** §8 lifecycle callbacks for this agent (e.g. a §7F `compactor`). Set ⇒ replaces
36
+ * the runtime-wide `hooks` for this agent; forwarded verbatim. */
37
+ hooks?: AgentDef["hooks"]
38
+ /** §8 observability sink for this agent. Set ⇒ replaces the runtime-wide
39
+ * `onMetric` for this agent; resolves independently of `hooks`. */
40
+ onMetric?: AgentDef["onMetric"]
35
41
  }
36
42
 
37
43
  /** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
@@ -66,6 +72,8 @@ export class Agent {
66
72
  waitFor: this.spec.waitFor,
67
73
  onSpawn: this.spec.onSpawn,
68
74
  onClose: this.spec.onClose,
75
+ hooks: this.spec.hooks,
76
+ onMetric: this.spec.onMetric,
69
77
  }
70
78
  for (const t of this.spec.team ?? []) t.registry(acc)
71
79
  return acc
@@ -14,7 +14,7 @@
14
14
  * the ConversationStore. SPEC pins transitions, never scheduling — conformance is
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
- import { createClient, InMemoryConversationStore, type ClientStyle, type ConversationStore } from "../client.js"
17
+ import { createClient, InMemoryConversationStore, type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js"
18
18
  import { createToolkit, type Toolkit } from "../toolkit.js"
19
19
  import { defineTool } from "../native.js"
20
20
  import type { Answer, Request, Tool, ToolResult } from "../types.js"
@@ -90,6 +90,14 @@ export interface AgentDef {
90
90
  onSpawn?: (h: Handle) => void | Promise<void>
91
91
  /** Runs before the final checkpoint on close. */
92
92
  onClose?: (h: Handle, reason: CloseReason) => void | Promise<void>
93
+ /** §8 lifecycle callbacks for THIS agent's client. Set ⇒ REPLACES the runtime-wide
94
+ * `hooks` for this agent (never merged — composing two transcript rewrites has no
95
+ * defined order). Forwarded verbatim; the runtime never reads or wraps it.
96
+ * This is how a §7F compactor attaches per agent. */
97
+ hooks?: Hooks
98
+ /** §8 observability sink for THIS agent's client. Set ⇒ REPLACES the runtime-wide
99
+ * `onMetric` for this agent. Resolves independently of `hooks`. Forwarded verbatim. */
100
+ onMetric?: (ev: MetricEvent) => void
93
101
  }
94
102
 
95
103
  /** An unsolicited inbox item. Delivered ONLY as part of a fresh turn's coalesced
@@ -270,6 +278,13 @@ export interface RuntimeOptions {
270
278
  maxConcurrentTurns?: number
271
279
  /** Graceful-close bound for a running turn before escalating to abort. Default 200. */
272
280
  shutdownMs?: number
281
+ /** §8 lifecycle callbacks applied to EVERY agent's client, unless that agent's
282
+ * def sets its own `hooks` (def-over-runtime, replace never merge). Forwarded
283
+ * verbatim into `createClient` — never composed, wrapped, reordered or read. */
284
+ hooks?: Hooks
285
+ /** §8 observability sink applied to EVERY agent's client, unless that agent's def
286
+ * sets its own `onMetric`. Resolves independently of `hooks`. Forwarded verbatim. */
287
+ onMetric?: (ev: MetricEvent) => void
273
288
  }
274
289
 
275
290
  interface GateWaiter {
@@ -748,6 +763,10 @@ export class AgentRuntime {
748
763
  fetch: this.gatedFetch(),
749
764
  store: this.store,
750
765
  waitFor: oneShotWaitFor ?? this.wrapWaitFor(h),
766
+ // The §8 seams: def-over-runtime, REPLACE never merge, each field resolved
767
+ // independently, forwarded verbatim (never composed, wrapped or read).
768
+ hooks: h.def.hooks ?? this.opts.hooks,
769
+ onMetric: h.def.onMetric ?? this.opts.onMetric,
751
770
  })
752
771
  const r = await client.ask(input, { toolkit, id: h.id, signal: h.abort!.signal })
753
772
  h.turnsTotal += r.turns
package/src/client.ts CHANGED
@@ -6,6 +6,16 @@
6
6
  import type { Toolkit } from "./toolkit.js"
7
7
  import type { ToolResult, Request, Answer } from "./types.js"
8
8
  import { pendingOf } from "./types.js"
9
+ import type { TranslateRequest, TranslateResult, TranslatedToolCall } from "./translate.js"
10
+ import {
11
+ finishReasonFor,
12
+ contentText,
13
+ argsString,
14
+ openAIMessagesToAnthropic,
15
+ openAIToolsToAnthropic,
16
+ openAIToolChoiceToAnthropic,
17
+ hasSystemMessage,
18
+ } from "./translate.js"
9
19
 
10
20
  export type ClientStyle = "openai" | "anthropic"
11
21
 
@@ -401,6 +411,159 @@ export class Client {
401
411
  : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history)
402
412
  }
403
413
 
414
+ /**
415
+ * Single-turn translation (§11): exactly ONE provider call, returned in OpenAI shape.
416
+ * No agent loop, no tool execution, no conversation state — every call is
417
+ * self-contained, so this may be run statelessly and concurrently.
418
+ *
419
+ * The inbound half of the adapters: `toAnthropic`/`toGemini` send declarations out,
420
+ * this reads the provider's tool calls back in. A `toolkit` passed in the request is
421
+ * DECLARED and never executed.
422
+ *
423
+ * Retries/backoff, requestParams merging and the `llm` metric are shared with the loop.
424
+ * `beforeLLM`/`afterLLM` fire once; tool hooks do not, because no tool runs.
425
+ */
426
+ async translate(req: TranslateRequest): Promise<TranslateResult> {
427
+ return this.opts.style === "anthropic" ? this.translateAnthropic(req) : this.translateOpenAI(req)
428
+ }
429
+
430
+ /** OpenAI-style upstream: near-passthrough. */
431
+ private async translateOpenAI(req: TranslateRequest): Promise<TranslateResult> {
432
+ const key = resolveKey(this.opts)
433
+ const declared = [...(req.toolkit ? req.toolkit.toOpenAI() : []), ...(req.tools ?? [])]
434
+ let messages = (req.messages ?? []).slice()
435
+ const sys = req.system ?? this.opts.systemPrompt ?? ""
436
+ if (sys && !hasSystemMessage(messages)) messages = [{ role: "system", content: sys }, ...messages]
437
+
438
+ let tools = declared
439
+ if (this.opts.hooks?.beforeLLM) {
440
+ const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn: 0 })
441
+ if (ov?.messages) messages = ov.messages
442
+ if (ov?.tools) tools = ov.tools
443
+ }
444
+
445
+ const body: Record<string, any> = { model: this.opts.model, messages }
446
+ if (tools.length) body.tools = tools
447
+ if (req.toolChoice !== undefined) body.tool_choice = req.toolChoice
448
+ if (req.maxTokens) body.max_tokens = req.maxTokens
449
+
450
+ const signal = this.makeSignal(req.signal)
451
+ {
452
+ const data = await this.llmCallJson(
453
+ `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`,
454
+ {
455
+ method: "POST",
456
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
457
+ body: JSON.stringify(this.finalizeBody(body)),
458
+ },
459
+ signal,
460
+ "openai",
461
+ )
462
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn: 0 })
463
+ const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
464
+ addUsage(usage, data.usage, "openai")
465
+ const choice = data.choices?.[0]
466
+ const toolCalls: TranslatedToolCall[] = (choice?.message?.tool_calls ?? []).map((tc: any) => ({
467
+ id: String(tc.id ?? ""),
468
+ name: String(tc.function?.name ?? ""),
469
+ arguments: argsString(tc.function?.arguments),
470
+ }))
471
+ return {
472
+ text: choice?.message?.content ?? "",
473
+ toolCalls,
474
+ finishReason: choice?.finish_reason ?? finishReasonFor(toolCalls.length > 0),
475
+ usage,
476
+ model: this.opts.model,
477
+ raw: data,
478
+ }
479
+ }
480
+ }
481
+
482
+ /** Anthropic-style upstream: the real translation. */
483
+ private async translateAnthropic(req: TranslateRequest): Promise<TranslateResult> {
484
+ const key = resolveKey(this.opts)
485
+ const base = this.opts.baseUrl.replace(/\/$/, "")
486
+ const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
487
+
488
+ const converted = openAIMessagesToAnthropic(req.messages ?? [])
489
+ let messages = converted.messages
490
+ const system = req.system ?? this.opts.systemPrompt ?? converted.system
491
+ let tools = [
492
+ ...(req.toolkit ? req.toolkit.toAnthropic() : []),
493
+ ...openAIToolsToAnthropic(req.tools),
494
+ ]
495
+
496
+ if (this.opts.hooks?.beforeLLM) {
497
+ const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn: 0 })
498
+ if (ov?.messages) messages = ov.messages
499
+ if (ov?.tools) tools = ov.tools
500
+ }
501
+
502
+ const body: Record<string, any> = {
503
+ model: this.opts.model,
504
+ max_tokens: req.maxTokens && req.maxTokens > 0 ? req.maxTokens : 4096,
505
+ messages,
506
+ }
507
+ if (system) body.system = system
508
+ if (tools.length) body.tools = tools
509
+ const tc = openAIToolChoiceToAnthropic(req.toolChoice)
510
+ if (tc) body.tool_choice = tc
511
+
512
+ const signal = this.makeSignal(req.signal)
513
+ {
514
+ const data = await this.llmCallJson(
515
+ endpoint,
516
+ {
517
+ method: "POST",
518
+ headers: {
519
+ "content-type": "application/json",
520
+ "x-api-key": key,
521
+ "anthropic-version": "2023-06-01",
522
+ ...this.opts.headers,
523
+ },
524
+ body: JSON.stringify(this.finalizeBody(body)),
525
+ },
526
+ signal,
527
+ "anthropic",
528
+ )
529
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn: 0 })
530
+ const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
531
+ addUsage(usage, data.usage, "anthropic")
532
+ const text: string[] = []
533
+ const toolCalls: TranslatedToolCall[] = []
534
+ for (const b of data.content ?? []) {
535
+ if (b?.type === "text") text.push(b.text ?? "")
536
+ else if (b?.type === "tool_use") {
537
+ toolCalls.push({
538
+ id: String(b.id ?? ""),
539
+ name: String(b.name ?? ""),
540
+ arguments: JSON.stringify(b.input ?? {}),
541
+ })
542
+ }
543
+ }
544
+ return {
545
+ text: text.join(""),
546
+ toolCalls,
547
+ finishReason: finishReasonFor(toolCalls.length > 0, data.stop_reason),
548
+ usage,
549
+ model: this.opts.model,
550
+ raw: data,
551
+ }
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Renders a translate result's tool calls as an OpenAI `tool_calls` array, ready to put
557
+ * on an assistant message. Convenience for assembling a response envelope.
558
+ */
559
+ static toolCallsJSON(res: TranslateResult): any[] {
560
+ return res.toolCalls.map((tc) => ({
561
+ id: tc.id,
562
+ type: "function",
563
+ function: { name: tc.name, arguments: tc.arguments },
564
+ }))
565
+ }
566
+
404
567
  /**
405
568
  * Stateful ask. With an `id`, the client's `store` remembers the conversation:
406
569
  * it loads that id's transcript, runs, saves the updated transcript, and returns
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./a2a.js"
8
8
  export * from "./serve.js"
9
9
  export * from "./mcpserve.js"
10
10
  export * from "./adapters.js"
11
+ export * from "./translate.js"
11
12
  export * from "./toolkit.js"
12
13
  export * from "./client.js"
13
14
  export * as agents from "./agents/index.js"
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Single-turn translation — the translator path (SPEC.md §11, ADR-0011).
3
+ *
4
+ * `client.translate(request)` is toolnexus used as a pure wire-format translator: OpenAI
5
+ * shapes in, exactly ONE provider call, OpenAI shapes out. No agent loop, no tool
6
+ * execution, no conversation state — so a caller can run it statelessly.
7
+ *
8
+ * It is the INBOUND half of the §5 adapters: `toOpenAI`/`toAnthropic`/`toGemini` send
9
+ * declarations out, this reads the provider's tool calls back in.
10
+ *
11
+ * Use it when the CALLER owns the conversation and executes tools itself (the standard
12
+ * OpenAI function-calling posture). When toolnexus owns the conversation, use the agent
13
+ * loop with relay tools + `runWithAnswer` (§10) instead.
14
+ */
15
+
16
+ import type { Toolkit } from "./toolkit.js"
17
+ import type { Usage } from "./client.js"
18
+
19
+ /** An OpenAI-shaped chat request, handed over verbatim (§11). */
20
+ export interface TranslateRequest {
21
+ /**
22
+ * The OpenAI `messages` array, verbatim — including assistant turns carrying
23
+ * `tool_calls` and `tool`-role results carrying `tool_call_id`.
24
+ */
25
+ messages: any[]
26
+ /**
27
+ * The OpenAI `tools` array, verbatim
28
+ * (`{type:"function",function:{name,description,parameters}}`). Declaration-only —
29
+ * nothing here is ever executed.
30
+ */
31
+ tools?: any[]
32
+ /**
33
+ * Declares an ordinary toolkit's tools to the provider WITHOUT executing any of them —
34
+ * MCP tools, skills, native functions, A2A agents, builtins. Use this when you have a
35
+ * toolkit and want the model's tool CALLS handed back to you to dispatch yourself.
36
+ * Composes with `tools`; toolkit declarations come first.
37
+ */
38
+ toolkit?: Toolkit
39
+ /** The OpenAI `tool_choice`, verbatim. Omitted when absent. */
40
+ toolChoice?: any
41
+ /** Overrides the system prompt. Absent uses any `system` message found in `messages`. */
42
+ system?: string
43
+ /** Overrides the per-provider default max tokens. */
44
+ maxTokens?: number
45
+ /** Aborts the in-flight provider call. */
46
+ signal?: AbortSignal
47
+ }
48
+
49
+ /** One tool call the model asked for, in OpenAI shape (§11). */
50
+ export interface TranslatedToolCall {
51
+ /** The tool-call id the caller must echo on its `tool` result message. */
52
+ id: string
53
+ /** The function name. */
54
+ name: string
55
+ /** Arguments as a JSON **string** — the OpenAI wire form, echoable byte-for-byte. */
56
+ arguments: string
57
+ }
58
+
59
+ /** An OpenAI-shaped single-turn result (§11). */
60
+ export interface TranslateResult {
61
+ /** Assistant text ("" when the model only called tools). */
62
+ text: string
63
+ /** The tool calls the model emitted, in provider order. None is ever dropped. */
64
+ toolCalls: TranslatedToolCall[]
65
+ /** The OpenAI finish reason. A turn with any tool call is always `"tool_calls"`. */
66
+ finishReason: "stop" | "tool_calls" | "length" | "content_filter"
67
+ /** This single call's token usage. */
68
+ usage: Usage
69
+ /** The model that answered. */
70
+ model: string
71
+ /** The provider's decoded response, for fields this type does not model. */
72
+ raw?: any
73
+ }
74
+
75
+ /**
76
+ * Maps a provider stop reason onto an OpenAI finish reason. Tool calls win: a turn that
77
+ * emitted any tool call is always `"tool_calls"` to a conforming client.
78
+ */
79
+ export function finishReasonFor(hasToolCalls: boolean, providerStop?: string): TranslateResult["finishReason"] {
80
+ if (hasToolCalls) return "tool_calls"
81
+ switch (providerStop) {
82
+ case "max_tokens":
83
+ case "length":
84
+ return "length"
85
+ case "refusal":
86
+ case "content_filter":
87
+ return "content_filter"
88
+ default:
89
+ return "stop"
90
+ }
91
+ }
92
+
93
+ /** Flattens an OpenAI `content` value to text: the string form and the parts-array form. */
94
+ export function contentText(content: unknown): string {
95
+ if (typeof content === "string") return content
96
+ if (Array.isArray(content)) {
97
+ return content
98
+ .map((p) => (p && typeof p === "object" && typeof (p as any).text === "string" ? (p as any).text : ""))
99
+ .join("")
100
+ }
101
+ return ""
102
+ }
103
+
104
+ /** Parses a tool-call arguments value into an object, tolerating both wire forms. */
105
+ export function argsObject(args: unknown): Record<string, unknown> {
106
+ if (args && typeof args === "object") return args as Record<string, unknown>
107
+ if (typeof args === "string" && args.trim()) {
108
+ try {
109
+ const parsed = JSON.parse(args)
110
+ if (parsed && typeof parsed === "object") return parsed
111
+ } catch {
112
+ /* fall through to {} — a malformed arguments string is not fatal */
113
+ }
114
+ }
115
+ return {}
116
+ }
117
+
118
+ /** Renders a tool-call arguments value as the JSON string the OpenAI wire format uses. */
119
+ export function argsString(args: unknown): string {
120
+ if (typeof args === "string") return args
121
+ if (args && typeof args === "object") return JSON.stringify(args)
122
+ return "{}"
123
+ }
124
+
125
+ /** Reads an assistant message's OpenAI `tool_calls`. */
126
+ export function toolCallsOf(m: any): TranslatedToolCall[] {
127
+ const raw = m?.tool_calls
128
+ if (!Array.isArray(raw)) return []
129
+ const out: TranslatedToolCall[] = []
130
+ for (const tc of raw) {
131
+ const fn = tc?.function
132
+ if (!fn) continue
133
+ out.push({ id: String(tc.id ?? ""), name: String(fn.name ?? ""), arguments: argsString(fn.arguments) })
134
+ }
135
+ return out
136
+ }
137
+
138
+ /**
139
+ * Converts an OpenAI `messages` array into Anthropic-native messages plus the extracted
140
+ * system prompt, preserving the tool structure a text flattening destroys (§11):
141
+ *
142
+ * - an assistant turn's `tool_calls` become `tool_use` blocks (arguments re-parsed to objects),
143
+ * - a `tool`-role result becomes a `tool_result` block keyed by `tool_call_id`, MERGED into a
144
+ * single user turn when consecutive (providers want one result-bearing turn per assistant turn),
145
+ * - `system`/`developer` messages are hoisted out, since Anthropic takes system separately.
146
+ */
147
+ export function openAIMessagesToAnthropic(messages: any[]): { messages: any[]; system: string } {
148
+ const out: any[] = []
149
+ const systemParts: string[] = []
150
+ let pendingResults: any[] = []
151
+
152
+ const flush = () => {
153
+ if (pendingResults.length) {
154
+ out.push({ role: "user", content: pendingResults })
155
+ pendingResults = []
156
+ }
157
+ }
158
+
159
+ for (const m of messages ?? []) {
160
+ if (!m || typeof m !== "object") continue
161
+ switch (m.role) {
162
+ case "system":
163
+ case "developer": {
164
+ flush()
165
+ const s = contentText(m.content)
166
+ if (s) systemParts.push(s)
167
+ break
168
+ }
169
+ case "tool":
170
+ case "function": {
171
+ const block: any = { type: "tool_result", content: contentText(m.content) }
172
+ if (m.tool_call_id) block.tool_use_id = String(m.tool_call_id)
173
+ pendingResults.push(block)
174
+ break
175
+ }
176
+ case "assistant": {
177
+ flush()
178
+ const blocks: any[] = []
179
+ const s = contentText(m.content)
180
+ if (s) blocks.push({ type: "text", text: s })
181
+ for (const tc of toolCallsOf(m)) {
182
+ blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: argsObject(tc.arguments) })
183
+ }
184
+ if (!blocks.length) break // an empty assistant turn would be rejected
185
+ out.push({ role: "assistant", content: blocks })
186
+ break
187
+ }
188
+ default: {
189
+ flush()
190
+ const s = contentText(m.content)
191
+ if (s) out.push({ role: "user", content: s })
192
+ else if (Array.isArray(m.content) && m.content.length) out.push({ role: "user", content: m.content })
193
+ }
194
+ }
195
+ }
196
+ flush()
197
+ return { messages: out, system: systemParts.join("\n\n") }
198
+ }
199
+
200
+ /**
201
+ * Converts an OpenAI `tools` array into Anthropic tool declarations. Entries that are
202
+ * already provider-native pass through; anything unrecognized is skipped.
203
+ */
204
+ export function openAIToolsToAnthropic(tools: any[] | undefined): any[] {
205
+ const out: any[] = []
206
+ for (const t of tools ?? []) {
207
+ if (!t || typeof t !== "object") continue
208
+ const fn = (t as any).function
209
+ if (!fn) {
210
+ if ((t as any).name) out.push(t) // already native
211
+ continue
212
+ }
213
+ if (!fn.name) continue
214
+ const decl: any = { name: fn.name }
215
+ if (fn.description) decl.description = fn.description
216
+ decl.input_schema = fn.parameters ?? { type: "object", properties: {} }
217
+ out.push(decl)
218
+ }
219
+ return out
220
+ }
221
+
222
+ /**
223
+ * Maps OpenAI `tool_choice` onto Anthropic's shape. Returns undefined for absent/"auto"
224
+ * (the provider default) and for anything unrecognized.
225
+ */
226
+ export function openAIToolChoiceToAnthropic(choice: unknown): any {
227
+ if (typeof choice === "string") {
228
+ if (choice === "required" || choice === "any") return { type: "any" }
229
+ if (choice === "none") return { type: "none" }
230
+ return undefined
231
+ }
232
+ if (choice && typeof choice === "object") {
233
+ const name = (choice as any).function?.name
234
+ if (name) return { type: "tool", name }
235
+ }
236
+ return undefined
237
+ }
238
+
239
+ /** True when the message list already carries a system-ish message. */
240
+ export function hasSystemMessage(messages: any[]): boolean {
241
+ return (messages ?? []).some((m) => m?.role === "system" || m?.role === "developer")
242
+ }