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.
- package/backend/core/config.py +2 -2
- package/backend/core/json_store.py +2 -2
- package/backend/core/mcp_client.py +4 -4
- package/backend/core/messaging/store.py +2 -2
- package/backend/core/orchestration/engine.py +12 -6
- package/backend/core/orchestration/runner.py +70 -0
- package/backend/core/orchestration/state.py +7 -3
- package/backend/core/orchestration/steps.py +3 -3
- package/backend/core/personal_details.py +2 -2
- package/backend/core/react_engine.py +60 -27
- package/backend/core/routes/api_v1.py +17 -6
- package/backend/core/routes/chat.py +3 -0
- package/backend/core/routes/data.py +6 -6
- package/backend/core/routes/import_export.py +2 -2
- package/backend/core/routes/orchestrations.py +15 -66
- package/backend/core/routes/settings.py +6 -6
- package/backend/core/routes/tools.py +1 -1
- package/backend/core/scale/context.py +1 -1
- package/backend/core/scale/sync.py +1 -1
- package/backend/core/server.py +3 -3
- package/backend/core/tools.py +1 -1
- package/backend/services/code_indexer.py +2 -2
- package/backend/services/google.py +7 -7
- package/backend/services/synthetic_data.py +1 -1
- package/backend/tools/bash.py +1 -1
- package/backend/tools/code_search.py +2 -2
- package/backend/tools/sandbox.py +1 -1
- package/backend/tools/sql_agent.py +1 -1
- package/frontend-build/.next/BUILD_ID +1 -1
- package/frontend-build/.next/build-manifest.json +3 -3
- package/frontend-build/.next/prerender-manifest.json +3 -3
- package/frontend-build/.next/server/app/_global-error.html +1 -1
- package/frontend-build/.next/server/app/_global-error.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.html +1 -1
- package/frontend-build/.next/server/app/_not-found.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.html +1 -1
- package/frontend-build/.next/server/app/index.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.html +1 -1
- package/frontend-build/.next/server/app/login.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
- package/frontend-build/.next/server/pages/404.html +1 -1
- package/frontend-build/.next/server/pages/500.html +1 -1
- package/frontend-build/.next/server/server-reference-manifest.js +1 -1
- package/frontend-build/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- /package/frontend-build/.next/static/{a-CFL11mzxHaVjsbJG19x → ZnXvTR9Wo4Qc1Vgx3ou4L}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{a-CFL11mzxHaVjsbJG19x → ZnXvTR9Wo4Qc1Vgx3ou4L}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{a-CFL11mzxHaVjsbJG19x → ZnXvTR9Wo4Qc1Vgx3ou4L}/_ssgManifest.js +0 -0
package/backend/core/config.py
CHANGED
|
@@ -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:
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
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
|
-
|
|
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
|
|
61
|
-
closed in the finally block.
|
|
74
|
+
generator), in which case the pump is cancelled and closes the source.
|
|
62
75
|
"""
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
208
|
-
|
|
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(
|
|
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(
|
|
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
|