andito HF Staff commited on
Commit
8556c56
Β·
1 Parent(s): 17e7387

Sync ordered assistant output changes

Browse files
Files changed (5) hide show
  1. auth.py +2 -2
  2. main.js +30 -23
  3. server.py +4 -0
  4. tool-call-batcher.js +59 -0
  5. ws/s2s-ws-client.js +26 -15
auth.py CHANGED
@@ -114,10 +114,10 @@ def _oauth_token_expired(info, *, now=None) -> bool:
114
  logger.warning("Unexpected OAuth expiry value; requiring a fresh login.")
115
  return True
116
  if expires_at.tzinfo is None:
117
- expires_at = expires_at.replace(tzinfo=timezone.utc)
118
  current = now or datetime.now(timezone.utc)
119
  if current.tzinfo is None:
120
- current = current.replace(tzinfo=timezone.utc)
121
  return expires_at <= current + _OAUTH_EXPIRY_SKEW
122
 
123
 
 
114
  logger.warning("Unexpected OAuth expiry value; requiring a fresh login.")
115
  return True
116
  if expires_at.tzinfo is None:
117
+ expires_at = expires_at.astimezone(timezone.utc)
118
  current = now or datetime.now(timezone.utc)
119
  if current.tzinfo is None:
120
+ current = current.astimezone(timezone.utc)
121
  return expires_at <= current + _OAUTH_EXPIRY_SKEW
122
 
123
 
main.js CHANGED
@@ -17,6 +17,7 @@
17
  */
18
 
19
  import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
 
20
  import { $, truncateError, DEBUG } from "./ui/dom.js";
21
  import { ChatView } from "./ui/chat.js";
22
  import { Account } from "./ui/account.js";
@@ -767,14 +768,14 @@ function flashPreview() {
767
  }
768
 
769
  // ── Tool executor ─────────────────────────────────────────────────────────
770
- // Runs the function the model called, returns the result, and asks for a
771
- // response so the model speaks it. Errors come back as the tool output too, so
772
- // the model can recover gracefully instead of the turn stalling.
 
773
 
774
  /**
775
- * Run the function the model called, return its result to the backend, and ask
776
- * for a follow-up response. We also hand the result back to the caller so it
777
- * can be shown in the conversation once the tool has actually run.
778
  * @param {string} name @param {string} argsJson @param {string} callId
779
  * @returns {Promise<{ output: string, image?: string }>}
780
  */
@@ -792,37 +793,23 @@ async function runTool(name, argsJson, callId) {
792
  if (name === "web_search") {
793
  const query = typeof args.query === "string" ? args.query : "";
794
  result.output = await execWebSearch(query);
795
- // Return the result and let the bare response.create (below) trigger the
796
- // spoken answer.
797
- client.sendToolOutput(callId, result.output);
798
  } else if (name === "camera_snapshot") {
799
  const dataUrl = captureSnapshot();
800
  if (dataUrl) {
801
  if (DEBUG) console.debug(`[tool] camera_snapshot captured frame (${dataUrl.length} chars), sending image + output`);
802
  result = { output: "Snapshot captured from the webcam and attached as an image.", image: dataUrl };
803
- // Return the tool output; the frame itself rides along with the
804
- // response.create below (sent right before it), so the model sees the
805
- // snapshot in the very response it's about to speak.
806
- client.sendToolOutput(callId, result.output);
807
  flashPreview();
808
  } else {
809
  console.warn("[tool] camera_snapshot: no frame β€” camera off or not ready");
810
  result.output = "The camera is not available right now.";
811
- client.sendToolOutput(callId, result.output);
812
  }
813
  } else {
814
  result.output = `Unknown tool: ${name}`;
815
- client.sendToolOutput(callId, result.output);
816
  }
817
  } catch (err) {
818
  const msg = err instanceof Error ? err.message : String(err);
819
  result.output = `Tool failed: ${msg}`;
820
- client.sendToolOutput(callId, result.output);
821
  }
822
- if (DEBUG) console.debug(`[tool] requesting model response after ${name}`);
823
- // Camera: the captured frame rides with the response.create (sent just before
824
- // it) so it's in context for the reply. Other tools: a bare create.
825
- client.requestResponse(result.image ? { image: result.image } : undefined);
826
  return result;
827
  }
828
 
@@ -1337,6 +1324,18 @@ async function doStart(audioContext = null) {
1337
  client = c;
1338
  c.setMuted(micMuted || userAudioReplaying);
1339
 
 
 
 
 
 
 
 
 
 
 
 
 
1340
  c.addEventListener("queue", (e) => {
1341
  const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
1342
  if (queueId) queuedTicketId = queueId;
@@ -1381,16 +1380,24 @@ async function doStart(audioContext = null) {
1381
  c.addEventListener("response-finished", (e) => {
1382
  const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
1383
  chat.onResponseFinished(detail);
 
 
1384
  });
1385
 
1386
  c.addEventListener("toolcall", (e) => {
1387
- const { name, arguments: args, callId } = /** @type {CustomEvent<{ name: string; arguments: string; callId: string }>} */ (e).detail;
 
 
 
 
1388
  chat.onToolCall(name);
1389
  // Execute the tool, then push it to the conversation once the result is in,
1390
  // so the toggle shows both the call input and its output together.
1391
- void runTool(name, args, callId).then(({ output, image }) => {
1392
- chat.onToolResult(name, args, output, image);
 
1393
  });
 
1394
  });
1395
  c.addEventListener("error", (e) => {
1396
  const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
 
17
  */
18
 
19
  import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
20
+ import { ToolCallBatcher } from "./tool-call-batcher.js";
21
  import { $, truncateError, DEBUG } from "./ui/dom.js";
22
  import { ChatView } from "./ui/chat.js";
23
  import { Account } from "./ui/account.js";
 
768
  }
769
 
770
  // ── Tool executor ─────────────────────────────────────────────────────────
771
+ // Runs the function the model called and returns the result. The connection's
772
+ // ToolCallBatcher sends every result from the originating response together,
773
+ // then requests one follow-up. Errors come back as tool output too, so the
774
+ // model can recover gracefully instead of the turn stalling.
775
 
776
  /**
777
+ * Run the function the model called. The caller batches the returned result
778
+ * with the other calls from the same response before sending it to the model.
 
779
  * @param {string} name @param {string} argsJson @param {string} callId
780
  * @returns {Promise<{ output: string, image?: string }>}
781
  */
 
793
  if (name === "web_search") {
794
  const query = typeof args.query === "string" ? args.query : "";
795
  result.output = await execWebSearch(query);
 
 
 
796
  } else if (name === "camera_snapshot") {
797
  const dataUrl = captureSnapshot();
798
  if (dataUrl) {
799
  if (DEBUG) console.debug(`[tool] camera_snapshot captured frame (${dataUrl.length} chars), sending image + output`);
800
  result = { output: "Snapshot captured from the webcam and attached as an image.", image: dataUrl };
 
 
 
 
801
  flashPreview();
802
  } else {
803
  console.warn("[tool] camera_snapshot: no frame β€” camera off or not ready");
804
  result.output = "The camera is not available right now.";
 
805
  }
806
  } else {
807
  result.output = `Unknown tool: ${name}`;
 
808
  }
809
  } catch (err) {
810
  const msg = err instanceof Error ? err.message : String(err);
811
  result.output = `Tool failed: ${msg}`;
 
812
  }
 
 
 
 
813
  return result;
814
  }
815
 
 
1324
  client = c;
1325
  c.setMuted(micMuted || userAudioReplaying);
1326
 
1327
+ /** @param {{ callId: string; output: string; image?: string }[]} results */
1328
+ const sendToolBatch = (results) => {
1329
+ if (client !== c) return;
1330
+ for (const result of results) c.sendToolOutput(result.callId, result.output);
1331
+ for (const result of results) {
1332
+ if (result.image) c.sendUserImage(result.image);
1333
+ }
1334
+ if (DEBUG) console.debug(`[tool] requesting one follow-up after ${results.length} tool result(s)`);
1335
+ c.requestResponse();
1336
+ };
1337
+ const toolBatches = new ToolCallBatcher(sendToolBatch);
1338
+
1339
  c.addEventListener("queue", (e) => {
1340
  const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
1341
  if (queueId) queuedTicketId = queueId;
 
1380
  c.addEventListener("response-finished", (e) => {
1381
  const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
1382
  chat.onResponseFinished(detail);
1383
+ const flush = toolBatches.finish(detail.responseId, detail.status);
1384
+ if (flush) void flush.catch((err) => onFatalError(err));
1385
  });
1386
 
1387
  c.addEventListener("toolcall", (e) => {
1388
+ const { name, arguments: args, callId, responseId } = /** @type {CustomEvent<{ name: string; arguments: string; callId: string; responseId: string }>} */ (e).detail;
1389
+ if (!responseId) {
1390
+ console.warn(`[tool] call ${callId || "<unknown>"} has no response_id; ignoring uncorrelated tool call`);
1391
+ return;
1392
+ }
1393
  chat.onToolCall(name);
1394
  // Execute the tool, then push it to the conversation once the result is in,
1395
  // so the toggle shows both the call input and its output together.
1396
+ const execution = runTool(name, args, callId).then(({ output, image }) => {
1397
+ if (client === c) chat.onToolResult(name, args, output, image);
1398
+ return { callId, output, ...(image ? { image } : {}) };
1399
  });
1400
+ toolBatches.add(responseId, execution);
1401
  });
1402
  c.addEventListener("error", (e) => {
1403
  const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
server.py CHANGED
@@ -321,6 +321,10 @@ async def session(request: Request):
321
  if lb.status_code == 401:
322
  body = _safe_json(lb)
323
  reason = body.get("reason") or "login_required"
 
 
 
 
324
  logger.info("Session authentication rejected: %s", reason)
325
  return _login_required_response(reason, set_cookie)
326
 
 
321
  if lb.status_code == 401:
322
  body = _safe_json(lb)
323
  reason = body.get("reason") or "login_required"
324
+ if reason == "token_invalid":
325
+ session = getattr(request, "scope", {}).get("session")
326
+ if isinstance(session, dict):
327
+ session.pop("oauth_info", None)
328
  logger.info("Session authentication rejected: %s", reason)
329
  return _login_required_response(reason, set_cookie)
330
 
tool-call-batcher.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {Object} ToolExecutionResult
5
+ * @property {string} callId
6
+ * @property {string} output
7
+ * @property {string} [image]
8
+ */
9
+
10
+ /** Collect every tool execution from one response before requesting its follow-up. */
11
+ export class ToolCallBatcher {
12
+ /** @param {(results: ToolExecutionResult[]) => void | Promise<void>} onReady */
13
+ constructor(onReady) {
14
+ this._onReady = onReady;
15
+ /** @type {Map<string, { executions: Promise<ToolExecutionResult>[]; flush: Promise<void> | null }>} */
16
+ this._batches = new Map();
17
+ }
18
+
19
+ /**
20
+ * Register a tool execution in the order its call appeared in the response.
21
+ * @param {string} responseId
22
+ * @param {Promise<ToolExecutionResult>} execution
23
+ */
24
+ add(responseId, execution) {
25
+ let batch = this._batches.get(responseId);
26
+ if (!batch) {
27
+ batch = { executions: [], flush: null };
28
+ this._batches.set(responseId, batch);
29
+ }
30
+ batch.executions.push(execution);
31
+ }
32
+
33
+ /**
34
+ * Finish the originating response. Completed responses flush once all tools
35
+ * settle; unsuccessful responses discard calls the backend rolled back.
36
+ * @param {string} responseId
37
+ * @param {string} status
38
+ * @returns {Promise<void> | null}
39
+ */
40
+ finish(responseId, status) {
41
+ const batch = this._batches.get(responseId);
42
+ if (!batch) return null;
43
+ if (status !== "completed") {
44
+ this._batches.delete(responseId);
45
+ // Executions cannot be cancelled, but a discarded rejection should not
46
+ // become unhandled after the response is gone.
47
+ for (const execution of batch.executions) void execution.catch(() => {});
48
+ return null;
49
+ }
50
+ if (batch.flush) return batch.flush;
51
+
52
+ batch.flush = Promise.all(batch.executions)
53
+ .then((results) => this._onReady(results))
54
+ .finally(() => {
55
+ if (this._batches.get(responseId) === batch) this._batches.delete(responseId);
56
+ });
57
+ return batch.flush;
58
+ }
59
+ }
ws/s2s-ws-client.js CHANGED
@@ -177,12 +177,12 @@ export class S2sWsRealtimeClient extends EventTarget {
177
  * UI can tell a barge-in cut (keep it) from a never-heard speculative
178
  * response (drop it). */
179
  this._audibleResponses = new Set();
180
- /** @type {Map<string, string>} The CURRENT assistant transcript segment per
181
- * response, accumulated from streamed deltas (reset on each segment's done). */
182
  this._asstTranscriptByResp = new Map();
183
- /** @type {Map<string, string>} Completed assistant transcript segments per
184
- * response, space-joined. A single response can emit several
185
- * `*.transcript.done` events; we concatenate them until response.done. */
186
  this._asstFullByResp = new Map();
187
  this._muted = false;
188
  // ── Response lock ────────────────────────────────────────────────────
@@ -222,8 +222,8 @@ export class S2sWsRealtimeClient extends EventTarget {
222
  this.dispatchEvent(new CustomEvent("status", { detail: { status } }));
223
  }
224
 
225
- /** Full assistant transcript so far for a response: the completed segments
226
- * plus the in-progress one, all space-joined.
227
  * @param {string} rid @returns {string} */
228
  _asstDisplay(rid) {
229
  const full = this._asstFullByResp.get(rid) || "";
@@ -775,9 +775,9 @@ export class S2sWsRealtimeClient extends EventTarget {
775
  const audible = responseId ? this._audibleResponses.has(responseId) : false;
776
  this._audibleResponses.delete(responseId);
777
  // Pull whatever transcript the response carries, falling back to the
778
- // segments we concatenated from the `*.transcript.done` events (plus any
779
- // in-progress delta). For an interrupted reply the response payload may
780
- // be empty, so this is the last chance to capture the text.
781
  const transcript =
782
  extractResponseTranscript(event.response) ||
783
  this._asstDisplay(responseId) ||
@@ -798,9 +798,10 @@ export class S2sWsRealtimeClient extends EventTarget {
798
  const name = typeof event.name === "string" ? event.name : "";
799
  const args = typeof event.arguments === "string" ? event.arguments : "{}";
800
  const callId = typeof event.call_id === "string" ? event.call_id : "";
 
801
  if (name) {
802
  this.dispatchEvent(new CustomEvent("toolcall", {
803
- detail: { name, arguments: args, callId },
804
  }));
805
  } else {
806
  // A nameless call can't be executed, so no function_call_output is
@@ -844,6 +845,10 @@ export class S2sWsRealtimeClient extends EventTarget {
844
  },
845
  }),
846
  );
 
 
 
 
847
  }
848
  break;
849
  }
@@ -859,7 +864,7 @@ export class S2sWsRealtimeClient extends EventTarget {
859
  const delta = typeof event.delta === "string" ? event.delta : "";
860
  if (delta) {
861
  this._asstTranscriptByResp.set(rid, (this._asstTranscriptByResp.get(rid) || "") + delta);
862
- // Show completed segments + the segment streaming in right now.
863
  this.dispatchEvent(
864
  new CustomEvent("transcript", {
865
  detail: { role: "assistant", text: this._asstDisplay(rid), partial: true, responseId: rid },
@@ -872,13 +877,14 @@ export class S2sWsRealtimeClient extends EventTarget {
872
  case "response.audio_transcript.done":
873
  case "response.output_audio_transcript.done": {
874
  const rid = typeof event.response_id === "string" ? event.response_id : "";
875
- // This is ONE completed segment. A response can emit several; concatenate
876
- // them, space-separated, until response.done clears the accumulator.
 
877
  const segment =
878
  (typeof event.transcript === "string" && event.transcript) ||
879
  this._asstTranscriptByResp.get(rid) ||
880
  "";
881
- this._asstTranscriptByResp.delete(rid); // segment finished; next one starts fresh
882
  if (segment) {
883
  const prev = this._asstFullByResp.get(rid) || "";
884
  this._asstFullByResp.set(rid, prev ? `${prev} ${segment}` : segment);
@@ -1083,6 +1089,11 @@ export class S2sWsRealtimeClient extends EventTarget {
1083
  return this._openResponses > 0 || this._createInFlight;
1084
  }
1085
 
 
 
 
 
 
1086
  /** Send a response.create immediately and arm the in-flight guard. Any image
1087
  * on the payload is added as user content right before the create.
1088
  * @param {{ image?: string }} [opts] */
 
177
  * UI can tell a barge-in cut (keep it) from a never-heard speculative
178
  * response (drop it). */
179
  this._audibleResponses = new Set();
180
+ /** @type {Map<string, string>} The in-progress assistant transcript per
181
+ * response, accumulated from streamed deltas until the terminal done. */
182
  this._asstTranscriptByResp = new Map();
183
+ /** @type {Map<string, string>} Terminal assistant transcript per response.
184
+ * Realtime emits one `*.transcript.done`; appending remains for compatibility
185
+ * with legacy endpoints that emitted a done event for every text segment. */
186
  this._asstFullByResp = new Map();
187
  this._muted = false;
188
  // ── Response lock ────────────────────────────────────────────────────
 
222
  this.dispatchEvent(new CustomEvent("status", { detail: { status } }));
223
  }
224
 
225
+ /** Full assistant transcript so far for a response: a terminal transcript,
226
+ * when present, plus any in-progress deltas from a legacy segmented stream.
227
  * @param {string} rid @returns {string} */
228
  _asstDisplay(rid) {
229
  const full = this._asstFullByResp.get(rid) || "";
 
775
  const audible = responseId ? this._audibleResponses.has(responseId) : false;
776
  this._audibleResponses.delete(responseId);
777
  // Pull whatever transcript the response carries, falling back to the
778
+ // terminal `*.transcript.done` value (plus any in-progress delta from a
779
+ // legacy segmented stream). For an interrupted reply the response
780
+ // payload may be empty, so this is the last chance to capture the text.
781
  const transcript =
782
  extractResponseTranscript(event.response) ||
783
  this._asstDisplay(responseId) ||
 
798
  const name = typeof event.name === "string" ? event.name : "";
799
  const args = typeof event.arguments === "string" ? event.arguments : "{}";
800
  const callId = typeof event.call_id === "string" ? event.call_id : "";
801
+ const responseId = typeof event.response_id === "string" ? event.response_id : "";
802
  if (name) {
803
  this.dispatchEvent(new CustomEvent("toolcall", {
804
+ detail: { name, arguments: args, callId, responseId },
805
  }));
806
  } else {
807
  // A nameless call can't be executed, so no function_call_output is
 
845
  },
846
  }),
847
  );
848
+ } else if (this._status === "processing" && !this._responsePending()) {
849
+ // Empty STT results intentionally do not create a response, so there
850
+ // will be no response.done event to return the UI to listening.
851
+ this._setStatus("connected");
852
  }
853
  break;
854
  }
 
864
  const delta = typeof event.delta === "string" ? event.delta : "";
865
  if (delta) {
866
  this._asstTranscriptByResp.set(rid, (this._asstTranscriptByResp.get(rid) || "") + delta);
867
+ // Show the complete transcript accumulated from deltas so far.
868
  this.dispatchEvent(
869
  new CustomEvent("transcript", {
870
  detail: { role: "assistant", text: this._asstDisplay(rid), partial: true, responseId: rid },
 
877
  case "response.audio_transcript.done":
878
  case "response.output_audio_transcript.done": {
879
  const rid = typeof event.response_id === "string" ? event.response_id : "";
880
+ // This terminalizes the assistant transcript for the response. Realtime
881
+ // emits it once after all deltas; retain space-joining only for legacy
882
+ // endpoints that emitted a done event for every text segment.
883
  const segment =
884
  (typeof event.transcript === "string" && event.transcript) ||
885
  this._asstTranscriptByResp.get(rid) ||
886
  "";
887
+ this._asstTranscriptByResp.delete(rid);
888
  if (segment) {
889
  const prev = this._asstFullByResp.get(rid) || "";
890
  this._asstFullByResp.set(rid, prev ? `${prev} ${segment}` : segment);
 
1089
  return this._openResponses > 0 || this._createInFlight;
1090
  }
1091
 
1092
+ /** True while a response is active, awaiting confirmation, or queued. */
1093
+ _responsePending() {
1094
+ return this._responseActive() || this._createQueue.length > 0;
1095
+ }
1096
+
1097
  /** Send a response.create immediately and arm the in-flight guard. Any image
1098
  * on the payload is added as user content right before the create.
1099
  * @param {{ image?: string }} [opts] */