python-codex 0.2.6__py3-none-any.whl → 0.3.0__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 (84) hide show
  1. pycodex/__init__.py +18 -14
  2. pycodex/agent.py +468 -462
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -436
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +329 -252
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +90 -52
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +235 -803
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -174
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +4 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +201 -0
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +13 -13
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +50 -66
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/utils/image_utils.py +76 -0
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +263 -161
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +25 -22
  61. responses_server/messages_api.py +96 -49
  62. responses_server/payload_processors.py +25 -19
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +196 -107
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +51 -13
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +470 -384
  71. workspace_server/workspace.html +859 -232
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +168 -100
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -553
  80. python_codex-0.2.6.dist-info/METADATA +0 -441
  81. python_codex-0.2.6.dist-info/RECORD +0 -91
  82. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -11,6 +11,7 @@ Expected behavior:
11
11
  same success/error text shape Codex expects.
12
12
  """
13
13
 
14
+ import typing
14
15
  from dataclasses import dataclass
15
16
  from pathlib import Path
16
17
 
@@ -18,7 +19,6 @@ from loguru import logger
18
19
 
19
20
  from ..protocol import JSONValue
20
21
  from .base_tool import BaseTool, ToolContext
21
- import typing
22
22
 
23
23
  APPLY_PATCH_LARK_GRAMMAR = """start: begin_patch hunk+ end_patch
24
24
  begin_patch: \"*** Begin Patch\" LF
@@ -46,28 +46,36 @@ class ApplyPatchError(RuntimeError):
46
46
  pass
47
47
 
48
48
 
49
- @dataclass(frozen=True, )
49
+ @dataclass(
50
+ frozen=True,
51
+ )
50
52
  class _AddFileOp:
51
- path: 'str'
52
- content: 'str'
53
+ path: "str"
54
+ content: "str"
53
55
 
54
56
 
55
- @dataclass(frozen=True, )
57
+ @dataclass(
58
+ frozen=True,
59
+ )
56
60
  class _DeleteFileOp:
57
- path: 'str'
61
+ path: "str"
58
62
 
59
63
 
60
- @dataclass(frozen=True, )
64
+ @dataclass(
65
+ frozen=True,
66
+ )
61
67
  class _UpdateSection:
62
- lines: 'typing.Tuple[str, ...]'
63
- anchor_end_of_file: 'bool' = False
68
+ lines: "typing.Tuple[str, ...]"
69
+ anchor_end_of_file: "bool" = False
64
70
 
65
71
 
66
- @dataclass(frozen=True, )
72
+ @dataclass(
73
+ frozen=True,
74
+ )
67
75
  class _UpdateFileOp:
68
- path: 'str'
69
- move_to: 'typing.Union[str, None]'
70
- sections: 'typing.Tuple[_UpdateSection, ...]'
76
+ path: "str"
77
+ move_to: "typing.Union[str, None]"
78
+ sections: "typing.Tuple[_UpdateSection, ...]"
71
79
 
72
80
 
73
81
  class ApplyPatchTool(BaseTool):
@@ -84,20 +92,26 @@ class ApplyPatchTool(BaseTool):
84
92
  }
85
93
  supports_parallel = False
86
94
 
87
- def __init__(self, cwd: 'typing.Union[typing.Union[str, Path], None]' = None) -> 'None':
95
+ def __init__(
96
+ self, cwd: "typing.Union[typing.Union[str, Path], None]" = None
97
+ ) -> "None":
88
98
  self._workspace_root = Path(cwd or Path.cwd()).resolve()
89
99
 
90
- async def run(self, context: 'ToolContext', args: 'JSONValue') -> 'JSONValue':
100
+ async def run(self, context: "ToolContext", args: "JSONValue") -> "JSONValue":
91
101
  del context
92
102
  patch_text = str(args)
93
- logger.debug("apply_patch workspace={} bytes={}", self._workspace_root, len(patch_text))
103
+ logger.debug(
104
+ "apply_patch workspace={} bytes={}", self._workspace_root, len(patch_text)
105
+ )
94
106
  try:
95
107
  operations = self._parse_patch(patch_text)
96
108
  return self._format_result(self._apply_operations(operations), exit_code=0)
97
109
  except ApplyPatchError as exc:
98
110
  return self._format_result(str(exc), exit_code=1)
99
111
 
100
- def _parse_patch(self, patch_text: 'str') -> 'typing.List[typing.Union[typing.Union[_AddFileOp, _DeleteFileOp], _UpdateFileOp]]':
112
+ def _parse_patch(
113
+ self, patch_text: "str"
114
+ ) -> "typing.List[typing.Union[typing.Union[_AddFileOp, _DeleteFileOp], _UpdateFileOp]]":
101
115
  lines = patch_text.splitlines()
102
116
  if not lines:
103
117
  raise ApplyPatchError("patch rejected: empty patch")
@@ -106,7 +120,7 @@ class ApplyPatchTool(BaseTool):
106
120
  "apply_patch verification failed: missing '*** Begin Patch' header"
107
121
  )
108
122
 
109
- operations: 'typing.List[typing.Union[typing.Union[_AddFileOp, _DeleteFileOp], _UpdateFileOp]]' = []
123
+ operations: "typing.List[typing.Union[typing.Union[_AddFileOp, _DeleteFileOp], _UpdateFileOp]]" = ([])
110
124
  index = 1
111
125
  while index < len(lines):
112
126
  line = lines[index]
@@ -123,7 +137,7 @@ class ApplyPatchTool(BaseTool):
123
137
  if line.startswith("*** Add File: "):
124
138
  path = line[len("*** Add File: ") :]
125
139
  index += 1
126
- content_lines: 'typing.List[str]' = []
140
+ content_lines: "typing.List[str]" = []
127
141
  while index < len(lines) and not lines[index].startswith("*** "):
128
142
  entry = lines[index]
129
143
  if not entry.startswith("+"):
@@ -136,7 +150,9 @@ class ApplyPatchTool(BaseTool):
136
150
  raise ApplyPatchError(
137
151
  f"apply_patch verification failed: add for {path} is missing file content"
138
152
  )
139
- operations.append(_AddFileOp(path=path, content=self._join_lines(content_lines)))
153
+ operations.append(
154
+ _AddFileOp(path=path, content=self._join_lines(content_lines))
155
+ )
140
156
  continue
141
157
 
142
158
  if line.startswith("*** Delete File: "):
@@ -153,8 +169,8 @@ class ApplyPatchTool(BaseTool):
153
169
  move_to = lines[index][len("*** Move to: ") :]
154
170
  index += 1
155
171
 
156
- sections: 'typing.List[_UpdateSection]' = []
157
- current_lines: 'typing.List[str]' = []
172
+ sections: "typing.List[_UpdateSection]" = []
173
+ current_lines: "typing.List[str]" = []
158
174
  saw_hunk_header = False
159
175
  anchor_end_of_file = False
160
176
  while index < len(lines):
@@ -216,14 +232,16 @@ class ApplyPatchTool(BaseTool):
216
232
  f"apply_patch verification failed: {line!r} is not a valid hunk header"
217
233
  )
218
234
 
219
- raise ApplyPatchError("apply_patch verification failed: missing '*** End Patch' footer")
235
+ raise ApplyPatchError(
236
+ "apply_patch verification failed: missing '*** End Patch' footer"
237
+ )
220
238
 
221
239
  def _apply_operations(
222
240
  self,
223
- operations: 'typing.List[typing.Union[typing.Union[_AddFileOp, _DeleteFileOp], _UpdateFileOp]]',
224
- ) -> 'str':
225
- preview: 'typing.Dict[Path, typing.Union[str, None]]' = {}
226
- summaries: 'typing.Dict[Path, str]' = {}
241
+ operations: "typing.List[typing.Union[typing.Union[_AddFileOp, _DeleteFileOp], _UpdateFileOp]]",
242
+ ) -> "str":
243
+ preview: "typing.Dict[Path, typing.Union[str, None]]" = {}
244
+ summaries: "typing.Dict[Path, str]" = {}
227
245
 
228
246
  for operation in operations:
229
247
  if isinstance(operation, _AddFileOp):
@@ -253,7 +271,9 @@ class ApplyPatchTool(BaseTool):
253
271
  self._write_preview(preview)
254
272
  return self._format_success(summaries)
255
273
 
256
- def _read_preview_file(self, path: 'Path', preview: 'typing.Dict[Path, typing.Union[str, None]]') -> 'str':
274
+ def _read_preview_file(
275
+ self, path: "Path", preview: "typing.Dict[Path, typing.Union[str, None]]"
276
+ ) -> "str":
257
277
  if path in preview:
258
278
  content = preview[path]
259
279
  if content is None:
@@ -270,10 +290,10 @@ class ApplyPatchTool(BaseTool):
270
290
 
271
291
  def _apply_update(
272
292
  self,
273
- path: 'Path',
274
- original_text: 'str',
275
- sections: 'typing.Tuple[_UpdateSection, ...]',
276
- ) -> 'str':
293
+ path: "Path",
294
+ original_text: "str",
295
+ sections: "typing.Tuple[_UpdateSection, ...]",
296
+ ) -> "str":
277
297
  lines = original_text.splitlines()
278
298
  cursor = 0
279
299
  for section in sections:
@@ -282,7 +302,9 @@ class ApplyPatchTool(BaseTool):
282
302
  if not old_block and not new_block:
283
303
  continue
284
304
 
285
- match_index = self._find_match(lines, old_block, cursor, section.anchor_end_of_file)
305
+ match_index = self._find_match(
306
+ lines, old_block, cursor, section.anchor_end_of_file
307
+ )
286
308
  if match_index is None:
287
309
  raise ApplyPatchError(
288
310
  "apply_patch verification failed: Failed to find expected lines in "
@@ -294,11 +316,11 @@ class ApplyPatchTool(BaseTool):
294
316
 
295
317
  def _find_match(
296
318
  self,
297
- lines: 'typing.List[str]',
298
- old_block: 'typing.List[str]',
299
- cursor: 'int',
300
- anchor_end_of_file: 'bool',
301
- ) -> 'typing.Union[int, None]':
319
+ lines: "typing.List[str]",
320
+ old_block: "typing.List[str]",
321
+ cursor: "int",
322
+ anchor_end_of_file: "bool",
323
+ ) -> "typing.Union[int, None]":
302
324
  if anchor_end_of_file:
303
325
  start = len(lines) - len(old_block)
304
326
  if start >= 0 and lines[start : start + len(old_block)] == old_block:
@@ -317,7 +339,9 @@ class ApplyPatchTool(BaseTool):
317
339
  return start
318
340
  return None
319
341
 
320
- def _write_preview(self, preview: 'typing.Dict[Path, typing.Union[str, None]]') -> 'None':
342
+ def _write_preview(
343
+ self, preview: "typing.Dict[Path, typing.Union[str, None]]"
344
+ ) -> "None":
321
345
  for path, content in preview.items():
322
346
  if content is None:
323
347
  if path.exists():
@@ -326,7 +350,7 @@ class ApplyPatchTool(BaseTool):
326
350
  path.parent.mkdir(parents=True, exist_ok=True)
327
351
  path.write_text(content, encoding="utf-8")
328
352
 
329
- def _format_success(self, summaries: 'typing.Dict[Path, str]') -> 'str':
353
+ def _format_success(self, summaries: "typing.Dict[Path, str]") -> "str":
330
354
  buckets = {"A": [], "M": [], "D": []}
331
355
  for path, status in summaries.items():
332
356
  buckets[status].append(self._display_path(path))
@@ -336,26 +360,23 @@ class ApplyPatchTool(BaseTool):
336
360
  lines.append(f"{status} {rel_path}")
337
361
  return " ".join(lines) + "\n"
338
362
 
339
- def _format_result(self, output: 'str', exit_code: 'int') -> 'str':
363
+ def _format_result(self, output: "str", exit_code: "int") -> "str":
340
364
  return (
341
- f"Exit code: {exit_code}\n"
342
- "Wall time: 0 seconds\n"
343
- "Output:\n"
344
- f"{output}"
365
+ f"Exit code: {exit_code}\n" "Wall time: 0 seconds\n" "Output:\n" f"{output}"
345
366
  )
346
367
 
347
- def _resolve_workspace_path(self, path_text: 'str') -> 'Path':
368
+ def _resolve_workspace_path(self, path_text: "str") -> "Path":
348
369
  path = Path(path_text).expanduser()
349
370
  resolved = path if path.is_absolute() else self._workspace_root / path
350
371
  return resolved.resolve()
351
372
 
352
- def _display_path(self, path: 'Path') -> 'str':
373
+ def _display_path(self, path: "Path") -> "str":
353
374
  try:
354
375
  return path.relative_to(self._workspace_root).as_posix()
355
376
  except ValueError:
356
377
  return str(path)
357
378
 
358
- def _join_lines(self, lines: 'typing.List[str]') -> 'str':
379
+ def _join_lines(self, lines: "typing.List[str]") -> "str":
359
380
  if not lines:
360
381
  return ""
361
382
  return "\n".join(lines) + "\n"
@@ -10,47 +10,61 @@ Expected behavior:
10
10
  model, and dispatches `ToolCall` executions back into `ToolResult`s.
11
11
  """
12
12
 
13
- import inspect
13
+ import asyncio
14
14
  import json
15
+ import traceback
16
+ import typing
15
17
  from abc import ABC, abstractmethod
16
18
  from dataclasses import dataclass
17
- import traceback
18
19
 
19
- from ..protocol import ConversationItem, JSONDict, JSONValue, ToolCall, ToolResult, ToolSpec
20
+ from ..events import Event
21
+ from ..protocol import (
22
+ ConversationItem,
23
+ JSONDict,
24
+ JSONValue,
25
+ ToolCall,
26
+ ToolResult,
27
+ ToolSpec,
28
+ UserMessage,
29
+ )
30
+ from ..runtime_services import AgentRuntimeEnvironment
20
31
  from ..utils import get_debug_dir
21
- import typing
22
32
 
33
+ if typing.TYPE_CHECKING:
34
+ from ..agent import Agent
23
35
 
24
- @dataclass(frozen=True, )
36
+
37
+ @dataclass(
38
+ frozen=True,
39
+ )
25
40
  class ToolContext:
26
- turn_id: 'str'
27
- history: 'typing.Tuple[ConversationItem, ...]'
28
- collaboration_mode: 'str' = "default"
41
+ turn_id: "str"
42
+ history: "typing.Tuple[ConversationItem, ...]"
29
43
 
30
44
 
31
45
  class StructuredToolOutput:
32
46
  def __init__(
33
47
  self,
34
- output: 'JSONValue',
35
- content_items: 'typing.Union[typing.Union[typing.Tuple[JSONDict, ...], typing.List[JSONDict]], None]' = None,
36
- success: 'typing.Union[bool, None]' = None,
37
- ) -> 'None':
48
+ output: "JSONValue",
49
+ content_items: "typing.Union[typing.Union[typing.Tuple[JSONDict, ...], typing.List[JSONDict]], None]" = None,
50
+ success: "typing.Union[bool, None]" = None,
51
+ ) -> "None":
38
52
  self.output = output
39
53
  self.content_items = None if content_items is None else tuple(content_items)
40
54
  self.success = success
41
55
 
42
56
 
43
57
  class BaseTool(ABC):
44
- name: 'str'
45
- description: 'str'
46
- input_schema: 'typing.Union[JSONDict, None]' = None
47
- tool_type: 'str' = "function"
48
- format: 'typing.Union[JSONDict, None]' = None
49
- options: 'typing.Union[JSONDict, None]' = None
50
- output_schema: 'typing.Union[JSONDict, None]' = None
51
- supports_parallel: 'bool' = True
52
-
53
- def spec(self) -> 'ToolSpec':
58
+ name: "str"
59
+ description: "str"
60
+ input_schema: "typing.Union[JSONDict, None]" = None
61
+ tool_type: "str" = "function"
62
+ format: "typing.Union[JSONDict, None]" = None
63
+ options: "typing.Union[JSONDict, None]" = None
64
+ output_schema: "typing.Union[JSONDict, None]" = None
65
+ supports_parallel: "bool" = True
66
+
67
+ def spec(self) -> "ToolSpec":
54
68
  return ToolSpec(
55
69
  name=self.name,
56
70
  description=self.description,
@@ -62,29 +76,51 @@ class BaseTool(ABC):
62
76
  supports_parallel=self.supports_parallel,
63
77
  )
64
78
 
65
- def serialize(self) -> 'JSONDict':
79
+ def serialize(self) -> "JSONDict":
66
80
  return self.spec().serialize()
67
81
 
68
82
  @abstractmethod
69
- async def run(self, context: 'ToolContext', args: 'JSONValue') -> 'JSONValue':
83
+ async def run(
84
+ self, context: "ToolContext", args: "JSONValue"
85
+ ) -> "typing.Union[JSONValue, StructuredToolOutput]":
70
86
  raise NotImplementedError
71
87
 
88
+ def follow_up_messages(
89
+ self, output: "JSONValue"
90
+ ) -> "typing.Tuple[UserMessage, ...]":
91
+ return ()
92
+
93
+ def bind_agent(self, agent: "Agent") -> "None":
94
+ pass
95
+
96
+ def handle_agent_event(self, event: "Event") -> "None":
97
+ pass
98
+
99
+ def shutdown(self) -> "None":
100
+ pass
101
+
102
+ def background_work_count(self, after_reply: "bool") -> "int":
103
+ return 0
104
+
72
105
 
73
106
  class ToolRegistry:
74
- def __init__(self) -> 'None':
75
- self._tools: 'typing.Dict[str, BaseTool]' = {}
107
+ def __init__(
108
+ self, runtime_environment: "typing.Union[AgentRuntimeEnvironment, None]" = None
109
+ ) -> "None":
110
+ self._tools: "typing.Dict[str, BaseTool]" = {}
111
+ self.runtime_environment = runtime_environment or AgentRuntimeEnvironment()
76
112
 
77
- def register(self, tool: 'BaseTool') -> 'None':
113
+ def register(self, tool: "BaseTool") -> "None":
78
114
  self._tools[tool.name] = tool
79
115
 
80
- def model_visible_specs(self) -> 'typing.List[ToolSpec]':
116
+ def model_visible_specs(self) -> "typing.List[ToolSpec]":
81
117
  return [tool.spec() for tool in self._tools.values()]
82
118
 
83
- def supports_parallel(self, tool_name: 'str') -> 'bool':
119
+ def supports_parallel(self, tool_name: "str") -> "bool":
84
120
  tool = self._tools.get(tool_name)
85
121
  return False if tool is None else tool.supports_parallel
86
122
 
87
- async def execute(self, call: 'ToolCall', context: 'ToolContext') -> 'ToolResult':
123
+ async def execute(self, call: "ToolCall", context: "ToolContext") -> "ToolResult":
88
124
  tool = self._tools.get(call.name)
89
125
  if tool is None:
90
126
  return ToolResult(
@@ -96,11 +132,7 @@ class ToolRegistry:
96
132
  )
97
133
 
98
134
  try:
99
- maybe_result = tool.run(context, call.arguments)
100
- if inspect.isawaitable(maybe_result):
101
- output = await maybe_result
102
- else:
103
- output = maybe_result
135
+ output = await tool.run(context, call.arguments)
104
136
  if isinstance(output, StructuredToolOutput):
105
137
  return ToolResult(
106
138
  call_id=call.call_id,
@@ -116,10 +148,14 @@ class ToolRegistry:
116
148
  output=output,
117
149
  tool_type=call.tool_type,
118
150
  )
119
- except Exception as exc: # pragma: no cover - defensive wrapper
151
+ except asyncio.CancelledError:
152
+ raise
153
+ except Exception as exc:
120
154
  debug_dir = get_debug_dir()
121
155
  if debug_dir is not None:
122
- with (debug_dir / "tool_errors.jsonl").open("a", encoding="utf-8") as handle:
156
+ with (debug_dir / "tool_errors.jsonl").open(
157
+ "a", encoding="utf-8"
158
+ ) as handle:
123
159
  handle.write(
124
160
  json.dumps(
125
161
  {
@@ -140,19 +176,30 @@ class ToolRegistry:
140
176
  tool_type=call.tool_type,
141
177
  )
142
178
 
143
- def __contains__(self, tool_name: 'str') -> 'bool':
179
+ def follow_up_messages(
180
+ self, results: "typing.Iterable[ToolResult]"
181
+ ) -> "typing.Tuple[UserMessage, ...]":
182
+ messages = []
183
+ for result in results:
184
+ if not result.is_error:
185
+ messages.extend(
186
+ self._tools[result.name].follow_up_messages(result.output)
187
+ )
188
+ return tuple(messages)
189
+
190
+ def __contains__(self, tool_name: "str") -> "bool":
144
191
  return tool_name in self._tools
145
192
 
146
- def __len__(self) -> 'int':
193
+ def __len__(self) -> "int":
147
194
  return len(self._tools)
148
195
 
149
- def names(self) -> 'typing.Tuple[str, ...]':
196
+ def names(self) -> "typing.Tuple[str, ...]":
150
197
  return tuple(self._tools)
151
198
 
152
- def get_tool(self, tool_name: 'str') -> 'typing.Union[BaseTool, None]':
199
+ def get_tool(self, tool_name: "str") -> "typing.Union[BaseTool, None]":
153
200
  return self._tools.get(tool_name)
154
201
 
155
- def tools(self) -> 'typing.Tuple[BaseTool, ...]':
202
+ def tools(self) -> "typing.Tuple[BaseTool, ...]":
156
203
  return tuple(self._tools.values())
157
204
 
158
205
 
@@ -0,0 +1,201 @@
1
+ """Session-scoped periodic clock for waking an idle Agent.
2
+
3
+ `clock` is a pycodex extension rather than an upstream Codex tool. A configured
4
+ clock starts counting only after the Agent completes a reply. Every new turn
5
+ cancels the pending countdown, and every successful reply starts a fresh one.
6
+ """
7
+
8
+ import asyncio
9
+ import math
10
+ import typing
11
+ from datetime import datetime
12
+
13
+ from ..events import (
14
+ CompactCompletedEvent,
15
+ CompactStartedEvent,
16
+ Event,
17
+ TurnCompletedEvent,
18
+ TurnStartedEvent,
19
+ )
20
+ from ..protocol import JSONDict, JSONValue
21
+ from .base_tool import BaseTool, ToolContext
22
+
23
+ CLOCK_STATE_OUTPUT_SCHEMA = {
24
+ "type": "object",
25
+ "properties": {
26
+ "enabled": {
27
+ "type": "boolean",
28
+ "description": "Whether the periodic clock is enabled.",
29
+ },
30
+ "period_m": {
31
+ "anyOf": [
32
+ {"type": "number"},
33
+ {"type": "null"},
34
+ ],
35
+ "description": "Configured period in minutes, or null when disabled.",
36
+ },
37
+ },
38
+ "required": ["enabled", "period_m"],
39
+ "additionalProperties": False,
40
+ }
41
+
42
+
43
+ def _current_time() -> "str":
44
+ return datetime.now().astimezone().isoformat(timespec="seconds")
45
+
46
+
47
+ class ClockManager:
48
+ def __init__(self, seconds_per_minute: "float" = 60.0) -> "None":
49
+ self._seconds_per_minute = seconds_per_minute
50
+ self._period_m: "typing.Union[float, None]" = None
51
+ self._timer_task: "typing.Union[asyncio.Task, None]" = None
52
+ self._generation = 0
53
+ self._notify_hook: "typing.Union[typing.Callable[[typing.Dict[str, object]], typing.Awaitable[typing.Any]], None]" = (None)
54
+
55
+ def set_notify_hook(
56
+ self,
57
+ callback: "typing.Union[typing.Callable[[typing.Dict[str, object]], typing.Awaitable[typing.Any]], None]",
58
+ ) -> "None":
59
+ self._notify_hook = callback
60
+
61
+ def set_period(self, value: "object") -> "JSONDict":
62
+ self._cancel_pending()
63
+ if value is None:
64
+ self._period_m = None
65
+ return self.snapshot()
66
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
67
+ raise ValueError("period_m must be a positive finite number or null")
68
+ period_m = float(value)
69
+ if not math.isfinite(period_m) or period_m <= 0:
70
+ raise ValueError("period_m must be a positive finite number or null")
71
+ self._period_m = period_m
72
+ return self.snapshot()
73
+
74
+ def snapshot(self) -> "JSONDict":
75
+ return {
76
+ "enabled": self.enabled,
77
+ "period_m": self._period_m,
78
+ }
79
+
80
+ @property
81
+ def enabled(self) -> "bool":
82
+ return self._period_m is not None
83
+
84
+ def turn_started(self) -> "None":
85
+ self._cancel_pending()
86
+
87
+ def arm_after_reply(self) -> "None":
88
+ self._cancel_pending()
89
+ period_m = self._period_m
90
+ if period_m is None or self._notify_hook is None:
91
+ return
92
+ generation = self._generation
93
+ self._timer_task = asyncio.create_task(
94
+ self._wait_and_notify(generation, period_m)
95
+ )
96
+ self._timer_task.add_done_callback(
97
+ lambda task: None if task.cancelled() else task.exception()
98
+ )
99
+
100
+ def cancel(self) -> "None":
101
+ self.set_period(None)
102
+
103
+ def _cancel_pending(self) -> "None":
104
+ self._generation += 1
105
+ task = self._timer_task
106
+ self._timer_task = None
107
+ if task is not None and not task.done():
108
+ task.cancel()
109
+
110
+ async def _wait_and_notify(self, generation: "int", period_m: "float") -> "None":
111
+ from ..agent import TurnInterrupted
112
+
113
+ try:
114
+ await asyncio.sleep(period_m * self._seconds_per_minute)
115
+ except asyncio.CancelledError:
116
+ return
117
+ if generation != self._generation or period_m != self._period_m:
118
+ return
119
+
120
+ self._timer_task = None
121
+ callback = self._notify_hook
122
+ if callback is None:
123
+ return
124
+ try:
125
+ started = await callback(
126
+ {
127
+ "type": "clock_tick",
128
+ "period_m": period_m,
129
+ "current_time": _current_time(),
130
+ }
131
+ )
132
+ except TurnInterrupted:
133
+ # The next successful reply will arm the clock after a steer.
134
+ return
135
+ except asyncio.CancelledError:
136
+ raise
137
+ except Exception as exc:
138
+ asyncio.get_running_loop().call_exception_handler(
139
+ {
140
+ "message": "Clock notification failed",
141
+ "exception": exc,
142
+ }
143
+ )
144
+ return
145
+
146
+ if (
147
+ not started
148
+ and generation == self._generation
149
+ and period_m == self._period_m
150
+ ):
151
+ self.arm_after_reply()
152
+
153
+
154
+ class ClockTool(BaseTool):
155
+ name = "clock"
156
+ description = (
157
+ "Set a periodic clock in minutes, or cancel it with null. After each "
158
+ "Agent reply, the clock waits period_m minutes and then sends a "
159
+ "<clock_tick> message to wake the Agent."
160
+ )
161
+ input_schema = {
162
+ "type": "object",
163
+ "properties": {
164
+ "period_m": {
165
+ "anyOf": [
166
+ {"type": "number"},
167
+ {"type": "null"},
168
+ ],
169
+ "description": ("Positive period in minutes, or null to cancel."),
170
+ },
171
+ },
172
+ "required": ["period_m"],
173
+ "additionalProperties": False,
174
+ }
175
+ output_schema = CLOCK_STATE_OUTPUT_SCHEMA
176
+ supports_parallel = False
177
+
178
+ def __init__(self, manager: "ClockManager") -> "None":
179
+ self._manager = manager
180
+
181
+ def bind_agent(self, agent) -> "None":
182
+ self._manager.set_notify_hook(agent.maybe_invoke)
183
+
184
+ def handle_agent_event(self, event: "Event") -> "None":
185
+ if isinstance(event, (TurnStartedEvent, CompactStartedEvent)):
186
+ self._manager.turn_started()
187
+ elif isinstance(event, (TurnCompletedEvent, CompactCompletedEvent)):
188
+ self._manager.arm_after_reply()
189
+
190
+ def shutdown(self) -> "None":
191
+ self._manager.cancel()
192
+ self._manager.set_notify_hook(None)
193
+
194
+ def background_work_count(self, after_reply: "bool") -> "int":
195
+ return int(after_reply and self._manager.enabled)
196
+
197
+ async def run(self, context: "ToolContext", args: "JSONDict") -> "JSONValue":
198
+ del context
199
+ if not isinstance(args, dict) or "period_m" not in args:
200
+ raise ValueError("clock requires period_m")
201
+ return self._manager.set_period(args["period_m"])
@@ -49,10 +49,10 @@ class CloseAgentTool(BaseTool):
49
49
  output_schema = CLOSE_AGENT_OUTPUT_SCHEMA
50
50
  supports_parallel = False
51
51
 
52
- def __init__(self, subagent_manager: 'SubAgentManager') -> 'None':
52
+ def __init__(self, subagent_manager: "SubAgentManager") -> "None":
53
53
  self._subagent_manager = subagent_manager
54
54
 
55
- async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
55
+ async def run(self, context: "ToolContext", args: "JSONDict") -> "JSONValue":
56
56
  del context
57
57
  agent_id = str(args.get("id", "")).strip()
58
58
  if not agent_id: