@ouro.bot/cli 0.1.0-alpha.880 → 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 +12 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/senses/voice/call-auth.js +248 -0
- package/dist/senses/voice/process-guards.js +51 -0
- package/dist/senses/voice/realtime-tool-schema.js +195 -0
- package/dist/senses/voice/twilio-phone-runtime.js +13 -0
- package/dist/senses/voice/twilio-phone.js +628 -317
- package/dist/senses/voice-entry.js +2 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
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
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.881",
|
|
12
|
+
"changes": [
|
|
13
|
+
"Voice calls are authenticated end to end: the signed Twilio webhook records who is calling and hands the Media Stream (or the OpenAI SIP leg) a short-lived single-use token, and a stream or SIP call without a valid token is refused, so the caller identity, friend, direction and greeting come from server state and never from stream parameters. defaultFriendId no longer decides who an inbound caller is on any transport, friend ids are validated before they touch the friend store, the voice transport stays off without voice.twilioAuthToken, a call that cannot connect now hears a short failure line instead of dead air, and a failed transcript save or stray rejection no longer takes the voice process down. A malformed frame from an unauthenticated client no longer crashes the voice process, a second stream for the same call is refused even while the first is still starting, and a burst of more than five uncaught exceptions in a minute makes the voice process exit so its supervisor restarts it."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"version": "0.1.0-alpha.880",
|
|
6
18
|
"changes": [
|
|
@@ -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.
|
|
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,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.RecentIds = exports.PendingVoiceCalls = exports.DEFAULT_PENDING_VOICE_CALL_MAX_ENTRIES = exports.DEFAULT_PENDING_VOICE_CALL_TTL_MS = exports.DEFAULT_VOICE_CALL_TOKEN_TTL_MS = void 0;
|
|
37
|
+
exports.newVoiceCallNonce = newVoiceCallNonce;
|
|
38
|
+
exports.mintVoiceCallToken = mintVoiceCallToken;
|
|
39
|
+
exports.verifyVoiceCallToken = verifyVoiceCallToken;
|
|
40
|
+
const crypto = __importStar(require("node:crypto"));
|
|
41
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
42
|
+
exports.DEFAULT_VOICE_CALL_TOKEN_TTL_MS = 60_000;
|
|
43
|
+
exports.DEFAULT_PENDING_VOICE_CALL_TTL_MS = 120_000;
|
|
44
|
+
exports.DEFAULT_PENDING_VOICE_CALL_MAX_ENTRIES = 256;
|
|
45
|
+
const TOKEN_KEY_DOMAIN = "ouro-voice-stream-v1";
|
|
46
|
+
function newVoiceCallNonce() {
|
|
47
|
+
return crypto.randomBytes(16).toString("hex");
|
|
48
|
+
}
|
|
49
|
+
function signingKey(secret) {
|
|
50
|
+
return crypto.createHmac("sha256", secret).update(TOKEN_KEY_DOMAIN).digest();
|
|
51
|
+
}
|
|
52
|
+
function sign(secret, payload) {
|
|
53
|
+
return crypto.createHmac("sha256", signingKey(secret)).update(payload).digest("base64url");
|
|
54
|
+
}
|
|
55
|
+
function mintVoiceCallToken(input) {
|
|
56
|
+
const claims = {
|
|
57
|
+
v: 1,
|
|
58
|
+
p: input.purpose,
|
|
59
|
+
a: input.agentName,
|
|
60
|
+
c: input.callSid,
|
|
61
|
+
d: input.direction,
|
|
62
|
+
e: input.nowMs + (input.ttlMs ?? exports.DEFAULT_VOICE_CALL_TOKEN_TTL_MS),
|
|
63
|
+
n: input.nonce ?? newVoiceCallNonce(),
|
|
64
|
+
...(input.outboundId ? { o: input.outboundId } : {}),
|
|
65
|
+
...(input.from ? { f: input.from } : {}),
|
|
66
|
+
...(input.to ? { t: input.to } : {}),
|
|
67
|
+
};
|
|
68
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
|
|
69
|
+
return `${payload}.${sign(input.secret, payload)}`;
|
|
70
|
+
}
|
|
71
|
+
function parseClaims(payload) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
80
|
+
return null;
|
|
81
|
+
const claims = parsed;
|
|
82
|
+
if (claims.v !== 1
|
|
83
|
+
|| (claims.p !== "stream" && claims.p !== "sip")
|
|
84
|
+
|| typeof claims.a !== "string"
|
|
85
|
+
|| typeof claims.c !== "string"
|
|
86
|
+
|| typeof claims.d !== "string"
|
|
87
|
+
|| typeof claims.e !== "number"
|
|
88
|
+
|| typeof claims.n !== "string")
|
|
89
|
+
return null;
|
|
90
|
+
return claims;
|
|
91
|
+
}
|
|
92
|
+
function verifyVoiceCallToken(input) {
|
|
93
|
+
const token = input.token.trim();
|
|
94
|
+
if (!token)
|
|
95
|
+
return { ok: false, reason: "missing" };
|
|
96
|
+
const parts = token.split(".");
|
|
97
|
+
if (parts.length !== 2 || !parts[0] || !parts[1])
|
|
98
|
+
return { ok: false, reason: "malformed" };
|
|
99
|
+
const [payload, signature] = parts;
|
|
100
|
+
const expected = Buffer.from(sign(input.secret, payload));
|
|
101
|
+
const actual = Buffer.from(signature);
|
|
102
|
+
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
|
|
103
|
+
return { ok: false, reason: "bad_signature" };
|
|
104
|
+
}
|
|
105
|
+
const claims = parseClaims(payload);
|
|
106
|
+
if (!claims)
|
|
107
|
+
return { ok: false, reason: "malformed" };
|
|
108
|
+
if (input.nowMs > claims.e)
|
|
109
|
+
return { ok: false, reason: "expired" };
|
|
110
|
+
if (claims.p !== input.purpose)
|
|
111
|
+
return { ok: false, reason: "wrong_purpose" };
|
|
112
|
+
if (claims.a !== input.agentName)
|
|
113
|
+
return { ok: false, reason: "wrong_agent" };
|
|
114
|
+
if (claims.c !== input.callSid)
|
|
115
|
+
return { ok: false, reason: "wrong_call" };
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
nonce: claims.n,
|
|
119
|
+
direction: claims.d,
|
|
120
|
+
...(claims.o ? { outboundId: claims.o } : {}),
|
|
121
|
+
...(claims.f ? { from: claims.f } : {}),
|
|
122
|
+
...(claims.t ? { to: claims.t } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Bounded, TTL'd, in-memory record of calls the signed webhooks have admitted, keyed by CallSid.
|
|
127
|
+
* A call can hold several nonces at once (Twilio may retry the webhook while a greeting is being
|
|
128
|
+
* prepared); the first successful consume removes the whole entry, so the call connects once.
|
|
129
|
+
*/
|
|
130
|
+
class PendingVoiceCalls {
|
|
131
|
+
entries = new Map();
|
|
132
|
+
connected = new Map();
|
|
133
|
+
rejected = new Map();
|
|
134
|
+
ttlMs;
|
|
135
|
+
maxEntries;
|
|
136
|
+
now;
|
|
137
|
+
constructor(options = {}) {
|
|
138
|
+
this.ttlMs = options.ttlMs ?? exports.DEFAULT_PENDING_VOICE_CALL_TTL_MS;
|
|
139
|
+
this.maxEntries = options.maxEntries ?? exports.DEFAULT_PENDING_VOICE_CALL_MAX_ENTRIES;
|
|
140
|
+
this.now = options.now ?? Date.now;
|
|
141
|
+
}
|
|
142
|
+
record(identity, nonce) {
|
|
143
|
+
const existing = this.live(identity.callSid);
|
|
144
|
+
const nonces = existing?.nonces ?? new Set();
|
|
145
|
+
nonces.add(nonce);
|
|
146
|
+
// Re-insert so the Map's insertion order is least-recently-recorded first.
|
|
147
|
+
this.entries.delete(identity.callSid);
|
|
148
|
+
this.entries.set(identity.callSid, { identity, nonces, expiresAtMs: this.now() + this.ttlMs });
|
|
149
|
+
while (this.entries.size > this.maxEntries) {
|
|
150
|
+
const evicted = this.entries.keys().next().value;
|
|
151
|
+
this.entries.delete(evicted);
|
|
152
|
+
// An evicted call's stream will be refused, so make the pressure visible.
|
|
153
|
+
(0, runtime_1.emitNervesEvent)({
|
|
154
|
+
level: "warn",
|
|
155
|
+
component: "senses",
|
|
156
|
+
event: "senses.voice_pending_call_evicted",
|
|
157
|
+
message: "evicted a pending voice call before its stream started",
|
|
158
|
+
meta: { callSid: evicted, maxEntries: this.maxEntries },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
consume(callSid, nonce) {
|
|
163
|
+
const entry = this.live(callSid);
|
|
164
|
+
if (!entry || !entry.nonces.has(nonce))
|
|
165
|
+
return null;
|
|
166
|
+
this.entries.delete(callSid);
|
|
167
|
+
this.remember(this.connected, callSid);
|
|
168
|
+
return entry.identity;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Remember that a stream for this call was refused, so the call's end can say so. Ignored for a
|
|
172
|
+
* call that already connected: a replayed token must not turn a good call into a failure.
|
|
173
|
+
*/
|
|
174
|
+
markRejected(callSid) {
|
|
175
|
+
if (this.recalls(this.connected, callSid))
|
|
176
|
+
return;
|
|
177
|
+
this.remember(this.rejected, callSid);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Called when the stream's `<Connect>` ends. Returns true when the call never connected to a
|
|
181
|
+
* session (still waiting, or its stream was refused); forgets everything known about the call.
|
|
182
|
+
*/
|
|
183
|
+
settle(callSid) {
|
|
184
|
+
const failed = this.live(callSid) !== undefined || this.recalls(this.rejected, callSid);
|
|
185
|
+
this.entries.delete(callSid);
|
|
186
|
+
this.connected.delete(callSid);
|
|
187
|
+
this.rejected.delete(callSid);
|
|
188
|
+
return failed;
|
|
189
|
+
}
|
|
190
|
+
has(callSid) {
|
|
191
|
+
return this.live(callSid) !== undefined;
|
|
192
|
+
}
|
|
193
|
+
discard(callSid) {
|
|
194
|
+
return this.entries.delete(callSid);
|
|
195
|
+
}
|
|
196
|
+
size() {
|
|
197
|
+
return this.entries.size;
|
|
198
|
+
}
|
|
199
|
+
remember(marks, callSid) {
|
|
200
|
+
marks.delete(callSid);
|
|
201
|
+
marks.set(callSid, this.now() + this.ttlMs);
|
|
202
|
+
while (marks.size > this.maxEntries)
|
|
203
|
+
marks.delete(marks.keys().next().value);
|
|
204
|
+
}
|
|
205
|
+
recalls(marks, callSid) {
|
|
206
|
+
const expiresAtMs = marks.get(callSid);
|
|
207
|
+
if (expiresAtMs === undefined)
|
|
208
|
+
return false;
|
|
209
|
+
if (this.now() > expiresAtMs) {
|
|
210
|
+
marks.delete(callSid);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
live(callSid) {
|
|
216
|
+
const entry = this.entries.get(callSid);
|
|
217
|
+
if (!entry)
|
|
218
|
+
return undefined;
|
|
219
|
+
if (this.now() > entry.expiresAtMs) {
|
|
220
|
+
this.entries.delete(callSid);
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return entry;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
exports.PendingVoiceCalls = PendingVoiceCalls;
|
|
227
|
+
/** Bounded, TTL'd "have I seen this id" set, used to ignore replayed or duplicate webhooks. */
|
|
228
|
+
class RecentIds {
|
|
229
|
+
options;
|
|
230
|
+
seen = new Map();
|
|
231
|
+
constructor(options = {}) {
|
|
232
|
+
this.options = options;
|
|
233
|
+
}
|
|
234
|
+
/** Returns true when the id is new (and remembers it), false when it was already seen. */
|
|
235
|
+
add(id) {
|
|
236
|
+
const now = (this.options.now ?? Date.now)();
|
|
237
|
+
const expiresAtMs = this.seen.get(id);
|
|
238
|
+
if (expiresAtMs !== undefined && now <= expiresAtMs)
|
|
239
|
+
return false;
|
|
240
|
+
this.seen.delete(id);
|
|
241
|
+
this.seen.set(id, now + (this.options.ttlMs ?? 600_000));
|
|
242
|
+
while (this.seen.size > (this.options.maxEntries ?? 1_024)) {
|
|
243
|
+
this.seen.delete(this.seen.keys().next().value);
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
exports.RecentIds = RecentIds;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installVoiceProcessGuards = installVoiceProcessGuards;
|
|
4
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
5
|
+
function describeError(error) {
|
|
6
|
+
return error instanceof Error ? error.stack ?? error.message : String(error);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Keep one bad call from taking the voice process down for every other caller. A failure that
|
|
10
|
+
* escapes a call's own handlers is logged and the process keeps serving. A burst of uncaught
|
|
11
|
+
* exceptions means the process is no longer healthy, so it exits and lets the supervisor restart it.
|
|
12
|
+
*/
|
|
13
|
+
function installVoiceProcessGuards(proc, options = {}) {
|
|
14
|
+
const limit = options.limit ?? 5;
|
|
15
|
+
const windowMs = options.windowMs ?? 60_000;
|
|
16
|
+
const exit = options.exit ?? ((code) => proc.exit(code));
|
|
17
|
+
const now = options.now ?? Date.now;
|
|
18
|
+
const recent = [];
|
|
19
|
+
proc.on("unhandledRejection", (reason) => {
|
|
20
|
+
(0, runtime_1.emitNervesEvent)({
|
|
21
|
+
level: "error",
|
|
22
|
+
component: "senses",
|
|
23
|
+
event: "senses.voice_process_error",
|
|
24
|
+
message: "Voice process caught an unhandled promise rejection and kept running",
|
|
25
|
+
meta: { kind: "unhandledRejection", error: describeError(reason) },
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
proc.on("uncaughtException", (error) => {
|
|
29
|
+
(0, runtime_1.emitNervesEvent)({
|
|
30
|
+
level: "error",
|
|
31
|
+
component: "senses",
|
|
32
|
+
event: "senses.voice_process_error",
|
|
33
|
+
message: "Voice process caught an uncaught exception and kept running",
|
|
34
|
+
meta: { kind: "uncaughtException", error: describeError(error) },
|
|
35
|
+
});
|
|
36
|
+
const at = now();
|
|
37
|
+
recent.push(at);
|
|
38
|
+
while (recent.length > 0 && at - recent[0] > windowMs)
|
|
39
|
+
recent.shift();
|
|
40
|
+
if (recent.length > limit) {
|
|
41
|
+
(0, runtime_1.emitNervesEvent)({
|
|
42
|
+
level: "error",
|
|
43
|
+
component: "senses",
|
|
44
|
+
event: "senses.voice_process_crash_budget_exhausted",
|
|
45
|
+
message: "Voice process exceeded its uncaught exception budget and is exiting for a supervisor restart",
|
|
46
|
+
meta: { limit, windowMs, count: recent.length },
|
|
47
|
+
});
|
|
48
|
+
exit(1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -516,6 +516,19 @@ async function startConfiguredTwilioPhoneTransport(options, deps = defaultTwilio
|
|
|
516
516
|
});
|
|
517
517
|
return { status: "disabled", reason };
|
|
518
518
|
}
|
|
519
|
+
if (!settings.twilioAuthToken?.trim()) {
|
|
520
|
+
// Call tokens and webhook signatures are keyed by the Twilio auth token; without it the bridge
|
|
521
|
+
// could not tell a real Twilio call from a forged one, so it does not listen at all.
|
|
522
|
+
const reason = "voice.twilioAuthToken is required for inbound calls";
|
|
523
|
+
(0, runtime_1.emitNervesEvent)({
|
|
524
|
+
level: "warn",
|
|
525
|
+
component: "senses",
|
|
526
|
+
event: "senses.voice_twilio_transport_disabled",
|
|
527
|
+
message: "Twilio phone voice transport is not attached because the Twilio auth token is missing",
|
|
528
|
+
meta: { agentName: options.agentName, reason },
|
|
529
|
+
});
|
|
530
|
+
return { status: "disabled", reason };
|
|
531
|
+
}
|
|
519
532
|
await deps.cacheSelectedProviderCredentials(options.agentName);
|
|
520
533
|
if (settings.openaiRealtime?.apiKeySource === "integrations.openaiEmbeddingsApiKey") {
|
|
521
534
|
(0, runtime_1.emitNervesEvent)({
|