@ouro.bot/cli 0.1.0-alpha.881 → 0.1.0-alpha.882

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/changelog.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.882",
6
+ "changes": [
7
+ "Live phone calls work again on both the OpenAI SIP and Media Streams paths: every tool offered to OpenAI Realtime now has a Realtime-valid schema (a top-level oneOf in external_event_disposition had made Realtime reject the whole session setup, which dropped SIP calls and left Media Streams audio as noise), folded schemas explain their alternatives in the description, a tool that cannot be advertised is dropped with one warning per call and cannot be executed, and the bridge logs when Twilio reports a stream ended."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.881",
6
12
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.881",
2
+ "runtimeVersion": "0.1.0-alpha.882",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.881</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.882</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeRealtimeToolSchema = normalizeRealtimeToolSchema;
4
+ exports.realtimeToolParameters = realtimeToolParameters;
5
+ exports.realtimeToolsFromDefinitions = realtimeToolsFromDefinitions;
6
+ exports.createRealtimeToolReporter = createRealtimeToolReporter;
7
+ exports.createRealtimeToolAdvertiser = createRealtimeToolAdvertiser;
8
+ // OpenAI Realtime rejects the whole session (invalid_function_parameters) when any
9
+ // tool's parameters schema lacks top-level `type: "object"` or carries a top-level
10
+ // oneOf/anyOf/allOf/enum/const/not. Other providers' handling of those keywords is not relied
11
+ // on here, so every tool is normalized before it is advertised to a Realtime session.
12
+ // Argument validation still runs against the original schema when a tool call arrives.
13
+ const runtime_1 = require("../../nerves/runtime");
14
+ const COMBINATORS = ["oneOf", "anyOf", "allOf"];
15
+ const FORBIDDEN_TOP_LEVEL = [...COMBINATORS, "enum", "const", "not"];
16
+ function isRecord(value) {
17
+ return !!value && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+ function deepEqual(a, b) {
20
+ if (a === b)
21
+ return true;
22
+ if (Array.isArray(a)) {
23
+ return Array.isArray(b) && a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
24
+ }
25
+ if (isRecord(a) && isRecord(b)) {
26
+ const keys = Object.keys(a);
27
+ return keys.length === Object.keys(b).length && keys.every((key) => key in b && deepEqual(a[key], b[key]));
28
+ }
29
+ return false;
30
+ }
31
+ function describeDefinition(value) {
32
+ if (isRecord(value) && typeof value.type === "string")
33
+ return value.type;
34
+ if (isRecord(value) && Array.isArray(value.type))
35
+ return value.type.join("|");
36
+ return JSON.stringify(value);
37
+ }
38
+ /** Resolves a branch that is a local `$ref`; returns undefined when it cannot be resolved. */
39
+ function resolveBranch(root, branch) {
40
+ let current = branch;
41
+ const seen = new Set();
42
+ while (isRecord(current) && typeof current.$ref === "string") {
43
+ const ref = current.$ref;
44
+ if (seen.has(ref))
45
+ return undefined;
46
+ seen.add(ref);
47
+ const match = /^#\/(\$defs|definitions)\/([^/]+)$/.exec(ref);
48
+ const container = match ? root[match[1]] : undefined;
49
+ const name = match ? match[2].replace(/~1/g, "/").replace(/~0/g, "~") : "";
50
+ if (!isRecord(container) || !(name in container))
51
+ return undefined;
52
+ current = container[name];
53
+ }
54
+ return current;
55
+ }
56
+ /**
57
+ * Normalizes a tool schema for Realtime. Returns null when the schema cannot be expressed
58
+ * as an object (the caller should drop that tool).
59
+ */
60
+ function normalizeRealtimeToolSchema(schema, onConflict = () => { }) {
61
+ if (schema === undefined)
62
+ return { parameters: { type: "object", properties: {} } };
63
+ if (!isRecord(schema))
64
+ return null;
65
+ if (schema.type !== undefined && schema.type !== "object")
66
+ return null;
67
+ const needsRewrite = schema.type === undefined || FORBIDDEN_TOP_LEVEL.some((key) => key in schema);
68
+ if (!needsRewrite)
69
+ return { parameters: schema };
70
+ // Fold the alternatives into one object whose properties are the union (all
71
+ // optional beyond the merged `required`), so the model can still call the tool.
72
+ const properties = isRecord(schema.properties) ? { ...schema.properties } : {};
73
+ const mergedRequired = [];
74
+ const alternatives = new Map();
75
+ const notes = [];
76
+ for (const key of COMBINATORS) {
77
+ const branches = schema[key];
78
+ if (!Array.isArray(branches))
79
+ continue;
80
+ const objectBranches = [];
81
+ for (const raw of branches) {
82
+ const branch = resolveBranch(schema, raw);
83
+ if (isRecord(raw) && typeof raw.$ref === "string" && branch === undefined)
84
+ return null;
85
+ if (isRecord(branch) && isRecord(branch.properties))
86
+ objectBranches.push(branch);
87
+ }
88
+ for (const branch of objectBranches) {
89
+ for (const [name, value] of Object.entries(branch.properties)) {
90
+ if (!(name in properties)) {
91
+ properties[name] = value;
92
+ }
93
+ else if (!deepEqual(properties[name], value)) {
94
+ const others = alternatives.get(name) ?? new Set();
95
+ if (others.size === 0)
96
+ onConflict(name);
97
+ others.add(describeDefinition(value));
98
+ alternatives.set(name, others);
99
+ }
100
+ }
101
+ }
102
+ const required = (branch) => Array.isArray(branch.required) ? branch.required.filter((n) => typeof n === "string") : [];
103
+ if (key === "allOf" || objectBranches.length === 1) {
104
+ for (const branch of objectBranches)
105
+ mergedRequired.push(...required(branch));
106
+ }
107
+ else if (objectBranches.length > 1) {
108
+ const parts = objectBranches.map((branch, i) => {
109
+ const names = required(branch);
110
+ return names.length > 0
111
+ ? `(${i + 1}) ${names.join(", ")} (all required)`
112
+ : `(${i + 1}) no required fields`;
113
+ });
114
+ notes.push(`Provide ${key === "oneOf" ? "exactly one of" : "one or more of"}: ${parts.join("; ")}.`);
115
+ }
116
+ }
117
+ for (const [name, others] of alternatives)
118
+ notes.push(`\`${name}\` may also be: ${[...others].join(", ")}.`);
119
+ const rewritten = { ...schema, type: "object", properties };
120
+ for (const key of FORBIDDEN_TOP_LEVEL)
121
+ delete rewritten[key];
122
+ const baseRequired = Array.isArray(schema.required) ? schema.required : [];
123
+ const required = [...new Set([...baseRequired, ...mergedRequired.filter((name) => name in properties)])];
124
+ if (required.length > 0)
125
+ rewritten.required = required;
126
+ return { parameters: rewritten, ...(notes.length > 0 ? { note: notes.join(" ") } : {}) };
127
+ }
128
+ function realtimeToolParameters(schema) {
129
+ return normalizeRealtimeToolSchema(schema)?.parameters ?? null;
130
+ }
131
+ function realtimeToolsFromDefinitions(definitions, reporter = createRealtimeToolReporter()) {
132
+ const tools = [];
133
+ for (const { function: fn } of definitions) {
134
+ const result = normalizeRealtimeToolSchema(fn.parameters, (property) => reporter.conflict(fn.name, property));
135
+ if (result === null) {
136
+ reporter.dropped(fn.name);
137
+ continue;
138
+ }
139
+ const description = [fn.description, result.note].filter(Boolean).join(" ");
140
+ tools.push({
141
+ type: "function",
142
+ name: fn.name,
143
+ ...(description ? { description } : {}),
144
+ parameters: result.parameters,
145
+ });
146
+ }
147
+ return tools;
148
+ }
149
+ /** Emits warn events carrying the given context (agent, call id); each distinct finding is emitted once. */
150
+ function createRealtimeToolReporter(context = {}) {
151
+ const reported = new Set();
152
+ return {
153
+ dropped(toolName) {
154
+ const key = `drop:${toolName}`;
155
+ if (reported.has(key))
156
+ return;
157
+ reported.add(key);
158
+ (0, runtime_1.emitNervesEvent)({
159
+ level: "warn",
160
+ component: "senses",
161
+ event: "senses.voice_realtime_tool_dropped",
162
+ message: "tool schema is not valid for OpenAI Realtime; tool omitted from the voice session",
163
+ meta: { ...context, toolName },
164
+ });
165
+ },
166
+ conflict(toolName, property) {
167
+ const key = `conflict:${toolName}:${property}`;
168
+ if (reported.has(key))
169
+ return;
170
+ reported.add(key);
171
+ (0, runtime_1.emitNervesEvent)({
172
+ level: "warn",
173
+ component: "senses",
174
+ event: "senses.voice_realtime_tool_schema_conflict",
175
+ message: "tool schema branches define the same property differently; first definition kept for OpenAI Realtime",
176
+ meta: { ...context, toolName, property },
177
+ });
178
+ },
179
+ };
180
+ }
181
+ /**
182
+ * Per-call advertiser: normalizes selections, reports each finding once per call, and
183
+ * answers which tool names were actually advertised to the model.
184
+ */
185
+ function createRealtimeToolAdvertiser(context = {}) {
186
+ const reporter = createRealtimeToolReporter(context);
187
+ return {
188
+ tools(definitions) {
189
+ return realtimeToolsFromDefinitions(definitions, reporter);
190
+ },
191
+ isAdvertised(definitions, name) {
192
+ return realtimeToolsFromDefinitions(definitions, reporter).some((tool) => tool.name === name);
193
+ },
194
+ };
195
+ }
@@ -50,6 +50,7 @@ exports.twilioPhoneVoiceSessionKey = twilioPhoneVoiceSessionKey;
50
50
  exports.outboundCallAnsweredPrompt = outboundCallAnsweredPrompt;
51
51
  exports.computeOpenAIWebhookSignature = computeOpenAIWebhookSignature;
52
52
  exports.validateOpenAIWebhookSignature = validateOpenAIWebhookSignature;
53
+ exports.parseToolArguments = parseToolArguments;
53
54
  exports.computeTwilioSignature = computeTwilioSignature;
54
55
  exports.validateTwilioSignature = validateTwilioSignature;
55
56
  exports.twilioRecordingMediaUrl = twilioRecordingMediaUrl;
@@ -74,6 +75,7 @@ const tools_1 = require("../../repertoire/tools");
74
75
  const tool_arguments_1 = require("../../repertoire/tool-arguments");
75
76
  const config_1 = require("../../heart/config");
76
77
  const runtime_1 = require("../../nerves/runtime");
78
+ const realtime_tool_schema_1 = require("./realtime-tool-schema");
77
79
  const playback_1 = require("./playback");
78
80
  const transcript_1 = require("./transcript");
79
81
  const turn_1 = require("./turn");
@@ -1519,17 +1521,15 @@ async function prepareRealtimeToolContext(context) {
1519
1521
  }
1520
1522
  return selectRealtimeToolContext(context, mcpManager);
1521
1523
  }
1522
- function realtimeToolsFromSelection(selection) {
1523
- return selection.ordinary.map(({ tool }) => ({
1524
- type: "function",
1525
- name: tool.function.name,
1526
- ...(tool.function.description ? { description: tool.function.description } : {}),
1527
- parameters: tool.function.parameters ?? { type: "object", properties: {} },
1528
- }));
1524
+ function realtimeToolsFromSelection(selection, advertiser) {
1525
+ return advertiser.tools(selection.ordinary.map(({ tool }) => tool));
1529
1526
  }
1530
- function parseToolArguments(raw, name, selection) {
1527
+ function parseToolArguments(raw, name, selection, advertiser) {
1531
1528
  if (typeof raw !== "string")
1532
1529
  throw new Error("invalid tool arguments: expected a JSON object string");
1530
+ if (!advertiser.isAdvertised(selection.ordinary.map(({ tool }) => tool), name)) {
1531
+ throw new Error(`tool ${name} is not advertised in this voice session`);
1532
+ }
1533
1533
  const schema = selection.ordinary.find((definition) => definition.tool.function.name === name)?.tool.function.parameters ?? {};
1534
1534
  const validation = (0, tool_arguments_1.validateAdvertisedToolArguments)(raw, schema);
1535
1535
  if (!validation.ok)
@@ -1673,6 +1673,11 @@ class TwilioOpenAIRealtimeMediaStreamSession {
1673
1673
  pendingAudioPayloads = [];
1674
1674
  openaiWs = null;
1675
1675
  toolContext;
1676
+ advertiser;
1677
+ get toolAdvertiser() {
1678
+ this.advertiser ??= (0, realtime_tool_schema_1.createRealtimeToolAdvertiser)({ agentName: this.options.agentName, callSid: safeSegment(this.callSid) });
1679
+ return this.advertiser;
1680
+ }
1676
1681
  friendStore;
1677
1682
  resolvedContext;
1678
1683
  sessionMessages = [];
@@ -1894,7 +1899,7 @@ class TwilioOpenAIRealtimeMediaStreamSession {
1894
1899
  session: {
1895
1900
  type: "realtime",
1896
1901
  instructions: fullInstructions,
1897
- tools: realtimeToolsFromSelection(fullContext.toolSelection),
1902
+ tools: realtimeToolsFromSelection(fullContext.toolSelection, this.toolAdvertiser),
1898
1903
  tool_choice: "auto",
1899
1904
  },
1900
1905
  });
@@ -1925,7 +1930,7 @@ class TwilioOpenAIRealtimeMediaStreamSession {
1925
1930
  },
1926
1931
  output: realtimeOutputAudioConfig(realtime, { type: "audio/pcmu" }),
1927
1932
  },
1928
- tools: realtimeToolsFromSelection(toolContext.toolSelection),
1933
+ tools: realtimeToolsFromSelection(toolContext.toolSelection, this.toolAdvertiser),
1929
1934
  tool_choice: "auto",
1930
1935
  max_output_tokens: OPENAI_REALTIME_MAX_OUTPUT_TOKENS,
1931
1936
  ...(realtime.reasoningEffort ? { reasoning: { effort: realtime.reasoningEffort } } : {}),
@@ -2352,7 +2357,7 @@ class TwilioOpenAIRealtimeMediaStreamSession {
2352
2357
  });
2353
2358
  let output;
2354
2359
  try {
2355
- const args = parseToolArguments(event.arguments, name, this.toolContext.toolSelection);
2360
+ const args = parseToolArguments(event.arguments, name, this.toolContext.toolSelection, this.toolAdvertiser);
2356
2361
  (0, runtime_1.emitNervesEvent)({
2357
2362
  component: "senses",
2358
2363
  event: "senses.voice_twilio_realtime_tool_start",
@@ -2712,6 +2717,11 @@ class OpenAISipPhoneSession {
2712
2717
  autoResponsesSuppressedForAmd = false;
2713
2718
  openaiWs = null;
2714
2719
  toolContext;
2720
+ advertiser;
2721
+ get toolAdvertiser() {
2722
+ this.advertiser ??= (0, realtime_tool_schema_1.createRealtimeToolAdvertiser)({ agentName: this.options.agentName, callId: safeSegment(this.metadata.callId) });
2723
+ return this.advertiser;
2724
+ }
2715
2725
  friendStore;
2716
2726
  resolvedContext;
2717
2727
  sessionMessages = [];
@@ -2879,7 +2889,7 @@ class OpenAISipPhoneSession {
2879
2889
  },
2880
2890
  output: realtimeOutputAudioConfig(realtime),
2881
2891
  },
2882
- tools: realtimeToolsFromSelection(toolContext.toolSelection),
2892
+ tools: realtimeToolsFromSelection(toolContext.toolSelection, this.toolAdvertiser),
2883
2893
  tool_choice: "auto",
2884
2894
  max_output_tokens: OPENAI_REALTIME_MAX_OUTPUT_TOKENS,
2885
2895
  }),
@@ -2961,7 +2971,7 @@ class OpenAISipPhoneSession {
2961
2971
  session: {
2962
2972
  type: "realtime",
2963
2973
  instructions,
2964
- tools: realtimeToolsFromSelection(toolContext.toolSelection),
2974
+ tools: realtimeToolsFromSelection(toolContext.toolSelection, this.toolAdvertiser),
2965
2975
  tool_choice: "auto",
2966
2976
  ...(realtime.reasoningEffort ? { reasoning: { effort: realtime.reasoningEffort } } : {}),
2967
2977
  },
@@ -3450,7 +3460,7 @@ class OpenAISipPhoneSession {
3450
3460
  });
3451
3461
  let output;
3452
3462
  try {
3453
- const args = parseToolArguments(event.arguments, name, this.toolContext.toolSelection);
3463
+ const args = parseToolArguments(event.arguments, name, this.toolContext.toolSelection, this.toolAdvertiser);
3454
3464
  (0, runtime_1.emitNervesEvent)({
3455
3465
  component: "senses",
3456
3466
  event: "senses.voice_openai_sip_tool_start",
@@ -5126,7 +5136,15 @@ function createTwilioPhoneBridge(options) {
5126
5136
  if (routePath === `${basePath}/stream-ended`) {
5127
5137
  // `<Connect action>` fires when the stream ends. A normal end just hangs up; a call whose
5128
5138
  // stream was refused (or never connected) hears the failure line instead of dead air.
5129
- return xmlResponse(pendingCalls.settle(params.CallSid?.trim() ?? "") ? streamFailureTwiml() : "<Hangup />");
5139
+ const callSid = params.CallSid?.trim() ?? "";
5140
+ const settledPending = pendingCalls.settle(callSid);
5141
+ (0, runtime_1.emitNervesEvent)({
5142
+ component: "senses",
5143
+ event: "senses.voice_twilio_stream_ended",
5144
+ message: "Twilio reported the call's media stream ended",
5145
+ meta: { agentName: options.agentName, callSid: safeSegment(callSid), settledPending },
5146
+ });
5147
+ return xmlResponse(settledPending ? streamFailureTwiml() : "<Hangup />");
5130
5148
  }
5131
5149
  if (routePath.startsWith(`${basePath}/outgoing/`)) {
5132
5150
  const outgoingRest = routePath.slice(`${basePath}/outgoing/`.length);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.881",
3
+ "version": "0.1.0-alpha.882",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.881",
9
+ "version": "0.1.0-alpha.882",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.881",
3
+ "version": "0.1.0-alpha.882",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },