evolv-agent-sdk 0.2.0__py3-none-any.whl

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,712 @@
1
+ """Subprocess transport implementation using evolv Code CLI."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import platform
7
+ import re
8
+ import shutil
9
+ import sys
10
+ from collections.abc import AsyncIterable, AsyncIterator
11
+ from contextlib import suppress
12
+ from pathlib import Path
13
+ from subprocess import PIPE
14
+ from typing import Any
15
+
16
+ import anyio
17
+ import anyio.abc
18
+ from anyio.abc import Process
19
+ from anyio.streams.text import TextReceiveStream, TextSendStream
20
+
21
+ from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError
22
+ from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError
23
+ from ..._version import __version__
24
+ from ...types import EvolvAgentOptions
25
+ from . import Transport
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
30
+ MINIMUM_EVOLV_VERSION = "1.0.0"
31
+
32
+ # Platform-specific command line length limits
33
+ _CMD_LENGTH_LIMIT = 8000 if platform.system() == "Windows" else 100000
34
+
35
+
36
+ class SubprocessCLITransport(Transport):
37
+ """Subprocess transport using evolv Code CLI.
38
+
39
+ When evolv Code CLI is detected, this transport supports full bidirectional
40
+ streaming mode with:
41
+ - can_use_tool callback for tool permissions
42
+ - SDK hooks (PreToolUse, PostToolUse, etc.)
43
+ - SDK MCP servers (in-process)
44
+ - Runtime control: interrupt(), set_permission_mode(), set_model()
45
+
46
+ For fallback Cortex CLI, it uses print mode (--print "prompt") for one-shot queries.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ prompt: str | AsyncIterable[dict[str, Any]],
52
+ options: EvolvAgentOptions,
53
+ bidirectional: bool = False,
54
+ ):
55
+ self._prompt = prompt
56
+ self._is_streaming = not isinstance(prompt, str)
57
+ self._options = options
58
+ self._cli_path = (
59
+ str(options.cli_path) if options.cli_path is not None else self._find_cli()
60
+ )
61
+ self._cwd = str(options.cwd) if options.cwd else None
62
+ self._process: Process | None = None
63
+ self._stdout_stream: TextReceiveStream | None = None
64
+ self._stdin_stream: TextSendStream | None = None
65
+ self._stderr_stream: TextReceiveStream | None = None
66
+ self._stderr_task_group: anyio.abc.TaskGroup | None = None
67
+ self._ready = False
68
+ self._exit_error: Exception | None = None
69
+ self._max_buffer_size = (
70
+ options.max_buffer_size
71
+ if options.max_buffer_size is not None
72
+ else _DEFAULT_MAX_BUFFER_SIZE
73
+ )
74
+ self._temp_files: list[str] = []
75
+ self._write_lock: anyio.Lock = anyio.Lock()
76
+
77
+ # Check if we're using evolv CLI (supports bidirectional mode)
78
+ self._is_evolv_cli = self._detect_evolv_cli()
79
+ # Enable bidirectional mode if explicitly requested OR if streaming with evolv CLI
80
+ self._bidirectional_mode = bidirectional or (self._is_evolv_cli and self._is_streaming)
81
+
82
+ # For non-bidirectional streaming mode, we need to track prompts
83
+ self._pending_prompts: list[str] = []
84
+
85
+ def _detect_evolv_cli(self) -> bool:
86
+ """Detect if the CLI is evolv (supports bidirectional mode)."""
87
+ cli_name = Path(self._cli_path).name.lower()
88
+ return cli_name.startswith("evolv")
89
+
90
+ def _find_cli(self) -> str:
91
+ """Find evolv Code CLI binary.
92
+
93
+ Search order:
94
+ 1. EVOLV_CODE_CLI_PATH environment variable
95
+ 2. CORTEX_CODE_CLI_PATH environment variable (fallback)
96
+ 3. Bundled CLI (if present)
97
+ 4. System PATH - evolv first, then cortex
98
+ 5. Common installation locations
99
+ """
100
+ # Check evolv environment variable first
101
+ env_path = os.environ.get("EVOLV_CODE_CLI_PATH")
102
+ if env_path and Path(env_path).exists():
103
+ return env_path
104
+
105
+ # Check cortex environment variable (fallback)
106
+ env_path = os.environ.get("CORTEX_CODE_CLI_PATH")
107
+ if env_path and Path(env_path).exists():
108
+ return env_path
109
+
110
+ # Check for bundled CLI
111
+ bundled_cli = self._find_bundled_cli()
112
+ if bundled_cli:
113
+ return bundled_cli
114
+
115
+ # Check system PATH - evolv first
116
+ if cli := shutil.which("evolv"):
117
+ return cli
118
+ if cli := shutil.which("cortex"):
119
+ return cli
120
+
121
+ # Check common locations - evolv first
122
+ locations = [
123
+ # evolv locations
124
+ Path.home() / ".local/bin/evolv",
125
+ Path("/usr/local/bin/evolv"),
126
+ Path.home() / ".evolv/local/evolv",
127
+ # cortex fallback locations
128
+ Path.home() / ".local/bin/cortex",
129
+ Path("/usr/local/bin/cortex"),
130
+ Path.home() / ".cortex/local/cortex",
131
+ Path.home() / ".snowflake/cortex/cortex",
132
+ Path.home() / "node_modules/.bin/cortex",
133
+ Path.home() / ".npm-global/bin/cortex",
134
+ Path.home() / ".yarn/bin/cortex",
135
+ ]
136
+
137
+ for path in locations:
138
+ if path.exists() and path.is_file():
139
+ return str(path)
140
+
141
+ raise CLINotFoundError(
142
+ "evolv Code not found. Install from:\n"
143
+ " https://github.com/evolv-ai/evolv-code\n"
144
+ "\nOr provide the path via EvolvAgentOptions:\n"
145
+ " EvolvAgentOptions(cli_path='/path/to/evolv')\n"
146
+ "\nOr set the EVOLV_CODE_CLI_PATH environment variable."
147
+ )
148
+
149
+ def _find_bundled_cli(self) -> str | None:
150
+ """Find bundled CLI binary if it exists."""
151
+ # Check for evolv first
152
+ cli_name = "evolv.exe" if platform.system() == "Windows" else "evolv"
153
+ bundled_path = Path(__file__).parent.parent.parent / "_bundled" / cli_name
154
+
155
+ if bundled_path.exists() and bundled_path.is_file():
156
+ logger.info(f"Using bundled evolv Code CLI: {bundled_path}")
157
+ return str(bundled_path)
158
+
159
+ # Fall back to cortex
160
+ cli_name = "cortex.exe" if platform.system() == "Windows" else "cortex"
161
+ bundled_path = Path(__file__).parent.parent.parent / "_bundled" / cli_name
162
+
163
+ if bundled_path.exists() and bundled_path.is_file():
164
+ logger.info(f"Using bundled Cortex Code CLI: {bundled_path}")
165
+ return str(bundled_path)
166
+
167
+ return None
168
+
169
+ def _build_settings_value(self) -> str | None:
170
+ """Build settings value, merging sandbox settings if provided."""
171
+ has_settings = self._options.settings is not None
172
+ has_sandbox = self._options.sandbox is not None
173
+
174
+ if not has_settings and not has_sandbox:
175
+ return None
176
+
177
+ if has_settings and not has_sandbox:
178
+ return self._options.settings
179
+
180
+ settings_obj: dict[str, Any] = {}
181
+
182
+ if has_settings:
183
+ assert self._options.settings is not None
184
+ settings_str = self._options.settings.strip()
185
+ if settings_str.startswith("{") and settings_str.endswith("}"):
186
+ try:
187
+ settings_obj = json.loads(settings_str)
188
+ except json.JSONDecodeError:
189
+ logger.warning(
190
+ f"Failed to parse settings as JSON, treating as file path: {settings_str}"
191
+ )
192
+ settings_path = Path(settings_str)
193
+ if settings_path.exists():
194
+ with settings_path.open(encoding="utf-8") as f:
195
+ settings_obj = json.load(f)
196
+ else:
197
+ settings_path = Path(settings_str)
198
+ if settings_path.exists():
199
+ with settings_path.open(encoding="utf-8") as f:
200
+ settings_obj = json.load(f)
201
+ else:
202
+ logger.warning(f"Settings file not found: {settings_path}")
203
+
204
+ if has_sandbox:
205
+ settings_obj["sandbox"] = self._options.sandbox
206
+
207
+ return json.dumps(settings_obj)
208
+
209
+ def _build_command(self, prompt: str | None = None) -> list[str]:
210
+ """Build CLI command with arguments.
211
+
212
+ For evolv CLI in bidirectional mode:
213
+ - --input-format stream-json
214
+ - --output-format stream-json
215
+
216
+ For cortex CLI or non-bidirectional mode:
217
+ - --output-format stream-json
218
+ - --dangerously-allow-all-tool-calls
219
+ - -p/--print "prompt"
220
+ """
221
+ cmd = [self._cli_path, "--output-format", "stream-json"]
222
+
223
+ # Enable bidirectional mode for evolv CLI with streaming
224
+ if self._bidirectional_mode:
225
+ cmd.extend(["--input-format", "stream-json"])
226
+ # CLI requires --dangerously-allow-all-tool-calls for stream-json mode
227
+ cmd.append("--dangerously-allow-all-tool-calls")
228
+ else:
229
+ # For non-bidirectional mode, also require dangerously-allow-all
230
+ cmd.append("--dangerously-allow-all-tool-calls")
231
+
232
+ # Working directory
233
+ if self._cwd:
234
+ cmd.extend(["-w", self._cwd])
235
+
236
+ # Model selection
237
+ if self._options.model:
238
+ cmd.extend(["--model", self._options.model])
239
+
240
+ # Resume session
241
+ if self._options.resume:
242
+ cmd.extend(["--resume", self._options.resume])
243
+
244
+ # Continue most recent session
245
+ if self._options.continue_conversation:
246
+ cmd.append("--continue")
247
+
248
+ # Handle settings/config
249
+ settings_value = self._build_settings_value()
250
+ if settings_value:
251
+ cmd.extend(["--config", settings_value])
252
+
253
+ # Extra args for future CLI flags
254
+ for flag, value in self._options.extra_args.items():
255
+ if value is None:
256
+ cmd.append(f"--{flag}")
257
+ else:
258
+ cmd.extend([f"--{flag}", str(value)])
259
+
260
+ # Add prompt for non-bidirectional mode
261
+ if not self._bidirectional_mode:
262
+ actual_prompt = prompt if prompt is not None else self._prompt
263
+ if isinstance(actual_prompt, str):
264
+ cmd.extend(["--print", actual_prompt])
265
+ else:
266
+ # For streaming mode without bidirectional support
267
+ raise CLIConnectionError(
268
+ "CLI does not support bidirectional streaming mode. "
269
+ "Use evolv CLI or query() with a string prompt."
270
+ )
271
+
272
+ return cmd
273
+
274
+ async def connect(self) -> None:
275
+ """Start subprocess."""
276
+ if self._process:
277
+ return
278
+
279
+ # For bidirectional mode (evolv CLI with streaming), start the process now
280
+ if self._bidirectional_mode:
281
+ if not os.environ.get("EVOLV_AGENT_SDK_SKIP_VERSION_CHECK"):
282
+ await self._check_cli_version()
283
+ cmd = self._build_command()
284
+ await self._start_process(cmd, keep_stdin_open=True)
285
+ return
286
+
287
+ # For non-bidirectional streaming mode, defer process start
288
+ if self._is_streaming:
289
+ self._ready = True
290
+ return
291
+
292
+ if not os.environ.get("EVOLV_AGENT_SDK_SKIP_VERSION_CHECK"):
293
+ await self._check_cli_version()
294
+
295
+ cmd = self._build_command()
296
+ await self._start_process(cmd)
297
+
298
+ async def _start_process(
299
+ self, cmd: list[str], keep_stdin_open: bool = False
300
+ ) -> None:
301
+ """Start the CLI process with the given command."""
302
+ try:
303
+ process_env = {
304
+ **os.environ,
305
+ **self._options.env,
306
+ "EVOLV_CODE_ENTRYPOINT": "sdk-py",
307
+ "EVOLV_AGENT_SDK_VERSION": __version__,
308
+ }
309
+
310
+ if self._options.enable_file_checkpointing:
311
+ process_env["EVOLV_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true"
312
+
313
+ should_pipe_stderr = (
314
+ self._options.stderr is not None
315
+ or "debug-to-stderr" in self._options.extra_args
316
+ )
317
+
318
+ stderr_dest = PIPE if should_pipe_stderr else None
319
+
320
+ self._process = await anyio.open_process(
321
+ cmd,
322
+ stdin=PIPE,
323
+ stdout=PIPE,
324
+ stderr=stderr_dest,
325
+ cwd=self._cwd,
326
+ env=process_env,
327
+ user=self._options.user,
328
+ )
329
+
330
+ if self._process.stdout:
331
+ self._stdout_stream = TextReceiveStream(self._process.stdout)
332
+
333
+ if self._process.stdin and keep_stdin_open:
334
+ self._stdin_stream = TextSendStream(self._process.stdin)
335
+ elif self._process.stdin:
336
+ # Close stdin immediately for non-bidirectional mode
337
+ await self._process.stdin.aclose()
338
+
339
+ if should_pipe_stderr and self._process.stderr:
340
+ self._stderr_stream = TextReceiveStream(self._process.stderr)
341
+ self._stderr_task_group = anyio.create_task_group()
342
+ await self._stderr_task_group.__aenter__()
343
+ self._stderr_task_group.start_soon(self._handle_stderr)
344
+
345
+ self._ready = True
346
+
347
+ except FileNotFoundError as e:
348
+ if self._cwd and not Path(self._cwd).exists():
349
+ error = CLIConnectionError(
350
+ f"Working directory does not exist: {self._cwd}"
351
+ )
352
+ self._exit_error = error
353
+ raise error from e
354
+ error = CLINotFoundError(f"evolv Code not found at: {self._cli_path}")
355
+ self._exit_error = error
356
+ raise error from e
357
+ except Exception as e:
358
+ error = CLIConnectionError(f"Failed to start evolv Code: {e}")
359
+ self._exit_error = error
360
+ raise error from e
361
+
362
+ async def _handle_stderr(self) -> None:
363
+ """Handle stderr stream."""
364
+ if not self._stderr_stream:
365
+ return
366
+
367
+ try:
368
+ async for line in self._stderr_stream:
369
+ line_str = line.rstrip()
370
+ if not line_str:
371
+ continue
372
+
373
+ if self._options.stderr:
374
+ self._options.stderr(line_str)
375
+ elif (
376
+ "debug-to-stderr" in self._options.extra_args
377
+ and self._options.debug_stderr
378
+ ):
379
+ self._options.debug_stderr.write(line_str + "\n")
380
+ if hasattr(self._options.debug_stderr, "flush"):
381
+ self._options.debug_stderr.flush()
382
+ except anyio.ClosedResourceError:
383
+ pass
384
+ except Exception:
385
+ pass
386
+
387
+ async def close(self) -> None:
388
+ """Close the transport and clean up resources."""
389
+ for temp_file in self._temp_files:
390
+ with suppress(Exception):
391
+ Path(temp_file).unlink(missing_ok=True)
392
+ self._temp_files.clear()
393
+
394
+ if not self._process:
395
+ self._ready = False
396
+ return
397
+
398
+ if self._stderr_task_group:
399
+ with suppress(Exception):
400
+ self._stderr_task_group.cancel_scope.cancel()
401
+ await self._stderr_task_group.__aexit__(None, None, None)
402
+ self._stderr_task_group = None
403
+
404
+ async with self._write_lock:
405
+ self._ready = False
406
+ if self._stdin_stream:
407
+ with suppress(Exception):
408
+ await self._stdin_stream.aclose()
409
+ self._stdin_stream = None
410
+
411
+ if self._stderr_stream:
412
+ with suppress(Exception):
413
+ await self._stderr_stream.aclose()
414
+ self._stderr_stream = None
415
+
416
+ if self._process.returncode is None:
417
+ with suppress(ProcessLookupError):
418
+ self._process.terminate()
419
+ with suppress(Exception):
420
+ await self._process.wait()
421
+
422
+ self._process = None
423
+ self._stdout_stream = None
424
+ self._stdin_stream = None
425
+ self._stderr_stream = None
426
+ self._exit_error = None
427
+
428
+ async def write(self, data: str) -> None:
429
+ """Write raw data to the transport.
430
+
431
+ For bidirectional mode (evolv CLI), writes directly to stdin.
432
+ For non-bidirectional mode, queues messages for separate queries.
433
+ """
434
+ async with self._write_lock:
435
+ if not self._ready:
436
+ raise CLIConnectionError("ProcessTransport is not ready for writing")
437
+
438
+ if self._bidirectional_mode and self._stdin_stream:
439
+ # Bidirectional mode: write directly to stdin
440
+ await self._stdin_stream.send(data)
441
+ else:
442
+ # Non-bidirectional mode: queue prompts
443
+ try:
444
+ msg = json.loads(data.strip())
445
+ if msg.get("type") == "user":
446
+ content = msg.get("message", {}).get("content", "")
447
+ if isinstance(content, str) and content:
448
+ self._pending_prompts.append(content)
449
+ except json.JSONDecodeError:
450
+ pass
451
+
452
+ async def end_input(self) -> None:
453
+ """End the input stream."""
454
+ if self._bidirectional_mode and self._stdin_stream:
455
+ async with self._write_lock:
456
+ with suppress(Exception):
457
+ await self._stdin_stream.aclose()
458
+ self._stdin_stream = None
459
+
460
+ def read_messages(self) -> AsyncIterator[dict[str, Any]]:
461
+ """Read and parse messages from the transport."""
462
+ return self._read_messages_impl()
463
+
464
+ async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]:
465
+ """Internal implementation of read_messages."""
466
+ # For non-bidirectional streaming mode with pending prompts
467
+ if self._is_streaming and not self._bidirectional_mode and self._pending_prompts:
468
+ for prompt in self._pending_prompts:
469
+ cmd = self._build_command_for_prompt(prompt)
470
+ async for msg in self._run_single_query(cmd):
471
+ yield msg
472
+ self._pending_prompts.clear()
473
+ return
474
+
475
+ # For normal mode, read from the existing process
476
+ if not self._process or not self._stdout_stream:
477
+ raise CLIConnectionError("Not connected")
478
+
479
+ json_buffer = ""
480
+
481
+ try:
482
+ async for line in self._stdout_stream:
483
+ line_str = line.strip()
484
+ if not line_str:
485
+ continue
486
+
487
+ json_lines = line_str.split("\n")
488
+
489
+ for json_line in json_lines:
490
+ json_line = json_line.strip()
491
+ if not json_line:
492
+ continue
493
+
494
+ json_buffer += json_line
495
+
496
+ if len(json_buffer) > self._max_buffer_size:
497
+ buffer_length = len(json_buffer)
498
+ json_buffer = ""
499
+ raise SDKJSONDecodeError(
500
+ f"JSON message exceeded maximum buffer size of {self._max_buffer_size} bytes",
501
+ ValueError(
502
+ f"Buffer size {buffer_length} exceeds limit {self._max_buffer_size}"
503
+ ),
504
+ )
505
+
506
+ try:
507
+ data = json.loads(json_buffer)
508
+ json_buffer = ""
509
+
510
+ # Transform output to match SDK expectations
511
+ transformed_data = self._transform_output(data)
512
+ yield transformed_data
513
+ except json.JSONDecodeError:
514
+ continue
515
+
516
+ except anyio.ClosedResourceError:
517
+ pass
518
+ except GeneratorExit:
519
+ pass
520
+
521
+ try:
522
+ returncode = await self._process.wait()
523
+ except Exception:
524
+ returncode = -1
525
+
526
+ if returncode is not None and returncode != 0:
527
+ self._exit_error = ProcessError(
528
+ f"Command failed with exit code {returncode}",
529
+ exit_code=returncode,
530
+ stderr="Check stderr output for details",
531
+ )
532
+ raise self._exit_error
533
+
534
+ def _build_command_for_prompt(self, prompt: str) -> list[str]:
535
+ """Build a command for a specific prompt (non-bidirectional mode)."""
536
+ cmd = [self._cli_path, "--output-format", "stream-json"]
537
+ cmd.append("--dangerously-allow-all-tool-calls")
538
+
539
+ if self._cwd:
540
+ cmd.extend(["-w", self._cwd])
541
+
542
+ if self._options.model:
543
+ cmd.extend(["--model", self._options.model])
544
+
545
+ if self._options.resume:
546
+ cmd.extend(["--resume", self._options.resume])
547
+
548
+ if self._options.continue_conversation:
549
+ cmd.append("--continue")
550
+
551
+ settings_value = self._build_settings_value()
552
+ if settings_value:
553
+ cmd.extend(["--config", settings_value])
554
+
555
+ for flag, value in self._options.extra_args.items():
556
+ if value is None:
557
+ cmd.append(f"--{flag}")
558
+ else:
559
+ cmd.extend([f"--{flag}", str(value)])
560
+
561
+ cmd.extend(["--print", prompt])
562
+ return cmd
563
+
564
+ async def _run_single_query(self, cmd: list[str]) -> AsyncIterator[dict[str, Any]]:
565
+ """Run a single query command and yield messages."""
566
+ process_env = {
567
+ **os.environ,
568
+ **self._options.env,
569
+ "EVOLV_CODE_ENTRYPOINT": "sdk-py",
570
+ "EVOLV_AGENT_SDK_VERSION": __version__,
571
+ }
572
+
573
+ process = await anyio.open_process(
574
+ cmd,
575
+ stdin=PIPE,
576
+ stdout=PIPE,
577
+ stderr=PIPE,
578
+ cwd=self._cwd,
579
+ env=process_env,
580
+ user=self._options.user,
581
+ )
582
+
583
+ if process.stdin:
584
+ await process.stdin.aclose()
585
+
586
+ if not process.stdout:
587
+ return
588
+
589
+ stdout_stream = TextReceiveStream(process.stdout)
590
+ json_buffer = ""
591
+
592
+ try:
593
+ async for line in stdout_stream:
594
+ line_str = line.strip()
595
+ if not line_str:
596
+ continue
597
+
598
+ for json_line in line_str.split("\n"):
599
+ json_line = json_line.strip()
600
+ if not json_line:
601
+ continue
602
+
603
+ json_buffer += json_line
604
+
605
+ try:
606
+ data = json.loads(json_buffer)
607
+ json_buffer = ""
608
+ yield self._transform_output(data)
609
+ except json.JSONDecodeError:
610
+ continue
611
+ except anyio.ClosedResourceError:
612
+ pass
613
+
614
+ await process.wait()
615
+
616
+ def _transform_output(self, data: dict[str, Any]) -> dict[str, Any]:
617
+ """Transform CLI output to match SDK expectations.
618
+
619
+ Adds missing fields:
620
+ - message.content[].signature -> "" for thinking blocks
621
+ - message.usage -> {input_tokens: 0, output_tokens: 0}
622
+ - result.duration_ms -> 0
623
+ - result.duration_api_ms -> 0
624
+ - result.num_turns -> 1
625
+ - result.cost_usd -> 0
626
+ - result.is_error -> derived from subtype
627
+ """
628
+ msg_type = data.get("type")
629
+
630
+ if msg_type == "assistant":
631
+ message = data.get("message", {})
632
+
633
+ # Add missing usage field
634
+ if "usage" not in message:
635
+ message["usage"] = {"input_tokens": 0, "output_tokens": 0}
636
+
637
+ # Add missing signature field to thinking blocks
638
+ content = message.get("content", [])
639
+ for block in content:
640
+ if block.get("type") == "thinking" and "signature" not in block:
641
+ block["signature"] = ""
642
+
643
+ # Ensure model field exists
644
+ if "model" not in message:
645
+ message["model"] = "evolv"
646
+
647
+ data["message"] = message
648
+
649
+ elif msg_type == "result":
650
+ # Add missing fields with placeholder values
651
+ if "duration_ms" not in data:
652
+ data["duration_ms"] = 0
653
+ if "duration_api_ms" not in data:
654
+ data["duration_api_ms"] = 0
655
+ if "num_turns" not in data:
656
+ data["num_turns"] = 1
657
+ if "total_cost_usd" not in data:
658
+ data["total_cost_usd"] = 0
659
+ if "is_error" not in data:
660
+ data["is_error"] = data.get("subtype") != "success"
661
+ if "session_id" not in data:
662
+ data["session_id"] = "evolv-session"
663
+ if "usage" not in data:
664
+ data["usage"] = {"input_tokens": 0, "output_tokens": 0}
665
+
666
+ return data
667
+
668
+ async def _check_cli_version(self) -> None:
669
+ """Check CLI version and warn if below minimum."""
670
+ version_process = None
671
+ try:
672
+ with anyio.fail_after(2):
673
+ version_process = await anyio.open_process(
674
+ [self._cli_path, "--version"],
675
+ stdout=PIPE,
676
+ stderr=PIPE,
677
+ )
678
+
679
+ if version_process.stdout:
680
+ stdout_bytes = await version_process.stdout.receive()
681
+ version_output = stdout_bytes.decode().strip()
682
+
683
+ match = re.match(r"v?([0-9]+\.[0-9]+\.[0-9]+)", version_output)
684
+ if match:
685
+ version = match.group(1)
686
+ version_parts = [int(x) for x in version.split(".")]
687
+ min_parts = [int(x) for x in MINIMUM_EVOLV_VERSION.split(".")]
688
+
689
+ if version_parts < min_parts:
690
+ warning = (
691
+ f"Warning: CLI version {version} may be unsupported. "
692
+ f"Minimum recommended version is {MINIMUM_EVOLV_VERSION}. "
693
+ "Some features may not work correctly."
694
+ )
695
+ logger.warning(warning)
696
+ print(warning, file=sys.stderr)
697
+ except Exception:
698
+ pass
699
+ finally:
700
+ if version_process:
701
+ with suppress(Exception):
702
+ version_process.terminate()
703
+ with suppress(Exception):
704
+ await version_process.wait()
705
+
706
+ def is_ready(self) -> bool:
707
+ """Check if transport is ready for communication."""
708
+ return self._ready
709
+
710
+ def is_bidirectional(self) -> bool:
711
+ """Check if transport is in bidirectional mode."""
712
+ return self._bidirectional_mode