python-codex 0.1.3__py3-none-any.whl → 0.1.5__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/agent.py CHANGED
@@ -20,6 +20,9 @@ from .tools import ToolContext, ToolRegistry
20
20
  from .utils import uuid7_string
21
21
  import typing
22
22
 
23
+ if typing.TYPE_CHECKING:
24
+ from .utils.session_persist import SessionRolloutRecorder
25
+
23
26
 
24
27
  EventHandler = Callable[[AgentEvent], None]
25
28
  NOOP_EVENT_HANDLER: 'EventHandler' = lambda _event: None
@@ -46,6 +49,7 @@ class AgentLoop:
46
49
  parallel_tool_calls: 'bool' = True,
47
50
  event_handler: 'EventHandler' = NOOP_EVENT_HANDLER,
48
51
  initial_history: 'typing.Tuple[ConversationItem, ...]' = (),
52
+ rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]' = None,
49
53
  ) -> 'None':
50
54
  self._model_client = model_client
51
55
  self._tool_registry = tool_registry
@@ -53,6 +57,7 @@ class AgentLoop:
53
57
  self._parallel_tool_calls = parallel_tool_calls
54
58
  self._event_handler = event_handler
55
59
  self._history: 'typing.List[ConversationItem]' = list(initial_history)
60
+ self._rollout_recorder = rollout_recorder
56
61
  self.interrupt_asap = False
57
62
 
58
63
  @property
@@ -64,6 +69,18 @@ class AgentLoop:
64
69
  ) -> 'None':
65
70
  self._event_handler = event_handler
66
71
 
72
+ def replace_history(
73
+ self,
74
+ history: 'typing.Iterable[ConversationItem]',
75
+ ) -> 'None':
76
+ self._history = list(history)
77
+
78
+ def set_rollout_recorder(
79
+ self,
80
+ rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]',
81
+ ) -> 'None':
82
+ self._rollout_recorder = rollout_recorder
83
+
67
84
  def _raise_if_interrupt_requested(
68
85
  self,
69
86
  turn_id: 'str',
@@ -83,8 +100,9 @@ class AgentLoop:
83
100
  ) -> 'TurnResult':
84
101
  turn_id = turn_id or uuid7_string()
85
102
  self.interrupt_asap = False
86
- for text in texts:
87
- self._history.append(UserMessage(text=text))
103
+ 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)
88
106
 
89
107
  self._emit(
90
108
  "turn_started",
@@ -131,12 +149,15 @@ class AgentLoop:
131
149
  )
132
150
 
133
151
  tool_calls: 'typing.List[ToolCall]' = []
152
+ persisted_response_items: 'typing.List[ConversationItem]' = []
134
153
  for item in response.items:
135
154
  self._history.append(item)
155
+ persisted_response_items.append(item)
136
156
  if isinstance(item, AssistantMessage):
137
157
  last_assistant_message = item.text
138
158
  elif isinstance(item, ToolCall):
139
159
  tool_calls.append(item)
160
+ self._persist_history_items(persisted_response_items)
140
161
 
141
162
  if not tool_calls:
142
163
  self._raise_if_interrupt_requested(
@@ -160,7 +181,10 @@ class AgentLoop:
160
181
 
161
182
  tool_results = await self._execute_tool_batch(turn_id, tool_calls)
162
183
  self._history.extend(tool_results)
163
- self._history.extend(self._build_follow_up_messages(tool_results))
184
+ self._persist_history_items(tool_results)
185
+ follow_up_messages = self._build_follow_up_messages(tool_results)
186
+ self._history.extend(follow_up_messages)
187
+ self._persist_history_items(follow_up_messages)
164
188
  self._raise_if_interrupt_requested(
165
189
  turn_id,
166
190
  iteration,
@@ -226,7 +250,12 @@ class AgentLoop:
226
250
  call: 'ToolCall',
227
251
  prior_results: 'typing.Tuple[ToolResult, ...]' = (),
228
252
  ) -> 'ToolResult':
229
- self._emit("tool_started", turn_id, tool_name=call.name, call_id=call.call_id)
253
+ payload: 'typing.Dict[str, object]' = {
254
+ "tool_name": call.name,
255
+ "call_id": call.call_id,
256
+ "call": call,
257
+ }
258
+ self._emit("tool_started", turn_id, **payload)
230
259
  result = await self._tool_registry.execute(
231
260
  call,
232
261
  ToolContext(
@@ -235,13 +264,8 @@ class AgentLoop:
235
264
  collaboration_mode=self._context_manager.collaboration_mode,
236
265
  ),
237
266
  )
238
- payload: 'typing.Dict[str, object]' = {
239
- "tool_name": call.name,
240
- "call_id": call.call_id,
241
- "is_error": result.is_error,
242
- "call": call,
243
- "result": result,
244
- }
267
+ payload["result"] = result
268
+ payload["is_error"] = result.is_error
245
269
  self._emit("tool_completed", turn_id, **payload)
246
270
  return result
247
271
 
@@ -250,11 +274,27 @@ class AgentLoop:
250
274
  AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
251
275
  )
252
276
 
277
+ def _persist_history_items(
278
+ self,
279
+ items: 'typing.Iterable[ConversationItem]',
280
+ ) -> 'None':
281
+ recorder = self._rollout_recorder
282
+ if recorder is None:
283
+ return
284
+ try:
285
+ recorder.append_history_items(items)
286
+ except Exception: # pragma: no cover - persistence should not break turns
287
+ return
288
+
253
289
  def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
254
290
  if event.kind == "assistant_delta":
255
291
  self._emit("assistant_delta", turn_id, **event.payload)
256
292
  elif event.kind == "tool_call":
257
293
  self._emit("tool_called", turn_id, **event.payload)
294
+ elif event.kind == "token_count":
295
+ self._emit("token_count", turn_id, **event.payload)
296
+ elif event.kind == "stream_error":
297
+ self._emit("stream_error", turn_id, **event.payload)
258
298
 
259
299
  def _build_follow_up_messages(
260
300
  self,
pycodex/cli.py CHANGED
@@ -20,7 +20,15 @@ from .portable import bootstrap_called_home, upload_codex_home
20
20
  from .protocol import AgentEvent
21
21
  from .runtime import AgentRuntime
22
22
  from .runtime_services import RuntimeEnvironment, create_runtime_environment
23
- from .utils import CliSessionView, load_codex_dotenv
23
+ from .utils import CliSessionView, load_codex_dotenv, uuid7_string
24
+ from .utils.compactor import compact_agent_loop
25
+ from .utils.session_persist import (
26
+ SessionRolloutRecorder,
27
+ conversation_history_to_turns,
28
+ list_resumable_sessions,
29
+ load_resumed_session,
30
+ resolve_codex_home,
31
+ )
24
32
  import typing
25
33
 
26
34
  EXIT_COMMANDS = {"/exit", "/quit"}
@@ -28,6 +36,8 @@ HISTORY_COMMAND = "/history"
28
36
  TITLE_COMMAND = "/title"
29
37
  MODEL_COMMAND = "/model"
30
38
  QUEUE_COMMAND = "/queue"
39
+ RESUME_COMMAND = "/resume"
40
+ COMPACT_COMMAND = "/compact"
31
41
  CliSessionMode = Literal["exec", "tui"]
32
42
  LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
33
43
  CLI_ORIGINATOR = "codex-tui"
@@ -284,6 +294,9 @@ def build_runtime(
284
294
  collaboration_mode=collaboration_mode,
285
295
  include_collaboration_instructions=use_tui_context,
286
296
  )
297
+ session_id = getattr(client, "_session_id", None) or uuid7_string()
298
+ if hasattr(client, "_session_id"):
299
+ client._session_id = session_id
287
300
  subagent_context_manager = ContextManager.from_codex_config(
288
301
  config_path,
289
302
  profile,
@@ -293,6 +306,14 @@ def build_runtime(
293
306
  runtime_environment = create_runtime_environment()
294
307
  runtime_environment.request_user_input_manager.set_handler(None)
295
308
  runtime_environment.request_permissions_manager.set_handler(None)
309
+ rollout_recorder = SessionRolloutRecorder.create(
310
+ resolve_codex_home(config_path),
311
+ session_id,
312
+ context_manager.cwd,
313
+ getattr(client, "_originator", CLI_ORIGINATOR),
314
+ getattr(getattr(client, "_config", None), "provider_name", None),
315
+ context_manager.resolve_base_instructions(),
316
+ )
296
317
 
297
318
  def make_subagent_runtime_builder(base_client):
298
319
  def build_subagent_runtime(
@@ -330,7 +351,10 @@ def build_runtime(
330
351
  )
331
352
  return AgentRuntime(
332
353
  AgentLoop(
333
- client, get_tools(runtime_environment, exec_mode=True), context_manager
354
+ client,
355
+ get_tools(runtime_environment, exec_mode=True),
356
+ context_manager,
357
+ rollout_recorder=rollout_recorder,
334
358
  ),
335
359
  runtime_environment=runtime_environment,
336
360
  )
@@ -498,10 +522,14 @@ async def prompt_request_permissions(
498
522
  async def run_interactive_session(
499
523
  runtime: 'AgentRuntime',
500
524
  json_mode: 'bool',
525
+ config_path: 'typing.Union[str, None]' = None,
501
526
  ) -> 'int':
502
527
  worker = asyncio.create_task(runtime.run_forever())
528
+ context_window_tokens = runtime._agent_loop._context_manager.resolve_model_context_window()
503
529
  view = CliSessionView()
530
+ view.set_context_window_tokens(context_window_tokens)
504
531
  model_client = runtime._agent_loop._model_client
532
+ codex_home = resolve_codex_home(config_path)
505
533
  runtime.set_event_handler(view.handle_event)
506
534
  pending_turn_tasks: 'typing.Set[asyncio.Task[None]]' = set()
507
535
  runtime_environment = runtime.runtime_environment
@@ -515,7 +543,7 @@ async def run_interactive_session(
515
543
  lambda payload: prompt_request_permissions(view, payload)
516
544
  )
517
545
  view.write_line("pycodex interactive mode. Type /exit to quit.")
518
- view.write_line("Extra commands: /history, /title, /model")
546
+ view.write_line("Extra commands: /history, /title, /model, /resume, /compact")
519
547
  try:
520
548
 
521
549
  def has_pending_turn_tasks() -> 'bool':
@@ -524,6 +552,39 @@ async def run_interactive_session(
524
552
  )
525
553
  return bool(pending_turn_tasks)
526
554
 
555
+ async def run_manual_compact() -> 'None':
556
+ agent_loop = runtime._agent_loop
557
+ if not agent_loop.history:
558
+ view.write_line("Nothing to compact.")
559
+ return
560
+
561
+ compact_turn_id = uuid7_string()
562
+
563
+ def handle_compact_stream_event(event) -> 'None':
564
+ if event.kind not in {"token_count", "stream_error"}:
565
+ return
566
+ view.handle_event(
567
+ AgentEvent(
568
+ kind=event.kind,
569
+ turn_id=compact_turn_id,
570
+ payload=dict(event.payload),
571
+ )
572
+ )
573
+
574
+ view.write_line("Compacting conversation history...")
575
+ compact_result = await compact_agent_loop(
576
+ agent_loop,
577
+ handle_compact_stream_event,
578
+ )
579
+ if compact_result is None:
580
+ view.write_line("Nothing to compact.")
581
+ return
582
+ view.load_session_history(
583
+ getattr(view, "_title", None),
584
+ conversation_history_to_turns(compact_result.history),
585
+ )
586
+ view.write_line(compact_result.display_text())
587
+
527
588
  async def wait_for_turn_result(future) -> 'None':
528
589
  try:
529
590
  result = await future
@@ -556,6 +617,50 @@ async def run_interactive_session(
556
617
  if prompt_text == TITLE_COMMAND:
557
618
  view.show_title()
558
619
  continue
620
+ if prompt_text == RESUME_COMMAND:
621
+ sessions = list_resumable_sessions(codex_home)
622
+ if not sessions:
623
+ view.write_line("No resumable sessions found.")
624
+ continue
625
+ view.write_line("Available sessions:")
626
+ for index, session in enumerate(sessions, start=1):
627
+ view.write_line(f"[{index}] {session['preview']}")
628
+ continue
629
+ if prompt_text.startswith(f"{RESUME_COMMAND} "):
630
+ if has_pending_turn_tasks():
631
+ view.write_line(
632
+ "Cannot resume while work is running or queued."
633
+ )
634
+ continue
635
+ resume_target = prompt_text[len(RESUME_COMMAND) :].strip()
636
+ try:
637
+ resumed = load_resumed_session(codex_home, resume_target)
638
+ runtime._agent_loop.replace_history(resumed["history"])
639
+ if hasattr(model_client, "_session_id"):
640
+ model_client._session_id = str(resumed["session_id"])
641
+ runtime._agent_loop.set_rollout_recorder(
642
+ SessionRolloutRecorder.resume(resumed["rollout_path"])
643
+ )
644
+ view.load_session_history(
645
+ str(resumed["title"]),
646
+ tuple(resumed["turns"]),
647
+ )
648
+ view.write_line(f"Resumed session: {resumed['title']}")
649
+ view.show_history()
650
+ except Exception as exc: # pragma: no cover - defensive surface
651
+ view.show_error(str(exc))
652
+ continue
653
+ if prompt_text == COMPACT_COMMAND:
654
+ if has_pending_turn_tasks():
655
+ view.write_line(
656
+ "Cannot compact while work is running or queued."
657
+ )
658
+ continue
659
+ try:
660
+ await run_manual_compact()
661
+ except Exception as exc: # pragma: no cover - defensive surface
662
+ view.show_error(str(exc))
663
+ continue
559
664
  if prompt_text.startswith(f"{QUEUE_COMMAND} "):
560
665
  queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
561
666
  if not queued_text:
@@ -663,6 +768,7 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
663
768
  return await run_interactive_session(
664
769
  runtime,
665
770
  args.json,
771
+ args.config,
666
772
  )
667
773
  else:
668
774
  prompt_text = resolve_prompt_text(args.prompt)
pycodex/context.py CHANGED
@@ -30,6 +30,7 @@ DEFAULT_COLLABORATION_INSTRUCTIONS_PATH = (
30
30
  PLAN_COLLABORATION_INSTRUCTIONS_PATH = (
31
31
  Path(__file__).resolve().parent / "prompts" / "collaboration_plan.md"
32
32
  )
33
+ DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95
33
34
  PERMISSIONS_SANDBOX_PROMPTS_PATH = (
34
35
  Path(__file__).resolve().parent / "prompts" / "permissions" / "sandbox_mode"
35
36
  )
@@ -76,6 +77,7 @@ class ContextConfig:
76
77
  codex_home: 'typing.Union[Path, None]' = None
77
78
  project_doc_max_bytes: 'typing.Union[int, None]' = None
78
79
  model: 'typing.Union[str, None]' = None
80
+ model_context_window: 'typing.Union[int, None]' = None
79
81
  personality: 'typing.Union[str, None]' = None
80
82
  approval_policy: 'typing.Union[str, None]' = None
81
83
  sandbox_mode: 'typing.Union[str, None]' = None
@@ -117,6 +119,7 @@ class ContextConfig:
117
119
  codex_home=codex_home,
118
120
  project_doc_max_bytes=_normalize_int(selected.get("project_doc_max_bytes")),
119
121
  model=_normalize_text(selected.get("model")),
122
+ model_context_window=_normalize_int(selected.get("model_context_window")),
120
123
  personality=_normalize_text(selected.get("personality")),
121
124
  approval_policy=_normalize_text(selected.get("approval_policy")),
122
125
  sandbox_mode=_normalize_text(selected.get("sandbox_mode")),
@@ -240,6 +243,26 @@ class ContextManager:
240
243
  return resolved
241
244
  return self._default_base_instructions
242
245
 
246
+ def resolve_model_context_window(self) -> 'typing.Union[int, None]':
247
+ model_metadata = None
248
+ model_slug = self._config.model
249
+ if model_slug is not None:
250
+ model_metadata = _load_models_by_slug().get(model_slug)
251
+
252
+ context_window = self._config.model_context_window
253
+ if context_window is None and model_metadata is not None:
254
+ context_window = _normalize_int(model_metadata.get("context_window"))
255
+ if context_window is None:
256
+ return None
257
+ effective_percent = None
258
+ if model_metadata is not None:
259
+ effective_percent = _normalize_int(
260
+ model_metadata.get("effective_context_window_percent")
261
+ )
262
+ if effective_percent is None:
263
+ effective_percent = DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT
264
+ return context_window * max(effective_percent, 0) // 100
265
+
243
266
  def _resolve_model_instructions(self) -> 'typing.Union[str, None]':
244
267
  model_slug = self._config.model
245
268
  if model_slug is None: