python-codex 0.1.13__py3-none-any.whl → 0.1.14__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 +77 -12
- pycodex/cli.py +13 -356
- pycodex/feishu_card.py +76 -30
- pycodex/feishu_link.py +131 -11
- pycodex/interactive_session.py +397 -0
- pycodex/model.py +1 -19
- pycodex/protocol.py +0 -5
- pycodex/runtime.py +13 -0
- pycodex/runtime_services.py +2 -2
- pycodex/tools/agent_tool_schemas.py +1 -1
- pycodex/tools/apply_patch_tool.py +1 -1
- pycodex/tools/base_tool.py +1 -27
- pycodex/tools/close_agent_tool.py +11 -4
- pycodex/tools/exec_command_tool.py +40 -16
- pycodex/tools/exec_tool.py +18 -2
- pycodex/tools/grep_files_tool.py +19 -6
- pycodex/tools/ipython_tool.py +3 -2
- pycodex/tools/list_dir_tool.py +19 -6
- pycodex/tools/read_file_tool.py +39 -9
- pycodex/tools/request_permissions_tool.py +12 -1
- pycodex/tools/request_user_input_tool.py +28 -1
- pycodex/tools/send_input_tool.py +4 -2
- pycodex/tools/shell_command_tool.py +23 -6
- pycodex/tools/shell_tool.py +13 -4
- pycodex/tools/spawn_agent_tool.py +31 -8
- pycodex/tools/unified_exec_manager.py +42 -1
- pycodex/tools/update_plan_tool.py +14 -6
- pycodex/tools/view_image_tool.py +17 -16
- pycodex/tools/wait_agent_tool.py +15 -3
- pycodex/tools/wait_tool.py +18 -4
- pycodex/tools/web_search_tool.py +2 -1
- pycodex/tools/write_stdin_tool.py +42 -10
- pycodex/utils/compactor.py +7 -1
- pycodex/utils/visualize.py +34 -15
- {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/METADATA +4 -1
- {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/RECORD +43 -40
- {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/entry_points.txt +1 -0
- workspace_server/__init__.py +21 -0
- workspace_server/__main__.py +5 -0
- workspace_server/app.py +983 -0
- workspace_server/workspace.html +790 -0
- pycodex/prompts/exec_tools.json +0 -411
- pycodex/prompts/subagent_tools.json +0 -163
- {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/WHEEL +0 -0
- {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/licenses/LICENSE +0 -0
pycodex/agent.py
CHANGED
|
@@ -5,7 +5,7 @@ import re
|
|
|
5
5
|
from typing import Callable
|
|
6
6
|
|
|
7
7
|
from .context import ContextManager
|
|
8
|
-
from .model import ModelClient
|
|
8
|
+
from .model import ModelClient, ResponsesIncompleteError
|
|
9
9
|
from .protocol import (
|
|
10
10
|
AgentEvent,
|
|
11
11
|
AssistantMessage,
|
|
@@ -17,7 +17,7 @@ from .protocol import (
|
|
|
17
17
|
TurnResult,
|
|
18
18
|
UserMessage,
|
|
19
19
|
)
|
|
20
|
-
from .tools import ToolContext, ToolRegistry
|
|
20
|
+
from .tools import ExecCommandTool, ToolContext, ToolRegistry, UnifiedExecManager
|
|
21
21
|
from .utils import uuid7_string
|
|
22
22
|
import typing
|
|
23
23
|
|
|
@@ -46,6 +46,7 @@ _CONTEXT_LENGTH_ERROR_MARKERS = (
|
|
|
46
46
|
"exceeds the context window",
|
|
47
47
|
"exceeded the context window",
|
|
48
48
|
)
|
|
49
|
+
TERMINAL_TURN_EVENTS = {"turn_completed", "turn_failed", "turn_interrupted"}
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
class TurnInterrupted(RuntimeError):
|
|
@@ -85,6 +86,15 @@ class Agent:
|
|
|
85
86
|
self._last_total_usage_tokens: 'typing.Union[int, None]' = None
|
|
86
87
|
self.runtime_environment = runtime_environment
|
|
87
88
|
self.interrupt_asap = False
|
|
89
|
+
self._turn_running = False
|
|
90
|
+
exec_command_tool = self._tool_registry.get_tool("exec_command")
|
|
91
|
+
self._exec_manager = (
|
|
92
|
+
exec_command_tool._manager
|
|
93
|
+
if isinstance(exec_command_tool, ExecCommandTool)
|
|
94
|
+
else None
|
|
95
|
+
)
|
|
96
|
+
if self._exec_manager is not None:
|
|
97
|
+
self._exec_manager.set_notify_hook(self.maybe_invoke)
|
|
88
98
|
|
|
89
99
|
@property
|
|
90
100
|
def history(self) -> 'typing.Tuple[ConversationItem, ...]':
|
|
@@ -129,6 +139,7 @@ class Agent:
|
|
|
129
139
|
async def run_turn(
|
|
130
140
|
self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
|
|
131
141
|
) -> 'TurnResult':
|
|
142
|
+
self._turn_running = True
|
|
132
143
|
turn_id = turn_id or uuid7_string()
|
|
133
144
|
self.interrupt_asap = False
|
|
134
145
|
new_user_messages = [UserMessage(text=text) for text in texts]
|
|
@@ -168,16 +179,10 @@ class Agent:
|
|
|
168
179
|
item_count=len(response.items),
|
|
169
180
|
)
|
|
170
181
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
persisted_response_items.append(item)
|
|
176
|
-
if isinstance(item, AssistantMessage):
|
|
177
|
-
last_assistant_message = item.text
|
|
178
|
-
elif isinstance(item, ToolCall):
|
|
179
|
-
tool_calls.append(item)
|
|
180
|
-
self._persist_history_items(persisted_response_items)
|
|
182
|
+
recorded_items = self._record_model_response_items(response.items)
|
|
183
|
+
tool_calls = recorded_items[1]
|
|
184
|
+
if recorded_items[2] is not None:
|
|
185
|
+
last_assistant_message = recorded_items[2]
|
|
181
186
|
|
|
182
187
|
if not tool_calls:
|
|
183
188
|
self._raise_if_interrupt_requested(
|
|
@@ -191,6 +196,7 @@ class Agent:
|
|
|
191
196
|
iteration=iteration,
|
|
192
197
|
output_text=last_assistant_message,
|
|
193
198
|
)
|
|
199
|
+
self._turn_running = False
|
|
194
200
|
return TurnResult(
|
|
195
201
|
turn_id=turn_id,
|
|
196
202
|
output_text=last_assistant_message,
|
|
@@ -211,6 +217,7 @@ class Agent:
|
|
|
211
217
|
output_text=last_assistant_message,
|
|
212
218
|
)
|
|
213
219
|
except TurnInterrupted:
|
|
220
|
+
self._turn_running = False
|
|
214
221
|
raise
|
|
215
222
|
except Exception as exc:
|
|
216
223
|
context_usage = _usage_from_context_length_error(str(exc))
|
|
@@ -224,8 +231,29 @@ class Agent:
|
|
|
224
231
|
error=str(exc),
|
|
225
232
|
error_type=type(exc).__name__,
|
|
226
233
|
)
|
|
234
|
+
self._turn_running = False
|
|
227
235
|
raise
|
|
228
236
|
|
|
237
|
+
async def maybe_invoke(self, event: 'typing.Dict[str, object]') -> 'bool':
|
|
238
|
+
if self._turn_running or event.get("type") != "exec_command_completed":
|
|
239
|
+
return False
|
|
240
|
+
payload = {
|
|
241
|
+
"session_id": event.get("session_id"),
|
|
242
|
+
"exit_code": event.get("exit_code"),
|
|
243
|
+
"command": event.get("command"),
|
|
244
|
+
}
|
|
245
|
+
text = (
|
|
246
|
+
"<exec_command_completed>\n"
|
|
247
|
+
f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
|
|
248
|
+
"</exec_command_completed>"
|
|
249
|
+
)
|
|
250
|
+
self._turn_running = True
|
|
251
|
+
task = asyncio.create_task(self.run_turn([text]))
|
|
252
|
+
task.add_done_callback(
|
|
253
|
+
lambda task: None if task.cancelled() else task.exception()
|
|
254
|
+
)
|
|
255
|
+
return True
|
|
256
|
+
|
|
229
257
|
async def _execute_tool_batch(
|
|
230
258
|
self,
|
|
231
259
|
turn_id: 'str',
|
|
@@ -294,10 +322,18 @@ class Agent:
|
|
|
294
322
|
return result
|
|
295
323
|
|
|
296
324
|
def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
|
|
325
|
+
if kind in TERMINAL_TURN_EVENTS:
|
|
326
|
+
payload["background_exec_count"] = self._background_exec_count()
|
|
297
327
|
self._event_handler(
|
|
298
328
|
AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
|
|
299
329
|
)
|
|
300
330
|
|
|
331
|
+
def _background_exec_count(self) -> 'int':
|
|
332
|
+
manager: 'typing.Union[UnifiedExecManager, None]' = self._exec_manager
|
|
333
|
+
if manager is None:
|
|
334
|
+
return 0
|
|
335
|
+
return manager.running_session_count()
|
|
336
|
+
|
|
301
337
|
def _persist_history_items(
|
|
302
338
|
self,
|
|
303
339
|
items: 'typing.Iterable[ConversationItem]',
|
|
@@ -310,6 +346,28 @@ class Agent:
|
|
|
310
346
|
except Exception: # pragma: no cover - persistence should not break turns
|
|
311
347
|
return
|
|
312
348
|
|
|
349
|
+
def _record_model_response_items(
|
|
350
|
+
self,
|
|
351
|
+
items: 'typing.Iterable[object]',
|
|
352
|
+
include_tool_calls: 'bool' = True,
|
|
353
|
+
) -> 'typing.Tuple[typing.Tuple[ConversationItem, ...], typing.List[ToolCall], typing.Union[str, None]]':
|
|
354
|
+
persisted_response_items: 'typing.List[ConversationItem]' = []
|
|
355
|
+
tool_calls: 'typing.List[ToolCall]' = []
|
|
356
|
+
last_assistant_message = None
|
|
357
|
+
for item in items:
|
|
358
|
+
if isinstance(item, ToolCall) and not include_tool_calls:
|
|
359
|
+
continue
|
|
360
|
+
if not isinstance(item, (AssistantMessage, ToolCall, ReasoningItem)):
|
|
361
|
+
continue
|
|
362
|
+
self._history.append(item)
|
|
363
|
+
persisted_response_items.append(item)
|
|
364
|
+
if isinstance(item, AssistantMessage):
|
|
365
|
+
last_assistant_message = item.text
|
|
366
|
+
elif isinstance(item, ToolCall):
|
|
367
|
+
tool_calls.append(item)
|
|
368
|
+
self._persist_history_items(persisted_response_items)
|
|
369
|
+
return tuple(persisted_response_items), tool_calls, last_assistant_message
|
|
370
|
+
|
|
313
371
|
def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
|
|
314
372
|
if event.kind == "token_count":
|
|
315
373
|
self._remember_token_usage(event.payload.get("usage"))
|
|
@@ -355,6 +413,13 @@ class Agent:
|
|
|
355
413
|
prompt,
|
|
356
414
|
lambda event: self._handle_model_stream_event(turn_id, event),
|
|
357
415
|
)
|
|
416
|
+
except ResponsesIncompleteError as exc:
|
|
417
|
+
if exc.reason == "max_output_tokens":
|
|
418
|
+
self._record_model_response_items(
|
|
419
|
+
exc.partial_items,
|
|
420
|
+
include_tool_calls=False,
|
|
421
|
+
)
|
|
422
|
+
raise
|
|
358
423
|
except Exception as exc:
|
|
359
424
|
error_message = str(exc)
|
|
360
425
|
if (
|
pycodex/cli.py
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
import atexit
|
|
3
3
|
import argparse
|
|
4
4
|
import asyncio
|
|
5
|
-
import json
|
|
6
5
|
import os
|
|
7
6
|
import shlex
|
|
8
7
|
import sys
|
|
9
8
|
import tempfile
|
|
10
9
|
import traceback
|
|
11
|
-
from dataclasses import
|
|
10
|
+
from dataclasses import replace
|
|
12
11
|
from pathlib import Path
|
|
13
12
|
from typing import Sequence
|
|
14
13
|
|
|
@@ -18,32 +17,22 @@ from .compat import Literal
|
|
|
18
17
|
from .context import ContextManager
|
|
19
18
|
from .model import DEFAULT_CODEX_CONFIG_PATH, ResponsesModelClient, ResponsesProviderConfig
|
|
20
19
|
from .portable import bootstrap_called_home, upload_codex_home
|
|
21
|
-
from .protocol import AgentEvent
|
|
22
20
|
from .runtime import CliSubmissionQueue
|
|
23
21
|
from .runtime_services import AgentRuntimeEnvironment, create_agent_runtime_environment
|
|
24
22
|
from .utils import CliSessionView, get_debug_dir, load_codex_dotenv, uuid7_string
|
|
25
|
-
from .
|
|
23
|
+
from .interactive_session import (
|
|
24
|
+
EXTRA_COMMANDS_LINE,
|
|
25
|
+
format_turn_output,
|
|
26
|
+
run_interactive_session as _run_interactive_session,
|
|
27
|
+
prompt_request_permissions,
|
|
28
|
+
prompt_request_user_input,
|
|
29
|
+
)
|
|
26
30
|
from .utils.session_persist import (
|
|
27
31
|
SessionRolloutRecorder,
|
|
28
|
-
conversation_history_to_turns,
|
|
29
|
-
list_resumable_sessions,
|
|
30
|
-
load_resumed_session,
|
|
31
32
|
resolve_codex_home,
|
|
32
33
|
)
|
|
33
34
|
import typing
|
|
34
35
|
|
|
35
|
-
EXIT_COMMANDS = {"/exit", "/quit"}
|
|
36
|
-
HISTORY_COMMAND = "/history"
|
|
37
|
-
TITLE_COMMAND = "/title"
|
|
38
|
-
MODEL_COMMAND = "/model"
|
|
39
|
-
QUEUE_COMMAND = "/queue"
|
|
40
|
-
RESUME_COMMAND = "/resume"
|
|
41
|
-
COMPACT_COMMAND = "/compact"
|
|
42
|
-
LINK_COMMAND = "/link"
|
|
43
|
-
UNLINK_COMMAND = "/unlink"
|
|
44
|
-
EXTRA_COMMANDS_LINE = (
|
|
45
|
-
"Extra commands: /history, /title, /model, /resume, /compact, /link, /unlink"
|
|
46
|
-
)
|
|
47
36
|
CliSessionMode = Literal["exec", "tui"]
|
|
48
37
|
LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
|
|
49
38
|
CLI_ORIGINATOR = "codex-tui"
|
|
@@ -372,12 +361,6 @@ def build_agent(
|
|
|
372
361
|
)
|
|
373
362
|
|
|
374
363
|
|
|
375
|
-
def format_turn_output(result, json_mode: 'bool') -> 'str':
|
|
376
|
-
if json_mode:
|
|
377
|
-
return json.dumps(asdict(result), ensure_ascii=False, indent=2)
|
|
378
|
-
return result.output_text or ""
|
|
379
|
-
|
|
380
|
-
|
|
381
364
|
def build_model(
|
|
382
365
|
config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
|
|
383
366
|
profile: 'typing.Union[str, None]' = None,
|
|
@@ -443,343 +426,17 @@ def build_cli_queue(agent: 'Agent') -> 'CliSubmissionQueue':
|
|
|
443
426
|
return CliSubmissionQueue(agent)
|
|
444
427
|
|
|
445
428
|
|
|
446
|
-
async def prompt_request_user_input(
|
|
447
|
-
view: 'CliSessionView',
|
|
448
|
-
payload: 'typing.Dict[str, object]',
|
|
449
|
-
) -> 'typing.Union[typing.Dict[str, object], None]':
|
|
450
|
-
view.finish_stream()
|
|
451
|
-
view.write_line("[request_user_input] waiting for user response")
|
|
452
|
-
answers: 'typing.Dict[str, typing.Dict[str, typing.List[str]]]' = {}
|
|
453
|
-
for question in payload.get("questions", []):
|
|
454
|
-
if not isinstance(question, dict):
|
|
455
|
-
continue
|
|
456
|
-
header = str(question.get("header", "")).strip()
|
|
457
|
-
question_text = str(question.get("question", "")).strip()
|
|
458
|
-
question_id = str(question.get("id", "")).strip()
|
|
459
|
-
if header:
|
|
460
|
-
view.write_line(f"[{header}] {question_text}")
|
|
461
|
-
else:
|
|
462
|
-
view.write_line(question_text)
|
|
463
|
-
|
|
464
|
-
options = question.get("options") or []
|
|
465
|
-
if isinstance(options, list):
|
|
466
|
-
for index, option in enumerate(options, start=1):
|
|
467
|
-
if not isinstance(option, dict):
|
|
468
|
-
continue
|
|
469
|
-
label = str(option.get("label", "")).strip()
|
|
470
|
-
description = str(option.get("description", "")).strip()
|
|
471
|
-
view.write_line(f" {index}. {label} - {description}")
|
|
472
|
-
view.write_line(" 0. Other")
|
|
473
|
-
|
|
474
|
-
try:
|
|
475
|
-
raw_answer = await view.get_prompt("answer> ")
|
|
476
|
-
except EOFError:
|
|
477
|
-
return None
|
|
478
|
-
answer_text = raw_answer.strip()
|
|
479
|
-
if not answer_text:
|
|
480
|
-
return None
|
|
481
|
-
|
|
482
|
-
selected_answer = answer_text
|
|
483
|
-
if answer_text.isdigit() and isinstance(options, list):
|
|
484
|
-
choice = int(answer_text)
|
|
485
|
-
if 1 <= choice <= len(options):
|
|
486
|
-
option = options[choice - 1]
|
|
487
|
-
if isinstance(option, dict):
|
|
488
|
-
selected_answer = (
|
|
489
|
-
str(option.get("label", "")).strip() or answer_text
|
|
490
|
-
)
|
|
491
|
-
elif choice == 0:
|
|
492
|
-
try:
|
|
493
|
-
raw_answer = await view.get_prompt("other> ")
|
|
494
|
-
except EOFError:
|
|
495
|
-
return None
|
|
496
|
-
selected_answer = raw_answer.strip()
|
|
497
|
-
if not selected_answer:
|
|
498
|
-
return None
|
|
499
|
-
|
|
500
|
-
answers[question_id] = {"answers": [selected_answer]}
|
|
501
|
-
|
|
502
|
-
return {"answers": answers}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
async def prompt_request_permissions(
|
|
506
|
-
view: 'CliSessionView',
|
|
507
|
-
payload: 'typing.Dict[str, object]',
|
|
508
|
-
) -> 'typing.Union[typing.Dict[str, object], None]':
|
|
509
|
-
view.finish_stream()
|
|
510
|
-
view.write_line("[request_permissions] user approval required")
|
|
511
|
-
reason = payload.get("reason")
|
|
512
|
-
if reason:
|
|
513
|
-
view.write_line(f"Reason: {reason}")
|
|
514
|
-
view.write_line("Requested permissions:")
|
|
515
|
-
view.write_line(
|
|
516
|
-
json.dumps(payload.get("permissions", {}), ensure_ascii=False, indent=2)
|
|
517
|
-
)
|
|
518
|
-
view.write_line("Choose: [n] deny / [t] grant for turn / [s] grant for session")
|
|
519
|
-
try:
|
|
520
|
-
raw_answer = await view.get_prompt("permissions> ")
|
|
521
|
-
except EOFError:
|
|
522
|
-
return None
|
|
523
|
-
|
|
524
|
-
answer = raw_answer.strip().lower()
|
|
525
|
-
if answer in {"t", "turn", "y", "yes"}:
|
|
526
|
-
return {
|
|
527
|
-
"permissions": payload.get("permissions", {}),
|
|
528
|
-
"scope": "turn",
|
|
529
|
-
}
|
|
530
|
-
if answer in {"s", "session"}:
|
|
531
|
-
return {
|
|
532
|
-
"permissions": payload.get("permissions", {}),
|
|
533
|
-
"scope": "session",
|
|
534
|
-
}
|
|
535
|
-
return {
|
|
536
|
-
"permissions": {},
|
|
537
|
-
"scope": "turn",
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
|
|
541
429
|
async def run_interactive_session(
|
|
542
430
|
queue: 'CliSubmissionQueue',
|
|
543
431
|
json_mode: 'bool',
|
|
544
432
|
config_path: 'typing.Union[str, None]' = None,
|
|
545
433
|
) -> 'int':
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
codex_home = resolve_codex_home(config_path)
|
|
552
|
-
queue.set_event_handler(view.handle_event)
|
|
553
|
-
pending_turn_tasks: 'typing.Set[asyncio.Task[None]]' = set()
|
|
554
|
-
runtime_environment = queue._agent.runtime_environment
|
|
555
|
-
if runtime_environment is None:
|
|
556
|
-
runtime_environment = create_agent_runtime_environment()
|
|
557
|
-
queue._agent.runtime_environment = runtime_environment
|
|
558
|
-
runtime_environment.request_user_input_manager.set_handler(
|
|
559
|
-
lambda payload: prompt_request_user_input(view, payload)
|
|
560
|
-
)
|
|
561
|
-
runtime_environment.request_permissions_manager.set_handler(
|
|
562
|
-
lambda payload: prompt_request_permissions(view, payload)
|
|
434
|
+
return await _run_interactive_session(
|
|
435
|
+
queue,
|
|
436
|
+
json_mode,
|
|
437
|
+
config_path,
|
|
438
|
+
view_factory=CliSessionView,
|
|
563
439
|
)
|
|
564
|
-
view.write_line("pycodex interactive mode. Type /exit to quit.")
|
|
565
|
-
view.write_line(EXTRA_COMMANDS_LINE)
|
|
566
|
-
feishu_link = None
|
|
567
|
-
try:
|
|
568
|
-
|
|
569
|
-
def has_pending_turn_tasks() -> 'bool':
|
|
570
|
-
pending_turn_tasks.difference_update(
|
|
571
|
-
task for task in tuple(pending_turn_tasks) if task.done()
|
|
572
|
-
)
|
|
573
|
-
return bool(pending_turn_tasks)
|
|
574
|
-
|
|
575
|
-
async def run_manual_compact() -> 'None':
|
|
576
|
-
current_agent = queue._agent
|
|
577
|
-
if not current_agent.history:
|
|
578
|
-
view.write_line("Nothing to compact.")
|
|
579
|
-
return
|
|
580
|
-
|
|
581
|
-
compact_turn_id = uuid7_string()
|
|
582
|
-
|
|
583
|
-
def handle_compact_stream_event(event) -> 'None':
|
|
584
|
-
if event.kind not in {"token_count", "stream_error"}:
|
|
585
|
-
return
|
|
586
|
-
view.handle_event(
|
|
587
|
-
AgentEvent(
|
|
588
|
-
kind=event.kind,
|
|
589
|
-
turn_id=compact_turn_id,
|
|
590
|
-
payload=dict(event.payload),
|
|
591
|
-
)
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
view.write_line("Compacting conversation history...")
|
|
595
|
-
compact_result = await compact_agent(
|
|
596
|
-
current_agent,
|
|
597
|
-
handle_compact_stream_event,
|
|
598
|
-
True,
|
|
599
|
-
)
|
|
600
|
-
if compact_result is None:
|
|
601
|
-
view.write_line("Nothing to compact.")
|
|
602
|
-
return
|
|
603
|
-
view.load_session_history(
|
|
604
|
-
getattr(view, "_title", None),
|
|
605
|
-
conversation_history_to_turns(compact_result.history),
|
|
606
|
-
)
|
|
607
|
-
view.write_line(compact_result.display_text())
|
|
608
|
-
|
|
609
|
-
async def wait_for_turn_result(future) -> 'None':
|
|
610
|
-
try:
|
|
611
|
-
result = await future
|
|
612
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
613
|
-
if str(exc) == "submission interrupted":
|
|
614
|
-
return
|
|
615
|
-
view.show_error(str(exc))
|
|
616
|
-
return
|
|
617
|
-
|
|
618
|
-
if json_mode:
|
|
619
|
-
view.write_line(format_turn_output(result, True))
|
|
620
|
-
|
|
621
|
-
while True:
|
|
622
|
-
try:
|
|
623
|
-
raw_line = await view.poll_prompt()
|
|
624
|
-
except EOFError:
|
|
625
|
-
break
|
|
626
|
-
if raw_line is None:
|
|
627
|
-
await asyncio.sleep(0.05)
|
|
628
|
-
continue
|
|
629
|
-
|
|
630
|
-
prompt_text = raw_line.strip()
|
|
631
|
-
if not prompt_text:
|
|
632
|
-
continue
|
|
633
|
-
if prompt_text in EXIT_COMMANDS:
|
|
634
|
-
break
|
|
635
|
-
if prompt_text == HISTORY_COMMAND:
|
|
636
|
-
view.show_history()
|
|
637
|
-
continue
|
|
638
|
-
if prompt_text == TITLE_COMMAND:
|
|
639
|
-
view.show_title()
|
|
640
|
-
continue
|
|
641
|
-
if prompt_text == RESUME_COMMAND:
|
|
642
|
-
sessions = list_resumable_sessions(codex_home)
|
|
643
|
-
if not sessions:
|
|
644
|
-
view.write_line("No resumable sessions found.")
|
|
645
|
-
continue
|
|
646
|
-
view.write_line("Available sessions:")
|
|
647
|
-
for index, session in enumerate(sessions, start=1):
|
|
648
|
-
view.write_line(f"[{index}] {session['preview']}")
|
|
649
|
-
continue
|
|
650
|
-
if prompt_text.startswith(f"{RESUME_COMMAND} "):
|
|
651
|
-
if has_pending_turn_tasks():
|
|
652
|
-
view.write_line(
|
|
653
|
-
"Cannot resume while work is running or queued."
|
|
654
|
-
)
|
|
655
|
-
continue
|
|
656
|
-
resume_target = prompt_text[len(RESUME_COMMAND) :].strip()
|
|
657
|
-
try:
|
|
658
|
-
resumed = load_resumed_session(codex_home, resume_target)
|
|
659
|
-
queue._agent.replace_history(resumed["history"])
|
|
660
|
-
if hasattr(model_client, "_session_id"):
|
|
661
|
-
model_client._session_id = str(resumed["session_id"])
|
|
662
|
-
queue._agent.set_rollout_recorder(
|
|
663
|
-
SessionRolloutRecorder.resume(resumed["rollout_path"])
|
|
664
|
-
)
|
|
665
|
-
view.load_session_history(
|
|
666
|
-
str(resumed["title"]),
|
|
667
|
-
tuple(resumed["turns"]),
|
|
668
|
-
)
|
|
669
|
-
view.write_line(f"Resumed session: {resumed['title']}")
|
|
670
|
-
view.show_history()
|
|
671
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
672
|
-
view.show_error(str(exc))
|
|
673
|
-
continue
|
|
674
|
-
if prompt_text == COMPACT_COMMAND:
|
|
675
|
-
if has_pending_turn_tasks():
|
|
676
|
-
view.write_line(
|
|
677
|
-
"Cannot compact while work is running or queued."
|
|
678
|
-
)
|
|
679
|
-
continue
|
|
680
|
-
try:
|
|
681
|
-
await run_manual_compact()
|
|
682
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
683
|
-
view.show_error(str(exc))
|
|
684
|
-
continue
|
|
685
|
-
if prompt_text.startswith(f"{LINK_COMMAND} "):
|
|
686
|
-
link_target = prompt_text[len(LINK_COMMAND) :].strip()
|
|
687
|
-
if not link_target:
|
|
688
|
-
view.write_line("Usage: /link <feishu-email|open_id|chat_id>")
|
|
689
|
-
continue
|
|
690
|
-
if feishu_link:
|
|
691
|
-
view.write_line("A Feishu card is already linked. Use /unlink first.")
|
|
692
|
-
continue
|
|
693
|
-
try:
|
|
694
|
-
from .feishu_link import PycodexRuntimeLink
|
|
695
|
-
|
|
696
|
-
view.write_line(f"Linking Feishu card to current session: {link_target}")
|
|
697
|
-
link = await PycodexRuntimeLink(
|
|
698
|
-
queue,
|
|
699
|
-
link_target,
|
|
700
|
-
).start_async()
|
|
701
|
-
feishu_link = link
|
|
702
|
-
view.write_line(
|
|
703
|
-
"Linked Feishu card: session_key={0} message_id={1}".format(
|
|
704
|
-
link.session_key,
|
|
705
|
-
link.message_id or "-",
|
|
706
|
-
)
|
|
707
|
-
)
|
|
708
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
709
|
-
view.show_error(str(exc))
|
|
710
|
-
continue
|
|
711
|
-
if prompt_text == UNLINK_COMMAND:
|
|
712
|
-
if not feishu_link:
|
|
713
|
-
view.write_line("No Feishu card is linked.")
|
|
714
|
-
continue
|
|
715
|
-
feishu_link.detach()
|
|
716
|
-
feishu_link = None
|
|
717
|
-
view.write_line("Unlinked Feishu card.")
|
|
718
|
-
continue
|
|
719
|
-
if prompt_text.startswith(f"{QUEUE_COMMAND} "):
|
|
720
|
-
queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
|
|
721
|
-
if not queued_text:
|
|
722
|
-
view.write_line("Usage: /queue <message>")
|
|
723
|
-
continue
|
|
724
|
-
try:
|
|
725
|
-
submission_id, future = await queue.enqueue_user_turn(
|
|
726
|
-
queued_text, queue="enqueue"
|
|
727
|
-
)
|
|
728
|
-
view.show_steer_queued(submission_id, queued_text)
|
|
729
|
-
turn_task = asyncio.create_task(wait_for_turn_result(future))
|
|
730
|
-
pending_turn_tasks.add(turn_task)
|
|
731
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
732
|
-
view.show_error(str(exc))
|
|
733
|
-
continue
|
|
734
|
-
if prompt_text == MODEL_COMMAND:
|
|
735
|
-
view.write_line(
|
|
736
|
-
f"Current model: {getattr(model_client, 'model', None) or 'unavailable'}"
|
|
737
|
-
)
|
|
738
|
-
models = await model_client.list_models()
|
|
739
|
-
view.write_line(f"Available models: {', '.join(models)}")
|
|
740
|
-
continue
|
|
741
|
-
if prompt_text.startswith(f"{MODEL_COMMAND} "):
|
|
742
|
-
if has_pending_turn_tasks():
|
|
743
|
-
view.write_line(
|
|
744
|
-
"Cannot change model while work is running or queued in steer mode."
|
|
745
|
-
)
|
|
746
|
-
continue
|
|
747
|
-
model_name = prompt_text[len(MODEL_COMMAND) :].strip()
|
|
748
|
-
if not model_name:
|
|
749
|
-
view.write_line("Usage: /model <model>")
|
|
750
|
-
continue
|
|
751
|
-
|
|
752
|
-
model_client.model = model_name
|
|
753
|
-
view.write_line(f"Switched model to {model_name}.")
|
|
754
|
-
continue
|
|
755
|
-
|
|
756
|
-
try:
|
|
757
|
-
steered = has_pending_turn_tasks()
|
|
758
|
-
submission_id, future = await queue.enqueue_user_turn(
|
|
759
|
-
prompt_text,
|
|
760
|
-
queue="steer",
|
|
761
|
-
)
|
|
762
|
-
if steered:
|
|
763
|
-
view.schedule_steer_inserted(submission_id, prompt_text)
|
|
764
|
-
turn_task = asyncio.create_task(wait_for_turn_result(future))
|
|
765
|
-
pending_turn_tasks.add(turn_task)
|
|
766
|
-
continue
|
|
767
|
-
except Exception as exc: # pragma: no cover - defensive surface
|
|
768
|
-
view.show_error(str(exc))
|
|
769
|
-
continue
|
|
770
|
-
finally:
|
|
771
|
-
if feishu_link:
|
|
772
|
-
feishu_link.detach()
|
|
773
|
-
feishu_link.stop()
|
|
774
|
-
runtime_environment.request_user_input_manager.set_handler(None)
|
|
775
|
-
runtime_environment.request_permissions_manager.set_handler(None)
|
|
776
|
-
await queue.shutdown()
|
|
777
|
-
await worker
|
|
778
|
-
if pending_turn_tasks:
|
|
779
|
-
await asyncio.gather(*pending_turn_tasks, return_exceptions=True)
|
|
780
|
-
view.close()
|
|
781
|
-
|
|
782
|
-
return 0
|
|
783
440
|
|
|
784
441
|
|
|
785
442
|
async def run_cli(args: 'argparse.Namespace') -> 'int':
|