synapse-orch-ai 1.8.3 → 1.8.5

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.
Files changed (70) hide show
  1. package/backend/core/config.py +2 -2
  2. package/backend/core/json_store.py +2 -2
  3. package/backend/core/mcp_client.py +4 -4
  4. package/backend/core/messaging/store.py +2 -2
  5. package/backend/core/orchestration/engine.py +12 -6
  6. package/backend/core/orchestration/runner.py +70 -0
  7. package/backend/core/orchestration/state.py +7 -3
  8. package/backend/core/orchestration/steps.py +3 -3
  9. package/backend/core/personal_details.py +2 -2
  10. package/backend/core/react_engine.py +60 -27
  11. package/backend/core/routes/api_v1.py +17 -6
  12. package/backend/core/routes/chat.py +3 -0
  13. package/backend/core/routes/data.py +6 -6
  14. package/backend/core/routes/import_export.py +2 -2
  15. package/backend/core/routes/orchestrations.py +15 -66
  16. package/backend/core/routes/settings.py +6 -6
  17. package/backend/core/routes/tools.py +1 -1
  18. package/backend/core/scale/context.py +1 -1
  19. package/backend/core/scale/sync.py +1 -1
  20. package/backend/core/server.py +3 -3
  21. package/backend/core/tools.py +1 -1
  22. package/backend/services/code_indexer.py +2 -2
  23. package/backend/services/google.py +7 -7
  24. package/backend/services/synthetic_data.py +1 -1
  25. package/backend/tools/bash.py +1 -1
  26. package/backend/tools/code_search.py +2 -2
  27. package/backend/tools/sandbox.py +1 -1
  28. package/backend/tools/sql_agent.py +1 -1
  29. package/frontend-build/.next/BUILD_ID +1 -1
  30. package/frontend-build/.next/build-manifest.json +3 -3
  31. package/frontend-build/.next/prerender-manifest.json +3 -3
  32. package/frontend-build/.next/server/app/_global-error.html +1 -1
  33. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  34. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  35. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  36. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  37. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  38. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  39. package/frontend-build/.next/server/app/_not-found.html +1 -1
  40. package/frontend-build/.next/server/app/_not-found.rsc +1 -1
  41. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  42. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  43. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  44. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  45. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  46. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  47. package/frontend-build/.next/server/app/index.html +1 -1
  48. package/frontend-build/.next/server/app/index.rsc +1 -1
  49. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  50. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +1 -1
  51. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  52. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +1 -1
  53. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  54. package/frontend-build/.next/server/app/login.html +1 -1
  55. package/frontend-build/.next/server/app/login.rsc +1 -1
  56. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +1 -1
  57. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  58. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +1 -1
  59. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  60. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  61. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  62. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  63. package/frontend-build/.next/server/pages/404.html +1 -1
  64. package/frontend-build/.next/server/pages/500.html +1 -1
  65. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  66. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  67. package/package.json +1 -1
  68. /package/frontend-build/.next/static/{a-CFL11mzxHaVjsbJG19x → ZnXvTR9Wo4Qc1Vgx3ou4L}/_buildManifest.js +0 -0
  69. /package/frontend-build/.next/static/{a-CFL11mzxHaVjsbJG19x → ZnXvTR9Wo4Qc1Vgx3ou4L}/_clientMiddlewareManifest.js +0 -0
  70. /package/frontend-build/.next/static/{a-CFL11mzxHaVjsbJG19x → ZnXvTR9Wo4Qc1Vgx3ou4L}/_ssgManifest.js +0 -0
@@ -129,7 +129,7 @@ def load_settings():
129
129
  file_settings = {}
130
130
  else:
131
131
  try:
132
- with open(SETTINGS_FILE, 'r') as f:
132
+ with open(SETTINGS_FILE, 'r', encoding="utf-8") as f:
133
133
  file_settings = json.load(f)
134
134
  except Exception as e:
135
135
  print(f"DEBUG: Error loading settings: {e}")
@@ -168,7 +168,7 @@ def get_or_create_jwt_secret() -> str:
168
168
 
169
169
  if env_file.exists():
170
170
  try:
171
- for line in env_file.read_text().splitlines():
171
+ for line in env_file.read_text(encoding="utf-8").splitlines():
172
172
  line = line.strip()
173
173
  if line.startswith(f"{var}=") and len(line) > len(f"{var}="):
174
174
  val = line.split("=", 1)[1].strip()
@@ -32,7 +32,7 @@ class JsonStore:
32
32
  if not os.path.exists(self.path):
33
33
  return self.default_factory()
34
34
  try:
35
- with open(self.path, "r") as f:
35
+ with open(self.path, "r", encoding="utf-8") as f:
36
36
  data = json.load(f)
37
37
  if self._cache_ttl > 0:
38
38
  self._cache = data
@@ -43,7 +43,7 @@ class JsonStore:
43
43
 
44
44
  def save(self, data):
45
45
  with self._lock:
46
- with open(self.path, "w") as f:
46
+ with open(self.path, "w", encoding="utf-8") as f:
47
47
  json.dump(data, f, indent=4)
48
48
  if self._cache_ttl > 0:
49
49
  self._cache = data
@@ -69,14 +69,14 @@ class FileTokenStorage(TokenStorage):
69
69
  if not os.path.exists(path):
70
70
  return None
71
71
  try:
72
- with open(path) as f:
72
+ with open(path, encoding="utf-8") as f:
73
73
  return json.load(f)
74
74
  except Exception:
75
75
  return None
76
76
 
77
77
  def _write(self, path: str, data: dict):
78
78
  os.makedirs(MCP_TOKENS_DIR, exist_ok=True)
79
- with open(path, "w") as f:
79
+ with open(path, "w", encoding="utf-8") as f:
80
80
  json.dump(data, f, indent=2)
81
81
 
82
82
  async def get_tokens(self) -> Optional[OAuthToken]:
@@ -181,7 +181,7 @@ class MCPClientManager:
181
181
  if not os.path.exists(MCP_SERVERS_FILE):
182
182
  return []
183
183
  try:
184
- with open(MCP_SERVERS_FILE) as f:
184
+ with open(MCP_SERVERS_FILE, encoding="utf-8") as f:
185
185
  return json.load(f)
186
186
  except Exception as e:
187
187
  print(f"Error loading MCP servers config: {e}")
@@ -189,7 +189,7 @@ class MCPClientManager:
189
189
 
190
190
  def save_servers(self):
191
191
  os.makedirs(os.path.dirname(MCP_SERVERS_FILE), exist_ok=True)
192
- with open(MCP_SERVERS_FILE, "w") as f:
192
+ with open(MCP_SERVERS_FILE, "w", encoding="utf-8") as f:
193
193
  json.dump(self.servers_config, f, indent=4)
194
194
 
195
195
  def _set_status(self, name: str, status: str):
@@ -17,7 +17,7 @@ def _load_raw() -> list[dict]:
17
17
  if not os.path.exists(CHANNELS_FILE):
18
18
  return []
19
19
  try:
20
- with open(CHANNELS_FILE, "r") as f:
20
+ with open(CHANNELS_FILE, "r", encoding="utf-8") as f:
21
21
  data = json.load(f)
22
22
  return data if isinstance(data, list) else []
23
23
  except Exception:
@@ -26,7 +26,7 @@ def _load_raw() -> list[dict]:
26
26
 
27
27
  def _save_raw(data: list[dict]) -> None:
28
28
  os.makedirs(DATA_DIR, exist_ok=True)
29
- with open(CHANNELS_FILE, "w") as f:
29
+ with open(CHANNELS_FILE, "w", encoding="utf-8") as f:
30
30
  json.dump(data, f, indent=2)
31
31
 
32
32
 
@@ -182,12 +182,18 @@ class OrchestrationEngine:
182
182
  # This properly interrupts a stuck session.call_tool() inside
183
183
  # the executor when the step deadline is reached.
184
184
  #
185
- # IMPORTANT: never yield human_input_required inside the
186
- # fail_after scope. Python's GC finalizer runs aclose() on
187
- # abandoned async generators in a NEW asyncio Task; if a
188
- # cancel scope is active at the suspension point, anyio raises
189
- # "cancel scope in a different task". We break out of the scope
190
- # cleanly (in the current task) and yield the event below.
185
+ # IMPORTANT: this scope stays open across the yields below, so
186
+ # this generator must be advanced — and closed — from a single
187
+ # task, or anyio raises "cancel scope in a different task".
188
+ # core.orchestration.runner is what guarantees that; every
189
+ # entry point runs us through it (issue #356). Do not drive
190
+ # the engine directly from a task-per-pull consumer.
191
+ #
192
+ # human_input_required is still yielded outside the scope: the
193
+ # generator is abandoned at that point, and Python's GC
194
+ # finalizer runs aclose() on abandoned async generators in a
195
+ # NEW asyncio Task. We break out of the scope cleanly (in the
196
+ # current task) and yield the event below.
191
197
  human_input_event: dict | None = None
192
198
  try:
193
199
  with anyio.fail_after(step_timeout):
@@ -0,0 +1,70 @@
1
+ """Single-task runner for orchestration engine streams.
2
+
3
+ The engine holds anyio cancel scopes across `yield`s (the per-step timeout at
4
+ `engine._execute_loop`). anyio binds a cancel scope to the task that entered
5
+ it, so the engine's generator must be advanced -- and closed -- from ONE task,
6
+ whatever the HTTP layer does with the events. A consumer that pulls each event
7
+ in a fresh task raises "Attempted to exit cancel scope in a different task"
8
+ (issue #356).
9
+
10
+ Every orchestration entry point goes through here, so the launch path can no
11
+ longer change how the engine is driven.
12
+ """
13
+ import asyncio
14
+ from typing import AsyncGenerator
15
+
16
+ # Live in-process runs, keyed by run_id. The cancel endpoint cancels the task
17
+ # to interrupt an in-progress await; the run-status endpoint consults it to
18
+ # report a run that is still inside its first step (no checkpoint yet).
19
+ _active_tasks: dict[str, asyncio.Task] = {}
20
+
21
+ # Queue marker meaning "the engine generator is exhausted".
22
+ SENTINEL = object()
23
+
24
+
25
+ def spawn_engine_run(agen, run_id: str) -> tuple[asyncio.Task, asyncio.Queue]:
26
+ """Advance `agen` in one dedicated task, returning (task, queue).
27
+
28
+ The task is detached: it runs to completion even if nobody drains the
29
+ queue, so a client disconnect never kills a run. Only the cancel endpoint
30
+ stops it.
31
+ """
32
+ queue: asyncio.Queue = asyncio.Queue()
33
+
34
+ async def _pump():
35
+ try:
36
+ async for event in agen:
37
+ await queue.put(event)
38
+ # Yield so the SSE consumer can dequeue and flush this event
39
+ # to the HTTP response before the next one is enqueued.
40
+ await asyncio.sleep(0)
41
+ except asyncio.CancelledError:
42
+ await queue.put({"type": "orchestration_error", "error": "Cancelled"})
43
+ except FileNotFoundError:
44
+ await queue.put({"type": "orchestration_error", "error": "Run not found"})
45
+ except Exception as e:
46
+ await queue.put({"type": "orchestration_error", "error": str(e)})
47
+ finally:
48
+ _active_tasks.pop(run_id, None)
49
+ print(f"DEBUG SSE QUEUE: sentinel sent for '{run_id}', stream closing", flush=True)
50
+ await queue.put(SENTINEL)
51
+
52
+ task = asyncio.create_task(_pump())
53
+ _active_tasks[run_id] = task
54
+ return task, queue
55
+
56
+
57
+ async def stream_engine_events(agen, run_id: str) -> AsyncGenerator[dict, None]:
58
+ """Yield the engine's event dicts while a dedicated task advances it.
59
+
60
+ Deliberately does NOT cancel that task on close: the run is detached, so
61
+ abandoning this generator (client disconnect, GC finalization) must leave
62
+ it running -- the run still checkpoints and stays cancellable and
63
+ resumable.
64
+ """
65
+ _task, queue = spawn_engine_run(agen, run_id)
66
+ while True:
67
+ event = await queue.get()
68
+ if event is SENTINEL:
69
+ return
70
+ yield event
@@ -41,7 +41,11 @@ class SharedState:
41
41
  # Atomic write: write to temp, then rename
42
42
  fd, tmp_path = tempfile.mkstemp(dir=RUNS_DIR, suffix=".tmp")
43
43
  try:
44
- with os.fdopen(fd, "w") as f:
44
+ # encoding="utf-8" is required: model_dump_json emits raw non-ASCII
45
+ # characters, and without an explicit encoding os.fdopen uses the
46
+ # platform default (cp1252 on Windows), which raises
47
+ # UnicodeEncodeError when a run's state contains non-ASCII text.
48
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
45
49
  f.write(self.run.model_dump_json(indent=2))
46
50
  os.replace(tmp_path, target)
47
51
  except Exception:
@@ -55,7 +59,7 @@ class SharedState:
55
59
  path = RUNS_DIR / f"{run_id}.json"
56
60
  if not path.exists():
57
61
  raise FileNotFoundError(f"No checkpoint found for run {run_id}")
58
- data = json.loads(path.read_text())
62
+ data = json.loads(path.read_text(encoding="utf-8"))
59
63
  run = OrchestrationRun.model_validate(data)
60
64
  return cls(run)
61
65
 
@@ -66,7 +70,7 @@ class SharedState:
66
70
  runs = []
67
71
  for f in sorted(RUNS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:limit]:
68
72
  try:
69
- data = json.loads(f.read_text())
73
+ data = json.loads(f.read_text(encoding="utf-8"))
70
74
  runs.append({
71
75
  "run_id": data.get("run_id"),
72
76
  "orchestration_id": data.get("orchestration_id"),
@@ -493,7 +493,7 @@ class ToolStepExecutor:
493
493
  tmp_dir = tempfile.mkdtemp(prefix="pytool_")
494
494
  script_path = f"{tmp_dir}/script.py"
495
495
  try:
496
- with open(script_path, "w") as f:
496
+ with open(script_path, "w", encoding="utf-8") as f:
497
497
  f.write(injected_code)
498
498
 
499
499
  docker_cmd = [
@@ -1161,7 +1161,7 @@ class TransformStepExecutor:
1161
1161
  tmp_dir = tempfile.mkdtemp(prefix="transform_host_")
1162
1162
  script_path = str(Path(tmp_dir) / "transform.py")
1163
1163
  try:
1164
- with open(script_path, "w") as f:
1164
+ with open(script_path, "w", encoding="utf-8") as f:
1165
1165
  f.write(script_content)
1166
1166
 
1167
1167
  proc = await asyncio.create_subprocess_exec(
@@ -1210,7 +1210,7 @@ class TransformStepExecutor:
1210
1210
  tmp_dir = tempfile.mkdtemp(prefix="transform_")
1211
1211
  script_path = f"{tmp_dir}/transform.py"
1212
1212
  try:
1213
- with open(script_path, "w") as f:
1213
+ with open(script_path, "w", encoding="utf-8") as f:
1214
1214
  f.write(script_content)
1215
1215
 
1216
1216
  docker_cmd = [
@@ -29,7 +29,7 @@ def load_personal_details() -> dict:
29
29
  return defaults
30
30
 
31
31
  try:
32
- with open(PERSONAL_DETAILS_FILE, "r") as f:
32
+ with open(PERSONAL_DETAILS_FILE, "r", encoding="utf-8") as f:
33
33
  data = json.load(f)
34
34
 
35
35
  # Merge root
@@ -54,7 +54,7 @@ def save_personal_details(details: dict) -> dict:
54
54
  addr = d.get("address") if isinstance(d.get("address"), dict) else {}
55
55
  normalized["address"] = {**defaults["address"], **addr}
56
56
 
57
- with open(PERSONAL_DETAILS_FILE, "w") as f:
57
+ with open(PERSONAL_DETAILS_FILE, "w", encoding="utf-8") as f:
58
58
  json.dump(normalized, f, indent=4)
59
59
 
60
60
  return normalized
@@ -4,6 +4,7 @@ Yields structured event dicts that callers can handle differently
4
4
  (collect for sync response, or stream as SSE).
5
5
  """
6
6
  import asyncio
7
+ import contextlib
7
8
  import json
8
9
  import sys
9
10
  import time
@@ -47,50 +48,74 @@ MAX_TURNS = 30
47
48
  # Heartbeats are yielded as the raw SSE string, so endpoints can pass them
48
49
  # straight through; real events remain dicts (`isinstance(item, str)`
49
50
  # distinguishes them).
51
+ #
52
+ # The source is advanced by a single dedicated pump task rather than a fresh
53
+ # task per pull. anyio binds a cancel scope to the task that entered it, so a
54
+ # source holding a scope across a yield (the orchestration engine's per-step
55
+ # timeout) must be advanced — and closed — from one task. See issue #356.
50
56
  # ──────────────────────────────────────────────────────────────────────────────
51
57
  SSE_HEARTBEAT = ": ping\n\n"
52
58
 
53
59
 
60
+ class _StreamEnd:
61
+ """Sentinel handing the source's terminal state back to the consumer."""
62
+ __slots__ = ("error",)
63
+
64
+ def __init__(self, error):
65
+ self.error = error
66
+
67
+
54
68
  async def iter_with_heartbeat(agen, interval: float = 10.0):
55
69
  """Yield items from `agen`, injecting `SSE_HEARTBEAT` whenever no item
56
70
  arrives within `interval` seconds.
57
71
 
58
72
  The underlying generator is never cancelled on a heartbeat timeout — only
59
73
  when the consumer stops early (e.g. a client disconnect closes this
60
- generator), in which case the pending pull is cancelled and the source is
61
- closed in the finally block.
74
+ generator), in which case the pump is cancelled and closes the source.
62
75
  """
63
- ait = agen.__aiter__()
64
- pending = None
76
+ queue: asyncio.Queue = asyncio.Queue(maxsize=1) # maxsize=1 keeps backpressure
77
+
78
+ async def _pump():
79
+ # Advances `agen` in THIS single task, so a cancel scope the source
80
+ # holds across a yield is never exited from another task — and
81
+ # aclose() below runs in that scope's host task too.
82
+ try:
83
+ async for item in agen:
84
+ await queue.put(item)
85
+ await queue.put(_StreamEnd(None))
86
+ except asyncio.CancelledError:
87
+ raise
88
+ except BaseException as exc:
89
+ with contextlib.suppress(BaseException):
90
+ await queue.put(_StreamEnd(exc))
91
+ finally:
92
+ aclose = getattr(agen, "aclose", None)
93
+ if aclose is not None:
94
+ with contextlib.suppress(BaseException):
95
+ await aclose()
96
+
97
+ pump = asyncio.ensure_future(_pump())
65
98
  try:
66
99
  while True:
67
- if pending is None:
68
- pending = asyncio.ensure_future(ait.__anext__())
69
- done, _ = await asyncio.wait({pending}, timeout=interval)
70
- if not done:
100
+ try:
101
+ item = await asyncio.wait_for(queue.get(), timeout=interval)
102
+ except asyncio.TimeoutError:
103
+ if pump.done():
104
+ # Source ended without a sentinel — it was cancelled.
105
+ return
71
106
  # No event within the interval — keep the stream warm.
72
107
  yield SSE_HEARTBEAT
73
108
  continue
74
- task = pending
75
- pending = None
76
- try:
77
- item = task.result()
78
- except StopAsyncIteration:
109
+ if isinstance(item, _StreamEnd):
110
+ if item.error is not None:
111
+ raise item.error
79
112
  return
80
113
  yield item
81
114
  finally:
82
- if pending is not None:
83
- pending.cancel()
84
- try:
85
- await pending
86
- except BaseException:
87
- pass
88
- aclose = getattr(agen, "aclose", None)
89
- if aclose is not None:
90
- try:
91
- await aclose()
92
- except BaseException:
93
- pass
115
+ if not pump.done():
116
+ pump.cancel()
117
+ with contextlib.suppress(BaseException):
118
+ await pump
94
119
 
95
120
 
96
121
  async def drain_queue_with_heartbeat(queue, sentinel, interval: float = 10.0):
@@ -1208,7 +1233,7 @@ async def run_agent_step(
1208
1233
  tmp_dir = _tempfile.mkdtemp(prefix="pytool_")
1209
1234
  script_path = f"{tmp_dir}/script.py"
1210
1235
  try:
1211
- with open(script_path, "w") as _f:
1236
+ with open(script_path, "w", encoding="utf-8") as _f:
1212
1237
  _f.write(injected_code)
1213
1238
 
1214
1239
  docker_cmd = [
@@ -1591,6 +1616,7 @@ async def run_react_loop(request, server_module):
1591
1616
  from core.routes.orchestrations import load_orchestrations
1592
1617
  from core.models_orchestration import Orchestration
1593
1618
  from core.orchestration.engine import OrchestrationEngine
1619
+ from core.orchestration.runner import stream_engine_events
1594
1620
 
1595
1621
  orchs = load_orchestrations()
1596
1622
  orch_data = next((o for o in orchs if o["id"] == orch_id), None)
@@ -1598,7 +1624,14 @@ async def run_react_loop(request, server_module):
1598
1624
  orch = Orchestration.model_validate(orch_data)
1599
1625
  engine = OrchestrationEngine(orch, server_module)
1600
1626
  run_id = f"run_{orch_id}_{int(time.time() * 1000)}"
1601
- async for event in engine.run(user_message, run_id, session_id=session_id):
1627
+ # Route through the shared runner, exactly as the editor's
1628
+ # run endpoint does: the engine holds anyio cancel scopes
1629
+ # across yields, so it must be advanced from a single task
1630
+ # no matter how the SSE layer consumes us (issue #356).
1631
+ # It also registers the run for cancel/reconnect.
1632
+ async for event in stream_engine_events(
1633
+ engine.run(user_message, run_id, session_id=session_id), run_id
1634
+ ):
1602
1635
  yield event
1603
1636
  return
1604
1637
  else:
@@ -113,8 +113,9 @@ async def v1_chat(body: V1ChatRequest, key_record: dict = Depends(require_api_ke
113
113
  etype = event.get("type", "")
114
114
  if etype == "final":
115
115
  final_event = event
116
- elif etype == "error":
117
- error_msg = event.get("message", "Unknown error")
116
+ elif etype in ("error", "orchestration_error"):
117
+ # orchestration_error is what an orchestrator agent fails with.
118
+ error_msg = event.get("message") or event.get("error") or "Unknown error"
118
119
  except Exception as exc:
119
120
  log.exception("[v1/chat] Unhandled error for session=%s", chat_request.session_id)
120
121
  raise HTTPException(status_code=500, detail="An internal error occurred. Check server logs for details.")
@@ -204,8 +205,10 @@ async def v1_chat_stream(body: V1ChatRequest, key_record: dict = Depends(require
204
205
  })
205
206
  yield _format_sse_event({"type": "done"})
206
207
 
207
- elif etype == "error":
208
- log.error("[v1/chat/stream] Agent error session=%s: %s", chat_request.session_id, event.get("message"))
208
+ elif etype in ("error", "orchestration_error"):
209
+ # orchestration_error is what an orchestrator agent fails with.
210
+ log.error("[v1/chat/stream] Agent error session=%s: %s", chat_request.session_id,
211
+ event.get("message") or event.get("error"))
209
212
  yield _format_sse_event({"type": "error", "message": "The agent encountered an error processing your request."})
210
213
 
211
214
  await asyncio.sleep(0)
@@ -314,8 +317,11 @@ async def v1_orchestration_run_stream(
314
317
 
315
318
  async def event_stream():
316
319
  from core.react_engine import iter_with_heartbeat
320
+ from core.orchestration.runner import stream_engine_events
317
321
  try:
318
- async for event in iter_with_heartbeat(engine.run(body.message, run_id)):
322
+ async for event in iter_with_heartbeat(
323
+ stream_engine_events(engine.run(body.message, run_id), run_id)
324
+ ):
319
325
  # Heartbeat comments are yielded as raw SSE strings — pass through.
320
326
  if isinstance(event, str):
321
327
  yield event
@@ -436,8 +442,13 @@ async def v1_orchestration_resume_stream(
436
442
 
437
443
  async def event_stream():
438
444
  from core.react_engine import iter_with_heartbeat
445
+ from core.orchestration.runner import stream_engine_events
439
446
  try:
440
- async for event in iter_with_heartbeat(OrchestrationEngine.resume(run_id, human_response, _server)):
447
+ async for event in iter_with_heartbeat(
448
+ stream_engine_events(
449
+ OrchestrationEngine.resume(run_id, human_response, _server), run_id
450
+ )
451
+ ):
441
452
  # Heartbeat comments are yielded as raw SSE strings — pass through.
442
453
  if isinstance(event, str):
443
454
  yield event
@@ -39,6 +39,9 @@ async def chat(request: ChatRequest):
39
39
  final_event = event
40
40
  elif event["type"] == "error":
41
41
  return ChatResponse(response=event["message"], intent="chat", data=None, tool_name=None)
42
+ elif event["type"] == "orchestration_error":
43
+ # An orchestrator agent failed — same outcome, different event shape.
44
+ return ChatResponse(response=event["error"], intent="chat", data=None, tool_name=None)
42
45
 
43
46
  if not final_event:
44
47
  return ChatResponse(response="I completed the requested actions.", intent="chat", data=None, tool_name=None)
@@ -120,7 +120,7 @@ async def _get_github_token() -> "str | None":
120
120
  hosts_path = config_dir / "hosts.json"
121
121
  if hosts_path.exists():
122
122
  try:
123
- data = _json.loads(hosts_path.read_text())
123
+ data = _json.loads(hosts_path.read_text(encoding="utf-8"))
124
124
  t = (data.get("github.com", {}).get("oauth_token")
125
125
  or data.get("github.com", {}).get("token"))
126
126
  if t:
@@ -131,7 +131,7 @@ async def _get_github_token() -> "str | None":
131
131
  config_path = Path.home() / ".copilot" / "config.json"
132
132
  if config_path.exists():
133
133
  try:
134
- data = _json.loads(config_path.read_text())
134
+ data = _json.loads(config_path.read_text(encoding="utf-8"))
135
135
  if t := (data.get("oauth_token") or data.get("token")):
136
136
  return t
137
137
  except Exception:
@@ -839,7 +839,7 @@ async def clear_memory_items(req: MemoryClearRequest):
839
839
  repos = []
840
840
  if os.path.exists(repos_path):
841
841
  try:
842
- with open(repos_path) as f:
842
+ with open(repos_path, encoding="utf-8") as f:
843
843
  repos = json.load(f)
844
844
  except Exception:
845
845
  pass
@@ -849,7 +849,7 @@ async def clear_memory_items(req: MemoryClearRequest):
849
849
  drop_index(repo["id"])
850
850
  except Exception as e:
851
851
  print(f"Error dropping index for repo {repo.get('id')}: {e}")
852
- with open(repos_path, "w") as f:
852
+ with open(repos_path, "w", encoding="utf-8") as f:
853
853
  json.dump([], f)
854
854
  results["repos"] = f"Cleared {len(repos)} repo(s)"
855
855
 
@@ -859,11 +859,11 @@ async def clear_memory_items(req: MemoryClearRequest):
859
859
  configs = []
860
860
  if os.path.exists(configs_path):
861
861
  try:
862
- with open(configs_path) as f:
862
+ with open(configs_path, encoding="utf-8") as f:
863
863
  configs = json.load(f)
864
864
  except Exception:
865
865
  pass
866
- with open(configs_path, "w") as f:
866
+ with open(configs_path, "w", encoding="utf-8") as f:
867
867
  json.dump([], f)
868
868
  results["db_configs"] = f"Cleared {len(configs)} config(s)"
869
869
 
@@ -48,7 +48,7 @@ def _load_mcp_servers() -> List[dict]:
48
48
  if not os.path.exists(MCP_SERVERS_FILE):
49
49
  return []
50
50
  try:
51
- with open(MCP_SERVERS_FILE) as f:
51
+ with open(MCP_SERVERS_FILE, encoding="utf-8") as f:
52
52
  return json.load(f)
53
53
  except Exception:
54
54
  return []
@@ -253,7 +253,7 @@ async def import_bundle(req: ImportRequest):
253
253
 
254
254
  if mcp_changed:
255
255
  os.makedirs(os.path.dirname(MCP_SERVERS_FILE), exist_ok=True)
256
- with open(MCP_SERVERS_FILE, "w") as f:
256
+ with open(MCP_SERVERS_FILE, "w", encoding="utf-8") as f:
257
257
  json.dump(existing_mcp, f, indent=4)
258
258
 
259
259
  # Always sync manager with authoritative disk state and attempt connections