@agentsdock/server 1.0.11-beta.2 → 1.0.12-beta.1

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.
@@ -0,0 +1,184 @@
1
+ """Incremental unread provenance; journal recovery runs off the event loop."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Iterable
6
+ from transcript_reader import jsonl_records
7
+
8
+
9
+ COUNTER = "latest_non_job_agent_event_seq"
10
+ CHECKPOINT = "_non_job_agent_event_checkpoint_seq"
11
+ PENDING_COUNTER = "_non_job_agent_event_pending_seq"
12
+ LAST_RUN = "_non_job_agent_last_run"
13
+ JOURNAL = "_non_job_agent_event_journal"
14
+
15
+
16
+ def imported_event(event: dict[str, Any]) -> bool:
17
+ return event.get("imported") is True or str(event.get("run_id") or "").startswith("import_")
18
+
19
+
20
+ def scheduled_event(event: dict[str, Any]) -> bool:
21
+ job = event.get("job")
22
+ return bool(
23
+ event.get("purpose") == "scheduled_job"
24
+ or str(event.get("job_id") or "").strip()
25
+ or isinstance(job, dict) and str(job.get("id") or "").strip()
26
+ or str(event.get("type") or "").startswith("job_")
27
+ )
28
+
29
+
30
+ def project_event(
31
+ session: dict[str, Any], event: dict[str, Any], *,
32
+ visible: bool, run_metadata: dict[str, Any],
33
+ ) -> None:
34
+ """Apply an appended event without I/O or changing the ordinary read cursor."""
35
+ seq = int(event.get("seq") or 0)
36
+ if seq <= 0:
37
+ return
38
+ # Only a new journal can establish an empty baseline without recovery.
39
+ if seq == 1 and not int(session.get("latest_event_seq") or 0):
40
+ session.setdefault(COUNTER, 0)
41
+ session.setdefault(CHECKPOINT, 0)
42
+ known = isinstance(session.get(COUNTER), int) and CHECKPOINT in session
43
+ run_id = str(event.get("run_id") or "")
44
+ contexts: list[dict[str, Any]] = []
45
+ if event.get("type") == "turn_started" and run_id:
46
+ context = {"run_id": run_id, "scheduled_job": scheduled_event(event) or scheduled_event(run_metadata)}
47
+ session[LAST_RUN] = context
48
+ contexts.append(context)
49
+ else:
50
+ for candidate in (session.get("active_run"), session.get(LAST_RUN)):
51
+ if isinstance(candidate, dict) and run_id and candidate.get("run_id") == run_id:
52
+ contexts.append(candidate)
53
+ scheduled = scheduled_event(event) or scheduled_event(run_metadata) or any(
54
+ scheduled_event(context) or context.get("scheduled_job") is True for context in contexts
55
+ )
56
+ if scheduled and run_id and (session.get(LAST_RUN) or {}).get("run_id") == run_id:
57
+ session[LAST_RUN] = {"run_id": run_id, "scheduled_job": True}
58
+ if visible and not scheduled and not imported_event(event):
59
+ field = COUNTER if known else PENDING_COUNTER
60
+ session[field] = max(int(session.get(field) or 0), seq)
61
+ if known:
62
+ session[CHECKPOINT] = max(int(session.get(CHECKPOINT) or 0), seq)
63
+
64
+
65
+ def recover_projection(
66
+ path: Path, snapshot: dict[str, Any], *,
67
+ visible: Callable[[str, dict[str, Any]], bool],
68
+ resume_offset: Callable[[Path, int], int],
69
+ prior_events: Callable[[], Iterable[dict[str, Any]]],
70
+ cancelled: Callable[[], bool],
71
+ ) -> dict[str, Any]:
72
+ """Project one fixed journal extent, then resolve missing run provenance once.
73
+
74
+ Legacy chats need one forward pass. A durable checkpoint resumes through the
75
+ existing sparse index; only an untagged tail run whose start precedes that
76
+ checkpoint requires a backwards provenance pass. No history is read by the
77
+ session-list endpoint or the live event projection.
78
+ """
79
+ known = isinstance(snapshot.get(COUNTER), int) and CHECKPOINT in snapshot
80
+ after = int(snapshot.get(CHECKPOINT) or 0) if known else 0
81
+ latest = int(snapshot.get(COUNTER) or 0) if known else 0
82
+ through = after
83
+ last_run = dict(snapshot.get(LAST_RUN) or {})
84
+ runs: dict[str, dict[str, Any]] = {}
85
+ unresolved: dict[str, list[dict[str, Any]]] = {}
86
+
87
+ def finish(run_id: str, run: dict[str, Any]) -> None:
88
+ nonlocal latest
89
+ if run["scheduled"] or not run["visible_seq"]:
90
+ return
91
+ if run["origin"]:
92
+ latest = max(latest, run["visible_seq"])
93
+ else:
94
+ unresolved.setdefault(run_id, []).append(run)
95
+
96
+ try:
97
+ source = path.open("rb")
98
+ except FileNotFoundError:
99
+ if after or int(snapshot.get("latest_event_seq") or 0):
100
+ raise
101
+ return {COUNTER: 0, CHECKPOINT: 0, LAST_RUN: {}}
102
+ with source:
103
+ extent = source.seek(0, 2)
104
+ inode = path.stat().st_ino
105
+ journal = snapshot.get(JOURNAL) or {}
106
+ if known and journal and (journal.get("inode") != inode or int(journal.get("offset") or 0) > extent):
107
+ after = latest = through = 0
108
+ last_run = {}
109
+ offset = int(journal.get("offset") or 0) if after and journal.get("inode") == inode else 0
110
+ source.seek(offset or (resume_offset(path, after) if after else 0))
111
+ complete_offset = source.tell()
112
+ for event, ending, raw in jsonl_records(
113
+ source, end=extent, project=False, require_newline=False, deadline=float("inf"), cancelled=cancelled,
114
+ max_records=float("inf"), max_nodes=None, max_depth=512,
115
+ retained_fields=frozenset({"seq", "type", "run_id", "purpose", "job_id", "job", "text", "result_text", "goal", "imported"}),
116
+ ):
117
+ if not raw.complete:
118
+ break # Leave a concurrently written/crash fragment unclaimed.
119
+ complete_offset = ending
120
+ if not isinstance(event, dict):
121
+ continue
122
+ seq = int(event.get("seq") or 0)
123
+ if seq <= after:
124
+ continue
125
+ through = max(through, seq)
126
+ event_type = str(event.get("type") or "")
127
+ is_visible = visible(event_type, event) and not imported_event(event)
128
+ run_id = str(event.get("run_id") or "")
129
+ scheduled = scheduled_event(event)
130
+ if not run_id:
131
+ if is_visible and not scheduled:
132
+ latest = max(latest, seq)
133
+ continue
134
+ if event_type == "turn_started" and run_id in runs:
135
+ finish(run_id, runs.pop(run_id))
136
+ run = runs.setdefault(run_id, {"first_seq": seq, "visible_seq": 0, "scheduled": False, "origin": False})
137
+ run["scheduled"] = run["scheduled"] or scheduled
138
+ if event_type == "turn_started":
139
+ run["origin"] = True
140
+ last_run = {"run_id": run_id, "scheduled_job": scheduled}
141
+ elif scheduled and last_run.get("run_id") == run_id:
142
+ last_run["scheduled_job"] = True
143
+ if is_visible:
144
+ run["visible_seq"] = max(run["visible_seq"], seq)
145
+ if event_type in {"turn_finished", "turn_stopped"} and (run["scheduled"] or not run["visible_seq"]):
146
+ # Completed cron runs do not accumulate with the schedule's
147
+ # history. A later untagged event is resolved by the single
148
+ # backwards provenance pass, just like a restart tail.
149
+ runs.pop(run_id, None)
150
+ for run_id, run in runs.items():
151
+ finish(run_id, run)
152
+ # One reverse pass resolves all tail runs together, including stripped raw
153
+ # provider events and a job_ran marker emitted after the provider's start.
154
+ if unresolved:
155
+ for event in prior_events():
156
+ if cancelled():
157
+ raise InterruptedError("unread projection cancelled")
158
+ run_id = str(event.get("run_id") or "")
159
+ candidates = unresolved.get(run_id)
160
+ if not candidates:
161
+ continue
162
+ seq = int(event.get("seq") or 0)
163
+ scheduled = scheduled_event(event)
164
+ if not scheduled and event.get("type") != "turn_started":
165
+ continue
166
+ remaining = []
167
+ for run in candidates:
168
+ if seq >= run["first_seq"]:
169
+ remaining.append(run)
170
+ elif not scheduled:
171
+ latest = max(latest, run["visible_seq"])
172
+ if remaining:
173
+ unresolved[run_id] = remaining
174
+ else:
175
+ unresolved.pop(run_id)
176
+ if not unresolved:
177
+ break
178
+ # Legacy output with no recorded job provenance remains ordinary;
179
+ # merely owning a schedule never suppresses a human conversation.
180
+ for candidates in unresolved.values():
181
+ for run in candidates:
182
+ latest = max(latest, run["visible_seq"])
183
+ return {COUNTER: latest, CHECKPOINT: through, LAST_RUN: last_run,
184
+ JOURNAL: {"inode": inode, "offset": complete_offset}}
@@ -0,0 +1,5 @@
1
+ """Generated from the limited shared-chat analytics entry; do not edit by hand."""
2
+ import json
3
+
4
+ SCRIPT = json.loads("\"/*! @noble/hashes\\nThe MIT License (MIT)\\n\\nCopyright (c) 2022 Paul Miller (https://paulmillr.com)\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\nof this software and associated documentation files (the “Software”), to deal\\nin the Software without restriction, including without limitation the rights\\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\\ncopies of the Software, and to permit persons to whom the Software is\\nfurnished to do so, subject to the following conditions:\\n\\nThe above copyright notice and this permission notice shall be included in\\nall copies or substantial portions of the Software.\\n\\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\\nTHE SOFTWARE.\\n*/(function(){\\\"use strict\\\";function H(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name===\\\"Uint8Array\\\"&&\\\"BYTES_PER_ELEMENT\\\"in e&&e.BYTES_PER_ELEMENT===1}function p(e,t,s=\\\"\\\"){const n=H(e),r=e?.length;if(!n||t!==void 0){const i=s&&`\\\"${s}\\\" `,o=\\\"\\\",f=n?`length=${r}`:`type=${typeof e}`,h=i+\\\"expected Uint8Array\\\"+o+\\\", got \\\"+f;throw n?new RangeError(h):new TypeError(h)}return e}function m(e,t=!0){if(e.destroyed)throw new Error(\\\"Hash instance has been destroyed\\\");if(t&&e.finished)throw new Error(\\\"Hash#digest() has already been called\\\")}function S(e,t){p(e,void 0,\\\"digestInto() output\\\");const s=t.outputLen;if(e.length<s)throw new RangeError('\\\"digestInto() output\\\" expected to be of length >='+s)}function y(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}function _(e){return new DataView(e.buffer,e.byteOffset,e.byteLength)}function d(e,t){return e<<32-t|e>>>t}const T=typeof Uint8Array.from([]).toHex==\\\"function\\\"&&typeof Uint8Array.fromHex==\\\"function\\\",k=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,\\\"0\\\"));function B(e){if(p(e),T)return e.toHex();let t=\\\"\\\";for(let s=0;s<e.length;s++)t+=k[e[s]];return t}function O(e){if(typeof e!=\\\"string\\\")throw new TypeError(\\\"string expected\\\");return new Uint8Array(new TextEncoder().encode(e))}function U(e,t={}){const s=(r,c)=>e(c).update(r).digest(),n=e(void 0);return s.outputLen=n.outputLen,s.blockLen=n.blockLen,s.canXOF=n.canXOF,s.create=r=>e(r),Object.assign(s,t),Object.freeze(s)}const v=e=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,e])});function C(e,t,s){return e&t^~e&s}function I(e,t,s){return e&t^e&s^t&s}class D{blockLen;outputLen;canXOF=!1;padOffset;isLE;buffer;view;finished=!1;length=0;pos=0;destroyed=!1;constructor(t,s,n,r){this.blockLen=t,this.outputLen=s,this.padOffset=n,this.isLE=r,this.buffer=new Uint8Array(t),this.view=_(this.buffer)}update(t){m(this),p(t);const{view:s,buffer:n,blockLen:r}=this,c=t.length;for(let i=0;i<c;){const o=Math.min(r-this.pos,c-i);if(o===r){const f=_(t);for(;r<=c-i;i+=r)this.process(f,i);continue}n.set(t.subarray(i,i+o),this.pos),this.pos+=o,i+=o,this.pos===r&&(this.process(s,0),this.pos=0)}return this.length+=t.length,this.roundClean(),this}digestInto(t){m(this),S(t,this),this.finished=!0;const{buffer:s,view:n,blockLen:r,isLE:c}=this;let{pos:i}=this;s[i++]=128,y(this.buffer.subarray(i)),this.padOffset>r-i&&(this.process(n,0),i=0);for(let a=i;a<r;a++)s[a]=0;n.setBigUint64(r-8,BigInt(this.length*8),c),this.process(n,0);const o=_(t),f=this.outputLen;if(f%4)throw new Error(\\\"_sha2: outputLen must be aligned to 32bit\\\");const h=f/4,l=this.get();if(h>l.length)throw new Error(\\\"_sha2: outputLen bigger than state\\\");for(let a=0;a<h;a++)o.setUint32(4*a,l[a],c)}digest(){const{buffer:t,outputLen:s}=this;this.digestInto(t);const n=t.slice(0,s);return this.destroy(),n}_cloneInto(t){t||=new this.constructor,t.set(...this.get());const{blockLen:s,buffer:n,length:r,finished:c,destroyed:i,pos:o}=this;return t.destroyed=i,t.finished=c,t.length=r,t.pos=o,r%s&&t.buffer.set(n),t}clone(){return this._cloneInto()}}const x=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),N=Uint32Array.from([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),u=new Uint32Array(64);class F extends D{constructor(t){super(64,t,8,!1)}get(){const{A:t,B:s,C:n,D:r,E:c,F:i,G:o,H:f}=this;return[t,s,n,r,c,i,o,f]}set(t,s,n,r,c,i,o,f){this.A=t|0,this.B=s|0,this.C=n|0,this.D=r|0,this.E=c|0,this.F=i|0,this.G=o|0,this.H=f|0}process(t,s){for(let a=0;a<16;a++,s+=4)u[a]=t.getUint32(s,!1);for(let a=16;a<64;a++){const g=u[a-15],b=u[a-2],L=d(g,7)^d(g,18)^g>>>3,w=d(b,17)^d(b,19)^b>>>10;u[a]=w+u[a-7]+L+u[a-16]|0}let{A:n,B:r,C:c,D:i,E:o,F:f,G:h,H:l}=this;for(let a=0;a<64;a++){const g=d(o,6)^d(o,11)^d(o,25),b=l+g+C(o,f,h)+N[a]+u[a]|0,w=(d(n,2)^d(n,13)^d(n,22))+I(n,r,c)|0;l=h,h=f,f=o,o=i+b|0,i=c,c=r,r=n,n=b+w|0}n=n+this.A|0,r=r+this.B|0,c=c+this.C|0,i=i+this.D|0,o=o+this.E|0,f=f+this.F|0,h=h+this.G|0,l=l+this.H|0,this.set(n,r,c,i,o,f,h,l)}roundClean(){y(u)}destroy(){this.destroyed=!0,this.set(0,0,0,0,0,0,0,0),y(this.buffer)}}class M extends F{A=x[0]|0;B=x[1]|0;C=x[2]|0;D=x[3]|0;E=x[4]|0;F=x[5]|0;G=x[6]|0;H=x[7]|0;constructor(){super(32)}}const V=U(()=>new M,v(1)),$=[\\\"shared_chat_snapshot_activated\\\",\\\"shared_chat_interactive_activated\\\",\\\"shared_chat_message_sent\\\"],E=\\\"agentsdock:shared-analytics-seen:v1\\\",A=new Set;function G(e,t,s=\\\"\\\"){try{if(!$.includes(e)||!/^\\\\/(?:interactive-chat\\\\/interactive_[a-f0-9]{32}|shared-chat\\\\/share_[a-f0-9]{32}|share\\\\/[A-Za-z0-9_-]{43})$/.test(t)||e===\\\"shared_chat_message_sent\\\"&&!/^[A-Za-z0-9_-]{16,128}$/.test(s)||navigator.doNotTrack===\\\"1\\\"||navigator.globalPrivacyControl||localStorage.getItem(\\\"agentsdock:analytics-consent:v1\\\")===\\\"denied\\\")return;const n=B(V(O(JSON.stringify([e,t,s])))),r=JSON.parse(sessionStorage.getItem(E)??\\\"[]\\\"),c=Array.isArray(r)?r.filter(f=>typeof f==\\\"string\\\"&&/^[a-f0-9]{64}$/.test(f)):[];if(A.has(n)||c.includes(n)||c.length>=4096)return;sessionStorage.setItem(E,JSON.stringify([...c,n])),A.add(n);const i=new AbortController,o=setTimeout(()=>i.abort(),5e3);try{fetch(\\\"https://api.mixpanel.com/track?ip=0\\\",{method:\\\"POST\\\",credentials:\\\"omit\\\",referrerPolicy:\\\"no-referrer\\\",headers:{\\\"Content-Type\\\":\\\"application/json\\\"},signal:i.signal,body:JSON.stringify([{event:e,properties:{token:\\\"66b4fef625e5750d5527870f0ca96d5e\\\",distinct_id:\\\"\\\",platform:\\\"shared_web\\\",time:Math.floor(Date.now()/1e3)}}])}).catch(()=>{}).finally(()=>clearTimeout(o))}catch{clearTimeout(o)}}catch{}}G(\\\"shared_chat_snapshot_activated\\\",location.pathname)})();\"")
5
+ CSP_HASH = "mGoIxolAbIiMaBueHq0pZetv9HOC3PsCEy+QNKDYsaM="
@@ -27,10 +27,11 @@ from fastapi import APIRouter, HTTPException, Request
27
27
  from fastapi.responses import JSONResponse
28
28
 
29
29
 
30
- MAX_QUESTION_CHARS = 8000
31
30
  MAX_HISTORY_ITEMS = 32
32
31
  MAX_HISTORY_CHARS = 60000
33
32
  MAX_REQUEST_BYTES = 512 * 1024
33
+ # The streamed body limit bounds input; avoid a second, smaller model-text cap.
34
+ MAX_QUESTION_CHARS = MAX_REQUEST_BYTES
34
35
  MAX_CONTEXT_CHARS = 60000
35
36
  MAX_LOG_BYTES = 4 * 1024 * 1024
36
37
  MAX_OUTPUT_BYTES = 1024 * 1024
@@ -369,11 +370,6 @@ class _NativeConversation:
369
370
  closed: bool = False
370
371
  timer: object = None
371
372
  close_task: asyncio.Task | None = None
372
-
373
-
374
- NATIVE_IDLE_SECONDS = 30 * 60
375
-
376
-
377
373
  class SideQuestions:
378
374
  def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None, runtime_settings_for_session=None):
379
375
  self.answer = answer
@@ -396,6 +392,43 @@ class SideQuestions:
396
392
  def active_work_labels(self):
397
393
  return [f"Side chat in {session_id}" for session_id in sorted(self.active_session_ids())]
398
394
 
395
+ def background_work_scopes(self):
396
+ handles = [(key[1], conversation.handle) for key, conversation in self.conversations.items()
397
+ if not conversation.closed and conversation.handle is not None]
398
+ if self.synced is not None:
399
+ handles.extend((key[1], handle) for key, handle in self.synced.handles.items())
400
+ scopes = []
401
+ for session_id, handle in handles:
402
+ inspect = getattr(handle, "background_work_scope", None)
403
+ scope = inspect() if callable(inspect) else None
404
+ if scope is not None:
405
+ scopes.append((session_id, handle, scope))
406
+ return tuple(scopes)
407
+
408
+ async def prepare_background_work_snapshot(self, *, timeout_seconds=5.0):
409
+ scopes = self.background_work_scopes()
410
+
411
+ async def inspect(session_id, handle, _scope):
412
+ try:
413
+ active = await handle.has_background_work()
414
+ except Exception:
415
+ return f"Side chat provider state unknown in {session_id}"
416
+ return f"Side chat background work in {session_id}" if active else None
417
+
418
+ try:
419
+ values = await asyncio.wait_for(asyncio.gather(*(inspect(*scope) for scope in scopes)),
420
+ timeout=timeout_seconds)
421
+ labels = tuple(sorted(set(value for value in values if value)))
422
+ except asyncio.TimeoutError:
423
+ labels = tuple(f"Side chat provider state unknown in {session_id}" for session_id, _, _ in scopes)
424
+ return {"scopes": scopes, "labels": labels}
425
+
426
+ def background_work_labels_from_snapshot(self, snapshot):
427
+ scopes = self.background_work_scopes()
428
+ if snapshot.get("scopes", ()) != scopes:
429
+ return ["Side chat provider state changed during inspection"]
430
+ return list(snapshot.get("labels", ()))
431
+
399
432
  async def _close_conversation(self, conversation):
400
433
  conversation.closed = True
401
434
  if conversation.timer is not None:
@@ -415,7 +448,7 @@ class SideQuestions:
415
448
  await asyncio.shield(conversation.close_task)
416
449
 
417
450
  def _expire_conversation(self, key, conversation):
418
- if self.conversations.get(key) is not conversation or conversation.busy:
451
+ if self.conversations.get(key) is not conversation or conversation.busy or not conversation.closed:
419
452
  return
420
453
  self.conversations.pop(key, None)
421
454
  task = asyncio.create_task(self._close_conversation(conversation), name="side-chat-expiry")
@@ -461,8 +494,9 @@ class SideQuestions:
461
494
  raise
462
495
  finally:
463
496
  conversation.busy = False
464
- conversation.timer = asyncio.get_running_loop().call_later(
465
- NATIVE_IDLE_SECONDS, self._expire_conversation, key, conversation)
497
+ if conversation.closed:
498
+ conversation.timer = asyncio.get_running_loop().call_later(
499
+ RECEIPT_TTL_SECONDS, self._expire_conversation, key, conversation)
466
500
 
467
501
  async def close_conversation(self, owner, session_id, side_chat_id):
468
502
  key = (owner, session_id, side_chat_id)
@@ -473,7 +507,7 @@ class SideQuestions:
473
507
  await self.cancel(*receipt_key)
474
508
  await self._close_conversation(conversation)
475
509
  conversation.timer = asyncio.get_running_loop().call_later(
476
- NATIVE_IDLE_SECONDS, self._expire_conversation, key, conversation)
510
+ RECEIPT_TTL_SECONDS, self._expire_conversation, key, conversation)
477
511
 
478
512
  def _prune(self):
479
513
  now = time.monotonic()
@@ -874,8 +908,6 @@ class SyncedSideChats:
874
908
  self.cleanup_tasks.discard(cleanup)
875
909
  if self.tasks.get(key) is asyncio.current_task():
876
910
  self.tasks.pop(key, None)
877
- if not self.stopping:
878
- self.timers[key] = asyncio.get_running_loop().call_later(NATIVE_IDLE_SECONDS, self._expire, key)
879
911
 
880
912
  async def _finish(self, key, side_chat_id, request_id, status, **result):
881
913
  async with self._lock(key):
@@ -1053,7 +1085,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
1053
1085
  if not isinstance(request_id, str) or not IDENTIFIER.fullmatch(request_id):
1054
1086
  raise HTTPException(400, "Invalid side question request ID")
1055
1087
  if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
1056
- raise HTTPException(400, "Question must contain 1 to 8000 characters")
1088
+ raise HTTPException(400, f"Question must contain 1 to {MAX_QUESTION_CHARS} characters")
1057
1089
  try:
1058
1090
  question.encode("utf-8")
1059
1091
  except UnicodeEncodeError:
@@ -1152,7 +1184,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
1152
1184
  raise HTTPException(400, "Invalid side conversation ID")
1153
1185
  question = value["question"]
1154
1186
  if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
1155
- raise HTTPException(400, "Question must contain 1 to 8000 characters")
1187
+ raise HTTPException(400, f"Question must contain 1 to {MAX_QUESTION_CHARS} characters")
1156
1188
  try:
1157
1189
  question.encode("utf-8")
1158
1190
  except UnicodeEncodeError: