@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 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
- const waitMs = Math.max(0, this.responseCreateHoldUntilMs - Date.now());
2218
- if (this.realtimeResponseIsBusy() || waitMs > 0) {
2219
- this.holdRealtimeResponse(response ? { response } : {});
2220
- if (!this.realtimeResponseIsBusy())
2221
- this.schedulePendingRealtimeResponse(waitMs);
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
- this.sendRealtimeResponseCreate(response ? { response } : {});
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
- const waitMs = Math.max(0, this.responseCreateHoldUntilMs - Date.now());
3212
- if (this.realtimeResponseIsBusy() || waitMs > 0) {
3213
- this.holdRealtimeResponse(response ? { response } : {});
3214
- if (!this.realtimeResponseIsBusy())
3215
- this.schedulePendingRealtimeResponse(waitMs);
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.sendRealtimeResponseCreate(response ? { response } : {});
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.589",
3
+ "version": "0.1.0-alpha.590",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",