python-codex 0.0.1__py3-none-any.whl → 0.1.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 (62) hide show
  1. pycodex/__init__.py +139 -2
  2. pycodex/agent.py +290 -0
  3. pycodex/cli.py +641 -0
  4. pycodex/collaboration.py +21 -0
  5. pycodex/context.py +580 -0
  6. pycodex/doctor.py +360 -0
  7. pycodex/model.py +533 -0
  8. pycodex/prompts/collaboration_default.md +11 -0
  9. pycodex/prompts/collaboration_plan.md +128 -0
  10. pycodex/prompts/default_base_instructions.md +275 -0
  11. pycodex/prompts/exec_tools.json +411 -0
  12. pycodex/prompts/models.json +847 -0
  13. pycodex/prompts/permissions/approval_policy/never.md +1 -0
  14. pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
  15. pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
  16. pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
  17. pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
  18. pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
  19. pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
  20. pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
  21. pycodex/prompts/subagent_tools.json +163 -0
  22. pycodex/protocol.py +347 -0
  23. pycodex/runtime.py +200 -0
  24. pycodex/runtime_services.py +408 -0
  25. pycodex/tools/__init__.py +58 -0
  26. pycodex/tools/agent_tool_schemas.py +70 -0
  27. pycodex/tools/apply_patch_tool.py +363 -0
  28. pycodex/tools/base_tool.py +168 -0
  29. pycodex/tools/close_agent_tool.py +55 -0
  30. pycodex/tools/code_mode_manager.py +519 -0
  31. pycodex/tools/exec_command_tool.py +96 -0
  32. pycodex/tools/exec_runtime.js +161 -0
  33. pycodex/tools/exec_tool.py +48 -0
  34. pycodex/tools/grep_files_tool.py +150 -0
  35. pycodex/tools/list_dir_tool.py +135 -0
  36. pycodex/tools/read_file_tool.py +217 -0
  37. pycodex/tools/request_permissions_tool.py +95 -0
  38. pycodex/tools/request_user_input_tool.py +167 -0
  39. pycodex/tools/resume_agent_tool.py +56 -0
  40. pycodex/tools/send_input_tool.py +106 -0
  41. pycodex/tools/shell_command_tool.py +107 -0
  42. pycodex/tools/shell_tool.py +112 -0
  43. pycodex/tools/spawn_agent_tool.py +97 -0
  44. pycodex/tools/unified_exec_manager.py +380 -0
  45. pycodex/tools/update_plan_tool.py +79 -0
  46. pycodex/tools/view_image_tool.py +111 -0
  47. pycodex/tools/wait_agent_tool.py +75 -0
  48. pycodex/tools/wait_tool.py +68 -0
  49. pycodex/tools/web_search_tool.py +30 -0
  50. pycodex/tools/write_stdin_tool.py +75 -0
  51. pycodex/utils/__init__.py +40 -0
  52. pycodex/utils/dotenv.py +64 -0
  53. pycodex/utils/get_env.py +218 -0
  54. pycodex/utils/random_ids.py +19 -0
  55. pycodex/utils/visualize.py +978 -0
  56. python_codex-0.1.0.dist-info/METADATA +267 -0
  57. python_codex-0.1.0.dist-info/RECORD +60 -0
  58. python_codex-0.1.0.dist-info/entry_points.txt +2 -0
  59. python_codex-0.1.0.dist-info/licenses/LICENSE +201 -0
  60. python_codex-0.0.1.dist-info/METADATA +0 -30
  61. python_codex-0.0.1.dist-info/RECORD +0 -4
  62. {python_codex-0.0.1.dist-info → python_codex-0.1.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,161 @@
1
+ const readline = require('node:readline');
2
+ const { stdin, stdout } = require('node:process');
3
+
4
+ const pending = new Map();
5
+ let storedValues = {};
6
+ let toolMap = {};
7
+ let initialized = false;
8
+
9
+ function send(message) {
10
+ stdout.write(JSON.stringify(message) + '\n');
11
+ }
12
+
13
+ function stringifyValue(value) {
14
+ if (typeof value === 'string') {
15
+ return value;
16
+ }
17
+ if (value === undefined) {
18
+ return 'undefined';
19
+ }
20
+ try {
21
+ return JSON.stringify(value);
22
+ } catch (_error) {
23
+ return String(value);
24
+ }
25
+ }
26
+
27
+ function text(value) {
28
+ send({ type: 'output_text', text: stringifyValue(value) });
29
+ }
30
+
31
+ function notify(value) {
32
+ text(value);
33
+ }
34
+
35
+ function image(value) {
36
+ if (typeof value === 'string') {
37
+ send({ type: 'output_image', image_url: value, detail: null });
38
+ return;
39
+ }
40
+ if (value && typeof value === 'object' && typeof value.image_url === 'string') {
41
+ send({
42
+ type: 'output_image',
43
+ image_url: value.image_url,
44
+ detail: value.detail ?? null,
45
+ });
46
+ return;
47
+ }
48
+ throw new Error('image(...) expects an image URL string or { image_url, detail? }');
49
+ }
50
+
51
+ function store(key, value) {
52
+ storedValues[key] = value;
53
+ }
54
+
55
+ function load(key) {
56
+ return storedValues[key];
57
+ }
58
+
59
+ function exit() {
60
+ const error = new Error('__CODEX_EXIT__');
61
+ error.__codexExit = true;
62
+ throw error;
63
+ }
64
+
65
+ async function yield_control() {
66
+ send({ type: 'yield' });
67
+ }
68
+
69
+ function createToolCaller(toolName) {
70
+ return function callTool(argumentsValue = {}) {
71
+ const id = `${toolName}_${Math.random().toString(16).slice(2)}`;
72
+ send({
73
+ type: 'tool_call',
74
+ id,
75
+ tool_name: toolName,
76
+ arguments: argumentsValue,
77
+ });
78
+ return new Promise((resolve, reject) => {
79
+ pending.set(id, { resolve, reject });
80
+ });
81
+ };
82
+ }
83
+
84
+ function initialize(message) {
85
+ storedValues = message.stored_values || {};
86
+ toolMap = {};
87
+ for (const tool of message.tools || []) {
88
+ toolMap[tool.js_name] = createToolCaller(tool.tool_name);
89
+ }
90
+ const allTools = Object.freeze(
91
+ (message.tools || []).map((tool) => ({
92
+ name: tool.js_name,
93
+ description: tool.description,
94
+ }))
95
+ );
96
+
97
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
98
+ const fn = new AsyncFunction(
99
+ 'tools',
100
+ 'text',
101
+ 'image',
102
+ 'store',
103
+ 'load',
104
+ 'exit',
105
+ 'notify',
106
+ 'ALL_TOOLS',
107
+ 'yield_control',
108
+ 'console',
109
+ message.source
110
+ );
111
+
112
+ (async () => {
113
+ try {
114
+ await fn(
115
+ toolMap,
116
+ text,
117
+ image,
118
+ store,
119
+ load,
120
+ exit,
121
+ notify,
122
+ allTools,
123
+ yield_control,
124
+ undefined
125
+ );
126
+ send({ type: 'result', stored_values: storedValues, error_text: null });
127
+ } catch (error) {
128
+ if (error && error.__codexExit) {
129
+ send({ type: 'result', stored_values: storedValues, error_text: null });
130
+ return;
131
+ }
132
+ const errorText = error && error.stack ? error.stack : String(error);
133
+ send({ type: 'result', stored_values: storedValues, error_text: errorText });
134
+ }
135
+ })();
136
+ }
137
+
138
+ const rl = readline.createInterface({ input: stdin, crlfDelay: Infinity });
139
+ rl.on('line', (line) => {
140
+ if (!line.trim()) {
141
+ return;
142
+ }
143
+ const message = JSON.parse(line);
144
+ if (message.type === 'init' && !initialized) {
145
+ initialized = true;
146
+ initialize(message);
147
+ return;
148
+ }
149
+ if (message.type === 'tool_result') {
150
+ const entry = pending.get(message.id);
151
+ if (!entry) {
152
+ return;
153
+ }
154
+ pending.delete(message.id);
155
+ if (message.ok) {
156
+ entry.resolve(message.result);
157
+ } else {
158
+ entry.reject(new Error(message.error || 'nested tool failed'));
159
+ }
160
+ }
161
+ });
@@ -0,0 +1,48 @@
1
+ """`exec` tool for the Python Codex prototype.
2
+
3
+ Original Codex mapping:
4
+ - Corresponds to the original Codex code-mode `exec` tool.
5
+
6
+ Expected behavior:
7
+ - Accept raw JavaScript source text as a freeform/custom tool payload.
8
+ - Run the script inside a background exec cell with helper functions and nested
9
+ tool access.
10
+ - Return either a completed result or a running `cell_id` status that can be
11
+ resumed via `wait`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ..protocol import JSONValue
17
+ from .base_tool import BaseTool, ToolContext
18
+ from .code_mode_manager import CodeModeManager
19
+
20
+ EXEC_FREEFORM_GRAMMAR = """start: pragma_source | plain_source
21
+ pragma_source: PRAGMA_LINE NEWLINE SOURCE
22
+ plain_source: SOURCE
23
+
24
+ PRAGMA_LINE: /[ \t]*\/\/ @exec:[^\r\n]*/
25
+ NEWLINE: /\r?\n/
26
+ SOURCE: /[\s\S]+/
27
+ """
28
+
29
+
30
+ class ExecTool(BaseTool):
31
+ name = "exec"
32
+ description = (
33
+ "Runs raw JavaScript in an isolated context. Send raw JavaScript "
34
+ "source text, not JSON, quoted strings, or markdown code fences."
35
+ )
36
+ tool_type = "custom"
37
+ format = {
38
+ "type": "grammar",
39
+ "syntax": "lark",
40
+ "definition": EXEC_FREEFORM_GRAMMAR,
41
+ }
42
+ supports_parallel = False
43
+
44
+ def __init__(self, manager: CodeModeManager) -> None:
45
+ self._manager = manager
46
+
47
+ async def run(self, context: ToolContext, args: JSONValue) -> JSONValue:
48
+ return await self._manager.exec(str(args), context)
@@ -0,0 +1,150 @@
1
+ """`grep_files` tool for the Python Codex prototype.
2
+
3
+ Original Codex mapping:
4
+ - Corresponds to the original Codex `grep_files` tool.
5
+
6
+ Expected behavior:
7
+ - Search file contents with ripgrep and return only matching file paths.
8
+ - Support the original tool's `pattern`, `include`, `path`, and `limit`
9
+ parameters rather than delegating to a shell transcript.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import fnmatch
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from ..protocol import JSONDict, JSONValue
20
+ from .base_tool import BaseTool, ToolContext
21
+
22
+ DEFAULT_LIMIT = 100
23
+ MAX_LIMIT = 2000
24
+ COMMAND_TIMEOUT_SECONDS = 30
25
+
26
+
27
+ class GrepFilesTool(BaseTool):
28
+ name = "grep_files"
29
+ description = (
30
+ "Finds files whose contents match the pattern and lists them by "
31
+ "modification time."
32
+ )
33
+ input_schema = {
34
+ "type": "object",
35
+ "properties": {
36
+ "pattern": {"type": "string"},
37
+ "include": {"type": "string"},
38
+ "path": {"type": "string"},
39
+ "limit": {"type": "integer"},
40
+ },
41
+ "required": ["pattern"],
42
+ }
43
+
44
+ def __init__(self, cwd: str | Path | None = None) -> None:
45
+ self._working_directory = Path(cwd or Path.cwd()).resolve()
46
+
47
+ async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
48
+ del context
49
+ pattern = str(args.get("pattern", "")).strip()
50
+ include = str(args.get("include", "")).strip() or None
51
+ limit = min(int(args.get("limit", DEFAULT_LIMIT)), MAX_LIMIT)
52
+ path_arg = args.get("path")
53
+ search_path = self._resolve_path(path_arg)
54
+
55
+ if not pattern:
56
+ return "Error: `pattern` must not be empty."
57
+ if limit <= 0:
58
+ return "Error: `limit` must be greater than zero."
59
+ if not search_path.exists():
60
+ return f"Error: unable to access `{search_path}`."
61
+
62
+ command = [
63
+ "rg",
64
+ "--files-with-matches",
65
+ "--sortr=modified",
66
+ "--regexp",
67
+ pattern,
68
+ "--no-messages",
69
+ ]
70
+ if include is not None:
71
+ command.extend(["--glob", include])
72
+ command.extend(["--", str(search_path)])
73
+
74
+ try:
75
+ process = await asyncio.create_subprocess_exec(
76
+ *command,
77
+ cwd=str(self._working_directory),
78
+ stdout=asyncio.subprocess.PIPE,
79
+ stderr=asyncio.subprocess.PIPE,
80
+ )
81
+ except FileNotFoundError:
82
+ results = self._search_with_python(pattern, include, search_path, limit)
83
+ if not results:
84
+ return "No matches found."
85
+ return "\n".join(results)
86
+
87
+ try:
88
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
89
+ process.communicate(),
90
+ timeout=COMMAND_TIMEOUT_SECONDS,
91
+ )
92
+ except asyncio.TimeoutError:
93
+ process.kill()
94
+ await process.communicate()
95
+ return "Error: rg timed out after 30 seconds."
96
+
97
+ if process.returncode == 1:
98
+ return "No matches found."
99
+ if process.returncode != 0:
100
+ stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
101
+ return f"Error: rg failed: {stderr}"
102
+
103
+ results = [
104
+ line
105
+ for line in stdout_bytes.decode("utf-8", errors="replace").splitlines()
106
+ if line.strip()
107
+ ][:limit]
108
+ if not results:
109
+ return "No matches found."
110
+ return "\n".join(results)
111
+
112
+ def _search_with_python(
113
+ self,
114
+ pattern: str,
115
+ include: str | None,
116
+ search_path: Path,
117
+ limit: int,
118
+ ) -> list[str]:
119
+ regex = re.compile(pattern)
120
+ candidates: list[Path] = []
121
+
122
+ if search_path.is_file():
123
+ candidates = [search_path]
124
+ else:
125
+ candidates = [path for path in search_path.rglob("*") if path.is_file()]
126
+
127
+ if include is not None:
128
+ candidates = [
129
+ path for path in candidates if fnmatch.fnmatch(path.name, include)
130
+ ]
131
+
132
+ matches = []
133
+ for path in candidates:
134
+ try:
135
+ text = path.read_text(errors="replace")
136
+ except OSError:
137
+ continue
138
+ if regex.search(text):
139
+ matches.append(path)
140
+
141
+ matches.sort(key=lambda path: path.stat().st_mtime, reverse=True)
142
+ return [str(path) for path in matches[:limit]]
143
+
144
+ def _resolve_path(self, path_arg) -> Path:
145
+ if path_arg in (None, ""):
146
+ return self._working_directory
147
+ path = Path(str(path_arg))
148
+ if not path.is_absolute():
149
+ path = self._working_directory / path
150
+ return path.resolve()
@@ -0,0 +1,135 @@
1
+ """`list_dir` tool for the Python Codex prototype.
2
+
3
+ Original Codex mapping:
4
+ - Corresponds to the original Codex `list_dir` tool.
5
+
6
+ Expected behavior:
7
+ - List directory entries from an absolute path with offset/limit/depth controls.
8
+ - Produce a stable, human-readable directory tree slice instead of using shell
9
+ commands like `find` or `ls -R`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import deque
15
+ from pathlib import Path
16
+
17
+ from ..protocol import JSONDict, JSONValue
18
+ from .base_tool import BaseTool, ToolContext
19
+
20
+ MAX_ENTRY_LENGTH = 500
21
+ INDENTATION_SPACES = 2
22
+
23
+
24
+ class ListDirTool(BaseTool):
25
+ name = "list_dir"
26
+ description = (
27
+ "Lists entries in a local directory with 1-indexed entry numbers and "
28
+ "simple type labels."
29
+ )
30
+ input_schema = {
31
+ "type": "object",
32
+ "properties": {
33
+ "dir_path": {"type": "string"},
34
+ "offset": {"type": "integer"},
35
+ "limit": {"type": "integer"},
36
+ "depth": {"type": "integer"},
37
+ },
38
+ "required": ["dir_path"],
39
+ }
40
+
41
+ async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
42
+ del context
43
+ dir_path = Path(str(args.get("dir_path", "")))
44
+ offset = int(args.get("offset", 1))
45
+ limit = int(args.get("limit", 25))
46
+ depth = int(args.get("depth", 2))
47
+
48
+ if not dir_path.is_absolute():
49
+ return "Error: `dir_path` must be an absolute path."
50
+ if offset <= 0:
51
+ return "Error: `offset` must be a 1-indexed entry number."
52
+ if limit <= 0:
53
+ return "Error: `limit` must be greater than zero."
54
+ if depth <= 0:
55
+ return "Error: `depth` must be greater than zero."
56
+ if not dir_path.exists():
57
+ return f"Error: `{dir_path}` does not exist."
58
+ if not dir_path.is_dir():
59
+ return f"Error: `{dir_path}` is not a directory."
60
+
61
+ entries = self._collect_entries(dir_path, depth)
62
+ if not entries:
63
+ return f"Absolute path: {dir_path}"
64
+
65
+ start_index = offset - 1
66
+ if start_index >= len(entries):
67
+ return "Error: `offset` exceeds directory entry count."
68
+
69
+ end_index = min(start_index + limit, len(entries))
70
+ selected = entries[start_index:end_index]
71
+ lines = [f"Absolute path: {dir_path}"]
72
+ lines.extend(self._format_entry_line(entry) for entry in selected)
73
+ if end_index < len(entries):
74
+ lines.append(f"More than {len(selected)} entries found")
75
+ return "\n".join(lines)
76
+
77
+ def _collect_entries(self, root: Path, depth: int) -> list[dict[str, object]]:
78
+ entries: list[dict[str, object]] = []
79
+ queue = deque([(root, Path(), depth)])
80
+
81
+ while queue:
82
+ current_dir, prefix, remaining_depth = queue.popleft()
83
+ dir_entries = []
84
+ for child in current_dir.iterdir():
85
+ relative_path = prefix / child.name if prefix.parts else Path(child.name)
86
+ kind = self._entry_kind(child)
87
+ dir_entries.append(
88
+ (
89
+ child,
90
+ relative_path,
91
+ {
92
+ "name": self._format_entry_name(relative_path),
93
+ "display_name": self._format_component(child.name),
94
+ "depth": len(prefix.parts),
95
+ "kind": kind,
96
+ },
97
+ )
98
+ )
99
+
100
+ dir_entries.sort(key=lambda item: item[2]["name"])
101
+ for child, relative_path, entry in dir_entries:
102
+ if entry["kind"] == "directory" and remaining_depth > 1:
103
+ queue.append((child, relative_path, remaining_depth - 1))
104
+ entries.append(entry)
105
+
106
+ entries.sort(key=lambda entry: entry["name"])
107
+ return entries
108
+
109
+ def _entry_kind(self, path: Path) -> str:
110
+ if path.is_symlink():
111
+ return "symlink"
112
+ if path.is_dir():
113
+ return "directory"
114
+ if path.is_file():
115
+ return "file"
116
+ return "other"
117
+
118
+ def _format_entry_name(self, path: Path) -> str:
119
+ text = path.as_posix()
120
+ return text[:MAX_ENTRY_LENGTH]
121
+
122
+ def _format_component(self, name: str) -> str:
123
+ return name[:MAX_ENTRY_LENGTH]
124
+
125
+ def _format_entry_line(self, entry: dict[str, object]) -> str:
126
+ indent = " " * (int(entry["depth"]) * INDENTATION_SPACES)
127
+ name = str(entry["display_name"])
128
+ kind = str(entry["kind"])
129
+ if kind == "directory":
130
+ name += "/"
131
+ elif kind == "symlink":
132
+ name += "@"
133
+ elif kind == "other":
134
+ name += "?"
135
+ return f"{indent}{name}"