python-codex 0.1.11__py3-none-any.whl → 0.1.13__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
@@ -2,12 +2,13 @@ from .compat import patch_asyncio
2
2
 
3
3
  patch_asyncio()
4
4
 
5
- from .agent import AgentLoop
5
+ from .agent import Agent
6
6
  from .context import ContextConfig, ContextManager
7
7
  from .model import (
8
8
  ModelClient,
9
9
  NOOP_MODEL_STREAM_EVENT_HANDLER,
10
10
  ResponsesApiError,
11
+ ResponsesIncompleteError,
11
12
  ResponsesModelClient,
12
13
  ResponsesProviderConfig,
13
14
  )
@@ -26,14 +27,14 @@ from .protocol import (
26
27
  TurnResult,
27
28
  UserMessage,
28
29
  )
29
- from .runtime import AgentRuntime
30
+ from .runtime import CliSubmissionQueue
30
31
  from .runtime_services import (
31
32
  PlanStore,
32
33
  RequestPermissionsManager,
33
34
  RequestUserInputManager,
34
35
  SubAgentManager,
35
- create_runtime_environment,
36
- get_runtime_environment,
36
+ create_agent_runtime_environment,
37
+ get_agent_runtime_environment,
37
38
  )
38
39
  from .tools import (
39
40
  ApplyPatchTool,
@@ -90,13 +91,13 @@ def debug(stop: 'bool' = False):
90
91
 
91
92
  __all__ = [
92
93
  "AgentEvent",
93
- "AgentLoop",
94
- "AgentRuntime",
94
+ "Agent",
95
+ "CliSubmissionQueue",
95
96
  "ApplyPatchTool",
96
97
  "AssistantMessage",
97
98
  "BaseTool",
98
99
  "CloseAgentTool",
99
- "create_runtime_environment",
100
+ "create_agent_runtime_environment",
100
101
  "CodeModeManager",
101
102
  "ContextConfig",
102
103
  "ContextManager",
@@ -120,6 +121,7 @@ __all__ = [
120
121
  "RequestUserInputManager",
121
122
  "ResumeAgentTool",
122
123
  "ResponsesApiError",
124
+ "ResponsesIncompleteError",
123
125
  "ResponsesModelClient",
124
126
  "ResponsesProviderConfig",
125
127
  "SendInputTool",
@@ -142,5 +144,5 @@ __all__ = [
142
144
  "WaitTool",
143
145
  "WebSearchTool",
144
146
  "WriteStdinTool",
145
- "get_runtime_environment",
147
+ "get_agent_runtime_environment",
146
148
  ]
pycodex/agent.py CHANGED
@@ -1,6 +1,7 @@
1
1
 
2
2
  import asyncio
3
3
  import json
4
+ import re
4
5
  from typing import Callable
5
6
 
6
7
  from .context import ContextManager
@@ -22,17 +23,36 @@ import typing
22
23
 
23
24
  if typing.TYPE_CHECKING:
24
25
  from .utils.session_persist import SessionRolloutRecorder
26
+ from .runtime_services import AgentRuntimeEnvironment
25
27
 
26
28
 
27
29
  EventHandler = Callable[[AgentEvent], None]
28
- NOOP_EVENT_HANDLER: 'EventHandler' = lambda _event: None
30
+ BASE_EVENT_HANDLER: 'EventHandler' = lambda _event: None
31
+ _REQUESTED_TOKENS_RE = re.compile(
32
+ r"requested\s+([0-9,]+)\s+tokens",
33
+ re.IGNORECASE,
34
+ )
35
+ _REQUESTED_TOKEN_SPLIT_RE = re.compile(
36
+ r"\(([0-9,]+)\s+in\s+the\s+messages,\s+([0-9,]+)\s+in\s+the\s+completion\)",
37
+ re.IGNORECASE,
38
+ )
39
+ _MAX_CONTEXT_TOKENS_RE = re.compile(
40
+ r"maximum\s+context\s+length\s+is\s+([0-9,]+)\s+tokens",
41
+ re.IGNORECASE,
42
+ )
43
+ _CONTEXT_LENGTH_ERROR_MARKERS = (
44
+ "context_length_exceeded",
45
+ "maximum context length",
46
+ "exceeds the context window",
47
+ "exceeded the context window",
48
+ )
29
49
 
30
50
 
31
51
  class TurnInterrupted(RuntimeError):
32
52
  pass
33
53
 
34
54
 
35
- class AgentLoop:
55
+ class Agent:
36
56
  """Minimal Python port of Codex's turn loop.
37
57
 
38
58
  The core idea mirrors the Rust implementation:
@@ -47,9 +67,10 @@ class AgentLoop:
47
67
  tool_registry: 'ToolRegistry',
48
68
  context_manager: 'typing.Union[ContextManager, None]' = None,
49
69
  parallel_tool_calls: 'bool' = True,
50
- event_handler: 'EventHandler' = NOOP_EVENT_HANDLER,
70
+ event_handler: 'EventHandler' = BASE_EVENT_HANDLER,
51
71
  initial_history: 'typing.Tuple[ConversationItem, ...]' = (),
52
72
  rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]' = None,
73
+ runtime_environment: 'AgentRuntimeEnvironment' = None,
53
74
  ) -> 'None':
54
75
  self._model_client = model_client
55
76
  self._tool_registry = tool_registry
@@ -58,6 +79,11 @@ class AgentLoop:
58
79
  self._event_handler = event_handler
59
80
  self._history: 'typing.List[ConversationItem]' = list(initial_history)
60
81
  self._rollout_recorder = rollout_recorder
82
+ self._auto_compact_token_limit = (
83
+ self._context_manager.resolve_auto_compact_token_limit()
84
+ )
85
+ self._last_total_usage_tokens: 'typing.Union[int, None]' = None
86
+ self.runtime_environment = runtime_environment
61
87
  self.interrupt_asap = False
62
88
 
63
89
  @property
@@ -65,7 +91,7 @@ class AgentLoop:
65
91
  return tuple(self._history)
66
92
 
67
93
  def set_event_handler(
68
- self, event_handler: 'EventHandler' = NOOP_EVENT_HANDLER
94
+ self, event_handler: 'EventHandler' = BASE_EVENT_HANDLER
69
95
  ) -> 'None':
70
96
  self._event_handler = event_handler
71
97
 
@@ -81,6 +107,11 @@ class AgentLoop:
81
107
  ) -> 'None':
82
108
  self._rollout_recorder = rollout_recorder
83
109
 
110
+ def ask(self, text: 'str') -> 'TurnResult':
111
+ from .utils.async_bridge import run_async
112
+
113
+ return run_async(self.run_turn([text]))
114
+
84
115
  def _raise_if_interrupt_requested(
85
116
  self,
86
117
  turn_id: 'str',
@@ -101,8 +132,6 @@ class AgentLoop:
101
132
  turn_id = turn_id or uuid7_string()
102
133
  self.interrupt_asap = False
103
134
  new_user_messages = [UserMessage(text=text) for text in texts]
104
- self._history.extend(new_user_messages)
105
- self._persist_history_items(new_user_messages)
106
135
 
107
136
  self._emit(
108
137
  "turn_started",
@@ -110,6 +139,9 @@ class AgentLoop:
110
139
  user_text="\n".join(texts),
111
140
  user_texts=list(texts),
112
141
  )
142
+ await self._maybe_auto_compact(turn_id, phase="pre_turn")
143
+ self._history.extend(new_user_messages)
144
+ self._persist_history_items(new_user_messages)
113
145
 
114
146
  last_assistant_message: 'typing.Union[str, None]' = None
115
147
  final_response_items: 'typing.Tuple[\n typing.Union[typing.Union[AssistantMessage, ToolCall], ReasoningItem], ...\n]' = ()
@@ -122,23 +154,11 @@ class AgentLoop:
122
154
  iteration,
123
155
  output_text=last_assistant_message,
124
156
  )
157
+ await self._maybe_auto_compact(turn_id, phase="mid_turn")
125
158
  iteration += 1
126
- prompt = self._context_manager.build_prompt(
127
- self._history,
128
- self._tool_registry.model_visible_specs(),
129
- self._parallel_tool_calls,
130
- turn_id=turn_id,
131
- )
132
- self._emit(
133
- "model_called",
159
+ response = await self._complete_model_request(
134
160
  turn_id,
135
- iteration=iteration,
136
- history_size=len(prompt.input),
137
- tool_count=len(prompt.tools),
138
- )
139
- response = await self._model_client.complete(
140
- prompt,
141
- lambda event: self._handle_model_stream_event(turn_id, event),
161
+ iteration,
142
162
  )
143
163
  final_response_items = tuple(response.items)
144
164
  self._emit(
@@ -193,6 +213,10 @@ class AgentLoop:
193
213
  except TurnInterrupted:
194
214
  raise
195
215
  except Exception as exc:
216
+ context_usage = _usage_from_context_length_error(str(exc))
217
+ if context_usage is not None:
218
+ self._remember_token_usage(context_usage)
219
+ self._emit("token_count", turn_id, usage=context_usage)
196
220
  self._emit(
197
221
  "turn_failed",
198
222
  turn_id,
@@ -287,6 +311,8 @@ class AgentLoop:
287
311
  return
288
312
 
289
313
  def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
314
+ if event.kind == "token_count":
315
+ self._remember_token_usage(event.payload.get("usage"))
290
316
  if event.kind == "assistant_delta":
291
317
  self._emit("assistant_delta", turn_id, **event.payload)
292
318
  elif event.kind == "tool_call":
@@ -296,6 +322,149 @@ class AgentLoop:
296
322
  elif event.kind == "stream_error":
297
323
  self._emit("stream_error", turn_id, **event.payload)
298
324
 
325
+ def _remember_token_usage(self, usage: 'object') -> 'None':
326
+ if not isinstance(usage, dict):
327
+ return
328
+ try:
329
+ self._last_total_usage_tokens = int(usage["total_tokens"])
330
+ except (KeyError, TypeError, ValueError):
331
+ return
332
+
333
+ async def _complete_model_request(
334
+ self,
335
+ turn_id: 'str',
336
+ iteration: 'int',
337
+ ) -> 'typing.Any':
338
+ attempted_context_compact = False
339
+ while True:
340
+ prompt = self._context_manager.build_prompt(
341
+ self._history,
342
+ self._tool_registry.model_visible_specs(),
343
+ self._parallel_tool_calls,
344
+ turn_id=turn_id,
345
+ )
346
+ self._emit(
347
+ "model_called",
348
+ turn_id,
349
+ iteration=iteration,
350
+ history_size=len(prompt.input),
351
+ tool_count=len(prompt.tools),
352
+ )
353
+ try:
354
+ return await self._model_client.complete(
355
+ prompt,
356
+ lambda event: self._handle_model_stream_event(turn_id, event),
357
+ )
358
+ except Exception as exc:
359
+ error_message = str(exc)
360
+ if (
361
+ not _is_context_length_error_message(error_message)
362
+ or attempted_context_compact
363
+ ):
364
+ raise
365
+ attempted_context_compact = True
366
+ context_usage = _usage_from_context_length_error(error_message)
367
+ if context_usage is not None:
368
+ self._remember_token_usage(context_usage)
369
+ self._emit("token_count", turn_id, usage=context_usage)
370
+ await self._run_auto_compact(
371
+ turn_id,
372
+ phase="context_length_exceeded",
373
+ total_tokens=(
374
+ context_usage.get("total_tokens")
375
+ if context_usage is not None
376
+ else None
377
+ ),
378
+ token_limit=_context_length_error_token_limit(error_message),
379
+ prune_tool_results_on_context_error=True,
380
+ )
381
+ self._raise_if_interrupt_requested(turn_id, iteration)
382
+
383
+ async def _maybe_auto_compact(
384
+ self,
385
+ turn_id: 'str',
386
+ phase: 'str',
387
+ ) -> 'None':
388
+ limit = self._auto_compact_token_limit
389
+ total_tokens = self._last_total_usage_tokens
390
+ if limit is None or total_tokens is None:
391
+ return
392
+ if total_tokens < limit or not self._history:
393
+ return
394
+
395
+ await self._run_auto_compact(
396
+ turn_id,
397
+ phase=phase,
398
+ total_tokens=total_tokens,
399
+ token_limit=limit,
400
+ prune_tool_results_on_context_error=True,
401
+ )
402
+
403
+ async def _run_auto_compact(
404
+ self,
405
+ turn_id: 'str',
406
+ phase: 'str',
407
+ total_tokens: 'typing.Union[int, None]' = None,
408
+ token_limit: 'typing.Union[int, None]' = None,
409
+ prune_tool_results_on_context_error: 'bool' = False,
410
+ ) -> 'None':
411
+ from .utils.compactor import compact_agent
412
+
413
+ payload: 'typing.Dict[str, object]' = {"phase": phase}
414
+ if total_tokens is not None:
415
+ payload["total_tokens"] = total_tokens
416
+ if token_limit is not None:
417
+ payload["token_limit"] = token_limit
418
+ self._emit(
419
+ "auto_compact_started",
420
+ turn_id,
421
+ **payload,
422
+ )
423
+
424
+ def handle_compact_stream_event(event: 'ModelStreamEvent') -> 'None':
425
+ if event.kind == "stream_error":
426
+ self._emit("stream_error", turn_id, **event.payload)
427
+
428
+ try:
429
+ compact_result = await compact_agent(
430
+ self,
431
+ handle_compact_stream_event,
432
+ prune_tool_results_on_context_error,
433
+ )
434
+ except Exception as exc:
435
+ failed_payload = dict(payload)
436
+ failed_payload.update(
437
+ {
438
+ "error": str(exc),
439
+ "error_type": type(exc).__name__,
440
+ }
441
+ )
442
+ self._emit(
443
+ "auto_compact_failed",
444
+ turn_id,
445
+ **failed_payload,
446
+ )
447
+ raise
448
+
449
+ self._last_total_usage_tokens = None
450
+ if compact_result is None:
451
+ return
452
+ completed_payload = dict(payload)
453
+ completed_payload.update(
454
+ {
455
+ "original_item_count": compact_result.original_item_count,
456
+ "retained_item_count": compact_result.retained_item_count,
457
+ "summary": compact_result.display_text(),
458
+ }
459
+ )
460
+ if compact_result.pruned_tool_results:
461
+ completed_payload["pruned_tool_results"] = compact_result.pruned_tool_results
462
+ self._emit(
463
+ "auto_compact_completed",
464
+ turn_id,
465
+ **completed_payload,
466
+ )
467
+
299
468
  def _build_follow_up_messages(
300
469
  self,
301
470
  tool_results: 'typing.List[ToolResult]',
@@ -326,3 +495,39 @@ class AgentLoop:
326
495
  )
327
496
  )
328
497
  return follow_ups
498
+
499
+
500
+ def _usage_from_context_length_error(
501
+ message: 'str',
502
+ ) -> 'typing.Union[typing.Dict[str, int], None]':
503
+ if not _is_context_length_error_message(message):
504
+ return None
505
+
506
+ requested_match = _REQUESTED_TOKENS_RE.search(message)
507
+ if requested_match is None:
508
+ return None
509
+
510
+ usage = {"total_tokens": _parse_token_count(requested_match.group(1))}
511
+ split_match = _REQUESTED_TOKEN_SPLIT_RE.search(message)
512
+ if split_match is not None:
513
+ usage["input_tokens"] = _parse_token_count(split_match.group(1))
514
+ usage["output_tokens"] = _parse_token_count(split_match.group(2))
515
+ else:
516
+ usage["input_tokens"] = usage["total_tokens"]
517
+ return usage
518
+
519
+
520
+ def _is_context_length_error_message(message: 'str') -> 'bool':
521
+ lower = message.lower()
522
+ return any(marker in lower for marker in _CONTEXT_LENGTH_ERROR_MARKERS)
523
+
524
+
525
+ def _context_length_error_token_limit(message: 'str') -> 'typing.Union[int, None]':
526
+ limit_match = _MAX_CONTEXT_TOKENS_RE.search(message)
527
+ if limit_match is None:
528
+ return None
529
+ return _parse_token_count(limit_match.group(1))
530
+
531
+
532
+ def _parse_token_count(value: 'str') -> 'int':
533
+ return int(value.replace(",", ""))