toolnexus 0.16.0 → 0.18.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/src/translate.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { Toolkit } from "./toolkit.js"
17
+ import { encodeParts, inboundPart, type ContentPart } from "./content.js"
17
18
  import type { Usage } from "./client.js"
18
19
 
19
20
  /** An OpenAI-shaped chat request, handed over verbatim (§11). */
@@ -187,9 +188,14 @@ export function openAIMessagesToAnthropic(messages: any[]): { messages: any[]; s
187
188
  }
188
189
  default: {
189
190
  flush()
191
+ // A `content` array has its text parts concatenated and its NON-text parts translated
192
+ // into Anthropic's block shape (§11). Six ports used to pass a text-empty array through
193
+ // raw and undocumented; one specified mapping replaces that, so nothing is flattened
194
+ // away and nothing is dropped.
195
+ const blocks = contentPartBlocks(m.content)
196
+ if (blocks) { out.push({ role: "user", content: blocks }); break }
190
197
  const s = contentText(m.content)
191
198
  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
199
  }
194
200
  }
195
201
  }
@@ -197,6 +203,24 @@ export function openAIMessagesToAnthropic(messages: any[]): { messages: any[]; s
197
203
  return { messages: out, system: systemParts.join("\n\n") }
198
204
  }
199
205
 
206
+ /**
207
+ * Translate an OpenAI-shaped `content` array into Anthropic blocks — but ONLY when it
208
+ * actually carries something non-text; an all-text array still concatenates, so the
209
+ * common case is byte-identical. Both spellings are accepted: a literal `ContentPart`
210
+ * and the provider-native block that same part encodes to.
211
+ */
212
+ function contentPartBlocks(content: unknown): any[] | undefined {
213
+ if (!Array.isArray(content) || content.length === 0) return undefined
214
+ const parts: ContentPart[] = []
215
+ for (const raw of content) {
216
+ const p = inboundPart(raw)
217
+ if (!p) return undefined // an array we do not understand is left to contentText, as before
218
+ parts.push(p)
219
+ }
220
+ if (parts.every((p) => p.type === "text")) return undefined
221
+ return encodeParts(parts, { style: "anthropic", provenance: "attached" })
222
+ }
223
+
200
224
  /**
201
225
  * Converts an OpenAI `tools` array into Anthropic tool declarations. Entries that are
202
226
  * already provider-native pass through; anything unrecognized is skipped.
package/src/types.ts CHANGED
@@ -10,11 +10,20 @@ export type JSONSchema = {
10
10
  [k: string]: unknown
11
11
  }
12
12
 
13
+ import type { ContentPart } from "./content.js"
14
+
13
15
  export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom"
14
16
 
15
17
  export interface ToolResult {
16
18
  output: string
17
19
  isError: boolean
20
+ /**
21
+ * §1B non-text output. `output` stays required and remains what the transcript,
22
+ * compaction, token estimation and any text-only provider see; `parts` is the extra
23
+ * channel a screenshot / PDF / audio clip travels on. Absent ⇒ byte-identical to a
24
+ * pre-0.17 result, which is why it is optional rather than a fourth required field.
25
+ */
26
+ parts?: ContentPart[]
18
27
  metadata?: Record<string, unknown>
19
28
  }
20
29
 
package/src/wire.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Canonical transcript → provider wire messages (SPEC.md §8A).
3
+ *
4
+ * The loop's `messages` array is the **canonical transcript**: a user turn carrying parts
5
+ * holds `ContentPart[]`, and a tool turn carrying parts holds them under a `parts` key.
6
+ * Neither shape is a wire shape. This module is the single place that turns the canonical
7
+ * transcript into what each style actually accepts, so the four loop sites (run/stream ×
8
+ * openai/anthropic) only ever have to *record* parts, never encode them.
9
+ *
10
+ * It is also where the **tool-result relocation rule** lives. Anthropic defines blocks
11
+ * inside `tool_result.content`, so its parts are emitted natively, keyed to their
12
+ * `tool_use_id`. OpenAI's `tool` message rejects an image outright (a hard 400 — "Image URLs
13
+ * are only allowed for messages with role 'user'"), so there every non-text part from every
14
+ * tool result answering one assistant turn is relocated, in tool-call order, into ONE
15
+ * synthetic `user` message emitted immediately after the last tool message. That synthetic
16
+ * message is an adapter artifact: it is built here, on the way out, and never written back
17
+ * to the transcript, the `ConversationStore` or `translate` output — which is what keeps a
18
+ * mid-conversation provider switch free of OpenAI-shaped residue.
19
+ */
20
+ import type { ContentPart, PartStyle, UnsupportedPartMode } from "./content.js"
21
+ import { encodeParts, isContentPart, validatePart } from "./content.js"
22
+
23
+ /** Shared shaping options for a wire build. */
24
+ export interface WireOptions {
25
+ onUnsupportedPart?: UnsupportedPartMode
26
+ maxPartBytes?: number
27
+ }
28
+
29
+ /** The text preceding each relocated part, so the model can attribute it to its call. */
30
+ export const relocationHeader = (name: string, id: string) => `Output of tool ${name} (${id}):`
31
+
32
+ /** Split parts into the text ones (which ride along in place) and the rest. */
33
+ function split(parts: readonly ContentPart[]): { texts: ContentPart[]; others: ContentPart[] } {
34
+ const texts: ContentPart[] = []
35
+ const others: ContentPart[] = []
36
+ for (const p of parts) (p.type === "text" ? texts : others).push(p)
37
+ return { texts, others }
38
+ }
39
+
40
+ function partsOf(m: any): ContentPart[] | undefined {
41
+ const p = m?.parts
42
+ return Array.isArray(p) && p.length ? (p as ContentPart[]) : undefined
43
+ }
44
+
45
+ /** True when a message's `content` is a canonical `ContentPart[]` rather than a wire array. */
46
+ function isPartArray(content: unknown): content is ContentPart[] {
47
+ return Array.isArray(content) && content.length > 0 && content.every(isContentPart)
48
+ }
49
+
50
+ /**
51
+ * Build the OpenAI-style wire messages. Tool messages carry `output` plus text parts only;
52
+ * their non-text parts are collected and flushed as one synthetic user message the moment
53
+ * the run of tool messages ends.
54
+ */
55
+ export function toOpenAIWire(messages: any[], opts: WireOptions = {}): any[] {
56
+ const out: any[] = []
57
+ let relocated: { name: string; id: string; parts: ContentPart[] }[] = []
58
+
59
+ const flush = () => {
60
+ if (!relocated.length) return
61
+ const blocks: any[] = []
62
+ for (const r of relocated) {
63
+ blocks.push({ type: "text", text: relocationHeader(r.name, r.id) })
64
+ blocks.push(...encodeParts(r.parts, { style: "openai", provenance: "derived", ...opts }))
65
+ }
66
+ relocated = []
67
+ out.push({ role: "user", content: blocks })
68
+ }
69
+
70
+ for (const m of messages ?? []) {
71
+ if (m?.role === "tool") {
72
+ const parts = partsOf(m)
73
+ const { parts: _drop, ...rest } = m ?? {}
74
+ if (!parts) {
75
+ out.push(m)
76
+ continue
77
+ }
78
+ const { texts, others } = split(parts)
79
+ const msg: any = { ...rest }
80
+ if (texts.length) {
81
+ msg.content = [{ type: "text", text: String(rest.content ?? "") }, ...encodeParts(texts, { style: "openai", provenance: "derived", ...opts })]
82
+ }
83
+ out.push(msg)
84
+ if (others.length) relocated.push({ name: String(rest.name ?? ""), id: String(rest.tool_call_id ?? ""), parts: others })
85
+ continue
86
+ }
87
+ flush()
88
+ if (isPartArray(m?.content)) {
89
+ out.push({ ...m, content: encodeParts(m.content, { style: "openai", provenance: "attached", ...opts }) })
90
+ continue
91
+ }
92
+ out.push(m)
93
+ }
94
+ flush()
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * Build the Anthropic-style wire messages. Nothing is relocated: a `tool_result` block that
100
+ * carries parts gets them natively inside its own `content`, keyed to its `tool_use_id`.
101
+ */
102
+ export function toAnthropicWire(messages: any[], opts: WireOptions = {}): any[] {
103
+ return (messages ?? []).map((m) => {
104
+ if (isPartArray(m?.content)) {
105
+ return { ...m, content: encodeParts(m.content, { style: "anthropic", provenance: "attached", ...opts }) }
106
+ }
107
+ if (!Array.isArray(m?.content)) return m
108
+ let touched = false
109
+ const content = m.content.map((block: any) => {
110
+ if (block?.type !== "tool_result") return block
111
+ const parts = partsOf(block)
112
+ if (!parts) return block
113
+ touched = true
114
+ const { parts: _drop, ...rest } = block
115
+ return {
116
+ ...rest,
117
+ content: [
118
+ { type: "text", text: String(rest.content ?? "") },
119
+ ...encodeParts(parts, { style: "anthropic", provenance: "derived", ...opts }),
120
+ ],
121
+ }
122
+ })
123
+ return touched ? { ...m, content } : m
124
+ })
125
+ }
126
+
127
+ /** Validate every attached part before assembly, so `maxPartBytes` cannot be bypassed. */
128
+ export function checkPromptParts(prompt: unknown, opts: WireOptions): void {
129
+ if (!Array.isArray(prompt)) return
130
+ for (const p of prompt) validatePart(p as ContentPart, opts.maxPartBytes)
131
+ }