python-codex 0.1.2__py3-none-any.whl → 0.1.4__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.
Files changed (60) hide show
  1. pycodex/__init__.py +5 -1
  2. pycodex/agent.py +89 -51
  3. pycodex/cli.py +152 -45
  4. pycodex/collaboration.py +6 -7
  5. pycodex/compat.py +99 -0
  6. pycodex/context.py +110 -87
  7. pycodex/doctor.py +40 -40
  8. pycodex/model.py +429 -90
  9. pycodex/portable.py +33 -33
  10. pycodex/portable_server.py +22 -21
  11. pycodex/prompts/models.json +30 -0
  12. pycodex/protocol.py +84 -86
  13. pycodex/runtime.py +36 -35
  14. pycodex/runtime_services.py +69 -69
  15. pycodex/tools/agent_tool_schemas.py +0 -2
  16. pycodex/tools/apply_patch_tool.py +45 -46
  17. pycodex/tools/base_tool.py +35 -36
  18. pycodex/tools/close_agent_tool.py +2 -4
  19. pycodex/tools/code_mode_manager.py +61 -61
  20. pycodex/tools/exec_command_tool.py +5 -6
  21. pycodex/tools/exec_runtime.js +3 -3
  22. pycodex/tools/exec_tool.py +2 -4
  23. pycodex/tools/grep_files_tool.py +10 -11
  24. pycodex/tools/list_dir_tool.py +8 -9
  25. pycodex/tools/read_file_tool.py +13 -14
  26. pycodex/tools/request_permissions_tool.py +2 -4
  27. pycodex/tools/request_user_input_tool.py +13 -14
  28. pycodex/tools/resume_agent_tool.py +2 -4
  29. pycodex/tools/send_input_tool.py +8 -9
  30. pycodex/tools/shell_command_tool.py +5 -6
  31. pycodex/tools/shell_tool.py +5 -6
  32. pycodex/tools/spawn_agent_tool.py +4 -5
  33. pycodex/tools/unified_exec_manager.py +62 -61
  34. pycodex/tools/update_plan_tool.py +4 -5
  35. pycodex/tools/view_image_tool.py +4 -5
  36. pycodex/tools/wait_agent_tool.py +2 -4
  37. pycodex/tools/wait_tool.py +4 -5
  38. pycodex/tools/web_search_tool.py +1 -3
  39. pycodex/tools/write_stdin_tool.py +4 -5
  40. pycodex/utils/__init__.py +4 -0
  41. pycodex/utils/compactor.py +189 -0
  42. pycodex/utils/dotenv.py +6 -6
  43. pycodex/utils/get_env.py +37 -33
  44. pycodex/utils/random_ids.py +1 -2
  45. pycodex/utils/session_persist.py +483 -0
  46. pycodex/utils/visualize.py +197 -83
  47. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/METADATA +32 -11
  48. python_codex-0.1.4.dist-info/RECORD +76 -0
  49. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/WHEEL +1 -1
  50. responses_server/app.py +32 -20
  51. responses_server/config.py +17 -17
  52. responses_server/payload_processors.py +26 -17
  53. responses_server/server.py +11 -11
  54. responses_server/session_store.py +10 -10
  55. responses_server/stream_router.py +83 -64
  56. responses_server/tools/custom_adapter.py +12 -12
  57. responses_server/tools/web_search.py +33 -33
  58. python_codex-0.1.2.dist-info/RECORD +0 -73
  59. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/entry_points.txt +0 -0
  60. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/licenses/LICENSE +0 -0
pycodex/__init__.py CHANGED
@@ -1,3 +1,7 @@
1
+ from .compat import patch_asyncio
2
+
3
+ patch_asyncio()
4
+
1
5
  from .agent import AgentLoop
2
6
  from .context import ContextConfig, ContextManager
3
7
  from .model import (
@@ -60,7 +64,7 @@ from .tools import (
60
64
  WriteStdinTool,
61
65
  )
62
66
 
63
- def debug(stop: bool = False):
67
+ def debug(stop: 'bool' = False):
64
68
 
65
69
  import socket
66
70
 
pycodex/agent.py CHANGED
@@ -1,8 +1,7 @@
1
- from __future__ import annotations
2
1
 
3
2
  import asyncio
4
3
  import json
5
- from collections.abc import Callable
4
+ from typing import Callable
6
5
 
7
6
  from .context import ContextManager
8
7
  from .model import ModelClient
@@ -19,10 +18,14 @@ from .protocol import (
19
18
  )
20
19
  from .tools import ToolContext, ToolRegistry
21
20
  from .utils import uuid7_string
21
+ import typing
22
+
23
+ if typing.TYPE_CHECKING:
24
+ from .utils.session_persist import SessionRolloutRecorder
22
25
 
23
26
 
24
27
  EventHandler = Callable[[AgentEvent], None]
25
- NOOP_EVENT_HANDLER: EventHandler = lambda _event: None
28
+ NOOP_EVENT_HANDLER: 'EventHandler' = lambda _event: None
26
29
 
27
30
 
28
31
  class TurnInterrupted(RuntimeError):
@@ -40,51 +43,66 @@ class AgentLoop:
40
43
 
41
44
  def __init__(
42
45
  self,
43
- model_client: ModelClient,
44
- tool_registry: ToolRegistry,
45
- context_manager: ContextManager | None = None,
46
- parallel_tool_calls: bool = True,
47
- event_handler: EventHandler = NOOP_EVENT_HANDLER,
48
- initial_history: tuple[ConversationItem, ...] = (),
49
- ) -> None:
46
+ model_client: 'ModelClient',
47
+ tool_registry: 'ToolRegistry',
48
+ context_manager: 'typing.Union[ContextManager, None]' = None,
49
+ parallel_tool_calls: 'bool' = True,
50
+ event_handler: 'EventHandler' = NOOP_EVENT_HANDLER,
51
+ initial_history: 'typing.Tuple[ConversationItem, ...]' = (),
52
+ rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]' = None,
53
+ ) -> 'None':
50
54
  self._model_client = model_client
51
55
  self._tool_registry = tool_registry
52
56
  self._context_manager = context_manager or ContextManager()
53
57
  self._parallel_tool_calls = parallel_tool_calls
54
58
  self._event_handler = event_handler
55
- self._history: list[ConversationItem] = list(initial_history)
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
59
- def history(self) -> tuple[ConversationItem, ...]:
64
+ def history(self) -> 'typing.Tuple[ConversationItem, ...]':
60
65
  return tuple(self._history)
61
66
 
62
67
  def set_event_handler(
63
- self, event_handler: EventHandler = NOOP_EVENT_HANDLER
64
- ) -> None:
68
+ self, event_handler: 'EventHandler' = NOOP_EVENT_HANDLER
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
- turn_id: str,
70
- iteration: int,
71
- output_text: str | None = None,
72
- ) -> None:
86
+ turn_id: 'str',
87
+ iteration: 'int',
88
+ output_text: 'typing.Union[str, None]' = None,
89
+ ) -> 'None':
73
90
  if self.interrupt_asap:
74
91
  self.interrupt_asap = False
75
- payload: dict[str, object] = {"iteration": iteration}
92
+ payload: 'typing.Dict[str, object]' = {"iteration": iteration}
76
93
  if output_text is not None:
77
94
  payload["output_text"] = output_text
78
95
  self._emit("turn_interrupted", turn_id, **payload)
79
96
  raise TurnInterrupted("turn interrupted")
80
97
 
81
98
  async def run_turn(
82
- self, texts: list[str], turn_id: str | None = None
83
- ) -> TurnResult:
99
+ self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
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",
@@ -93,10 +111,8 @@ class AgentLoop:
93
111
  user_texts=list(texts),
94
112
  )
95
113
 
96
- last_assistant_message: str | None = None
97
- final_response_items: tuple[
98
- AssistantMessage | ToolCall | ReasoningItem, ...
99
- ] = ()
114
+ last_assistant_message: 'typing.Union[str, None]' = None
115
+ final_response_items: 'typing.Tuple[\n typing.Union[typing.Union[AssistantMessage, ToolCall], ReasoningItem], ...\n]' = ()
100
116
 
101
117
  iteration = 0
102
118
  try:
@@ -132,13 +148,16 @@ class AgentLoop:
132
148
  item_count=len(response.items),
133
149
  )
134
150
 
135
- tool_calls: list[ToolCall] = []
151
+ tool_calls: 'typing.List[ToolCall]' = []
152
+ persisted_response_items: 'typing.List[ConversationItem]' = []
136
153
  for item in response.items:
137
154
  self._history.append(item)
155
+ persisted_response_items.append(item)
138
156
  if isinstance(item, AssistantMessage):
139
157
  last_assistant_message = item.text
140
158
  elif isinstance(item, ToolCall):
141
159
  tool_calls.append(item)
160
+ self._persist_history_items(persisted_response_items)
142
161
 
143
162
  if not tool_calls:
144
163
  self._raise_if_interrupt_requested(
@@ -162,7 +181,10 @@ class AgentLoop:
162
181
 
163
182
  tool_results = await self._execute_tool_batch(turn_id, tool_calls)
164
183
  self._history.extend(tool_results)
165
- 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)
166
188
  self._raise_if_interrupt_requested(
167
189
  turn_id,
168
190
  iteration,
@@ -182,11 +204,11 @@ class AgentLoop:
182
204
 
183
205
  async def _execute_tool_batch(
184
206
  self,
185
- turn_id: str,
186
- tool_calls: list[ToolCall],
187
- ) -> list[ToolResult]:
188
- results: list[ToolResult] = []
189
- parallel_batch: list[ToolCall] = []
207
+ turn_id: 'str',
208
+ tool_calls: 'typing.List[ToolCall]',
209
+ ) -> 'typing.List[ToolResult]':
210
+ results: 'typing.List[ToolResult]' = []
211
+ parallel_batch: 'typing.List[ToolCall]' = []
190
212
 
191
213
  for call in tool_calls:
192
214
  can_run_parallel = (
@@ -224,11 +246,16 @@ class AgentLoop:
224
246
 
225
247
  async def _run_single_tool(
226
248
  self,
227
- turn_id: str,
228
- call: ToolCall,
229
- prior_results: tuple[ToolResult, ...] = (),
230
- ) -> ToolResult:
231
- self._emit("tool_started", turn_id, tool_name=call.name, call_id=call.call_id)
249
+ turn_id: 'str',
250
+ call: 'ToolCall',
251
+ prior_results: 'typing.Tuple[ToolResult, ...]' = (),
252
+ ) -> 'ToolResult':
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)
232
259
  result = await self._tool_registry.execute(
233
260
  call,
234
261
  ToolContext(
@@ -237,32 +264,43 @@ class AgentLoop:
237
264
  collaboration_mode=self._context_manager.collaboration_mode,
238
265
  ),
239
266
  )
240
- payload: dict[str, object] = {
241
- "tool_name": call.name,
242
- "call_id": call.call_id,
243
- "is_error": result.is_error,
244
- "call": call,
245
- "result": result,
246
- }
267
+ payload["result"] = result
268
+ payload["is_error"] = result.is_error
247
269
  self._emit("tool_completed", turn_id, **payload)
248
270
  return result
249
271
 
250
- def _emit(self, kind: str, turn_id: str, **payload: object) -> None:
272
+ def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
251
273
  self._event_handler(
252
274
  AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
253
275
  )
254
276
 
255
- def _handle_model_stream_event(self, turn_id: str, event: ModelStreamEvent) -> None:
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
+
289
+ def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
256
290
  if event.kind == "assistant_delta":
257
291
  self._emit("assistant_delta", turn_id, **event.payload)
258
292
  elif event.kind == "tool_call":
259
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)
260
298
 
261
299
  def _build_follow_up_messages(
262
300
  self,
263
- tool_results: list[ToolResult],
264
- ) -> list[UserMessage]:
265
- follow_ups: list[UserMessage] = []
301
+ tool_results: 'typing.List[ToolResult]',
302
+ ) -> 'typing.List[UserMessage]':
303
+ follow_ups: 'typing.List[UserMessage]' = []
266
304
  for result in tool_results:
267
305
  statuses = None
268
306
  if (
pycodex/cli.py CHANGED
@@ -1,4 +1,3 @@
1
- from __future__ import annotations
2
1
 
3
2
  import atexit
4
3
  import argparse
@@ -10,23 +9,35 @@ import sys
10
9
  import tempfile
11
10
  from dataclasses import asdict, replace
12
11
  from pathlib import Path
13
- from typing import Literal, Sequence
12
+ from typing import Sequence
14
13
 
15
14
  from .agent import AgentLoop
16
15
  from .collaboration import DEFAULT_COLLABORATION_MODE, CollaborationMode
16
+ from .compat import Literal
17
17
  from .context import ContextManager
18
18
  from .model import ResponsesModelClient, ResponsesProviderConfig
19
19
  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
+ )
32
+ import typing
24
33
 
25
34
  EXIT_COMMANDS = {"/exit", "/quit"}
26
35
  HISTORY_COMMAND = "/history"
27
36
  TITLE_COMMAND = "/title"
28
37
  MODEL_COMMAND = "/model"
29
38
  QUEUE_COMMAND = "/queue"
39
+ RESUME_COMMAND = "/resume"
40
+ COMPACT_COMMAND = "/compact"
30
41
  CliSessionMode = Literal["exec", "tui"]
31
42
  LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
32
43
  CLI_ORIGINATOR = "codex-tui"
@@ -40,7 +51,7 @@ def launch_chat_completion_compat_server(*args, **kwargs):
40
51
  return launch_compat_server(*args, **kwargs)
41
52
 
42
53
 
43
- def configure_loguru() -> None:
54
+ def configure_loguru() -> 'None':
44
55
  try:
45
56
  from loguru import logger
46
57
  except ImportError: # pragma: no cover - dependency may be absent in minimal envs
@@ -61,7 +72,7 @@ def configure_loguru() -> None:
61
72
  logger.add(sys.stderr, level="DEBUG")
62
73
 
63
74
 
64
- def build_parser() -> argparse.ArgumentParser:
75
+ def build_parser() -> 'argparse.ArgumentParser':
65
76
  parser = argparse.ArgumentParser(
66
77
  prog="pycodex",
67
78
  description="Minimal Codex-style local CLI backed by ~/.codex/config.toml.",
@@ -131,11 +142,11 @@ def build_parser() -> argparse.ArgumentParser:
131
142
  return parser
132
143
 
133
144
 
134
- def should_run_interactive(prompt_parts: Sequence[str], stdin_is_tty: bool) -> bool:
145
+ def should_run_interactive(prompt_parts: 'Sequence[str]', stdin_is_tty: 'bool') -> 'bool':
135
146
  return not prompt_parts and stdin_is_tty
136
147
 
137
148
 
138
- def resolve_prompt_text(prompt_parts: Sequence[str]) -> str:
149
+ def resolve_prompt_text(prompt_parts: 'Sequence[str]') -> 'str':
139
150
  if prompt_parts:
140
151
  return " ".join(prompt_parts).strip()
141
152
 
@@ -148,8 +159,8 @@ def resolve_prompt_text(prompt_parts: Sequence[str]) -> str:
148
159
 
149
160
 
150
161
  def get_tools(
151
- runtime_environment: RuntimeEnvironment | None = None,
152
- exec_mode: bool = False,
162
+ runtime_environment: 'typing.Union[RuntimeEnvironment, None]' = None,
163
+ exec_mode: 'bool' = False,
153
164
  ):
154
165
  from .tools import (
155
166
  ApplyPatchTool,
@@ -243,7 +254,7 @@ def get_tools(
243
254
  return registry
244
255
 
245
256
 
246
- def get_subagent_tools(runtime_environment: RuntimeEnvironment | None = None):
257
+ def get_subagent_tools(runtime_environment: 'typing.Union[RuntimeEnvironment, None]' = None):
247
258
  from .tools import (
248
259
  ApplyPatchTool,
249
260
  ExecCommandTool,
@@ -268,13 +279,13 @@ def get_subagent_tools(runtime_environment: RuntimeEnvironment | None = None):
268
279
 
269
280
 
270
281
  def build_runtime(
271
- config_path: str,
272
- profile: str | None,
273
- system_prompt: str | None,
282
+ config_path: 'str',
283
+ profile: 'typing.Union[str, None]',
284
+ system_prompt: 'typing.Union[str, None]',
274
285
  client,
275
- session_mode: CliSessionMode = "exec",
276
- collaboration_mode: CollaborationMode = DEFAULT_COLLABORATION_MODE,
277
- ) -> AgentRuntime:
286
+ session_mode: 'CliSessionMode' = "exec",
287
+ collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
288
+ ) -> 'AgentRuntime':
278
289
  use_tui_context = session_mode == "tui"
279
290
  context_manager = ContextManager.from_codex_config(
280
291
  config_path,
@@ -283,6 +294,9 @@ def build_runtime(
283
294
  collaboration_mode=collaboration_mode,
284
295
  include_collaboration_instructions=use_tui_context,
285
296
  )
297
+ session_id = getattr(client, "_session_id", None) or uuid7_string()
298
+ if hasattr(client, "_session_id"):
299
+ client._session_id = session_id
286
300
  subagent_context_manager = ContextManager.from_codex_config(
287
301
  config_path,
288
302
  profile,
@@ -292,14 +306,22 @@ def build_runtime(
292
306
  runtime_environment = create_runtime_environment()
293
307
  runtime_environment.request_user_input_manager.set_handler(None)
294
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
+ )
295
317
 
296
318
  def make_subagent_runtime_builder(base_client):
297
319
  def build_subagent_runtime(
298
- model_override: str | None,
299
- reasoning_effort_override: str | None,
320
+ model_override: 'typing.Union[str, None]',
321
+ reasoning_effort_override: 'typing.Union[str, None]',
300
322
  initial_history=(),
301
- session_id: str | None = None,
302
- ) -> AgentRuntime:
323
+ session_id: 'typing.Union[str, None]' = None,
324
+ ) -> 'AgentRuntime':
303
325
  nested_client = base_client.with_overrides(
304
326
  model_override,
305
327
  reasoning_effort_override,
@@ -329,25 +351,28 @@ def build_runtime(
329
351
  )
330
352
  return AgentRuntime(
331
353
  AgentLoop(
332
- 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,
333
358
  ),
334
359
  runtime_environment=runtime_environment,
335
360
  )
336
361
 
337
362
 
338
- def format_turn_output(result, json_mode: bool) -> str:
363
+ def format_turn_output(result, json_mode: 'bool') -> 'str':
339
364
  if json_mode:
340
365
  return json.dumps(asdict(result), ensure_ascii=False, indent=2)
341
366
  return result.output_text or ""
342
367
 
343
368
 
344
369
  def _build_model_client(
345
- config_path: str,
346
- profile: str | None,
347
- timeout_seconds: float,
348
- managed_responses_base_url: str | None = None,
349
- vllm_endpoint: str | None = None,
350
- use_chat_completion: bool = False,
370
+ config_path: 'str',
371
+ profile: 'typing.Union[str, None]',
372
+ timeout_seconds: 'float',
373
+ managed_responses_base_url: 'typing.Union[str, None]' = None,
374
+ vllm_endpoint: 'typing.Union[str, None]' = None,
375
+ use_chat_completion: 'bool' = False,
351
376
  ):
352
377
  load_codex_dotenv(config_path)
353
378
  provider_config = ResponsesProviderConfig.from_codex_config(
@@ -393,13 +418,13 @@ def _build_model_client(
393
418
 
394
419
 
395
420
  async def prompt_request_user_input(
396
- view: CliSessionView,
397
- payload: dict[str, object],
398
- ) -> dict[str, object] | None:
421
+ view: 'CliSessionView',
422
+ payload: 'typing.Dict[str, object]',
423
+ ) -> 'typing.Union[typing.Dict[str, object], None]':
399
424
  view.finish_stream()
400
425
  view.pause_spinner()
401
426
  view.write_line("[request_user_input] waiting for user response")
402
- answers: dict[str, dict[str, list[str]]] = {}
427
+ answers: 'typing.Dict[str, typing.Dict[str, typing.List[str]]]' = {}
403
428
  try:
404
429
  for question in payload.get("questions", []):
405
430
  if not isinstance(question, dict):
@@ -456,9 +481,9 @@ async def prompt_request_user_input(
456
481
 
457
482
 
458
483
  async def prompt_request_permissions(
459
- view: CliSessionView,
460
- payload: dict[str, object],
461
- ) -> dict[str, object] | None:
484
+ view: 'CliSessionView',
485
+ payload: 'typing.Dict[str, object]',
486
+ ) -> 'typing.Union[typing.Dict[str, object], None]':
462
487
  view.finish_stream()
463
488
  view.pause_spinner()
464
489
  view.write_line("[request_permissions] user approval required")
@@ -495,14 +520,18 @@ async def prompt_request_permissions(
495
520
 
496
521
 
497
522
  async def run_interactive_session(
498
- runtime: AgentRuntime,
499
- json_mode: bool,
500
- ) -> int:
523
+ runtime: 'AgentRuntime',
524
+ json_mode: 'bool',
525
+ config_path: 'typing.Union[str, None]' = None,
526
+ ) -> 'int':
501
527
  worker = asyncio.create_task(runtime.run_forever())
528
+ context_window_tokens = runtime._agent_loop._context_manager.resolve_model_context_window()
502
529
  view = CliSessionView()
530
+ view.set_context_window_tokens(context_window_tokens)
503
531
  model_client = runtime._agent_loop._model_client
532
+ codex_home = resolve_codex_home(config_path)
504
533
  runtime.set_event_handler(view.handle_event)
505
- pending_turn_tasks: set[asyncio.Task[None]] = set()
534
+ pending_turn_tasks: 'typing.Set[asyncio.Task[None]]' = set()
506
535
  runtime_environment = runtime.runtime_environment
507
536
  if runtime_environment is None:
508
537
  runtime_environment = create_runtime_environment()
@@ -514,16 +543,49 @@ async def run_interactive_session(
514
543
  lambda payload: prompt_request_permissions(view, payload)
515
544
  )
516
545
  view.write_line("pycodex interactive mode. Type /exit to quit.")
517
- view.write_line("Extra commands: /history, /title, /model")
546
+ view.write_line("Extra commands: /history, /title, /model, /resume, /compact")
518
547
  try:
519
548
 
520
- def has_pending_turn_tasks() -> bool:
549
+ def has_pending_turn_tasks() -> 'bool':
521
550
  pending_turn_tasks.difference_update(
522
551
  task for task in tuple(pending_turn_tasks) if task.done()
523
552
  )
524
553
  return bool(pending_turn_tasks)
525
554
 
526
- async def wait_for_turn_result(future) -> None:
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
+
588
+ async def wait_for_turn_result(future) -> 'None':
527
589
  try:
528
590
  result = await future
529
591
  except Exception as exc: # pragma: no cover - defensive surface
@@ -555,6 +617,50 @@ async def run_interactive_session(
555
617
  if prompt_text == TITLE_COMMAND:
556
618
  view.show_title()
557
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
558
664
  if prompt_text.startswith(f"{QUEUE_COMMAND} "):
559
665
  queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
560
666
  if not queued_text:
@@ -618,7 +724,7 @@ async def run_interactive_session(
618
724
  return 0
619
725
 
620
726
 
621
- async def run_cli(args: argparse.Namespace) -> int:
727
+ async def run_cli(args: 'argparse.Namespace') -> 'int':
622
728
  runtime = None
623
729
  worker = None
624
730
  try:
@@ -628,7 +734,7 @@ async def run_cli(args: argparse.Namespace) -> int:
628
734
  raise ValueError("--put does not accept prompt text")
629
735
  configure_loguru()
630
736
  if args.put is not None:
631
- def emit_put_log(message: str) -> None:
737
+ def emit_put_log(message: 'str') -> 'None':
632
738
  print(message, flush=True)
633
739
 
634
740
  call_spec = upload_codex_home(args.put, event_handler=emit_put_log)
@@ -662,6 +768,7 @@ async def run_cli(args: argparse.Namespace) -> int:
662
768
  return await run_interactive_session(
663
769
  runtime,
664
770
  args.json,
771
+ args.config,
665
772
  )
666
773
  else:
667
774
  prompt_text = resolve_prompt_text(args.prompt)
@@ -678,7 +785,7 @@ async def run_cli(args: argparse.Namespace) -> int:
678
785
  await worker
679
786
 
680
787
 
681
- def main(argv: Sequence[str] | None = None) -> int:
788
+ def main(argv: 'typing.Union[Sequence[str], None]' = None) -> 'int':
682
789
  raw_args = list(argv) if argv is not None else None
683
790
  if raw_args is None:
684
791
  raw_args = sys.argv[1:]
pycodex/collaboration.py CHANGED
@@ -1,13 +1,13 @@
1
- from __future__ import annotations
2
1
 
3
- from typing import Literal
2
+ from .compat import Literal
3
+ import typing
4
4
 
5
5
  CollaborationMode = Literal["default", "plan", "execute", "pair_programming"]
6
6
 
7
- DEFAULT_COLLABORATION_MODE: CollaborationMode = "default"
8
- PLAN_COLLABORATION_MODE: CollaborationMode = "plan"
7
+ DEFAULT_COLLABORATION_MODE: 'CollaborationMode' = "default"
8
+ PLAN_COLLABORATION_MODE: 'CollaborationMode' = "plan"
9
9
 
10
- _MODE_DISPLAY_NAMES: dict[str, str] = {
10
+ _MODE_DISPLAY_NAMES: 'typing.Dict[str, str]' = {
11
11
  "default": "Default",
12
12
  "plan": "Plan",
13
13
  "execute": "Execute",
@@ -15,7 +15,6 @@ _MODE_DISPLAY_NAMES: dict[str, str] = {
15
15
  }
16
16
 
17
17
 
18
- def collaboration_mode_display_name(mode: str | None) -> str:
18
+ def collaboration_mode_display_name(mode: 'typing.Union[str, None]') -> 'str':
19
19
  normalized = (mode or DEFAULT_COLLABORATION_MODE).strip().lower()
20
20
  return _MODE_DISPLAY_NAMES.get(normalized, normalized.replace("_", " ").title())
21
-