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
pycodex/context.py CHANGED
@@ -1,16 +1,17 @@
1
-
2
- from dataclasses import dataclass
1
+ import typing
2
+ from dataclasses import dataclass, replace
3
3
  from datetime import datetime
4
4
  from pathlib import Path
5
- import typing
5
+ from xml.sax.saxutils import escape
6
+
7
+ import yaml
6
8
 
7
9
  try:
8
10
  import tomllib
9
11
  except ModuleNotFoundError: # pragma: no cover - Python 3.10 path
10
12
  import tomli as tomllib
11
13
 
12
- from .collaboration import DEFAULT_COLLABORATION_MODE, CollaborationMode
13
- from .model_metadata import load_models_by_slug
14
+ from .model_metadata import model_metadata
14
15
  from .protocol import ContextMessage, ConversationItem, JSONDict, Prompt, ToolSpec
15
16
  from .utils.get_env import (
16
17
  get_sandbox_tag,
@@ -22,12 +23,6 @@ from .utils.get_env import (
22
23
  DEFAULT_BASE_INSTRUCTIONS_PATH = (
23
24
  Path(__file__).resolve().parent / "prompts" / "default_base_instructions.md"
24
25
  )
25
- DEFAULT_COLLABORATION_INSTRUCTIONS_PATH = (
26
- Path(__file__).resolve().parent / "prompts" / "collaboration_default.md"
27
- )
28
- PLAN_COLLABORATION_INSTRUCTIONS_PATH = (
29
- Path(__file__).resolve().parent / "prompts" / "collaboration_plan.md"
30
- )
31
26
  DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95
32
27
  PERMISSIONS_SANDBOX_PROMPTS_PATH = (
33
28
  Path(__file__).resolve().parent / "prompts" / "permissions" / "sandbox_mode"
@@ -43,50 +38,56 @@ PERMISSIONS_OPEN_TAG = "<permissions instructions>"
43
38
  PERMISSIONS_CLOSE_TAG = "</permissions instructions>"
44
39
  SKILLS_OPEN_TAG = "<skills_instructions>"
45
40
  SKILLS_CLOSE_TAG = "</skills_instructions>"
46
- COLLABORATION_MODE_OPEN_TAG = "<collaboration_mode>"
47
- COLLABORATION_MODE_CLOSE_TAG = "</collaboration_mode>"
48
41
  PERSONALITY_PLACEHOLDER = "{{ personality }}"
49
- SKILLS_GUIDANCE = """- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
42
+ SKILLS_GUIDANCE = """- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`.
50
43
  - Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
51
44
  - Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
52
45
  - How to use a skill (progressive disclosure):
53
- 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
54
- 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
55
- 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
46
+ 1) After deciding to use a skill, the main agent must expand the listed short `path` with the matching alias from `### Skill roots`, then open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
47
+ 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed.
48
+ 3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
56
49
  4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
57
50
  5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
58
51
  - Coordination and sequencing:
59
52
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
60
53
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
61
54
  - Context hygiene:
62
- - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
55
+ - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets.
63
56
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
64
57
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
65
58
  - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."""
66
59
 
67
60
 
68
- @dataclass(frozen=True, )
61
+ @dataclass(
62
+ frozen=True,
63
+ )
69
64
  class ContextConfig:
70
- base_instructions: 'typing.Union[str, None]' = None
71
- developer_instructions: 'typing.Union[str, None]' = None
72
- user_instructions: 'typing.Union[str, None]' = None
73
- codex_home_instructions: 'typing.Union[str, None]' = None
74
- model_instructions_file: 'typing.Union[Path, None]' = None
75
- codex_home: 'typing.Union[Path, None]' = None
76
- project_doc_max_bytes: 'typing.Union[int, None]' = None
77
- model: 'typing.Union[str, None]' = None
78
- model_context_window: 'typing.Union[int, None]' = None
79
- model_auto_compact_token_limit: 'typing.Union[int, None]' = None
80
- personality: 'typing.Union[str, None]' = None
81
- approval_policy: 'typing.Union[str, None]' = None
82
- sandbox_mode: 'typing.Union[str, None]' = None
65
+ base_instructions: "typing.Union[str, None]" = None
66
+ developer_instructions: "typing.Union[str, None]" = None
67
+ user_instructions: "typing.Union[str, None]" = None
68
+ codex_home_instructions: "typing.Union[str, None]" = None
69
+ model_instructions_file: "typing.Union[Path, None]" = None
70
+ codex_home: "typing.Union[Path, None]" = None
71
+ project_doc_max_bytes: "typing.Union[int, None]" = None
72
+ model: "typing.Union[str, None]" = None
73
+ model_context_window: "typing.Union[int, None]" = None
74
+ model_auto_compact_token_limit: "typing.Union[int, None]" = None
75
+ personality: "typing.Union[str, None]" = None
76
+ approval_policy: "typing.Union[str, None]" = None
77
+ sandbox_mode: "typing.Union[str, None]" = None
78
+ base_instructions_override: "typing.Union[str, None]" = None
79
+ include_permissions_instructions: "bool" = True
80
+ include_skills_instructions: "bool" = True
81
+ network_access: "str" = "enabled"
82
+ extra_contextual_user_messages: "typing.Tuple[str, ...]" = ()
83
+ cwd: "typing.Union[str, Path, None]" = None
83
84
 
84
85
  @classmethod
85
86
  def from_codex_config(
86
87
  cls,
87
- config_path: 'typing.Union[str, Path]',
88
- profile: 'typing.Union[str, None]' = None,
89
- ) -> 'ContextConfig':
88
+ config_path: "typing.Union[str, Path]",
89
+ profile: "typing.Union[str, None]" = None,
90
+ ) -> "ContextConfig":
90
91
  path = Path(config_path)
91
92
  data = tomllib.loads(path.read_text(encoding="utf-8"))
92
93
  selected = dict(data)
@@ -128,91 +129,46 @@ class ContextConfig:
128
129
  )
129
130
 
130
131
 
131
- @dataclass(frozen=True, )
132
+ @dataclass(
133
+ frozen=True,
134
+ )
132
135
  class SkillDescriptor:
133
- name: 'str'
134
- description: 'str'
135
- path_to_skill_md: 'Path'
136
- scope_rank: 'int'
136
+ name: "str"
137
+ description: "str"
138
+ path_to_skill_md: "Path"
139
+ scope_rank: "int"
140
+ root: "Path"
137
141
 
138
142
 
139
143
  class ContextManager:
140
- def __init__(
141
- self,
142
- base_instructions_override: 'typing.Union[str, None]' = None,
143
- config: 'typing.Union[ContextConfig, None]' = None,
144
- collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
145
- collaboration_instructions: 'typing.Union[str, None]' = None,
146
- include_collaboration_instructions: 'bool' = False,
147
- include_permissions_instructions: 'bool' = True,
148
- include_skills_instructions: 'bool' = True,
149
- network_access: 'str' = "enabled",
150
- extra_contextual_user_messages: 'typing.Iterable[str]' = (),
151
- cwd: 'typing.Union[str, Path, None]' = None,
152
- ) -> 'None':
153
- self.cwd = Path(cwd or Path.cwd()).resolve()
144
+ def __init__(self, config: "ContextConfig") -> "None":
145
+ self.cwd = Path(config.cwd or Path.cwd()).resolve()
154
146
  self._shell = get_shell_name()
155
147
  self._current_date = datetime.now().date().isoformat()
156
148
  self._timezone_name = get_timezone_name()
157
- self._base_instructions_override = _normalize_text(base_instructions_override)
158
- self._config = config or ContextConfig()
159
- self._collaboration_mode = collaboration_mode
160
- self._collaboration_instructions = (
161
- collaboration_instructions
162
- if collaboration_instructions is not None
163
- else _default_collaboration_instructions(collaboration_mode)
149
+ self._base_instructions_override = _normalize_text(
150
+ config.base_instructions_override
164
151
  )
165
- self._include_collaboration_instructions = include_collaboration_instructions
166
- self._include_permissions_instructions = include_permissions_instructions
167
- self._include_skills_instructions = include_skills_instructions
168
- self._network_access = network_access
152
+ self._config = config
153
+ self._include_permissions_instructions = config.include_permissions_instructions
154
+ self._include_skills_instructions = config.include_skills_instructions
155
+ self._network_access = config.network_access
169
156
  self._extra_contextual_user_messages = tuple(
170
157
  text
171
158
  for text in (
172
159
  _normalize_text(message)
173
- for message in extra_contextual_user_messages
160
+ for message in config.extra_contextual_user_messages
174
161
  )
175
162
  if text is not None
176
163
  )
177
164
  self._default_base_instructions = DEFAULT_BASE_INSTRUCTIONS_PATH.read_text(
178
165
  encoding="utf-8"
179
166
  )
180
- self._workspace_metadata_turn_id: 'typing.Union[str, None]' = None
181
- self._workspace_metadata_cache: 'typing.Union[JSONDict, None]' = None
167
+ self._workspace_metadata_turn_id: "typing.Union[str, None]" = None
168
+ self._workspace_metadata_cache: "typing.Union[JSONDict, None]" = None
182
169
 
183
- @classmethod
184
- def from_codex_config(
185
- cls,
186
- config_path: 'typing.Union[str, Path]',
187
- profile: 'typing.Union[str, None]' = None,
188
- base_instructions_override: 'typing.Union[str, None]' = None,
189
- collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
190
- include_collaboration_instructions: 'bool' = False,
191
- include_permissions_instructions: 'bool' = True,
192
- include_skills_instructions: 'bool' = True,
193
- network_access: 'str' = "enabled",
194
- extra_contextual_user_messages: 'typing.Iterable[str]' = (),
195
- cwd: 'typing.Union[str, Path, None]' = None,
196
- ) -> 'ContextManager':
197
- config = ContextConfig.from_codex_config(config_path, profile)
198
- return cls(
199
- base_instructions_override=base_instructions_override,
200
- config=config,
201
- collaboration_mode=collaboration_mode,
202
- include_collaboration_instructions=include_collaboration_instructions,
203
- include_permissions_instructions=include_permissions_instructions,
204
- include_skills_instructions=include_skills_instructions,
205
- network_access=network_access,
206
- extra_contextual_user_messages=extra_contextual_user_messages,
207
- cwd=cwd,
208
- )
209
-
210
- @property
211
- def collaboration_mode(self) -> 'CollaborationMode':
212
- return self._collaboration_mode
213
-
214
- def get_turn_metadata(self, turn_id: 'str') -> 'JSONDict':
215
- metadata: 'JSONDict' = {"turn_id": turn_id}
170
+ def get_turn_metadata(self, turn_id: "str") -> "JSONDict":
171
+ metadata: "JSONDict" = {"turn_id": turn_id}
216
172
  if self._workspace_metadata_turn_id is None:
217
173
  self._workspace_metadata_turn_id = turn_id
218
174
  self._workspace_metadata_cache = get_workspace_turn_metadata(self.cwd)
@@ -226,12 +182,12 @@ class ContextManager:
226
182
 
227
183
  def build_prompt(
228
184
  self,
229
- history: 'typing.Union[typing.Tuple[ConversationItem, ...], typing.List[ConversationItem]]',
230
- tools: 'typing.List[ToolSpec]',
231
- parallel_tool_calls: 'bool',
232
- turn_id: 'typing.Union[str, None]' = None,
233
- ) -> 'Prompt':
234
- input_items: 'typing.List[ConversationItem]' = []
185
+ history: "typing.Union[typing.Tuple[ConversationItem, ...], typing.List[ConversationItem]]",
186
+ tools: "typing.List[ToolSpec]",
187
+ parallel_tool_calls: "bool",
188
+ turn_id: "typing.Union[str, None]" = None,
189
+ ) -> "Prompt":
190
+ input_items: "typing.List[ConversationItem]" = []
235
191
  turn_metadata = self.get_turn_metadata(turn_id) if turn_id is not None else None
236
192
 
237
193
  developer_message = self._build_developer_message()
@@ -249,7 +205,7 @@ class ContextManager:
249
205
  turn_metadata=turn_metadata,
250
206
  )
251
207
 
252
- def resolve_base_instructions(self) -> 'str':
208
+ def resolve_base_instructions(self) -> "str":
253
209
  if self._base_instructions_override is not None:
254
210
  return self._base_instructions_override
255
211
  if self._config.base_instructions is not None:
@@ -264,47 +220,51 @@ class ContextManager:
264
220
  return resolved
265
221
  return self._default_base_instructions
266
222
 
267
- def resolve_model_context_window(self) -> 'typing.Union[int, None]':
268
- model_metadata = None
269
- model_slug = self._config.model
270
- if model_slug is not None:
271
- model_metadata = load_models_by_slug().get(model_slug)
272
-
223
+ def resolve_model_max_context_window(self) -> "typing.Union[int, None]":
224
+ metadata = model_metadata(self._config.model)
273
225
  context_window = self._config.model_context_window
274
- if context_window is None and model_metadata is not None:
275
- context_window = _normalize_int(model_metadata.get("context_window"))
226
+ if context_window is None and metadata is not None:
227
+ context_window = _normalize_int(metadata.get("context_window"))
228
+ return context_window
229
+
230
+ def resolve_model_context_window(self) -> "typing.Union[int, None]":
231
+ context_window = self.resolve_model_max_context_window()
276
232
  if context_window is None:
277
233
  return None
234
+ metadata = model_metadata(self._config.model)
278
235
  effective_percent = None
279
- if model_metadata is not None:
236
+ if metadata is not None:
280
237
  effective_percent = _normalize_int(
281
- model_metadata.get("effective_context_window_percent")
238
+ metadata.get("effective_context_window_percent")
282
239
  )
283
240
  if effective_percent is None:
284
241
  effective_percent = DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT
285
242
  return context_window * max(effective_percent, 0) // 100
286
243
 
287
- def resolve_auto_compact_token_limit(self) -> 'typing.Union[int, None]':
244
+ def set_model(self, model: "str") -> "None":
245
+ self._config = replace(self._config, model=model)
246
+
247
+ def resolve_auto_compact_token_limit(self) -> "typing.Union[int, None]":
288
248
  if self._config.model_auto_compact_token_limit is not None:
289
249
  return self._config.model_auto_compact_token_limit
290
250
 
291
251
  model_slug = self._config.model
292
252
  if model_slug is None:
293
253
  return None
294
- model_metadata = load_models_by_slug().get(model_slug)
295
- if model_metadata is None:
254
+ metadata = model_metadata(model_slug)
255
+ if metadata is None:
296
256
  return None
297
- return _normalize_int(model_metadata.get("auto_compact_token_limit"))
257
+ return _normalize_int(metadata.get("auto_compact_token_limit"))
298
258
 
299
- def _resolve_model_instructions(self) -> 'typing.Union[str, None]':
259
+ def _resolve_model_instructions(self) -> "typing.Union[str, None]":
300
260
  model_slug = self._config.model
301
261
  if model_slug is None:
302
262
  return None
303
- model_metadata = load_models_by_slug().get(model_slug)
304
- if model_metadata is None:
263
+ metadata = model_metadata(model_slug)
264
+ if metadata is None:
305
265
  return None
306
266
 
307
- model_messages = model_metadata.get("model_messages")
267
+ model_messages = metadata.get("model_messages")
308
268
  if isinstance(model_messages, dict):
309
269
  template = model_messages.get("instructions_template")
310
270
  variables = model_messages.get("instructions_variables")
@@ -315,30 +275,23 @@ class ContextManager:
315
275
  )
316
276
  return template.replace(PERSONALITY_PLACEHOLDER, personality_message)
317
277
 
318
- base_instructions = model_metadata.get("base_instructions")
278
+ base_instructions = metadata.get("base_instructions")
319
279
  if isinstance(base_instructions, str):
320
280
  return base_instructions
321
281
  return None
322
282
 
323
- def _build_developer_message(self) -> 'typing.Union[ContextMessage, None]':
324
- sections: 'typing.List[str]' = []
325
- if self._include_permissions_instructions:
326
- permissions = self._build_permissions_instructions()
327
- if permissions is not None:
328
- sections.append(permissions)
283
+ def _build_developer_message(self) -> "typing.Union[ContextMessage, None]":
284
+ sections: "typing.List[str]" = []
329
285
  if self._config.developer_instructions is not None:
330
286
  sections.append(self._config.developer_instructions)
331
- if self._include_collaboration_instructions:
332
- collaboration = self._collaboration_instructions.strip()
333
- if collaboration:
334
- sections.append(
335
- f"{COLLABORATION_MODE_OPEN_TAG}{collaboration}"
336
- f"\n{COLLABORATION_MODE_CLOSE_TAG}"
337
- )
338
287
  if self._include_skills_instructions:
339
288
  skills = self._build_skills_instructions()
340
289
  if skills is not None:
341
290
  sections.append(skills)
291
+ if self._include_permissions_instructions:
292
+ permissions = self._build_permissions_instructions()
293
+ if permissions is not None:
294
+ sections.append(permissions)
342
295
  if not sections:
343
296
  return None
344
297
  return ContextMessage(
@@ -346,7 +299,7 @@ class ContextManager:
346
299
  content_items=tuple(_input_text_item(section) for section in sections),
347
300
  )
348
301
 
349
- def _build_permissions_instructions(self) -> 'typing.Union[str, None]':
302
+ def _build_permissions_instructions(self) -> "typing.Union[str, None]":
350
303
  sandbox_mode = self._config.sandbox_mode or "danger-full-access"
351
304
  approval_policy = self._config.approval_policy or "never"
352
305
  sandbox_prompt_name = sandbox_mode.replace("-", "_")
@@ -354,15 +307,16 @@ class ContextManager:
354
307
  PERMISSIONS_SANDBOX_PROMPTS_PATH / f"{sandbox_prompt_name}.md"
355
308
  )
356
309
  approval_prompt_path = (
357
- PERMISSIONS_APPROVAL_PROMPTS_PATH / f"{approval_policy.replace('-', '_')}.md"
310
+ PERMISSIONS_APPROVAL_PROMPTS_PATH
311
+ / f"{approval_policy.replace('-', '_')}.md"
358
312
  )
359
313
  if not sandbox_prompt_path.exists() or not approval_prompt_path.exists():
360
314
  return None
361
315
 
362
316
  sandbox_text = (
363
- sandbox_prompt_path.read_text(encoding="utf-8").strip().replace(
364
- "{network_access}", self._network_access
365
- )
317
+ sandbox_prompt_path.read_text(encoding="utf-8")
318
+ .strip()
319
+ .replace("{network_access}", self._network_access)
366
320
  )
367
321
  approval_text = approval_prompt_path.read_text(encoding="utf-8").strip()
368
322
  return "\n".join(
@@ -374,56 +328,68 @@ class ContextManager:
374
328
  ]
375
329
  )
376
330
 
377
- def _build_skills_instructions(self) -> 'typing.Union[str, None]':
331
+ def _build_skills_instructions(self) -> "typing.Union[str, None]":
378
332
  skills = self._discover_skills()
379
333
  if not skills:
380
334
  return None
381
335
 
382
336
  lines = [
383
337
  "## Skills",
384
- "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.",
385
- "### Available skills",
338
+ "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.",
339
+ "### Skill roots",
386
340
  ]
341
+ roots = {}
387
342
  for skill in skills:
388
- path_str = skill.path_to_skill_md.as_posix()
389
- lines.append(
390
- f"- {skill.name}: {skill.description} (file: {path_str})"
343
+ if skill.root not in roots:
344
+ alias = "r{0}".format(len(roots))
345
+ roots[skill.root] = alias
346
+ lines.append("- `{0}` = `{1}`".format(alias, skill.root.as_posix()))
347
+ lines.append("### Available skills")
348
+ for skill in sorted(
349
+ skills, key=lambda item: (item.scope_rank, item.name, item.path_to_skill_md)
350
+ ):
351
+ path_str = "{0}/{1}".format(
352
+ roots[skill.root],
353
+ skill.path_to_skill_md.relative_to(skill.root).as_posix(),
391
354
  )
392
- lines.append("### How to use skills")
393
- lines.extend(SKILLS_GUIDANCE.splitlines())
355
+ lines.append(f"- {skill.name}: {skill.description} (file: {path_str})")
356
+ metadata = model_metadata(self._config.model)
357
+ if metadata is None or metadata.get("include_skills_usage_instructions", True):
358
+ lines.append("### How to use skills")
359
+ lines.extend(SKILLS_GUIDANCE.splitlines())
394
360
  body = "\n".join(lines)
395
361
  return f"{SKILLS_OPEN_TAG}\n{body}\n{SKILLS_CLOSE_TAG}"
396
362
 
397
- def _discover_skills(self) -> 'typing.List[SkillDescriptor]':
363
+ def _discover_skills(self) -> "typing.List[SkillDescriptor]":
398
364
  codex_home = self._config.codex_home
399
365
  if codex_home is None:
400
366
  return []
401
367
 
402
368
  user_root = codex_home / "skills"
403
369
  system_root = user_root / ".system"
404
- discovered: 'typing.List[SkillDescriptor]' = []
405
- seen: 'typing.Set[Path]' = set()
370
+ discovered: "typing.List[SkillDescriptor]" = []
371
+ seen: "typing.Set[Path]" = set()
406
372
 
407
373
  user_paths = _discover_skill_files(user_root, excluded_root=system_root)
408
374
  system_paths = _discover_skill_files(system_root)
409
375
 
410
- for scope_rank, paths in ((0, user_paths), (1, system_paths)):
376
+ for scope_rank, root, paths in (
377
+ (3, user_root, user_paths),
378
+ (0, system_root, system_paths),
379
+ ):
411
380
  for path in paths:
412
381
  resolved = path.resolve()
413
382
  if resolved in seen:
414
383
  continue
415
384
  seen.add(resolved)
416
- descriptor = _parse_skill_descriptor(path, scope_rank)
385
+ descriptor = _parse_skill_descriptor(path, scope_rank, root)
417
386
  if descriptor is not None:
418
387
  discovered.append(descriptor)
419
388
 
420
- return sorted(
421
- discovered,
422
- key=lambda skill: (skill.scope_rank, skill.name, skill.path_to_skill_md),
423
- )
389
+ return discovered
424
390
 
425
- def _build_contextual_user_messages(self) -> 'typing.List[ContextMessage]':
426
- sections: 'typing.List[str]' = []
391
+ def _build_contextual_user_messages(self) -> "typing.List[ContextMessage]":
392
+ sections: "typing.List[str]" = []
427
393
  user_instructions = self._merged_user_instructions()
428
394
  if user_instructions is not None:
429
395
  sections.append(
@@ -443,8 +409,8 @@ class ContextManager:
443
409
  )
444
410
  ]
445
411
 
446
- def _merged_user_instructions(self) -> 'typing.Union[str, None]':
447
- parts: 'typing.List[str]' = []
412
+ def _merged_user_instructions(self) -> "typing.Union[str, None]":
413
+ parts: "typing.List[str]" = []
448
414
  if self._config.user_instructions is not None:
449
415
  parts.append(self._config.user_instructions)
450
416
  if self._config.codex_home_instructions is not None:
@@ -459,8 +425,8 @@ class ContextManager:
459
425
 
460
426
  return "\n\n".join(parts) or None
461
427
 
462
- def _read_project_docs(self) -> 'typing.Union[str, None]':
463
- docs: 'typing.List[str]' = []
428
+ def _read_project_docs(self) -> "typing.Union[str, None]":
429
+ docs: "typing.List[str]" = []
464
430
  remaining = self._config.project_doc_max_bytes
465
431
  for path in self._discover_project_doc_paths():
466
432
  text = path.read_text(encoding="utf-8", errors="replace")
@@ -478,13 +444,16 @@ class ContextManager:
478
444
  return None
479
445
  return "\n\n".join(docs)
480
446
 
481
- def _discover_project_doc_paths(self) -> 'typing.List[Path]':
482
- seen: 'typing.Set[Path]' = set()
483
- discovered: 'typing.List[Path]' = []
447
+ def _discover_project_doc_paths(self) -> "typing.List[Path]":
448
+ seen: "typing.Set[Path]" = set()
449
+ discovered: "typing.List[Path]" = []
484
450
 
485
451
  search_dirs = self._project_search_dirs()
486
452
  for directory in search_dirs:
487
- for candidate_name in (LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME):
453
+ for candidate_name in (
454
+ LOCAL_PROJECT_DOC_FILENAME,
455
+ DEFAULT_PROJECT_DOC_FILENAME,
456
+ ):
488
457
  candidate = (directory / candidate_name).resolve()
489
458
  if candidate.exists() and candidate.is_file() and candidate not in seen:
490
459
  discovered.append(candidate)
@@ -492,9 +461,9 @@ class ContextManager:
492
461
  break
493
462
  return discovered
494
463
 
495
- def _project_search_dirs(self) -> 'typing.List[Path]':
464
+ def _project_search_dirs(self) -> "typing.List[Path]":
496
465
  project_root = self._find_project_root()
497
- directories: 'typing.List[Path]' = []
466
+ directories: "typing.List[Path]" = []
498
467
  current = self.cwd
499
468
  chain = [current]
500
469
  while current != project_root and current.parent != current:
@@ -504,48 +473,75 @@ class ContextManager:
504
473
  directories.extend(chain)
505
474
  return directories
506
475
 
507
- def _find_project_root(self) -> 'Path':
476
+ def _find_project_root(self) -> "Path":
508
477
  for ancestor in [self.cwd, *self.cwd.parents]:
509
478
  if (ancestor / ".git").exists():
510
479
  return ancestor
511
480
  return self.cwd
512
481
 
513
- def _serialize_environment_context(self) -> 'str':
482
+ def _serialize_environment_context(self) -> "str":
483
+ cwd = escape(str(self.cwd), {'"': "&quot;", "'": "&apos;"})
514
484
  lines = [
515
485
  "<environment_context>",
516
- f" <cwd>{self.cwd}</cwd>",
486
+ f" <cwd>{cwd}</cwd>",
517
487
  f" <shell>{self._shell}</shell>",
518
488
  f" <current_date>{self._current_date}</current_date>",
519
489
  f" <timezone>{self._timezone_name}</timezone>",
520
- "</environment_context>",
521
490
  ]
491
+ sandbox_mode = self._config.sandbox_mode or "danger-full-access"
492
+ if sandbox_mode == "danger-full-access":
493
+ permissions = (
494
+ '<permission_profile type="disabled"><file_system type="unrestricted" />'
495
+ "</permission_profile>"
496
+ )
497
+ elif sandbox_mode in {"read-only", "workspace-write"}:
498
+ entries = ['<entry access="read"><special>:root</special></entry>']
499
+ if sandbox_mode == "workspace-write":
500
+ entries.extend(
501
+ [
502
+ '<entry access="write"><path>{0}</path></entry>'.format(cwd),
503
+ '<entry access="write"><special>:slash_tmp</special></entry>',
504
+ '<entry access="write"><special>:tmpdir</special></entry>',
505
+ ]
506
+ )
507
+ entries.extend(
508
+ '<entry access="read"><path>{0}</path></entry>'.format(
509
+ escape(str(self.cwd / name), {'"': "&quot;", "'": "&apos;"})
510
+ )
511
+ for name in (".git", ".agents", ".codex")
512
+ )
513
+ permissions = (
514
+ '<permission_profile type="managed"><file_system type="restricted">'
515
+ "{0}</file_system></permission_profile>".format("".join(entries))
516
+ )
517
+ else:
518
+ raise ValueError("unsupported sandbox mode: {0}".format(sandbox_mode))
519
+ lines.append(
520
+ " <filesystem><workspace_roots><root>{0}</root></workspace_roots>"
521
+ "{1}</filesystem>".format(cwd, permissions)
522
+ )
523
+ lines.append("</environment_context>")
522
524
  return "\n".join(lines)
523
525
 
524
526
 
525
- def _input_text_item(text: 'str') -> 'JSONDict':
527
+ def _input_text_item(text: "str") -> "JSONDict":
526
528
  return {"type": "input_text", "text": text}
527
529
 
528
530
 
529
- def _normalize_text(value) -> 'typing.Union[str, None]':
531
+ def _normalize_text(value) -> "typing.Union[str, None]":
530
532
  if value is None:
531
533
  return None
532
534
  text = str(value).strip()
533
535
  return text or None
534
536
 
535
537
 
536
- def _normalize_int(value) -> 'typing.Union[int, None]':
538
+ def _normalize_int(value) -> "typing.Union[int, None]":
537
539
  if value is None:
538
540
  return None
539
541
  return int(value)
540
542
 
541
543
 
542
- def _default_collaboration_instructions(mode: 'CollaborationMode') -> 'str':
543
- if mode == "plan":
544
- return PLAN_COLLABORATION_INSTRUCTIONS_PATH.read_text(encoding="utf-8")
545
- return DEFAULT_COLLABORATION_INSTRUCTIONS_PATH.read_text(encoding="utf-8")
546
-
547
-
548
- def _read_first_instruction_file(base: 'Path') -> 'typing.Union[str, None]':
544
+ def _read_first_instruction_file(base: "Path") -> "typing.Union[str, None]":
549
545
  for candidate_name in (LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME):
550
546
  candidate = base / candidate_name
551
547
  try:
@@ -558,7 +554,9 @@ def _read_first_instruction_file(base: 'Path') -> 'typing.Union[str, None]':
558
554
  return None
559
555
 
560
556
 
561
- def _resolve_personality_message(variables, personality: 'typing.Union[str, None]') -> 'str':
557
+ def _resolve_personality_message(
558
+ variables, personality: "typing.Union[str, None]"
559
+ ) -> "str":
562
560
  if not isinstance(variables, dict):
563
561
  return ""
564
562
  normalized = (personality or "").strip().lower()
@@ -577,22 +575,30 @@ def _resolve_personality_message(variables, personality: 'typing.Union[str, None
577
575
 
578
576
 
579
577
  def _discover_skill_files(
580
- root: 'Path',
581
- excluded_root: 'typing.Union[Path, None]' = None,
582
- ) -> 'typing.List[Path]':
578
+ root: "Path",
579
+ excluded_root: "typing.Union[Path, None]" = None,
580
+ ) -> "typing.List[Path]":
583
581
  if not root.exists() or not root.is_dir():
584
582
  return []
585
- excluded = excluded_root.resolve() if excluded_root is not None and excluded_root.exists() else None
586
- paths: 'typing.List[Path]' = []
583
+ excluded = (
584
+ excluded_root.resolve()
585
+ if excluded_root is not None and excluded_root.exists()
586
+ else None
587
+ )
588
+ paths: "typing.List[Path]" = []
587
589
  for path in root.glob("**/SKILL.md"):
588
590
  resolved = path.resolve()
589
- if excluded is not None and (resolved == excluded or excluded in resolved.parents):
591
+ if excluded is not None and (
592
+ resolved == excluded or excluded in resolved.parents
593
+ ):
590
594
  continue
591
595
  paths.append(path)
592
596
  return sorted(paths)
593
597
 
594
598
 
595
- def _parse_skill_descriptor(path: 'Path', scope_rank: 'int') -> 'typing.Union[SkillDescriptor, None]':
599
+ def _parse_skill_descriptor(
600
+ path: "Path", scope_rank: "int", root: "Path"
601
+ ) -> "typing.Union[SkillDescriptor, None]":
596
602
  text = path.read_text(encoding="utf-8", errors="replace")
597
603
  if not text.startswith("---\n"):
598
604
  return None
@@ -601,25 +607,29 @@ def _parse_skill_descriptor(path: 'Path', scope_rank: 'int') -> 'typing.Union[Sk
601
607
  if end_index == -1:
602
608
  return None
603
609
  frontmatter = text[4:end_index]
604
- fields: 'typing.Dict[str, str]' = {}
605
- for line in frontmatter.splitlines():
606
- if ":" not in line:
607
- continue
608
- key, _, raw_value = line.partition(":")
609
- fields[key.strip()] = _strip_yaml_string(raw_value.strip())
610
+ fields = yaml.safe_load(frontmatter)
611
+ if not isinstance(fields, dict):
612
+ return None
610
613
  name = fields.get("name")
611
614
  description = fields.get("description")
612
- if not name or not description:
615
+ if not isinstance(name, str) or not isinstance(description, str):
616
+ return None
617
+ if not name.strip() or not description.strip():
613
618
  return None
619
+ metadata_path = path.parent / "agents" / "openai.yaml"
620
+ if metadata_path.is_file():
621
+ metadata = yaml.safe_load(
622
+ metadata_path.read_text(encoding="utf-8", errors="replace")
623
+ )
624
+ if (
625
+ metadata is not None
626
+ and metadata.get("policy", {}).get("allow_implicit_invocation") is False
627
+ ):
628
+ return None
614
629
  return SkillDescriptor(
615
- name=name,
616
- description=description,
617
- path_to_skill_md=path.resolve(),
630
+ name=name.strip(),
631
+ description=description.strip(),
632
+ path_to_skill_md=root.resolve() / path.relative_to(root),
618
633
  scope_rank=scope_rank,
634
+ root=root.resolve(),
619
635
  )
620
-
621
-
622
- def _strip_yaml_string(value: 'str') -> 'str':
623
- if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
624
- return value[1:-1]
625
- return value