@juno-ai/bind 2.0.0 → 3.0.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.
Files changed (45) hide show
  1. package/README.md +1010 -60
  2. package/contracts/index.d.ts +1 -1
  3. package/contracts/index.js +1 -1
  4. package/contracts/turn.d.ts +5 -5
  5. package/index.d.ts +13 -5
  6. package/index.js +13 -5
  7. package/package.json +18 -2
  8. package/plugins/activation.d.ts +67 -0
  9. package/plugins/activation.js +61 -0
  10. package/plugins/index.d.ts +3 -0
  11. package/plugins/index.js +3 -0
  12. package/plugins/registry.d.ts +52 -0
  13. package/plugins/registry.js +54 -0
  14. package/plugins/tool.d.ts +164 -0
  15. package/plugins/tool.js +9 -0
  16. package/routing/billing-basis.d.ts +48 -0
  17. package/routing/billing-basis.js +67 -0
  18. package/routing/circuit-breaker.d.ts +2 -2
  19. package/routing/errors.d.ts +1 -1
  20. package/routing/executor.d.ts +3 -3
  21. package/routing/executor.js +1 -1
  22. package/routing/index.d.ts +11 -9
  23. package/routing/index.js +11 -9
  24. package/routing/plan-degradation.d.ts +34 -0
  25. package/routing/plan-degradation.js +38 -0
  26. package/routing/plan.d.ts +2 -2
  27. package/routing/planner.d.ts +4 -4
  28. package/routing/planner.js +1 -1
  29. package/routing/policy.d.ts +1 -1
  30. package/routing/policy.js +1 -1
  31. package/routing/transport.d.ts +2 -2
  32. package/run/harness.d.ts +94 -0
  33. package/run/harness.js +140 -0
  34. package/run/index.d.ts +2 -0
  35. package/run/index.js +2 -0
  36. package/run/tool-batch.d.ts +16 -0
  37. package/run/tool-batch.js +83 -0
  38. package/tools/index.d.ts +1 -0
  39. package/tools/index.js +1 -0
  40. package/tools/sanitize-schema.d.ts +150 -0
  41. package/tools/sanitize-schema.js +683 -0
  42. package/transcript/index.d.ts +1 -0
  43. package/transcript/index.js +1 -0
  44. package/transcript/validate.d.ts +54 -0
  45. package/transcript/validate.js +226 -0
@@ -0,0 +1 @@
1
+ export { validateAndHealMessages, type MessageIssue, type MessageIssueKind, type ValidateAndHealResult, } from "./validate.js";
@@ -0,0 +1 @@
1
+ export { validateAndHealMessages, } from "./validate.js";
@@ -0,0 +1,54 @@
1
+ import type OpenAI from "openai";
2
+ /**
3
+ * Heuristic issues detected in a `messages` array before it's sent to the
4
+ * provider. These are defensive checks — upstream code is supposed to
5
+ * produce valid conversations, but historical bugs (and the many providers
6
+ * reachable via OpenRouter) have surfaced invalid shapes that different
7
+ * providers reject in different ways. Logging and healing here keeps a
8
+ * single run from failing outright when the shape is still salvageable.
9
+ */
10
+ export type MessageIssueKind = "empty_messages" | "trailing_assistant_with_content" | "trailing_assistant_empty" | "orphan_tool_result" | "dangling_tool_calls_unanswered" | "empty_assistant_mid" | "empty_user_content";
11
+ export interface MessageIssue {
12
+ kind: MessageIssueKind;
13
+ index: number;
14
+ detail: string;
15
+ healed: boolean;
16
+ }
17
+ export interface ValidateAndHealResult {
18
+ messages: OpenAI.ChatCompletionMessageParam[];
19
+ issues: MessageIssue[];
20
+ }
21
+ /**
22
+ * Run sanity checks against the outgoing `messages` array and attempt to
23
+ * heal any detected issues. Returns the (possibly modified) messages along
24
+ * with a list of issues found — the caller should log these as errors.
25
+ *
26
+ * Heuristics:
27
+ * - Empty array → flagged, not healable (provider will reject).
28
+ * - Trailing assistant with content → append a synthetic user turn so
29
+ * providers that don't support assistant prefill (Azure-hosted Claude
30
+ * via OpenRouter) accept the request.
31
+ * - Trailing assistant with no content / no tool_calls → drop it, then
32
+ * re-check the new tail.
33
+ * - Assistant with `tool_calls` not followed by matching tool results →
34
+ * strip the unanswered tool_calls. If the message has no other content
35
+ * either, drop it. Without this, the provider rejects the next turn.
36
+ * - Tool message whose `tool_call_id` doesn't match any preceding
37
+ * assistant `tool_calls` → drop the orphan.
38
+ * - Mid-conversation assistant with no content and no tool_calls → drop.
39
+ * - User message with entirely empty content → drop.
40
+ */
41
+ export declare function validateAndHealMessages(input: OpenAI.ChatCompletionMessageParam[],
42
+ /**
43
+ * Tool-call ids that are
44
+ * *legitimately* open — a suspended `ask` awaiting a human answer. This is the
45
+ * UNIVERSAL strip site (the host runs it before every model call),
46
+ * and the never-send-unpaired invariant means a correct array never carries a
47
+ * suspended call here unpaired. So this set is a **fail-loud assertion**, NOT a
48
+ * silent-keep: if an allowed id reaches the validator unpaired, resume failed to
49
+ * thread its answer — surface the bug rather than strip it (which would mask it)
50
+ * or send it (which the provider would 400 on). Bounded to ids in the set:
51
+ * callers that pass nothing (every legacy/non-HITL path) get exactly today's
52
+ * non-throwing heal-and-log behaviour.
53
+ */
54
+ allowedOpenToolCallIds?: ReadonlySet<string>): ValidateAndHealResult;
@@ -0,0 +1,226 @@
1
+ /** Synthetic user message appended when the conversation would otherwise end
2
+ * on an assistant turn. Provider behavior diverges here — Anthropic's native
3
+ * API treats a trailing assistant as prefill, but Azure-hosted Claude (and
4
+ * some other providers routed through OpenRouter) reject the request
5
+ * outright with "model does not support assistant message prefill". */
6
+ const TRAILING_USER_PLACEHOLDER = "Continue.";
7
+ function isEmptyContent(content) {
8
+ if (content == null)
9
+ return true;
10
+ if (typeof content === "string")
11
+ return content.trim().length === 0;
12
+ if (Array.isArray(content)) {
13
+ if (content.length === 0)
14
+ return true;
15
+ return content.every((part) => {
16
+ if (part && typeof part === "object" && "type" in part) {
17
+ if (part.type === "text") {
18
+ return !("text" in part) || !part.text || String(part.text).trim().length === 0;
19
+ }
20
+ }
21
+ return false;
22
+ });
23
+ }
24
+ return false;
25
+ }
26
+ function assistantHasToolCalls(msg) {
27
+ return Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
28
+ }
29
+ /**
30
+ * Run sanity checks against the outgoing `messages` array and attempt to
31
+ * heal any detected issues. Returns the (possibly modified) messages along
32
+ * with a list of issues found — the caller should log these as errors.
33
+ *
34
+ * Heuristics:
35
+ * - Empty array → flagged, not healable (provider will reject).
36
+ * - Trailing assistant with content → append a synthetic user turn so
37
+ * providers that don't support assistant prefill (Azure-hosted Claude
38
+ * via OpenRouter) accept the request.
39
+ * - Trailing assistant with no content / no tool_calls → drop it, then
40
+ * re-check the new tail.
41
+ * - Assistant with `tool_calls` not followed by matching tool results →
42
+ * strip the unanswered tool_calls. If the message has no other content
43
+ * either, drop it. Without this, the provider rejects the next turn.
44
+ * - Tool message whose `tool_call_id` doesn't match any preceding
45
+ * assistant `tool_calls` → drop the orphan.
46
+ * - Mid-conversation assistant with no content and no tool_calls → drop.
47
+ * - User message with entirely empty content → drop.
48
+ */
49
+ export function validateAndHealMessages(input,
50
+ /**
51
+ * Tool-call ids that are
52
+ * *legitimately* open — a suspended `ask` awaiting a human answer. This is the
53
+ * UNIVERSAL strip site (the host runs it before every model call),
54
+ * and the never-send-unpaired invariant means a correct array never carries a
55
+ * suspended call here unpaired. So this set is a **fail-loud assertion**, NOT a
56
+ * silent-keep: if an allowed id reaches the validator unpaired, resume failed to
57
+ * thread its answer — surface the bug rather than strip it (which would mask it)
58
+ * or send it (which the provider would 400 on). Bounded to ids in the set:
59
+ * callers that pass nothing (every legacy/non-HITL path) get exactly today's
60
+ * non-throwing heal-and-log behaviour.
61
+ */
62
+ allowedOpenToolCallIds) {
63
+ const issues = [];
64
+ if (input.length === 0) {
65
+ issues.push({
66
+ kind: "empty_messages",
67
+ index: 0,
68
+ detail: "messages array is empty",
69
+ healed: false,
70
+ });
71
+ return { messages: [...input], issues };
72
+ }
73
+ const messages = [...input];
74
+ // 1. Drop mid-conversation junk (empty assistants, orphan tool results,
75
+ // empty user messages). Walk front-to-back with a running set of
76
+ // pending tool_call_ids so orphan detection is accurate.
77
+ const pendingToolCallIds = new Set();
78
+ const kept = [];
79
+ for (let i = 0; i < messages.length; i++) {
80
+ const msg = messages[i];
81
+ if (msg.role === "assistant") {
82
+ const hasToolCalls = assistantHasToolCalls(msg);
83
+ const emptyContent = isEmptyContent(msg.content) && !msg.refusal;
84
+ if (!hasToolCalls && emptyContent) {
85
+ issues.push({
86
+ kind: "empty_assistant_mid",
87
+ index: i,
88
+ detail: "assistant message has no content, no tool_calls, no refusal",
89
+ healed: true,
90
+ });
91
+ continue;
92
+ }
93
+ if (hasToolCalls) {
94
+ for (const tc of msg.tool_calls) {
95
+ pendingToolCallIds.add(tc.id);
96
+ }
97
+ }
98
+ kept.push(msg);
99
+ continue;
100
+ }
101
+ if (msg.role === "tool") {
102
+ const toolCallId = msg.tool_call_id;
103
+ if (!toolCallId || !pendingToolCallIds.has(toolCallId)) {
104
+ issues.push({
105
+ kind: "orphan_tool_result",
106
+ index: i,
107
+ detail: `tool message with tool_call_id=${toolCallId ?? "(missing)"} does not match any pending assistant tool_call`,
108
+ healed: true,
109
+ });
110
+ continue;
111
+ }
112
+ pendingToolCallIds.delete(toolCallId);
113
+ kept.push(msg);
114
+ continue;
115
+ }
116
+ if (msg.role === "user" && isEmptyContent(msg.content)) {
117
+ issues.push({
118
+ kind: "empty_user_content",
119
+ index: i,
120
+ detail: "user message has empty content",
121
+ healed: true,
122
+ });
123
+ continue;
124
+ }
125
+ kept.push(msg);
126
+ }
127
+ // 2. After a first pass, any remaining pendingToolCallIds belong to an
128
+ // assistant message whose tool_calls are never answered. Strip those
129
+ // tool_calls from the relevant assistant message(s); drop the message
130
+ // entirely if nothing else remains.
131
+ if (pendingToolCallIds.size > 0) {
132
+ // Fail-loud assertion: a legitimately-suspended call must
133
+ // never reach the provider unpaired (never-send-invariant) — if one does,
134
+ // resume failed to thread its answer. Surface it instead of silently
135
+ // stripping (which masks the bug) or sending (which 400s). Scoped to ids in
136
+ // the set, so legacy callers (empty/undefined) are unaffected.
137
+ if (allowedOpenToolCallIds && allowedOpenToolCallIds.size > 0) {
138
+ for (const id of pendingToolCallIds) {
139
+ if (allowedOpenToolCallIds.has(id)) {
140
+ throw new Error(`validateAndHealMessages: suspended tool_call ${id} reached the provider UNPAIRED — resume did not thread its answer (never-send-unpaired invariant violated)`);
141
+ }
142
+ }
143
+ }
144
+ for (let i = kept.length - 1; i >= 0; i--) {
145
+ const msg = kept[i];
146
+ if (msg.role !== "assistant" || !assistantHasToolCalls(msg))
147
+ continue;
148
+ const remainingCalls = msg.tool_calls.filter((tc) => !pendingToolCallIds.has(tc.id));
149
+ if (remainingCalls.length === msg.tool_calls.length)
150
+ continue;
151
+ const strippedIds = msg.tool_calls
152
+ .filter((tc) => pendingToolCallIds.has(tc.id))
153
+ .map((tc) => tc.id);
154
+ const emptyContent = isEmptyContent(msg.content) && !msg.refusal;
155
+ if (remainingCalls.length === 0 && emptyContent) {
156
+ issues.push({
157
+ kind: "dangling_tool_calls_unanswered",
158
+ index: i,
159
+ detail: `dropped assistant message with unanswered tool_calls [${strippedIds.join(", ")}] and no other content`,
160
+ healed: true,
161
+ });
162
+ kept.splice(i, 1);
163
+ }
164
+ else {
165
+ issues.push({
166
+ kind: "dangling_tool_calls_unanswered",
167
+ index: i,
168
+ detail: `stripped unanswered tool_calls [${strippedIds.join(", ")}] from assistant message`,
169
+ healed: true,
170
+ });
171
+ if (remainingCalls.length === 0) {
172
+ const { tool_calls: _tc, ...rest } = msg;
173
+ kept[i] = rest;
174
+ }
175
+ else {
176
+ kept[i] = {
177
+ ...msg,
178
+ tool_calls: remainingCalls,
179
+ };
180
+ }
181
+ }
182
+ }
183
+ }
184
+ // 3. Drop empty assistant messages at the tail — they can only be there
185
+ // if the prior pass re-exposed one, but handle defensively.
186
+ while (kept.length > 0) {
187
+ const last = kept[kept.length - 1];
188
+ if (last.role !== "assistant")
189
+ break;
190
+ const hasToolCalls = assistantHasToolCalls(last);
191
+ const emptyContent = isEmptyContent(last.content) && !last.refusal;
192
+ if (hasToolCalls || !emptyContent)
193
+ break;
194
+ issues.push({
195
+ kind: "trailing_assistant_empty",
196
+ index: kept.length - 1,
197
+ detail: "dropped trailing assistant message with no content/tool_calls",
198
+ healed: true,
199
+ });
200
+ kept.pop();
201
+ }
202
+ // 4. If the tail is still an assistant message (with content or
203
+ // tool_calls), append a synthetic user turn so providers that don't
204
+ // support assistant-prefill accept the request.
205
+ if (kept.length > 0) {
206
+ const last = kept[kept.length - 1];
207
+ if (last.role === "assistant") {
208
+ issues.push({
209
+ kind: "trailing_assistant_with_content",
210
+ index: kept.length - 1,
211
+ detail: "conversation ends with assistant; appending synthetic user turn to avoid provider prefill rejection",
212
+ healed: true,
213
+ });
214
+ kept.push({ role: "user", content: TRAILING_USER_PLACEHOLDER });
215
+ }
216
+ }
217
+ if (kept.length === 0) {
218
+ issues.push({
219
+ kind: "empty_messages",
220
+ index: 0,
221
+ detail: "all messages were dropped during healing; nothing to send",
222
+ healed: false,
223
+ });
224
+ }
225
+ return { messages: kept, issues };
226
+ }