python-codex 0.2.5__py3-none-any.whl → 0.2.7__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.
pycodex/__init__.py CHANGED
@@ -39,6 +39,8 @@ from .runtime_services import (
39
39
  from .tools import (
40
40
  ApplyPatchTool,
41
41
  BaseTool,
42
+ ClockManager,
43
+ ClockTool,
42
44
  CloseAgentTool,
43
45
  CodeModeManager,
44
46
  ExecTool,
@@ -96,6 +98,8 @@ __all__ = [
96
98
  "ApplyPatchTool",
97
99
  "AssistantMessage",
98
100
  "BaseTool",
101
+ "ClockManager",
102
+ "ClockTool",
99
103
  "CloseAgentTool",
100
104
  "create_agent_runtime_environment",
101
105
  "CodeModeManager",
pycodex/agent.py CHANGED
@@ -17,7 +17,14 @@ from .protocol import (
17
17
  TurnResult,
18
18
  UserMessage,
19
19
  )
20
- from .tools import ExecCommandTool, ToolContext, ToolRegistry, UnifiedExecManager
20
+ from .tools import (
21
+ ClockManager,
22
+ ClockTool,
23
+ ExecCommandTool,
24
+ ToolContext,
25
+ ToolRegistry,
26
+ UnifiedExecManager,
27
+ )
21
28
  from .utils.truncation import truncate_tool_results_for_history
22
29
  from .utils import uuid7_string
23
30
  import typing
@@ -96,6 +103,14 @@ class Agent:
96
103
  )
97
104
  if self._exec_manager is not None:
98
105
  self._exec_manager.set_notify_hook(self.maybe_invoke)
106
+ clock_tool = self._tool_registry.get_tool("clock")
107
+ self._clock_manager: 'typing.Union[ClockManager, None]' = (
108
+ clock_tool._manager
109
+ if isinstance(clock_tool, ClockTool)
110
+ else None
111
+ )
112
+ if self._clock_manager is not None:
113
+ self._clock_manager.set_notify_hook(self.maybe_invoke)
99
114
 
100
115
  @property
101
116
  def history(self) -> 'typing.Tuple[ConversationItem, ...]':
@@ -141,6 +156,8 @@ class Agent:
141
156
  self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
142
157
  ) -> 'TurnResult':
143
158
  self._turn_running = True
159
+ if self._clock_manager is not None:
160
+ self._clock_manager.turn_started()
144
161
  turn_id = turn_id or uuid7_string()
145
162
  self.interrupt_asap = False
146
163
  new_user_messages = [UserMessage(text=text) for text in texts]
@@ -198,6 +215,8 @@ class Agent:
198
215
  output_text=last_assistant_message,
199
216
  )
200
217
  self._turn_running = False
218
+ if self._clock_manager is not None:
219
+ self._clock_manager.arm_after_reply()
201
220
  return TurnResult(
202
221
  turn_id=turn_id,
203
222
  output_text=last_assistant_message,
@@ -240,17 +259,28 @@ class Agent:
240
259
  raise
241
260
 
242
261
  async def maybe_invoke(self, event: 'typing.Dict[str, object]') -> 'bool':
243
- if self._turn_running or event.get("type") != "exec_command_completed":
262
+ if self._turn_running:
263
+ return False
264
+ event_type = event.get("type")
265
+ if event_type == "exec_command_completed":
266
+ payload = {
267
+ "session_id": event.get("session_id"),
268
+ "exit_code": event.get("exit_code"),
269
+ "command": event.get("command"),
270
+ }
271
+ tag = "exec_command_completed"
272
+ elif event_type == "clock_tick":
273
+ payload = {
274
+ "period_m": event.get("period_m"),
275
+ "current_time": event.get("current_time"),
276
+ }
277
+ tag = "clock_tick"
278
+ else:
244
279
  return False
245
- payload = {
246
- "session_id": event.get("session_id"),
247
- "exit_code": event.get("exit_code"),
248
- "command": event.get("command"),
249
- }
250
280
  text = (
251
- "<exec_command_completed>\n"
281
+ f"<{tag}>\n"
252
282
  f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
253
- "</exec_command_completed>"
283
+ f"</{tag}>"
254
284
  )
255
285
  self._turn_running = True
256
286
  task = asyncio.create_task(self.run_turn([text]))
@@ -259,6 +289,10 @@ class Agent:
259
289
  )
260
290
  return True
261
291
 
292
+ def shutdown(self) -> 'None':
293
+ if self._clock_manager is not None:
294
+ self._clock_manager.cancel()
295
+
262
296
  async def _execute_tool_batch(
263
297
  self,
264
298
  turn_id: 'str',
@@ -328,16 +362,22 @@ class Agent:
328
362
 
329
363
  def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
330
364
  if kind in TERMINAL_TURN_EVENTS:
331
- payload["background_exec_count"] = self._background_exec_count()
365
+ payload["background_work_count"] = self._background_work_count(kind)
332
366
  self._event_handler(
333
367
  AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
334
368
  )
335
369
 
336
- def _background_exec_count(self) -> 'int':
370
+ def _background_work_count(self, terminal_event: 'str') -> 'int':
337
371
  manager: 'typing.Union[UnifiedExecManager, None]' = self._exec_manager
338
- if manager is None:
339
- return 0
340
- return manager.running_session_count()
372
+ count = 0 if manager is None else manager.running_session_count()
373
+ clock_manager = self._clock_manager
374
+ if (
375
+ terminal_event == "turn_completed"
376
+ and clock_manager is not None
377
+ and clock_manager.enabled
378
+ ):
379
+ count += 1
380
+ return count
341
381
 
342
382
  def _persist_history_items(
343
383
  self,
pycodex/cli.py CHANGED
@@ -46,6 +46,36 @@ def launch_chat_completion_compat_server(*args, **kwargs):
46
46
  return launch_compat_server(*args, **kwargs)
47
47
 
48
48
 
49
+ def _resolve_vllm_model(
50
+ endpoint: 'str',
51
+ provider_config: 'ResponsesProviderConfig',
52
+ timeout_seconds: 'float',
53
+ ) -> 'str':
54
+ from responses_server import CompatServerConfig
55
+
56
+ normalized = CompatServerConfig.from_base_url(endpoint)
57
+ probe_config = replace(
58
+ provider_config,
59
+ provider_name="vllm",
60
+ base_url=normalized.outcomming_base_url,
61
+ api_key_env=None,
62
+ query_params={},
63
+ responses_lite_override=False,
64
+ )
65
+ probe_client = ResponsesModelClient(
66
+ probe_config,
67
+ timeout_seconds,
68
+ originator=CLI_ORIGINATOR,
69
+ )
70
+ models = probe_client.list_models_sync()
71
+ if not models:
72
+ raise RuntimeError(
73
+ "vLLM endpoint returned no models from "
74
+ f"{normalized.outcomming_models_url()}"
75
+ )
76
+ return models[-1]
77
+
78
+
49
79
  def configure_loguru() -> 'None':
50
80
  try:
51
81
  from loguru import logger
@@ -166,9 +196,12 @@ def get_tools(
166
196
  runtime_environment: 'typing.Union[AgentRuntimeEnvironment, None]' = None,
167
197
  exec_mode: 'bool' = False,
168
198
  cwd: 'typing.Union[str, Path, None]' = None,
199
+ toolset: 'typing.Union[typing.Iterable[str], None]' = None,
169
200
  ):
170
201
  from .tools import (
171
202
  ApplyPatchTool,
203
+ ClockManager,
204
+ ClockTool,
172
205
  CloseAgentTool,
173
206
  CodeModeManager,
174
207
  ExecTool,
@@ -197,6 +230,7 @@ def get_tools(
197
230
  registry = Registry()
198
231
  code_mode_manager = CodeModeManager(registry, cwd=cwd)
199
232
  unified_exec_manager = UnifiedExecManager(cwd=cwd)
233
+ clock_manager = ClockManager()
200
234
  exec_tool = ExecTool(code_mode_manager)
201
235
  wait_tool = WaitTool(code_mode_manager)
202
236
  web_search_tool = WebSearchTool()
@@ -217,45 +251,61 @@ def get_tools(
217
251
  shell_command_tool = ShellCommandTool(cwd=cwd)
218
252
  exec_command_tool = ExecCommandTool(unified_exec_manager)
219
253
  write_stdin_tool = WriteStdinTool(unified_exec_manager)
254
+ clock_tool = ClockTool(clock_manager)
220
255
  grep_files_tool = GrepFilesTool(cwd=cwd)
221
256
  read_file_tool = ReadFileTool()
222
257
  list_dir_tool = ListDirTool()
223
258
  view_image_tool = ViewImageTool(cwd=cwd)
224
- if exec_mode:
225
- registry.register(exec_command_tool)
226
- registry.register(write_stdin_tool)
227
- registry.register(update_plan_tool)
228
- registry.register(request_user_input_tool)
229
- registry.register(apply_patch_tool)
230
- registry.register(web_search_tool)
231
- registry.register(view_image_tool)
232
- registry.register(spawn_agent_tool)
233
- registry.register(send_input_tool)
234
- registry.register(resume_agent_tool)
235
- registry.register(wait_agent_tool)
236
- registry.register(close_agent_tool)
237
- return registry
238
-
239
- registry.register(shell_tool)
240
- registry.register(shell_command_tool)
241
- registry.register(exec_command_tool)
242
- registry.register(write_stdin_tool)
243
- registry.register(exec_tool)
244
- registry.register(wait_tool)
245
- registry.register(web_search_tool)
246
- registry.register(update_plan_tool)
247
- registry.register(request_user_input_tool)
248
- registry.register(request_permissions_tool)
249
- registry.register(spawn_agent_tool)
250
- registry.register(send_input_tool)
251
- registry.register(resume_agent_tool)
252
- registry.register(wait_agent_tool)
253
- registry.register(close_agent_tool)
254
- registry.register(apply_patch_tool)
255
- registry.register(grep_files_tool)
256
- registry.register(read_file_tool)
257
- registry.register(list_dir_tool)
258
- registry.register(view_image_tool)
259
+ tools = (
260
+ shell_tool,
261
+ shell_command_tool,
262
+ exec_command_tool,
263
+ write_stdin_tool,
264
+ clock_tool,
265
+ exec_tool,
266
+ wait_tool,
267
+ web_search_tool,
268
+ update_plan_tool,
269
+ request_user_input_tool,
270
+ request_permissions_tool,
271
+ spawn_agent_tool,
272
+ send_input_tool,
273
+ resume_agent_tool,
274
+ wait_agent_tool,
275
+ close_agent_tool,
276
+ apply_patch_tool,
277
+ grep_files_tool,
278
+ read_file_tool,
279
+ list_dir_tool,
280
+ view_image_tool,
281
+ )
282
+ if toolset is not None:
283
+ available_tools = {tool.name: tool for tool in tools}
284
+ toolset = tuple(toolset)
285
+ unknown_tools = set(toolset) - set(available_tools)
286
+ if unknown_tools:
287
+ raise ValueError(
288
+ "unknown toolset entries: {0}".format(", ".join(sorted(unknown_tools)))
289
+ )
290
+ tools = tuple(available_tools[name] for name in toolset)
291
+ elif exec_mode:
292
+ tools = (
293
+ exec_command_tool,
294
+ write_stdin_tool,
295
+ clock_tool,
296
+ update_plan_tool,
297
+ request_user_input_tool,
298
+ apply_patch_tool,
299
+ web_search_tool,
300
+ view_image_tool,
301
+ spawn_agent_tool,
302
+ send_input_tool,
303
+ resume_agent_tool,
304
+ wait_agent_tool,
305
+ close_agent_tool,
306
+ )
307
+ for tool in tools:
308
+ registry.register(tool)
259
309
  return registry
260
310
 
261
311
 
@@ -295,6 +345,7 @@ def build_agent(
295
345
  collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
296
346
  extra_contextual_user_messages: 'typing.Iterable[str]' = (),
297
347
  cwd: 'typing.Union[str, Path, None]' = None,
348
+ toolset: 'typing.Union[typing.Iterable[str], None]' = None,
298
349
  ) -> 'Agent':
299
350
  config_path = str(config_path)
300
351
  resolved_cwd = Path(cwd or Path.cwd()).resolve()
@@ -365,7 +416,12 @@ def build_agent(
365
416
  )
366
417
  return Agent(
367
418
  client,
368
- get_tools(runtime_environment, exec_mode=True, cwd=resolved_cwd),
419
+ get_tools(
420
+ runtime_environment,
421
+ exec_mode=True,
422
+ cwd=resolved_cwd,
423
+ toolset=toolset,
424
+ ),
369
425
  context_manager,
370
426
  rollout_recorder=rollout_recorder,
371
427
  runtime_environment=runtime_environment,
@@ -392,6 +448,21 @@ def build_model(
392
448
  raise ValueError("--use-chat-completion and --use-messages cannot be combined")
393
449
  if vllm_endpoint and use_messages:
394
450
  raise ValueError("--vllm-endpoint and --use-messages cannot be combined")
451
+ uses_local_responses_compat = (
452
+ managed_responses_base_url is not None
453
+ or vllm_endpoint is not None
454
+ or bool(use_chat_completion)
455
+ or use_messages
456
+ )
457
+ if vllm_endpoint is not None:
458
+ provider_config = replace(
459
+ provider_config,
460
+ model=_resolve_vllm_model(
461
+ vllm_endpoint,
462
+ provider_config,
463
+ timeout_seconds,
464
+ ),
465
+ )
395
466
  url, key_env = provider_config.base_url, provider_config.api_key_env
396
467
  if managed_responses_base_url is not None:
397
468
  url, key_env = (
@@ -425,6 +496,9 @@ def build_model(
425
496
  provider_config,
426
497
  base_url=url,
427
498
  api_key_env=key_env,
499
+ responses_lite_override=(
500
+ False if uses_local_responses_compat else provider_config.responses_lite_override
501
+ ),
428
502
  )
429
503
  return ResponsesModelClient(
430
504
  provider_config,
pycodex/image_utils.py ADDED
@@ -0,0 +1,79 @@
1
+ """Image preparation helpers for the Python Codex prototype.
2
+
3
+ Original Codex mapping:
4
+ - Corresponds to `codex-rs/utils/image/src/lib.rs`.
5
+
6
+ Expected behavior:
7
+ - Resize images down to `MAX_DIMENSION` on the longest side before they are
8
+ attached to a model request, matching upstream `PromptImageMode::ResizeToFit`.
9
+ - Keep the original bytes when the caller asks for `original` detail or the
10
+ image already fits.
11
+ """
12
+
13
+ import base64
14
+ import io
15
+ import mimetypes
16
+ from pathlib import Path
17
+
18
+ from PIL import Image
19
+
20
+ import typing
21
+
22
+ MAX_DIMENSION = 2048
23
+
24
+ _PRESERVABLE_MIME_TYPES = ("image/png", "image/jpeg", "image/webp")
25
+
26
+
27
+ class ImageProcessingError(RuntimeError):
28
+ pass
29
+
30
+
31
+ def load_image_data_url(
32
+ path: 'Path',
33
+ resize_to_fit: 'bool' = True,
34
+ ) -> 'str':
35
+ mime_type, _ = mimetypes.guess_type(path.name)
36
+ if not mime_type or not mime_type.startswith("image/"):
37
+ raise ImageProcessingError(
38
+ "`{0}` does not look like an image file".format(path)
39
+ )
40
+
41
+ image_bytes = path.read_bytes()
42
+ if resize_to_fit:
43
+ mime_type, image_bytes = _resize_to_fit(mime_type, image_bytes)
44
+ encoded = base64.b64encode(image_bytes).decode("ascii")
45
+ return "data:{0};base64,{1}".format(mime_type, encoded)
46
+
47
+
48
+ def _resize_to_fit(
49
+ mime_type: 'str',
50
+ image_bytes: 'bytes',
51
+ ) -> 'typing.Tuple[str, bytes]':
52
+ with Image.open(io.BytesIO(image_bytes)) as image:
53
+ width, height = image.size
54
+ if width <= MAX_DIMENSION and height <= MAX_DIMENSION:
55
+ return mime_type, image_bytes
56
+
57
+ scale = float(MAX_DIMENSION) / float(max(width, height))
58
+ target_size = (
59
+ max(1, int(width * scale)),
60
+ max(1, int(height * scale)),
61
+ )
62
+ resized = image.resize(target_size, Image.BILINEAR)
63
+ target_mime = (
64
+ mime_type if mime_type in _PRESERVABLE_MIME_TYPES else "image/png"
65
+ )
66
+ if target_mime == "image/jpeg":
67
+ resized = resized.convert("RGB")
68
+ save_format, save_kwargs = "JPEG", {"quality": 85}
69
+ elif target_mime == "image/webp":
70
+ resized = resized.convert("RGBA")
71
+ save_format, save_kwargs = "WEBP", {"lossless": True}
72
+ else:
73
+ resized = resized.convert("RGBA")
74
+ save_format, save_kwargs = "PNG", {}
75
+
76
+ buffer = io.BytesIO()
77
+ resized.save(buffer, format=save_format, **save_kwargs)
78
+
79
+ return target_mime, buffer.getvalue()
pycodex/model.py CHANGED
@@ -69,6 +69,7 @@ class ResponsesProviderConfig:
69
69
  stream_max_retries: 'typing.Union[int, None]' = None
70
70
  stream_idle_timeout_ms: 'typing.Union[int, None]' = None
71
71
  service_tier: 'typing.Union[str, None]' = None
72
+ responses_lite_override: 'typing.Union[bool, None]' = None
72
73
 
73
74
  @classmethod
74
75
  def from_codex_config(
@@ -166,6 +167,8 @@ class ResponsesProviderConfig:
166
167
  return model_metadata(self.model)
167
168
 
168
169
  def use_responses_lite(self) -> 'bool':
170
+ if self.responses_lite_override is not None:
171
+ return self.responses_lite_override
169
172
  metadata = self.metadata()
170
173
  if metadata is None:
171
174
  return False
@@ -383,7 +386,10 @@ class ResponsesModelClient:
383
386
  return url
384
387
 
385
388
  async def list_models(self) -> 'typing.List[str]':
386
- return await asyncio.to_thread(self._list_models_sync)
389
+ return await asyncio.to_thread(self.list_models_sync)
390
+
391
+ def list_models_sync(self) -> 'typing.List[str]':
392
+ return self._list_models_sync()
387
393
 
388
394
  async def complete(
389
395
  self,
pycodex/portable.py CHANGED
@@ -11,8 +11,7 @@ from typing import Callable
11
11
  from urllib.parse import quote, urlparse
12
12
 
13
13
  import requests
14
- from cryptography.exceptions import InvalidTag
15
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
14
+ from Cryptodome.Cipher import AES
16
15
  import typing
17
16
 
18
17
  try:
@@ -234,18 +233,26 @@ def _normalize_optional_relative_file(root: 'Path', value: 'str') -> 'typing.Uni
234
233
 
235
234
  def _encrypt_bundle(bundle_bytes: 'bytes', secret: 'str') -> 'bytes':
236
235
  nonce = os.urandom(NONCE_LENGTH)
237
- ciphertext = AESGCM(_encryption_key(secret)).encrypt(nonce, bundle_bytes, None)
238
- return ENCRYPTED_BUNDLE_MAGIC + nonce + ciphertext
236
+ cipher = AES.new(_encryption_key(secret), AES.MODE_GCM, nonce=nonce)
237
+ ciphertext, tag = cipher.encrypt_and_digest(bundle_bytes)
238
+ return ENCRYPTED_BUNDLE_MAGIC + nonce + ciphertext + tag
239
239
 
240
240
 
241
241
  def _decrypt_bundle(payload: 'bytes', secret: 'str') -> 'bytes':
242
242
  if not payload.startswith(ENCRYPTED_BUNDLE_MAGIC):
243
243
  raise RemoteStorageError("stored bundle is not a recognized encrypted payload")
244
244
  nonce = payload[len(ENCRYPTED_BUNDLE_MAGIC) : len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH]
245
- ciphertext = payload[len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH :]
245
+ encrypted = payload[len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH :]
246
+ if len(encrypted) < 16:
247
+ raise RemoteStorageError("call secret is invalid or bundle is corrupted")
248
+ ciphertext = encrypted[:-16]
249
+ tag = encrypted[-16:]
246
250
  try:
247
- return AESGCM(_encryption_key(secret)).decrypt(nonce, ciphertext, None)
248
- except InvalidTag as exc:
251
+ cipher = AES.new(_encryption_key(secret), AES.MODE_GCM, nonce=nonce)
252
+ plaintext = cipher.decrypt(ciphertext)
253
+ cipher.verify(tag)
254
+ return plaintext
255
+ except ValueError as exc:
249
256
  raise RemoteStorageError("call secret is invalid or bundle is corrupted") from exc
250
257
 
251
258