@ouro.bot/cli 0.1.0-alpha.589 → 0.1.0-alpha.590
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 +6 -0
- package/dist/senses/voice/floor-controller.js +115 -0
- package/dist/senses/voice/twilio-phone.js +217 -13
- package/package.json +1 -1
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.590",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Voice Realtime/SIP runtime now routes every response.create through the deterministic VoiceFloorController gate. The shared controller wraps the pure floor-control reducer, emits senses.voice_floor_transition and senses.voice_floor_decision telemetry, and is the single bottleneck consulted before any wire-level response.create. No live human calls placed."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
4
10
|
{
|
|
5
11
|
"version": "0.1.0-alpha.589",
|
|
6
12
|
"changes": [
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoiceFloorController = void 0;
|
|
4
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
5
|
+
const floor_control_1 = require("./floor-control");
|
|
6
|
+
class VoiceFloorController {
|
|
7
|
+
currentState = (0, floor_control_1.createInitialVoiceFloorState)();
|
|
8
|
+
transport;
|
|
9
|
+
agentName;
|
|
10
|
+
now;
|
|
11
|
+
callId;
|
|
12
|
+
listeners = [];
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.transport = options.transport;
|
|
15
|
+
this.agentName = options.agentName;
|
|
16
|
+
this.callId = options.callId;
|
|
17
|
+
this.now = options.now ?? Date.now;
|
|
18
|
+
}
|
|
19
|
+
get state() {
|
|
20
|
+
return this.currentState;
|
|
21
|
+
}
|
|
22
|
+
isTerminal() {
|
|
23
|
+
return this.currentState.terminal || this.currentState.hangupRequested;
|
|
24
|
+
}
|
|
25
|
+
summary() {
|
|
26
|
+
return (0, floor_control_1.summarizeVoiceFloorState)(this.currentState);
|
|
27
|
+
}
|
|
28
|
+
onTransition(listener) {
|
|
29
|
+
this.listeners.push(listener);
|
|
30
|
+
return () => {
|
|
31
|
+
const index = this.listeners.indexOf(listener);
|
|
32
|
+
if (index >= 0)
|
|
33
|
+
this.listeners.splice(index, 1);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
apply(event) {
|
|
37
|
+
const transition = (0, floor_control_1.applyVoiceFloorEvent)(this.currentState, event);
|
|
38
|
+
this.currentState = transition.state;
|
|
39
|
+
if (event.type === "call.connected" && event.callId)
|
|
40
|
+
this.callId = event.callId;
|
|
41
|
+
(0, runtime_1.emitNervesEvent)({
|
|
42
|
+
component: "senses",
|
|
43
|
+
event: "senses.voice_floor_transition",
|
|
44
|
+
message: `voice floor transition ${event.type} -> ${transition.state.phase}`,
|
|
45
|
+
meta: this.meta({
|
|
46
|
+
eventType: event.type,
|
|
47
|
+
action: transition.decision.action,
|
|
48
|
+
reason: transition.decision.reason,
|
|
49
|
+
allowed: transition.decision.allowed,
|
|
50
|
+
atMs: event.atMs,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
for (const listener of [...this.listeners]) {
|
|
54
|
+
try {
|
|
55
|
+
listener(transition);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
(0, runtime_1.emitNervesEvent)({
|
|
59
|
+
level: "error",
|
|
60
|
+
component: "senses",
|
|
61
|
+
event: "senses.voice_floor_listener_error",
|
|
62
|
+
message: "voice floor transition listener threw",
|
|
63
|
+
meta: this.meta({ error: error instanceof Error ? error.message : String(error) }),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return transition;
|
|
68
|
+
}
|
|
69
|
+
canRequestResponse(input, atMs) {
|
|
70
|
+
const decision = (0, floor_control_1.canRequestVoiceResponse)(this.currentState, input);
|
|
71
|
+
const stamped = { ...decision, atMs: atMs ?? this.now() };
|
|
72
|
+
this.emitDecision("request", stamped, { responseId: input.responseId, inputReason: input.reason });
|
|
73
|
+
return stamped;
|
|
74
|
+
}
|
|
75
|
+
canSpeakToolHolding(input, atMs) {
|
|
76
|
+
const decision = (0, floor_control_1.canSpeakToolHolding)(this.currentState, input);
|
|
77
|
+
const stamped = { ...decision, atMs: atMs ?? this.now() };
|
|
78
|
+
this.emitDecision("tool_holding", stamped, { toolCallId: input.toolCallId });
|
|
79
|
+
return stamped;
|
|
80
|
+
}
|
|
81
|
+
canSpeakToolResult(input, atMs) {
|
|
82
|
+
const decision = (0, floor_control_1.canSpeakToolResult)(this.currentState, input);
|
|
83
|
+
const stamped = { ...decision, atMs: atMs ?? this.now() };
|
|
84
|
+
this.emitDecision("tool_result", stamped, { toolCallId: input.toolCallId });
|
|
85
|
+
return stamped;
|
|
86
|
+
}
|
|
87
|
+
emitDecision(query, decision, extra) {
|
|
88
|
+
(0, runtime_1.emitNervesEvent)({
|
|
89
|
+
component: "senses",
|
|
90
|
+
event: "senses.voice_floor_decision",
|
|
91
|
+
message: `voice floor decision ${query} ${decision.action} (${decision.reason})`,
|
|
92
|
+
meta: this.meta({
|
|
93
|
+
query,
|
|
94
|
+
action: decision.action,
|
|
95
|
+
reason: decision.reason,
|
|
96
|
+
allowed: decision.allowed,
|
|
97
|
+
atMs: decision.atMs,
|
|
98
|
+
...extra,
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
meta(extra) {
|
|
103
|
+
const base = {
|
|
104
|
+
phase: this.currentState.phase,
|
|
105
|
+
floorOwner: this.currentState.floorOwner,
|
|
106
|
+
transport: this.transport,
|
|
107
|
+
};
|
|
108
|
+
if (this.agentName)
|
|
109
|
+
base.agentName = this.agentName;
|
|
110
|
+
if (this.callId)
|
|
111
|
+
base.callId = this.callId;
|
|
112
|
+
return { ...base, ...extra };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
exports.VoiceFloorController = VoiceFloorController;
|
|
@@ -78,6 +78,7 @@ const transcript_1 = require("./transcript");
|
|
|
78
78
|
const turn_1 = require("./turn");
|
|
79
79
|
const phone_1 = require("./phone");
|
|
80
80
|
const audio_playback_1 = require("./audio-playback");
|
|
81
|
+
const floor_controller_1 = require("./floor-controller");
|
|
81
82
|
var phone_2 = require("./phone");
|
|
82
83
|
Object.defineProperty(exports, "normalizeTwilioE164PhoneNumber", { enumerable: true, get: function () { return phone_2.normalizeTwilioE164PhoneNumber; } });
|
|
83
84
|
exports.DEFAULT_TWILIO_PHONE_PORT = 18910;
|
|
@@ -1564,6 +1565,13 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
1564
1565
|
initialAudioPlayed = false;
|
|
1565
1566
|
callerBargeInSpeechMs = 0;
|
|
1566
1567
|
lastCallerBargeInSpeechAt = 0;
|
|
1568
|
+
floor = new floor_controller_1.VoiceFloorController({ transport: "twilio-media-stream" });
|
|
1569
|
+
floorUnsubscribe = null;
|
|
1570
|
+
queuedGatedRealtimeRequest = null;
|
|
1571
|
+
activeCallerTurnId;
|
|
1572
|
+
callerTurnSequence = 0;
|
|
1573
|
+
gatedResponseRequestSequence = 0;
|
|
1574
|
+
pendingGatedResponseId = null;
|
|
1567
1575
|
constructor(ws, options, lifecycle) {
|
|
1568
1576
|
this.ws = ws;
|
|
1569
1577
|
this.options = options;
|
|
@@ -1648,6 +1656,13 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
1648
1656
|
callSid: this.callSid,
|
|
1649
1657
|
});
|
|
1650
1658
|
this.lifecycle?.onIdentityChange?.(this, { callSid: this.callSid, outboundId: this.outboundId });
|
|
1659
|
+
this.floor = new floor_controller_1.VoiceFloorController({
|
|
1660
|
+
transport: "twilio-media-stream",
|
|
1661
|
+
agentName: this.options.agentName,
|
|
1662
|
+
callId: this.callSid,
|
|
1663
|
+
});
|
|
1664
|
+
this.floorUnsubscribe = this.floor.onTransition(() => this.flushQueuedGatedRealtimeRequest());
|
|
1665
|
+
this.floor.apply({ type: "call.connected", atMs: Date.now(), callId: this.callSid });
|
|
1651
1666
|
(0, runtime_1.emitNervesEvent)({
|
|
1652
1667
|
component: "senses",
|
|
1653
1668
|
event: "senses.voice_twilio_realtime_start",
|
|
@@ -1802,6 +1817,9 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
1802
1817
|
return realtimeSystem;
|
|
1803
1818
|
}
|
|
1804
1819
|
requestHangupFromTool() {
|
|
1820
|
+
if (!this.hangupRequested) {
|
|
1821
|
+
this.floor.apply({ type: "hangup.requested", atMs: Date.now(), reason: "voice_end_call" });
|
|
1822
|
+
}
|
|
1805
1823
|
this.hangupRequested = true;
|
|
1806
1824
|
setTimeout(() => this.completeHangupIfReady("tool_fallback"), 7_500).unref?.();
|
|
1807
1825
|
}
|
|
@@ -1991,6 +2009,9 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
1991
2009
|
if (!content)
|
|
1992
2010
|
return;
|
|
1993
2011
|
this.appendTranscript("user", content);
|
|
2012
|
+
const turnId = this.activeCallerTurnId ?? `caller-turn-${++this.callerTurnSequence}`;
|
|
2013
|
+
this.activeCallerTurnId = undefined;
|
|
2014
|
+
this.floor.apply({ type: "caller.transcript.final", atMs: Date.now(), turnId, text: content });
|
|
1994
2015
|
this.scheduleUserTurnResponse();
|
|
1995
2016
|
}
|
|
1996
2017
|
scheduleUserTurnResponse() {
|
|
@@ -2033,7 +2054,6 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
2033
2054
|
}
|
|
2034
2055
|
handleCallerSpeechStarted() {
|
|
2035
2056
|
this.clearPendingUserTurnResponse();
|
|
2036
|
-
const playback = this.playbackState;
|
|
2037
2057
|
if (!this.hasReliableCallerBargeInSpeech()) {
|
|
2038
2058
|
(0, runtime_1.emitNervesEvent)({
|
|
2039
2059
|
component: "senses",
|
|
@@ -2047,6 +2067,10 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
2047
2067
|
});
|
|
2048
2068
|
return;
|
|
2049
2069
|
}
|
|
2070
|
+
const playback = this.playbackState;
|
|
2071
|
+
const turnId = `caller-turn-${++this.callerTurnSequence}`;
|
|
2072
|
+
this.activeCallerTurnId = turnId;
|
|
2073
|
+
this.floor.apply({ type: "caller.speech.started", atMs: Date.now(), turnId });
|
|
2050
2074
|
this.playbackMarks.clear();
|
|
2051
2075
|
this.sendTwilioClear();
|
|
2052
2076
|
if (!playback?.itemId)
|
|
@@ -2116,9 +2140,24 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
2116
2140
|
this.clearRealtimeToolPresenceTimer(state);
|
|
2117
2141
|
if (state.suppressFollowup)
|
|
2118
2142
|
return true;
|
|
2143
|
+
this.releaseCallerFloorForToolFollowup();
|
|
2119
2144
|
this.requestRealtimeResponse();
|
|
2120
2145
|
return true;
|
|
2121
2146
|
}
|
|
2147
|
+
releaseCallerFloorForToolFollowup() {
|
|
2148
|
+
// OpenAI emitting a function-call result inside a coordinated response
|
|
2149
|
+
// means the caller's most recent turn has already been parsed by the
|
|
2150
|
+
// realtime server. If we still hold a synthetic caller turn (because the
|
|
2151
|
+
// matching transcript event has not been delivered yet — common in unit
|
|
2152
|
+
// fixtures and during fast-turn races), release it before asking the gate
|
|
2153
|
+
// to flush a follow-up response.create so the gate is not stuck thinking
|
|
2154
|
+
// the caller still owns the floor.
|
|
2155
|
+
if (!this.activeCallerTurnId)
|
|
2156
|
+
return;
|
|
2157
|
+
const turnId = this.activeCallerTurnId;
|
|
2158
|
+
this.activeCallerTurnId = undefined;
|
|
2159
|
+
this.floor.apply({ type: "caller.transcript.final", atMs: Date.now(), turnId });
|
|
2160
|
+
}
|
|
2122
2161
|
scheduleRealtimeToolPresence(responseId, state) {
|
|
2123
2162
|
if (!responseId || state.presenceRequested || state.presenceTimer)
|
|
2124
2163
|
return;
|
|
@@ -2156,6 +2195,13 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
2156
2195
|
toolState.suppressFollowup = true;
|
|
2157
2196
|
if (toolState && !toolState.suppressFollowup)
|
|
2158
2197
|
this.scheduleRealtimeToolPresence(responseId, toolState);
|
|
2198
|
+
this.floor.apply({
|
|
2199
|
+
type: "tool.call.started",
|
|
2200
|
+
atMs: Date.now(),
|
|
2201
|
+
toolCallId: callId,
|
|
2202
|
+
toolName: name,
|
|
2203
|
+
turnId: this.floor.state.latestCallerTurnId,
|
|
2204
|
+
});
|
|
2159
2205
|
let output;
|
|
2160
2206
|
try {
|
|
2161
2207
|
const args = parseToolArguments(typeof event.arguments === "string" ? event.arguments : "");
|
|
@@ -2191,6 +2237,12 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
2191
2237
|
output,
|
|
2192
2238
|
},
|
|
2193
2239
|
});
|
|
2240
|
+
this.floor.apply({
|
|
2241
|
+
type: "tool.call.completed",
|
|
2242
|
+
atMs: Date.now(),
|
|
2243
|
+
toolCallId: callId,
|
|
2244
|
+
turnId: this.floor.state.latestCallerTurnId,
|
|
2245
|
+
});
|
|
2194
2246
|
if (!this.completeRealtimeToolCall(responseId, callId) && !coordinated) {
|
|
2195
2247
|
this.requestRealtimeResponse();
|
|
2196
2248
|
}
|
|
@@ -2209,19 +2261,76 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
2209
2261
|
this.clearUntrackedActiveRealtimeResponseTimer();
|
|
2210
2262
|
this.activeRealtimeResponseId = null;
|
|
2211
2263
|
this.responseCreateHoldUntilMs = Math.max(this.responseCreateHoldUntilMs, Date.now() + OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS);
|
|
2264
|
+
const closingResponseId = this.pendingGatedResponseId;
|
|
2265
|
+
if (closingResponseId) {
|
|
2266
|
+
// Clear the field BEFORE applying the floor event so that a re-entrant
|
|
2267
|
+
// queued-flush triggered by the onTransition listener can install a new
|
|
2268
|
+
// pendingGatedResponseId for the follow-up request without us racing it
|
|
2269
|
+
// back to null after apply() returns.
|
|
2270
|
+
this.pendingGatedResponseId = null;
|
|
2271
|
+
this.floor.apply({ type: "assistant.speech.done", atMs: Date.now(), responseId: closingResponseId });
|
|
2272
|
+
}
|
|
2212
2273
|
this.schedulePendingRealtimeResponse(OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS);
|
|
2213
2274
|
}
|
|
2214
2275
|
requestRealtimeResponse(response) {
|
|
2215
2276
|
if (this.closed)
|
|
2216
2277
|
return;
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2278
|
+
this.sendGatedRealtimeResponseCreate(response ? { response } : {});
|
|
2279
|
+
}
|
|
2280
|
+
sendGatedRealtimeResponseCreate(request) {
|
|
2281
|
+
if (this.closed)
|
|
2282
|
+
return;
|
|
2283
|
+
const responseId = `gated-${++this.gatedResponseRequestSequence}`;
|
|
2284
|
+
const decision = this.floor.canRequestResponse({ responseId });
|
|
2285
|
+
if (decision.action === "allow") {
|
|
2286
|
+
this.queuedGatedRealtimeRequest = null;
|
|
2287
|
+
this.pendingGatedResponseId = responseId;
|
|
2288
|
+
this.floor.apply({ type: "assistant.response.requested", atMs: Date.now(), responseId });
|
|
2289
|
+
this.sendRealtimeResponseCreate(request);
|
|
2290
|
+
// OpenAI's `response.created` ACK is not guaranteed in every error/race path
|
|
2291
|
+
// (and fixture WebSocket mocks may omit it). Treat the wire send itself as
|
|
2292
|
+
// the moment the assistant has the floor so the gate cannot stay in a
|
|
2293
|
+
// pending state forever. Real `response.created` ids that arrive later are
|
|
2294
|
+
// idempotently re-applied in noteRealtimeResponseCreated().
|
|
2295
|
+
this.floor.apply({ type: "assistant.speech.started", atMs: Date.now(), responseId });
|
|
2222
2296
|
return;
|
|
2223
2297
|
}
|
|
2224
|
-
|
|
2298
|
+
if (decision.action === "delay") {
|
|
2299
|
+
this.queuedGatedRealtimeRequest = request;
|
|
2300
|
+
(0, runtime_1.emitNervesEvent)({
|
|
2301
|
+
component: "senses",
|
|
2302
|
+
event: "senses.voice_floor_gate_blocked",
|
|
2303
|
+
message: `voice floor gate delayed response.create (${decision.reason})`,
|
|
2304
|
+
meta: this.floorGateMeta({ action: "delay", reason: decision.reason, responseId, queued: true }),
|
|
2305
|
+
});
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
this.queuedGatedRealtimeRequest = null;
|
|
2309
|
+
(0, runtime_1.emitNervesEvent)({
|
|
2310
|
+
component: "senses",
|
|
2311
|
+
event: "senses.voice_floor_gate_blocked",
|
|
2312
|
+
message: `voice floor gate suppressed response.create (${decision.reason})`,
|
|
2313
|
+
meta: this.floorGateMeta({ action: decision.action, reason: decision.reason, responseId, queued: false }),
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
flushQueuedGatedRealtimeRequest() {
|
|
2317
|
+
if (this.closed || !this.queuedGatedRealtimeRequest)
|
|
2318
|
+
return;
|
|
2319
|
+
const queued = this.queuedGatedRealtimeRequest;
|
|
2320
|
+
const probeId = `gated-probe-${this.gatedResponseRequestSequence}`;
|
|
2321
|
+
const probe = this.floor.canRequestResponse({ responseId: probeId });
|
|
2322
|
+
if (probe.action !== "allow")
|
|
2323
|
+
return;
|
|
2324
|
+
this.queuedGatedRealtimeRequest = null;
|
|
2325
|
+
this.sendGatedRealtimeResponseCreate(queued);
|
|
2326
|
+
}
|
|
2327
|
+
floorGateMeta(extra) {
|
|
2328
|
+
return {
|
|
2329
|
+
agentName: this.options.agentName,
|
|
2330
|
+
phase: this.floor.state.phase,
|
|
2331
|
+
floorOwner: this.floor.state.floorOwner,
|
|
2332
|
+
...extra,
|
|
2333
|
+
};
|
|
2225
2334
|
}
|
|
2226
2335
|
realtimeResponseIsBusy() {
|
|
2227
2336
|
return !!this.activeRealtimeResponseId || !!this.realtimeResponseCreateInFlight || this.untrackedActiveRealtimeResponse;
|
|
@@ -2408,6 +2517,10 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
2408
2517
|
this.clearPendingUserTurnResponse();
|
|
2409
2518
|
this.clearRealtimeToolPresenceTimers();
|
|
2410
2519
|
this.clearUntrackedActiveRealtimeResponseTimer();
|
|
2520
|
+
this.queuedGatedRealtimeRequest = null;
|
|
2521
|
+
this.floor.apply({ type: "call.ended", atMs: Date.now() });
|
|
2522
|
+
this.floorUnsubscribe?.();
|
|
2523
|
+
this.floorUnsubscribe = null;
|
|
2411
2524
|
this.lifecycle?.onClose?.(this, { callSid: this.callSid, outboundId: this.outboundId });
|
|
2412
2525
|
(0, runtime_1.emitNervesEvent)({
|
|
2413
2526
|
component: "senses",
|
|
@@ -2449,6 +2562,13 @@ class OpenAISipPhoneSession {
|
|
|
2449
2562
|
pendingRealtimeResponseTimer = null;
|
|
2450
2563
|
pendingUserTurnResponseTimer = null;
|
|
2451
2564
|
responseCreateHoldUntilMs = 0;
|
|
2565
|
+
floor = new floor_controller_1.VoiceFloorController({ transport: "openai-sip" });
|
|
2566
|
+
floorUnsubscribe = null;
|
|
2567
|
+
queuedGatedRealtimeRequest = null;
|
|
2568
|
+
activeCallerTurnId;
|
|
2569
|
+
callerTurnSequence = 0;
|
|
2570
|
+
gatedResponseRequestSequence = 0;
|
|
2571
|
+
pendingGatedResponseId = null;
|
|
2452
2572
|
constructor(options, metadata, registry) {
|
|
2453
2573
|
this.options = options;
|
|
2454
2574
|
this.metadata = metadata;
|
|
@@ -2482,6 +2602,13 @@ class OpenAISipPhoneSession {
|
|
|
2482
2602
|
to: this.metadata.to,
|
|
2483
2603
|
callSid: this.metadata.callId,
|
|
2484
2604
|
});
|
|
2605
|
+
this.floor = new floor_controller_1.VoiceFloorController({
|
|
2606
|
+
transport: "openai-sip",
|
|
2607
|
+
agentName: this.options.agentName,
|
|
2608
|
+
callId: this.metadata.callId,
|
|
2609
|
+
});
|
|
2610
|
+
this.floorUnsubscribe = this.floor.onTransition(() => this.flushQueuedGatedRealtimeRequest());
|
|
2611
|
+
this.floor.apply({ type: "call.connected", atMs: Date.now(), callId: this.metadata.callId });
|
|
2485
2612
|
const initialGreetingMode = await this.outboundAmdInitialGreetingMode();
|
|
2486
2613
|
if (initialGreetingMode === "reject") {
|
|
2487
2614
|
await this.rejectOpenAISipCall(realtime, sip, "amd_preclassified_nonhuman");
|
|
@@ -3041,6 +3168,9 @@ class OpenAISipPhoneSession {
|
|
|
3041
3168
|
if (!content)
|
|
3042
3169
|
return;
|
|
3043
3170
|
this.appendTranscript("user", content);
|
|
3171
|
+
const turnId = this.activeCallerTurnId ?? `caller-turn-${++this.callerTurnSequence}`;
|
|
3172
|
+
this.activeCallerTurnId = undefined;
|
|
3173
|
+
this.floor.apply({ type: "caller.transcript.final", atMs: Date.now(), turnId, text: content });
|
|
3044
3174
|
this.scheduleUserTurnResponse();
|
|
3045
3175
|
}
|
|
3046
3176
|
scheduleUserTurnResponse() {
|
|
@@ -3150,6 +3280,13 @@ class OpenAISipPhoneSession {
|
|
|
3150
3280
|
toolState.suppressFollowup = true;
|
|
3151
3281
|
if (toolState && !toolState.suppressFollowup)
|
|
3152
3282
|
this.scheduleRealtimeToolPresence(responseId, toolState);
|
|
3283
|
+
this.floor.apply({
|
|
3284
|
+
type: "tool.call.started",
|
|
3285
|
+
atMs: Date.now(),
|
|
3286
|
+
toolCallId: callId,
|
|
3287
|
+
toolName: name,
|
|
3288
|
+
turnId: this.floor.state.latestCallerTurnId,
|
|
3289
|
+
});
|
|
3153
3290
|
let output;
|
|
3154
3291
|
try {
|
|
3155
3292
|
const args = parseToolArguments(typeof event.arguments === "string" ? event.arguments : "");
|
|
@@ -3185,6 +3322,12 @@ class OpenAISipPhoneSession {
|
|
|
3185
3322
|
output,
|
|
3186
3323
|
},
|
|
3187
3324
|
});
|
|
3325
|
+
this.floor.apply({
|
|
3326
|
+
type: "tool.call.completed",
|
|
3327
|
+
atMs: Date.now(),
|
|
3328
|
+
toolCallId: callId,
|
|
3329
|
+
turnId: this.floor.state.latestCallerTurnId,
|
|
3330
|
+
});
|
|
3188
3331
|
if (!this.completeRealtimeToolCall(responseId, callId) && !coordinated) {
|
|
3189
3332
|
this.requestRealtimeResponse();
|
|
3190
3333
|
}
|
|
@@ -3203,19 +3346,76 @@ class OpenAISipPhoneSession {
|
|
|
3203
3346
|
this.clearUntrackedActiveRealtimeResponseTimer();
|
|
3204
3347
|
this.activeRealtimeResponseId = null;
|
|
3205
3348
|
this.responseCreateHoldUntilMs = Math.max(this.responseCreateHoldUntilMs, Date.now() + OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS);
|
|
3349
|
+
const closingResponseId = this.pendingGatedResponseId;
|
|
3350
|
+
if (closingResponseId) {
|
|
3351
|
+
// Clear the field BEFORE applying the floor event so that a re-entrant
|
|
3352
|
+
// queued-flush triggered by the onTransition listener can install a new
|
|
3353
|
+
// pendingGatedResponseId for the follow-up request without us racing it
|
|
3354
|
+
// back to null after apply() returns.
|
|
3355
|
+
this.pendingGatedResponseId = null;
|
|
3356
|
+
this.floor.apply({ type: "assistant.speech.done", atMs: Date.now(), responseId: closingResponseId });
|
|
3357
|
+
}
|
|
3206
3358
|
this.schedulePendingRealtimeResponse(OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS);
|
|
3207
3359
|
}
|
|
3208
3360
|
requestRealtimeResponse(response) {
|
|
3209
3361
|
if (this.closed)
|
|
3210
3362
|
return;
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3363
|
+
this.sendGatedRealtimeResponseCreate(response ? { response } : {});
|
|
3364
|
+
}
|
|
3365
|
+
sendGatedRealtimeResponseCreate(request) {
|
|
3366
|
+
if (this.closed)
|
|
3367
|
+
return;
|
|
3368
|
+
const responseId = `gated-${++this.gatedResponseRequestSequence}`;
|
|
3369
|
+
const decision = this.floor.canRequestResponse({ responseId });
|
|
3370
|
+
if (decision.action === "allow") {
|
|
3371
|
+
this.queuedGatedRealtimeRequest = null;
|
|
3372
|
+
this.pendingGatedResponseId = responseId;
|
|
3373
|
+
this.floor.apply({ type: "assistant.response.requested", atMs: Date.now(), responseId });
|
|
3374
|
+
this.sendRealtimeResponseCreate(request);
|
|
3375
|
+
// OpenAI's `response.created` ACK is not guaranteed in every error/race path
|
|
3376
|
+
// (and fixture WebSocket mocks may omit it). Treat the wire send itself as
|
|
3377
|
+
// the moment the assistant has the floor so the gate cannot stay in a
|
|
3378
|
+
// pending state forever. Real `response.created` ids that arrive later are
|
|
3379
|
+
// idempotently re-applied in noteRealtimeResponseCreated().
|
|
3380
|
+
this.floor.apply({ type: "assistant.speech.started", atMs: Date.now(), responseId });
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
if (decision.action === "delay") {
|
|
3384
|
+
this.queuedGatedRealtimeRequest = request;
|
|
3385
|
+
(0, runtime_1.emitNervesEvent)({
|
|
3386
|
+
component: "senses",
|
|
3387
|
+
event: "senses.voice_floor_gate_blocked",
|
|
3388
|
+
message: `voice floor gate delayed response.create (${decision.reason})`,
|
|
3389
|
+
meta: this.floorGateMeta({ action: "delay", reason: decision.reason, responseId, queued: true }),
|
|
3390
|
+
});
|
|
3216
3391
|
return;
|
|
3217
3392
|
}
|
|
3218
|
-
this.
|
|
3393
|
+
this.queuedGatedRealtimeRequest = null;
|
|
3394
|
+
(0, runtime_1.emitNervesEvent)({
|
|
3395
|
+
component: "senses",
|
|
3396
|
+
event: "senses.voice_floor_gate_blocked",
|
|
3397
|
+
message: `voice floor gate suppressed response.create (${decision.reason})`,
|
|
3398
|
+
meta: this.floorGateMeta({ action: decision.action, reason: decision.reason, responseId, queued: false }),
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
flushQueuedGatedRealtimeRequest() {
|
|
3402
|
+
if (this.closed || !this.queuedGatedRealtimeRequest)
|
|
3403
|
+
return;
|
|
3404
|
+
const queued = this.queuedGatedRealtimeRequest;
|
|
3405
|
+
const probeId = `gated-probe-${this.gatedResponseRequestSequence}`;
|
|
3406
|
+
const probe = this.floor.canRequestResponse({ responseId: probeId });
|
|
3407
|
+
if (probe.action !== "allow")
|
|
3408
|
+
return;
|
|
3409
|
+
this.queuedGatedRealtimeRequest = null;
|
|
3410
|
+
this.sendGatedRealtimeResponseCreate(queued);
|
|
3411
|
+
}
|
|
3412
|
+
floorGateMeta(extra) {
|
|
3413
|
+
return {
|
|
3414
|
+
agentName: this.options.agentName,
|
|
3415
|
+
phase: this.floor.state.phase,
|
|
3416
|
+
floorOwner: this.floor.state.floorOwner,
|
|
3417
|
+
...extra,
|
|
3418
|
+
};
|
|
3219
3419
|
}
|
|
3220
3420
|
realtimeResponseIsBusy() {
|
|
3221
3421
|
return !!this.activeRealtimeResponseId || !!this.realtimeResponseCreateInFlight || this.untrackedActiveRealtimeResponse;
|
|
@@ -3356,6 +3556,10 @@ class OpenAISipPhoneSession {
|
|
|
3356
3556
|
this.clearPendingUserTurnResponse();
|
|
3357
3557
|
this.clearRealtimeToolPresenceTimers();
|
|
3358
3558
|
this.clearUntrackedActiveRealtimeResponseTimer();
|
|
3559
|
+
this.queuedGatedRealtimeRequest = null;
|
|
3560
|
+
this.floor.apply({ type: "call.ended", atMs: Date.now() });
|
|
3561
|
+
this.floorUnsubscribe?.();
|
|
3562
|
+
this.floorUnsubscribe = null;
|
|
3359
3563
|
(0, runtime_1.emitNervesEvent)({
|
|
3360
3564
|
component: "senses",
|
|
3361
3565
|
event: "senses.voice_openai_sip_call_stop",
|