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.
- evolv_agent_sdk/__init__.py +285 -0
- evolv_agent_sdk/_bundled/.gitkeep +1 -0
- evolv_agent_sdk/_cli_version.py +3 -0
- evolv_agent_sdk/_errors.py +61 -0
- evolv_agent_sdk/_internal/__init__.py +1 -0
- evolv_agent_sdk/_internal/client.py +108 -0
- evolv_agent_sdk/_internal/message_parser.py +181 -0
- evolv_agent_sdk/_internal/query.py +547 -0
- evolv_agent_sdk/_internal/transport/__init__.py +68 -0
- evolv_agent_sdk/_internal/transport/subprocess_cli.py +712 -0
- evolv_agent_sdk/_version.py +3 -0
- evolv_agent_sdk/client.py +365 -0
- evolv_agent_sdk/py.typed +0 -0
- evolv_agent_sdk/query.py +99 -0
- evolv_agent_sdk/types.py +689 -0
- evolv_agent_sdk-0.2.0.dist-info/METADATA +250 -0
- evolv_agent_sdk-0.2.0.dist-info/RECORD +19 -0
- evolv_agent_sdk-0.2.0.dist-info/WHEEL +4 -0
- evolv_agent_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|