python-codex 0.2.7__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 +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  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 +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +245 -983
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  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 +7 -5
  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 +41 -72
  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/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  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 +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +101 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +461 -375
  71. workspace_server/workspace.html +852 -228
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +137 -79
  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 -560
  80. python_codex-0.2.7.dist-info/METADATA +0 -455
  81. python_codex-0.2.7.dist-info/RECORD +0 -93
  82. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.7.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
 
@@ -6,13 +6,19 @@ cancels the pending countdown, and every successful reply starts a fresh one.
6
6
  """
7
7
 
8
8
  import asyncio
9
- from datetime import datetime
10
9
  import math
10
+ import typing
11
+ from datetime import datetime
11
12
 
13
+ from ..events import (
14
+ CompactCompletedEvent,
15
+ CompactStartedEvent,
16
+ Event,
17
+ TurnCompletedEvent,
18
+ TurnStartedEvent,
19
+ )
12
20
  from ..protocol import JSONDict, JSONValue
13
21
  from .base_tool import BaseTool, ToolContext
14
- import typing
15
-
16
22
 
17
23
  CLOCK_STATE_OUTPUT_SCHEMA = {
18
24
  "type": "object",
@@ -34,25 +40,25 @@ CLOCK_STATE_OUTPUT_SCHEMA = {
34
40
  }
35
41
 
36
42
 
37
- def _current_time() -> 'str':
43
+ def _current_time() -> "str":
38
44
  return datetime.now().astimezone().isoformat(timespec="seconds")
39
45
 
40
46
 
41
47
  class ClockManager:
42
- def __init__(self, seconds_per_minute: 'float' = 60.0) -> 'None':
48
+ def __init__(self, seconds_per_minute: "float" = 60.0) -> "None":
43
49
  self._seconds_per_minute = seconds_per_minute
44
- self._period_m: 'typing.Union[float, None]' = None
45
- self._timer_task: 'typing.Union[asyncio.Task, None]' = None
50
+ self._period_m: "typing.Union[float, None]" = None
51
+ self._timer_task: "typing.Union[asyncio.Task, None]" = None
46
52
  self._generation = 0
47
- self._notify_hook: 'typing.Union[typing.Callable[[typing.Dict[str, object]], typing.Awaitable[typing.Any]], None]' = None
53
+ self._notify_hook: "typing.Union[typing.Callable[[typing.Dict[str, object]], typing.Awaitable[typing.Any]], None]" = (None)
48
54
 
49
55
  def set_notify_hook(
50
56
  self,
51
- callback: 'typing.Union[typing.Callable[[typing.Dict[str, object]], typing.Awaitable[typing.Any]], None]',
52
- ) -> 'None':
57
+ callback: "typing.Union[typing.Callable[[typing.Dict[str, object]], typing.Awaitable[typing.Any]], None]",
58
+ ) -> "None":
53
59
  self._notify_hook = callback
54
60
 
55
- def set_period(self, value: 'object') -> 'JSONDict':
61
+ def set_period(self, value: "object") -> "JSONDict":
56
62
  self._cancel_pending()
57
63
  if value is None:
58
64
  self._period_m = None
@@ -65,20 +71,20 @@ class ClockManager:
65
71
  self._period_m = period_m
66
72
  return self.snapshot()
67
73
 
68
- def snapshot(self) -> 'JSONDict':
74
+ def snapshot(self) -> "JSONDict":
69
75
  return {
70
76
  "enabled": self.enabled,
71
77
  "period_m": self._period_m,
72
78
  }
73
79
 
74
80
  @property
75
- def enabled(self) -> 'bool':
81
+ def enabled(self) -> "bool":
76
82
  return self._period_m is not None
77
83
 
78
- def turn_started(self) -> 'None':
84
+ def turn_started(self) -> "None":
79
85
  self._cancel_pending()
80
86
 
81
- def arm_after_reply(self) -> 'None':
87
+ def arm_after_reply(self) -> "None":
82
88
  self._cancel_pending()
83
89
  period_m = self._period_m
84
90
  if period_m is None or self._notify_hook is None:
@@ -91,17 +97,19 @@ class ClockManager:
91
97
  lambda task: None if task.cancelled() else task.exception()
92
98
  )
93
99
 
94
- def cancel(self) -> 'None':
100
+ def cancel(self) -> "None":
95
101
  self.set_period(None)
96
102
 
97
- def _cancel_pending(self) -> 'None':
103
+ def _cancel_pending(self) -> "None":
98
104
  self._generation += 1
99
105
  task = self._timer_task
100
106
  self._timer_task = None
101
107
  if task is not None and not task.done():
102
108
  task.cancel()
103
109
 
104
- async def _wait_and_notify(self, generation: 'int', period_m: 'float') -> 'None':
110
+ async def _wait_and_notify(self, generation: "int", period_m: "float") -> "None":
111
+ from ..agent import TurnInterrupted
112
+
105
113
  try:
106
114
  await asyncio.sleep(period_m * self._seconds_per_minute)
107
115
  except asyncio.CancelledError:
@@ -121,8 +129,19 @@ class ClockManager:
121
129
  "current_time": _current_time(),
122
130
  }
123
131
  )
124
- except Exception:
125
- started = False
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
126
145
 
127
146
  if (
128
147
  not started
@@ -147,9 +166,7 @@ class ClockTool(BaseTool):
147
166
  {"type": "number"},
148
167
  {"type": "null"},
149
168
  ],
150
- "description": (
151
- "Positive period in minutes, or null to cancel."
152
- ),
169
+ "description": ("Positive period in minutes, or null to cancel."),
153
170
  },
154
171
  },
155
172
  "required": ["period_m"],
@@ -158,10 +175,26 @@ class ClockTool(BaseTool):
158
175
  output_schema = CLOCK_STATE_OUTPUT_SCHEMA
159
176
  supports_parallel = False
160
177
 
161
- def __init__(self, manager: 'ClockManager') -> 'None':
178
+ def __init__(self, manager: "ClockManager") -> "None":
162
179
  self._manager = manager
163
180
 
164
- async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
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":
165
198
  del context
166
199
  if not isinstance(args, dict) or "period_m" not in args:
167
200
  raise ValueError("clock requires 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: