forgexa-cli 1.54.1__tar.gz → 1.54.2__tar.gz
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.
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/PKG-INFO +1 -1
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/daemon.py +19 -4
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/zcode_protocol.py +256 -71
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/pyproject.toml +1 -1
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/README.md +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/setup.cfg +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.54.
|
|
2
|
+
__version__ = "1.54.2"
|
|
@@ -84,12 +84,24 @@ except ImportError:
|
|
|
84
84
|
import runtime_evidence # type: ignore[no-redef] # flat script layout (desktop resources)
|
|
85
85
|
|
|
86
86
|
try:
|
|
87
|
-
from app.zcode_protocol import
|
|
87
|
+
from app.zcode_protocol import (
|
|
88
|
+
ZCodeProtocolError,
|
|
89
|
+
ZCodeProtocolSession,
|
|
90
|
+
parse_model_selection,
|
|
91
|
+
)
|
|
88
92
|
except ImportError:
|
|
89
93
|
try:
|
|
90
|
-
from .zcode_protocol import
|
|
94
|
+
from .zcode_protocol import ( # type: ignore[no-redef]
|
|
95
|
+
ZCodeProtocolError,
|
|
96
|
+
ZCodeProtocolSession,
|
|
97
|
+
parse_model_selection,
|
|
98
|
+
)
|
|
91
99
|
except ImportError:
|
|
92
|
-
from zcode_protocol import
|
|
100
|
+
from zcode_protocol import ( # type: ignore[no-redef]
|
|
101
|
+
ZCodeProtocolError,
|
|
102
|
+
ZCodeProtocolSession,
|
|
103
|
+
parse_model_selection,
|
|
104
|
+
)
|
|
93
105
|
|
|
94
106
|
# Backwards-compatible aliases: keep every moved symbol importable from
|
|
95
107
|
# app.daemon (tests patch e.g. app.daemon._kill_proc) while the single source
|
|
@@ -1138,7 +1150,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1138
1150
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1139
1151
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1140
1152
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1141
|
-
DAEMON_VERSION = "1.54.
|
|
1153
|
+
DAEMON_VERSION = "1.54.2"
|
|
1142
1154
|
|
|
1143
1155
|
|
|
1144
1156
|
def _detect_client_type() -> str:
|
|
@@ -7033,8 +7045,10 @@ class ProcessManager:
|
|
|
7033
7045
|
if client.process is not None:
|
|
7034
7046
|
self.active_processes[task_id] = client.process
|
|
7035
7047
|
self.active_zcode_sessions[task_id] = client
|
|
7048
|
+
model_selection = parse_model_selection(os.environ.get("FACTORY_ZCODE_MODEL"))
|
|
7036
7049
|
await client.create_session(
|
|
7037
7050
|
cwd,
|
|
7051
|
+
model=model_selection,
|
|
7038
7052
|
tool_allowlist=list(agent_core.ZCODE_ALLOWED_TOOLS),
|
|
7039
7053
|
tool_denylist=list(agent_core.ZCODE_DENIED_TOOLS),
|
|
7040
7054
|
)
|
|
@@ -7042,6 +7056,7 @@ class ProcessManager:
|
|
|
7042
7056
|
prompt,
|
|
7043
7057
|
input_id=task_id,
|
|
7044
7058
|
timeout=float(timeout),
|
|
7059
|
+
model=model_selection,
|
|
7045
7060
|
)
|
|
7046
7061
|
metrics = {
|
|
7047
7062
|
"token_input": protocol_result.usage.get("input_tokens", 0),
|
|
@@ -58,6 +58,15 @@ class ZCodeTurnResult:
|
|
|
58
58
|
usage: dict[str, int]
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class _TurnTerminal:
|
|
63
|
+
"""Raw terminal signal from ``computer-use/operation-event`` before hydration."""
|
|
64
|
+
|
|
65
|
+
status: str
|
|
66
|
+
turn_id: str | None
|
|
67
|
+
error: str | None
|
|
68
|
+
|
|
69
|
+
|
|
61
70
|
# Union[], not PEP 604 `|`: these aliases are evaluated at runtime and the
|
|
62
71
|
# host must import on macOS system Python 3.9 (see agent_host.py's version gate).
|
|
63
72
|
PermissionHandler = Callable[[dict[str, Any]], Union[dict[str, Any], Awaitable[dict[str, Any]]]] # noqa: UP007 - runtime alias must stay 3.9-compatible
|
|
@@ -84,6 +93,49 @@ def _default_permission_response() -> dict[str, Any]:
|
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
|
|
96
|
+
# Answer to the server's session/requestRuntimePreferences reverse request,
|
|
97
|
+
# matching upstream zcodeSessionRuntimePreferencesResultSchema (strict). The
|
|
98
|
+
# server blocks session/create until this arrives, so reply deterministically:
|
|
99
|
+
# no ambient search, no cross-session memory, auto-resolve questions so a
|
|
100
|
+
# headless turn can never stall on one.
|
|
101
|
+
def _runtime_preferences_result() -> dict[str, Any]:
|
|
102
|
+
return {
|
|
103
|
+
"nativeSearchEnhancementsEnabled": False,
|
|
104
|
+
"memoryEnabled": False,
|
|
105
|
+
"askUserQuestionAutoResolutionEnabled": True,
|
|
106
|
+
"modelContextBudgetStrategy": "preflight-v1",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_model_selection(raw: object) -> dict[str, Any] | None:
|
|
111
|
+
"""Parse a ZCode model picker value into a wire ModelSelection.
|
|
112
|
+
|
|
113
|
+
ZCode refuses to silently default a model for headless sessions ("Select a
|
|
114
|
+
model before continuing"), so a configured selection must travel on the
|
|
115
|
+
wire the way the desktop sends it with each submission. The value uses
|
|
116
|
+
ZCode's picker format: ``provider/model`` with an optional
|
|
117
|
+
``$reasoningLevel`` suffix. A blank value returns None, letting ZCode fall
|
|
118
|
+
back to its own persisted default for the isolated profile.
|
|
119
|
+
"""
|
|
120
|
+
text = str(raw or "").strip()
|
|
121
|
+
if not text:
|
|
122
|
+
return None
|
|
123
|
+
provider, separator, model = text.partition("/")
|
|
124
|
+
model, _, reasoning = model.partition("$")
|
|
125
|
+
if not separator or not provider.strip() or not model.strip():
|
|
126
|
+
raise ZCodeProtocolError(
|
|
127
|
+
'FACTORY_ZCODE_MODEL must be formatted as "provider/model"'
|
|
128
|
+
' (optionally "provider/model$reasoningLevel")'
|
|
129
|
+
)
|
|
130
|
+
selection: dict[str, Any] = {
|
|
131
|
+
"providerId": provider.strip(),
|
|
132
|
+
"modelId": model.strip(),
|
|
133
|
+
}
|
|
134
|
+
if reasoning.strip():
|
|
135
|
+
selection["options"] = {"reasoningLevel": reasoning.strip()}
|
|
136
|
+
return selection
|
|
137
|
+
|
|
138
|
+
|
|
87
139
|
class ZCodeProtocolSession:
|
|
88
140
|
"""One ZCode Protocol process and one active native session at a time."""
|
|
89
141
|
|
|
@@ -108,11 +160,12 @@ class ZCodeProtocolSession:
|
|
|
108
160
|
self._stderr_task: asyncio.Task[None] | None = None
|
|
109
161
|
self._pending: dict[str, asyncio.Future[Any]] = {}
|
|
110
162
|
self._write_lock = asyncio.Lock()
|
|
111
|
-
self._active_turn: asyncio.Future[
|
|
163
|
+
self._active_turn: asyncio.Future[_TurnTerminal] | None = None
|
|
112
164
|
self._active_turn_id: str | None = None
|
|
165
|
+
self._stop_requested = False
|
|
166
|
+
self._last_turn_error: str | None = None
|
|
113
167
|
self._seen_event_ids: deque[str] = deque(maxlen=_MAX_SEEN_EVENT_IDS)
|
|
114
168
|
self._seen_event_id_set: set[str] = set()
|
|
115
|
-
self._last_seq = -1
|
|
116
169
|
self._stderr_tail: deque[str] = deque(maxlen=20)
|
|
117
170
|
self._closed = False
|
|
118
171
|
|
|
@@ -147,11 +200,12 @@ class ZCodeProtocolSession:
|
|
|
147
200
|
workspace: str | Path,
|
|
148
201
|
*,
|
|
149
202
|
mode: str = "build",
|
|
203
|
+
model: dict[str, Any] | None = None,
|
|
150
204
|
tool_allowlist: list[str] | None = None,
|
|
151
205
|
tool_denylist: list[str] | None = None,
|
|
152
206
|
) -> ZCodeSession:
|
|
153
207
|
workspace_path = str(Path(workspace).resolve())
|
|
154
|
-
|
|
208
|
+
params: dict[str, Any] = {
|
|
155
209
|
"workspace": {
|
|
156
210
|
"workspacePath": workspace_path,
|
|
157
211
|
"workspaceKey": workspace_path,
|
|
@@ -161,7 +215,10 @@ class ZCodeProtocolSession:
|
|
|
161
215
|
"mcpServers": [],
|
|
162
216
|
"toolAllowlist": tool_allowlist or [],
|
|
163
217
|
"toolDenylist": tool_denylist or [],
|
|
164
|
-
}
|
|
218
|
+
}
|
|
219
|
+
if model:
|
|
220
|
+
params["model"] = model
|
|
221
|
+
result = await self._request("session/create", params)
|
|
165
222
|
return self._accept_session_snapshot(result)
|
|
166
223
|
|
|
167
224
|
async def resume_session(self, session_id: str) -> ZCodeSession:
|
|
@@ -174,20 +231,30 @@ class ZCodeProtocolSession:
|
|
|
174
231
|
*,
|
|
175
232
|
input_id: str,
|
|
176
233
|
timeout: float | None,
|
|
234
|
+
model: dict[str, Any] | None = None,
|
|
177
235
|
) -> ZCodeTurnResult:
|
|
178
236
|
if not self.session_id:
|
|
179
237
|
raise ZCodeProtocolError("ZCode session has not been created or resumed")
|
|
180
238
|
if self._active_turn is not None and not self._active_turn.done():
|
|
181
239
|
raise ZCodeProtocolError("ZCode session already has an active turn")
|
|
182
|
-
active_turn =
|
|
240
|
+
active_turn: asyncio.Future[_TurnTerminal] = (
|
|
241
|
+
asyncio.get_running_loop().create_future()
|
|
242
|
+
)
|
|
183
243
|
self._active_turn = active_turn
|
|
184
244
|
self._active_turn_id = None
|
|
245
|
+
self._stop_requested = False
|
|
246
|
+
self._last_turn_error = None
|
|
185
247
|
try:
|
|
186
|
-
|
|
248
|
+
send_params: dict[str, Any] = {
|
|
187
249
|
"sessionId": self.session_id,
|
|
188
250
|
"content": content,
|
|
189
251
|
"inputId": input_id,
|
|
190
|
-
}
|
|
252
|
+
}
|
|
253
|
+
# session/send carries the selection for every turn, including
|
|
254
|
+
# resumed sessions where session/create's model field is absent.
|
|
255
|
+
if model:
|
|
256
|
+
send_params["modelSelection"] = model
|
|
257
|
+
acknowledgement = await self._request("session/send", send_params)
|
|
191
258
|
if isinstance(acknowledgement, dict) and acknowledgement.get("accepted") is False:
|
|
192
259
|
raise ZCodeProtocolError("ZCode rejected the turn")
|
|
193
260
|
if isinstance(acknowledgement, dict) and isinstance(
|
|
@@ -195,16 +262,71 @@ class ZCodeProtocolSession:
|
|
|
195
262
|
):
|
|
196
263
|
self._active_turn_id = acknowledgement["turnId"]
|
|
197
264
|
if timeout is None:
|
|
198
|
-
|
|
199
|
-
|
|
265
|
+
terminal = await active_turn
|
|
266
|
+
else:
|
|
267
|
+
terminal = await asyncio.wait_for(active_turn, timeout=timeout)
|
|
268
|
+
return await self._hydrate_turn_result(terminal)
|
|
200
269
|
finally:
|
|
201
270
|
if self._active_turn is active_turn:
|
|
202
271
|
self._active_turn = None
|
|
203
272
|
self._active_turn_id = None
|
|
204
273
|
|
|
274
|
+
async def _hydrate_turn_result(self, terminal: _TurnTerminal) -> ZCodeTurnResult:
|
|
275
|
+
# The turn-lifecycle channel carries no assistant text; the resolved
|
|
276
|
+
# answer lives in the persisted transcript, so read it back once the
|
|
277
|
+
# turn reaches a terminal state.
|
|
278
|
+
text = ""
|
|
279
|
+
usage: dict[str, int] = {}
|
|
280
|
+
if terminal.status == "success":
|
|
281
|
+
text, usage = await self._fetch_turn_transcript(terminal.turn_id)
|
|
282
|
+
return ZCodeTurnResult(
|
|
283
|
+
status=terminal.status,
|
|
284
|
+
session_id=self.session_id or "",
|
|
285
|
+
turn_id=terminal.turn_id,
|
|
286
|
+
text=text,
|
|
287
|
+
error=terminal.error,
|
|
288
|
+
usage=usage,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def _fetch_turn_transcript(
|
|
292
|
+
self, turn_id: str | None,
|
|
293
|
+
) -> tuple[str, dict[str, int]]:
|
|
294
|
+
result = await self._request("session/messages", {"sessionId": self.session_id})
|
|
295
|
+
messages = result.get("messages") if isinstance(result, dict) else None
|
|
296
|
+
if not isinstance(messages, list):
|
|
297
|
+
return "", {}
|
|
298
|
+
texts: list[str] = []
|
|
299
|
+
totals = {"input_tokens": 0, "output_tokens": 0}
|
|
300
|
+
for message in messages:
|
|
301
|
+
if not isinstance(message, dict):
|
|
302
|
+
continue
|
|
303
|
+
info = message.get("info") if isinstance(message.get("info"), dict) else {}
|
|
304
|
+
if info.get("role") != "assistant":
|
|
305
|
+
continue
|
|
306
|
+
anchor = info.get("anchor") if isinstance(info.get("anchor"), dict) else {}
|
|
307
|
+
message_turn = anchor.get("turnId")
|
|
308
|
+
if turn_id and isinstance(message_turn, str) and message_turn != turn_id:
|
|
309
|
+
continue
|
|
310
|
+
parts = message.get("parts") if isinstance(message.get("parts"), list) else []
|
|
311
|
+
for part in parts:
|
|
312
|
+
if (
|
|
313
|
+
isinstance(part, dict) and part.get("type") == "text"
|
|
314
|
+
and isinstance(part.get("text"), str)
|
|
315
|
+
):
|
|
316
|
+
texts.append(part["text"])
|
|
317
|
+
tokens = info.get("tokens") if isinstance(info.get("tokens"), dict) else {}
|
|
318
|
+
if isinstance(tokens.get("input"), (int, float)):
|
|
319
|
+
totals["input_tokens"] += int(tokens["input"])
|
|
320
|
+
if isinstance(tokens.get("output"), (int, float)):
|
|
321
|
+
totals["output_tokens"] += int(tokens["output"])
|
|
322
|
+
text = "\n".join(stripped for stripped in (t.strip() for t in texts) if stripped)
|
|
323
|
+
usage = {key: value for key, value in totals.items() if value}
|
|
324
|
+
return text, usage
|
|
325
|
+
|
|
205
326
|
async def stop_turn(self, *, timeout: float = 10.0) -> None:
|
|
206
327
|
if not self.session_id:
|
|
207
328
|
return
|
|
329
|
+
self._stop_requested = True
|
|
208
330
|
await self._request("session/stop", {"sessionId": self.session_id}, timeout=timeout)
|
|
209
331
|
|
|
210
332
|
async def close(self) -> None:
|
|
@@ -295,16 +417,135 @@ class ZCodeProtocolSession:
|
|
|
295
417
|
else:
|
|
296
418
|
future.set_result(frame.get("result"))
|
|
297
419
|
return
|
|
298
|
-
if method == "session/event":
|
|
299
|
-
params = frame.get("params")
|
|
300
|
-
if isinstance(params, dict):
|
|
301
|
-
await self._handle_session_event(params)
|
|
302
|
-
return
|
|
303
420
|
if frame_id is not None and isinstance(method, str):
|
|
304
421
|
await self._handle_server_request(str(frame_id), method, frame.get("params"))
|
|
422
|
+
return
|
|
423
|
+
params = frame.get("params")
|
|
424
|
+
if not isinstance(params, dict):
|
|
425
|
+
return
|
|
426
|
+
if method == "computer-use/operation-event":
|
|
427
|
+
await self._handle_operation_event(params)
|
|
428
|
+
return
|
|
429
|
+
if method == "v4/telemetry/event":
|
|
430
|
+
await self._handle_telemetry_event(params)
|
|
431
|
+
|
|
432
|
+
async def _handle_operation_event(self, params: dict[str, Any]) -> None:
|
|
433
|
+
# The 0.16.x app-server publishes turn lifecycle on this channel rather
|
|
434
|
+
# than the legacy session/event stream (proven against the live server):
|
|
435
|
+
# turn-started carries the native turnId that session/send only
|
|
436
|
+
# acknowledges, and turn-completed/turn-failed is the reliable terminal
|
|
437
|
+
# signal on both the success and failure paths.
|
|
438
|
+
if self.session_id and params.get("sessionId") != self.session_id:
|
|
439
|
+
return
|
|
440
|
+
kind = str(params.get("kind") or "")
|
|
441
|
+
turn_id = str(params.get("turnId") or "") or None
|
|
442
|
+
if kind == "turn-started":
|
|
443
|
+
if turn_id and self._active_turn is not None and not self._active_turn.done():
|
|
444
|
+
self._active_turn_id = turn_id
|
|
445
|
+
return
|
|
446
|
+
if kind not in {"turn-completed", "turn-failed"}:
|
|
447
|
+
return
|
|
448
|
+
active_turn = self._active_turn
|
|
449
|
+
if active_turn is None or active_turn.done():
|
|
450
|
+
return
|
|
451
|
+
if self._active_turn_id and turn_id and turn_id != self._active_turn_id:
|
|
452
|
+
return
|
|
453
|
+
failed = kind == "turn-failed"
|
|
454
|
+
resolved_turn_id = turn_id or self._active_turn_id
|
|
455
|
+
if not failed:
|
|
456
|
+
self._resolve_terminal(_TurnTerminal(
|
|
457
|
+
status="success", turn_id=resolved_turn_id, error=None,
|
|
458
|
+
))
|
|
459
|
+
return
|
|
460
|
+
if self._stop_requested:
|
|
461
|
+
self._resolve_terminal(_TurnTerminal(
|
|
462
|
+
status="cancelled", turn_id=resolved_turn_id, error="ZCode turn stopped",
|
|
463
|
+
))
|
|
464
|
+
return
|
|
465
|
+
if self._last_turn_error:
|
|
466
|
+
self._resolve_terminal(_TurnTerminal(
|
|
467
|
+
status="failed", turn_id=resolved_turn_id, error=self._last_turn_error,
|
|
468
|
+
))
|
|
469
|
+
return
|
|
470
|
+
# Observed live: the v4 telemetry turn.terminal carrying the provider
|
|
471
|
+
# error detail lands a few milliseconds AFTER turn-failed; wait briefly
|
|
472
|
+
# so users see the real reason (e.g. "not logged in") instead of a
|
|
473
|
+
# generic failure.
|
|
474
|
+
asyncio.get_running_loop().call_later(
|
|
475
|
+
0.25, self._resolve_deferred_failure, resolved_turn_id,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _resolve_deferred_failure(self, turn_id: str | None) -> None:
|
|
479
|
+
active_turn = self._active_turn
|
|
480
|
+
if active_turn is None or active_turn.done():
|
|
481
|
+
return
|
|
482
|
+
if self._stop_requested:
|
|
483
|
+
status, error = "cancelled", "ZCode turn stopped"
|
|
484
|
+
else:
|
|
485
|
+
status = "failed"
|
|
486
|
+
error = self._last_turn_error or "ZCode turn failed"
|
|
487
|
+
self._resolve_terminal(_TurnTerminal(status=status, turn_id=turn_id, error=error))
|
|
488
|
+
|
|
489
|
+
def _resolve_terminal(self, terminal: _TurnTerminal) -> None:
|
|
490
|
+
active_turn = self._active_turn
|
|
491
|
+
if active_turn is None or active_turn.done():
|
|
492
|
+
return
|
|
493
|
+
if terminal.turn_id:
|
|
494
|
+
self._active_turn_id = terminal.turn_id
|
|
495
|
+
active_turn.set_result(terminal)
|
|
496
|
+
|
|
497
|
+
async def _handle_telemetry_event(self, params: dict[str, Any]) -> None:
|
|
498
|
+
if self.session_id and params.get("sessionId") != self.session_id:
|
|
499
|
+
return
|
|
500
|
+
event_id = params.get("eventId")
|
|
501
|
+
if isinstance(event_id, str) and event_id:
|
|
502
|
+
if event_id in self._seen_event_id_set:
|
|
503
|
+
return
|
|
504
|
+
if len(self._seen_event_ids) == self._seen_event_ids.maxlen:
|
|
505
|
+
expired = self._seen_event_ids.popleft()
|
|
506
|
+
self._seen_event_id_set.discard(expired)
|
|
507
|
+
self._seen_event_ids.append(event_id)
|
|
508
|
+
self._seen_event_id_set.add(event_id)
|
|
509
|
+
kind = str(params.get("kind") or "")
|
|
510
|
+
turn_id = str(params.get("turnId") or "") or None
|
|
511
|
+
if kind == "turn.terminal":
|
|
512
|
+
# Capture the provider error detail for a subsequent op-event
|
|
513
|
+
# turn-failed; this fact carries errorMessage/errorCode that the
|
|
514
|
+
# lifecycle channel omits, so record it without resolving.
|
|
515
|
+
error_detail = params.get("errorMessage") or params.get("errorCode")
|
|
516
|
+
detail = _safe_detail(error_detail)
|
|
517
|
+
if detail and str(params.get("status") or "") == "failed":
|
|
518
|
+
self._last_turn_error = detail
|
|
519
|
+
return
|
|
520
|
+
if kind != "tool.lifecycle" or self.on_event is None:
|
|
521
|
+
return
|
|
522
|
+
payload: dict[str, Any] = {
|
|
523
|
+
"toolCallId": params.get("toolCallId"),
|
|
524
|
+
"toolName": params.get("toolName"),
|
|
525
|
+
"status": params.get("phase"),
|
|
526
|
+
}
|
|
527
|
+
if params.get("errorMessage"):
|
|
528
|
+
payload["output"] = _safe_detail(params.get("errorMessage"), 200)
|
|
529
|
+
emitted = self.on_event({
|
|
530
|
+
"type": "tool.updated",
|
|
531
|
+
"sessionId": self.session_id,
|
|
532
|
+
"turnId": turn_id,
|
|
533
|
+
"payload": payload,
|
|
534
|
+
})
|
|
535
|
+
if inspect.isawaitable(emitted):
|
|
536
|
+
await emitted
|
|
305
537
|
|
|
306
538
|
async def _handle_server_request(
|
|
307
539
|
self, request_id: str, method: str, params: object) -> None:
|
|
540
|
+
if method == "session/requestRuntimePreferences":
|
|
541
|
+
# Arrives during session/create; the server blocks the create
|
|
542
|
+
# response for ~15s until this is answered, so reply before any
|
|
543
|
+
# session-id checks (the session does not exist yet).
|
|
544
|
+
await self._write({
|
|
545
|
+
"id": request_id,
|
|
546
|
+
"result": _runtime_preferences_result(),
|
|
547
|
+
})
|
|
548
|
+
return
|
|
308
549
|
if method != "interaction/requestPermission" or not isinstance(params, dict):
|
|
309
550
|
await self._write({
|
|
310
551
|
"id": request_id,
|
|
@@ -336,61 +577,6 @@ class ZCodeProtocolSession:
|
|
|
336
577
|
logger.warning("ZCode permission handler failed; denying request")
|
|
337
578
|
await self._write({"id": request_id, "result": decision})
|
|
338
579
|
|
|
339
|
-
async def _handle_session_event(self, event: dict[str, Any]) -> None:
|
|
340
|
-
event_session_id = event.get("sessionId")
|
|
341
|
-
if self.session_id and event_session_id != self.session_id:
|
|
342
|
-
return
|
|
343
|
-
event_id = event.get("eventId")
|
|
344
|
-
if isinstance(event_id, str) and event_id:
|
|
345
|
-
if event_id in self._seen_event_id_set:
|
|
346
|
-
return
|
|
347
|
-
if len(self._seen_event_ids) == self._seen_event_ids.maxlen:
|
|
348
|
-
expired = self._seen_event_ids.popleft()
|
|
349
|
-
self._seen_event_id_set.discard(expired)
|
|
350
|
-
self._seen_event_ids.append(event_id)
|
|
351
|
-
self._seen_event_id_set.add(event_id)
|
|
352
|
-
sequence = event.get("seq")
|
|
353
|
-
if isinstance(sequence, int):
|
|
354
|
-
if sequence <= self._last_seq:
|
|
355
|
-
return
|
|
356
|
-
self._last_seq = sequence
|
|
357
|
-
if self.on_event is not None:
|
|
358
|
-
emitted = self.on_event(event)
|
|
359
|
-
if inspect.isawaitable(emitted):
|
|
360
|
-
await emitted
|
|
361
|
-
event_type = event.get("type")
|
|
362
|
-
event_turn_id = str(event.get("turnId")) if event.get("turnId") else None
|
|
363
|
-
if event_type == "turn.started" and self._active_turn is not None:
|
|
364
|
-
self._active_turn_id = event_turn_id
|
|
365
|
-
return
|
|
366
|
-
if event_type not in {"turn.completed", "turn.failed"}:
|
|
367
|
-
return
|
|
368
|
-
active_turn = self._active_turn
|
|
369
|
-
if active_turn is None or active_turn.done():
|
|
370
|
-
return
|
|
371
|
-
if not self._active_turn_id or event_turn_id != self._active_turn_id:
|
|
372
|
-
return
|
|
373
|
-
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
|
374
|
-
result_type = str(payload.get("resultType") or "")
|
|
375
|
-
failed = event_type == "turn.failed"
|
|
376
|
-
status = "failed" if failed else ("cancelled" if result_type == "cancelled" else "success")
|
|
377
|
-
error = payload.get("error") or payload.get("message") if failed else None
|
|
378
|
-
text = payload.get("response") or payload.get("result") or payload.get("text") or ""
|
|
379
|
-
usage_raw = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
|
380
|
-
usage = {
|
|
381
|
-
key: int(usage_raw.get(key, 0) or 0)
|
|
382
|
-
for key in ("input_tokens", "output_tokens")
|
|
383
|
-
if isinstance(usage_raw.get(key), int)
|
|
384
|
-
}
|
|
385
|
-
active_turn.set_result(ZCodeTurnResult(
|
|
386
|
-
status=status,
|
|
387
|
-
session_id=str(event_session_id or self.session_id or ""),
|
|
388
|
-
turn_id=event_turn_id,
|
|
389
|
-
text=str(text),
|
|
390
|
-
error=_safe_detail(error) or None,
|
|
391
|
-
usage=usage,
|
|
392
|
-
))
|
|
393
|
-
|
|
394
580
|
def _accept_session_snapshot(self, snapshot: object) -> ZCodeSession:
|
|
395
581
|
if not isinstance(snapshot, dict):
|
|
396
582
|
raise ZCodeProtocolError("ZCode returned an invalid session snapshot")
|
|
@@ -401,12 +587,11 @@ class ZCodeProtocolSession:
|
|
|
401
587
|
if version != _SUPPORTED_PROTOCOL_VERSION:
|
|
402
588
|
raise ZCodeProtocolError("ZCode Protocol version is incompatible")
|
|
403
589
|
session = snapshot.get("session") if isinstance(snapshot.get("session"), dict) else snapshot
|
|
404
|
-
session_id = session.get("id") or snapshot.get("sessionId")
|
|
590
|
+
session_id = session.get("sessionId") or session.get("id") or snapshot.get("sessionId")
|
|
405
591
|
if not isinstance(session_id, str) or not session_id:
|
|
406
592
|
raise ZCodeProtocolError("ZCode session snapshot did not include a session id")
|
|
407
593
|
self.session_id = session_id
|
|
408
594
|
self.protocol_version = version
|
|
409
|
-
self._last_seq = -1
|
|
410
595
|
self._seen_event_ids.clear()
|
|
411
596
|
self._seen_event_id_set.clear()
|
|
412
597
|
return ZCodeSession(session_id=session_id, protocol_version=version)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|