hud-python 0.4.45__py3-none-any.whl → 0.5.1__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.
- hud/__init__.py +27 -7
- hud/agents/__init__.py +11 -5
- hud/agents/base.py +220 -500
- hud/agents/claude.py +200 -240
- hud/agents/gemini.py +275 -0
- hud/agents/gemini_cua.py +335 -0
- hud/agents/grounded_openai.py +98 -100
- hud/agents/misc/integration_test_agent.py +51 -20
- hud/agents/misc/response_agent.py +41 -36
- hud/agents/openai.py +291 -292
- hud/agents/{openai_chat_generic.py → openai_chat.py} +80 -34
- hud/agents/operator.py +211 -0
- hud/agents/tests/conftest.py +133 -0
- hud/agents/tests/test_base.py +300 -622
- hud/agents/tests/test_base_runtime.py +233 -0
- hud/agents/tests/test_claude.py +379 -210
- hud/agents/tests/test_client.py +9 -10
- hud/agents/tests/test_gemini.py +369 -0
- hud/agents/tests/test_grounded_openai_agent.py +65 -50
- hud/agents/tests/test_openai.py +376 -140
- hud/agents/tests/test_operator.py +362 -0
- hud/agents/tests/test_run_eval.py +179 -0
- hud/cli/__init__.py +461 -545
- hud/cli/analyze.py +43 -5
- hud/cli/build.py +664 -110
- hud/cli/debug.py +8 -5
- hud/cli/dev.py +882 -734
- hud/cli/eval.py +782 -668
- hud/cli/flows/dev.py +167 -0
- hud/cli/flows/init.py +191 -0
- hud/cli/flows/tasks.py +153 -56
- hud/cli/flows/templates.py +151 -0
- hud/cli/flows/tests/__init__.py +1 -0
- hud/cli/flows/tests/test_dev.py +126 -0
- hud/cli/init.py +60 -58
- hud/cli/push.py +29 -11
- hud/cli/rft.py +311 -0
- hud/cli/rft_status.py +145 -0
- hud/cli/tests/test_analyze.py +5 -5
- hud/cli/tests/test_analyze_metadata.py +3 -2
- hud/cli/tests/test_analyze_module.py +120 -0
- hud/cli/tests/test_build.py +108 -6
- hud/cli/tests/test_build_failure.py +41 -0
- hud/cli/tests/test_build_module.py +50 -0
- hud/cli/tests/test_cli_init.py +6 -1
- hud/cli/tests/test_cli_more_wrappers.py +30 -0
- hud/cli/tests/test_cli_root.py +140 -0
- hud/cli/tests/test_convert.py +361 -0
- hud/cli/tests/test_debug.py +12 -10
- hud/cli/tests/test_dev.py +197 -0
- hud/cli/tests/test_eval.py +251 -0
- hud/cli/tests/test_eval_bedrock.py +51 -0
- hud/cli/tests/test_init.py +124 -0
- hud/cli/tests/test_main_module.py +11 -5
- hud/cli/tests/test_mcp_server.py +12 -100
- hud/cli/tests/test_push_happy.py +74 -0
- hud/cli/tests/test_push_wrapper.py +23 -0
- hud/cli/tests/test_registry.py +1 -1
- hud/cli/tests/test_utils.py +1 -1
- hud/cli/{rl → utils}/celebrate.py +14 -12
- hud/cli/utils/config.py +18 -1
- hud/cli/utils/docker.py +130 -4
- hud/cli/utils/env_check.py +9 -9
- hud/cli/utils/git.py +136 -0
- hud/cli/utils/interactive.py +39 -5
- hud/cli/utils/metadata.py +69 -0
- hud/cli/utils/runner.py +1 -1
- hud/cli/utils/server.py +2 -2
- hud/cli/utils/source_hash.py +3 -3
- hud/cli/utils/tasks.py +4 -1
- hud/cli/utils/tests/__init__.py +0 -0
- hud/cli/utils/tests/test_config.py +58 -0
- hud/cli/utils/tests/test_docker.py +93 -0
- hud/cli/utils/tests/test_docker_hints.py +71 -0
- hud/cli/utils/tests/test_env_check.py +74 -0
- hud/cli/utils/tests/test_environment.py +42 -0
- hud/cli/utils/tests/test_git.py +142 -0
- hud/cli/utils/tests/test_interactive_module.py +60 -0
- hud/cli/utils/tests/test_local_runner.py +50 -0
- hud/cli/utils/tests/test_logging_utils.py +23 -0
- hud/cli/utils/tests/test_metadata.py +49 -0
- hud/cli/utils/tests/test_package_runner.py +35 -0
- hud/cli/utils/tests/test_registry_utils.py +49 -0
- hud/cli/utils/tests/test_remote_runner.py +25 -0
- hud/cli/utils/tests/test_runner_modules.py +52 -0
- hud/cli/utils/tests/test_source_hash.py +36 -0
- hud/cli/utils/tests/test_tasks.py +80 -0
- hud/cli/utils/version_check.py +258 -0
- hud/cli/{rl → utils}/viewer.py +2 -2
- hud/clients/README.md +12 -11
- hud/clients/__init__.py +4 -3
- hud/clients/base.py +166 -26
- hud/clients/environment.py +51 -0
- hud/clients/fastmcp.py +13 -6
- hud/clients/mcp_use.py +40 -15
- hud/clients/tests/test_analyze_scenarios.py +206 -0
- hud/clients/tests/test_protocol.py +9 -3
- hud/datasets/__init__.py +23 -20
- hud/datasets/loader.py +327 -0
- hud/datasets/runner.py +192 -105
- hud/datasets/tests/__init__.py +0 -0
- hud/datasets/tests/test_loader.py +221 -0
- hud/datasets/tests/test_utils.py +315 -0
- hud/datasets/utils.py +270 -90
- hud/environment/__init__.py +50 -0
- hud/environment/connection.py +206 -0
- hud/environment/connectors/__init__.py +33 -0
- hud/environment/connectors/base.py +68 -0
- hud/environment/connectors/local.py +177 -0
- hud/environment/connectors/mcp_config.py +109 -0
- hud/environment/connectors/openai.py +101 -0
- hud/environment/connectors/remote.py +172 -0
- hud/environment/environment.py +694 -0
- hud/environment/integrations/__init__.py +45 -0
- hud/environment/integrations/adk.py +67 -0
- hud/environment/integrations/anthropic.py +196 -0
- hud/environment/integrations/gemini.py +92 -0
- hud/environment/integrations/langchain.py +82 -0
- hud/environment/integrations/llamaindex.py +68 -0
- hud/environment/integrations/openai.py +238 -0
- hud/environment/mock.py +306 -0
- hud/environment/router.py +112 -0
- hud/environment/scenarios.py +493 -0
- hud/environment/tests/__init__.py +1 -0
- hud/environment/tests/test_connection.py +317 -0
- hud/environment/tests/test_connectors.py +218 -0
- hud/environment/tests/test_environment.py +161 -0
- hud/environment/tests/test_integrations.py +257 -0
- hud/environment/tests/test_local_connectors.py +201 -0
- hud/environment/tests/test_scenarios.py +280 -0
- hud/environment/tests/test_tools.py +208 -0
- hud/environment/types.py +23 -0
- hud/environment/utils/__init__.py +35 -0
- hud/environment/utils/formats.py +215 -0
- hud/environment/utils/schema.py +171 -0
- hud/environment/utils/tool_wrappers.py +113 -0
- hud/eval/__init__.py +67 -0
- hud/eval/context.py +674 -0
- hud/eval/display.py +299 -0
- hud/eval/instrument.py +185 -0
- hud/eval/manager.py +466 -0
- hud/eval/parallel.py +268 -0
- hud/eval/task.py +340 -0
- hud/eval/tests/__init__.py +1 -0
- hud/eval/tests/test_context.py +178 -0
- hud/eval/tests/test_eval.py +210 -0
- hud/eval/tests/test_manager.py +152 -0
- hud/eval/tests/test_parallel.py +168 -0
- hud/eval/tests/test_task.py +145 -0
- hud/eval/types.py +63 -0
- hud/eval/utils.py +183 -0
- hud/patches/__init__.py +19 -0
- hud/patches/mcp_patches.py +151 -0
- hud/patches/warnings.py +54 -0
- hud/samples/browser.py +4 -4
- hud/server/__init__.py +2 -1
- hud/server/low_level.py +2 -1
- hud/server/router.py +164 -0
- hud/server/server.py +567 -80
- hud/server/tests/test_mcp_server_integration.py +11 -11
- hud/server/tests/test_mcp_server_more.py +1 -1
- hud/server/tests/test_server_extra.py +2 -0
- hud/settings.py +45 -3
- hud/shared/exceptions.py +36 -10
- hud/shared/hints.py +26 -1
- hud/shared/requests.py +15 -3
- hud/shared/tests/test_exceptions.py +40 -31
- hud/shared/tests/test_hints.py +167 -0
- hud/telemetry/__init__.py +20 -19
- hud/telemetry/exporter.py +201 -0
- hud/telemetry/instrument.py +158 -253
- hud/telemetry/tests/test_eval_telemetry.py +356 -0
- hud/telemetry/tests/test_exporter.py +258 -0
- hud/telemetry/tests/test_instrument.py +401 -0
- hud/tools/__init__.py +16 -2
- hud/tools/apply_patch.py +639 -0
- hud/tools/base.py +54 -4
- hud/tools/bash.py +2 -2
- hud/tools/computer/__init__.py +4 -0
- hud/tools/computer/anthropic.py +2 -2
- hud/tools/computer/gemini.py +385 -0
- hud/tools/computer/hud.py +23 -6
- hud/tools/computer/openai.py +20 -21
- hud/tools/computer/qwen.py +434 -0
- hud/tools/computer/settings.py +37 -0
- hud/tools/edit.py +3 -7
- hud/tools/executors/base.py +4 -2
- hud/tools/executors/pyautogui.py +1 -1
- hud/tools/grounding/grounded_tool.py +13 -18
- hud/tools/grounding/grounder.py +10 -31
- hud/tools/grounding/tests/test_grounded_tool.py +26 -44
- hud/tools/jupyter.py +330 -0
- hud/tools/playwright.py +18 -3
- hud/tools/shell.py +308 -0
- hud/tools/tests/test_apply_patch.py +718 -0
- hud/tools/tests/test_computer.py +4 -9
- hud/tools/tests/test_computer_actions.py +24 -2
- hud/tools/tests/test_jupyter_tool.py +181 -0
- hud/tools/tests/test_shell.py +596 -0
- hud/tools/tests/test_submit.py +85 -0
- hud/tools/tests/test_types.py +193 -0
- hud/tools/types.py +21 -1
- hud/types.py +167 -57
- hud/utils/__init__.py +2 -0
- hud/utils/env.py +67 -0
- hud/utils/hud_console.py +61 -3
- hud/utils/mcp.py +15 -58
- hud/utils/strict_schema.py +162 -0
- hud/utils/tests/test_init.py +1 -2
- hud/utils/tests/test_mcp.py +1 -28
- hud/utils/tests/test_pretty_errors.py +186 -0
- hud/utils/tests/test_tool_shorthand.py +154 -0
- hud/utils/tests/test_version.py +1 -1
- hud/utils/types.py +20 -0
- hud/version.py +1 -1
- hud_python-0.5.1.dist-info/METADATA +264 -0
- hud_python-0.5.1.dist-info/RECORD +299 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/WHEEL +1 -1
- hud/agents/langchain.py +0 -261
- hud/agents/lite_llm.py +0 -72
- hud/cli/rl/__init__.py +0 -180
- hud/cli/rl/config.py +0 -101
- hud/cli/rl/display.py +0 -133
- hud/cli/rl/gpu.py +0 -63
- hud/cli/rl/gpu_utils.py +0 -321
- hud/cli/rl/local_runner.py +0 -595
- hud/cli/rl/presets.py +0 -96
- hud/cli/rl/remote_runner.py +0 -463
- hud/cli/rl/rl_api.py +0 -150
- hud/cli/rl/vllm.py +0 -177
- hud/cli/rl/wait_utils.py +0 -89
- hud/datasets/parallel.py +0 -687
- hud/misc/__init__.py +0 -1
- hud/misc/claude_plays_pokemon.py +0 -292
- hud/otel/__init__.py +0 -35
- hud/otel/collector.py +0 -142
- hud/otel/config.py +0 -181
- hud/otel/context.py +0 -570
- hud/otel/exporters.py +0 -369
- hud/otel/instrumentation.py +0 -135
- hud/otel/processors.py +0 -121
- hud/otel/tests/__init__.py +0 -1
- hud/otel/tests/test_processors.py +0 -197
- hud/rl/README.md +0 -30
- hud/rl/__init__.py +0 -1
- hud/rl/actor.py +0 -176
- hud/rl/buffer.py +0 -405
- hud/rl/chat_template.jinja +0 -101
- hud/rl/config.py +0 -192
- hud/rl/distributed.py +0 -132
- hud/rl/learner.py +0 -637
- hud/rl/tests/__init__.py +0 -1
- hud/rl/tests/test_learner.py +0 -186
- hud/rl/train.py +0 -382
- hud/rl/types.py +0 -101
- hud/rl/utils/start_vllm_server.sh +0 -30
- hud/rl/utils.py +0 -524
- hud/rl/vllm_adapter.py +0 -143
- hud/telemetry/job.py +0 -352
- hud/telemetry/replay.py +0 -74
- hud/telemetry/tests/test_replay.py +0 -40
- hud/telemetry/tests/test_trace.py +0 -63
- hud/telemetry/trace.py +0 -158
- hud/utils/agent_factories.py +0 -86
- hud/utils/async_utils.py +0 -65
- hud/utils/group_eval.py +0 -223
- hud/utils/progress.py +0 -149
- hud/utils/tasks.py +0 -127
- hud/utils/tests/test_async_utils.py +0 -173
- hud/utils/tests/test_progress.py +0 -261
- hud_python-0.4.45.dist-info/METADATA +0 -552
- hud_python-0.4.45.dist-info/RECORD +0 -228
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
hud/tools/apply_patch.py
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Apply patch tool implementation conforming to OpenAI's apply_patch tool specification.
|
|
3
|
+
https://platform.openai.com/docs/guides/tools-apply-patch
|
|
4
|
+
|
|
5
|
+
Key features:
|
|
6
|
+
- Supports create_file, update_file, delete_file operations
|
|
7
|
+
- Parses V4A diff format
|
|
8
|
+
- Returns apply_patch_call_output format with status and output
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DiffError(ValueError):
|
|
19
|
+
"""Exception raised when diff parsing or application fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ActionType(str, Enum):
|
|
23
|
+
ADD = "add"
|
|
24
|
+
DELETE = "delete"
|
|
25
|
+
UPDATE = "update"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FileChange:
|
|
30
|
+
type: ActionType
|
|
31
|
+
old_content: str | None = None
|
|
32
|
+
new_content: str | None = None
|
|
33
|
+
move_path: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Commit:
|
|
38
|
+
changes: dict[str, FileChange] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Chunk:
|
|
43
|
+
orig_index: int = -1 # line index of the first line in the original file
|
|
44
|
+
del_lines: list[str] = field(default_factory=list)
|
|
45
|
+
ins_lines: list[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PatchAction:
|
|
50
|
+
type: ActionType
|
|
51
|
+
new_file: str | None = None
|
|
52
|
+
chunks: list[Chunk] = field(default_factory=list)
|
|
53
|
+
move_path: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Patch:
|
|
58
|
+
actions: dict[str, PatchAction] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ApplyPatchResult:
|
|
63
|
+
"""Result of apply_patch tool execution, conforming to apply_patch_call_output format."""
|
|
64
|
+
|
|
65
|
+
status: Literal["completed", "failed"]
|
|
66
|
+
output: str
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
return {"status": self.status, "output": self.output}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Parser:
|
|
73
|
+
"""Parser for V4A diff format."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, current_files: dict[str, str], lines: list[str], index: int = 0) -> None:
|
|
76
|
+
self.current_files = current_files
|
|
77
|
+
self.lines = lines
|
|
78
|
+
self.index = index
|
|
79
|
+
self.patch = Patch()
|
|
80
|
+
self.fuzz = 0
|
|
81
|
+
|
|
82
|
+
def is_done(self, prefixes: tuple[str, ...] | None = None) -> bool:
|
|
83
|
+
if self.index >= len(self.lines):
|
|
84
|
+
return True
|
|
85
|
+
return prefixes is not None and self.lines[self.index].startswith(prefixes)
|
|
86
|
+
|
|
87
|
+
def startswith(self, prefix: str | tuple[str, ...]) -> bool:
|
|
88
|
+
if self.index >= len(self.lines):
|
|
89
|
+
raise DiffError(f"Unexpected end of patch at index {self.index}")
|
|
90
|
+
return self.lines[self.index].startswith(prefix)
|
|
91
|
+
|
|
92
|
+
def read_str(self, prefix: str = "", return_everything: bool = False) -> str:
|
|
93
|
+
if self.index >= len(self.lines):
|
|
94
|
+
return "" # At EOF, no match possible
|
|
95
|
+
if self.lines[self.index].startswith(prefix):
|
|
96
|
+
if return_everything:
|
|
97
|
+
text = self.lines[self.index]
|
|
98
|
+
else:
|
|
99
|
+
text = self.lines[self.index][len(prefix) :]
|
|
100
|
+
self.index += 1
|
|
101
|
+
return text
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
def parse(self) -> None:
|
|
105
|
+
while not self.is_done(("*** End Patch",)):
|
|
106
|
+
path = self.read_str("*** Update File: ")
|
|
107
|
+
if path:
|
|
108
|
+
if path in self.patch.actions:
|
|
109
|
+
raise DiffError(f"Update File Error: Duplicate Path: {path}")
|
|
110
|
+
move_to = self.read_str("*** Move to: ")
|
|
111
|
+
if path not in self.current_files:
|
|
112
|
+
raise DiffError(f"Update File Error: Missing File: {path}")
|
|
113
|
+
text = self.current_files[path]
|
|
114
|
+
action = self.parse_update_file(text)
|
|
115
|
+
action.move_path = move_to if move_to else None
|
|
116
|
+
self.patch.actions[path] = action
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
path = self.read_str("*** Delete File: ")
|
|
120
|
+
if path:
|
|
121
|
+
if path in self.patch.actions:
|
|
122
|
+
raise DiffError(f"Delete File Error: Duplicate Path: {path}")
|
|
123
|
+
if path not in self.current_files:
|
|
124
|
+
raise DiffError(f"Delete File Error: Missing File: {path}")
|
|
125
|
+
self.patch.actions[path] = PatchAction(type=ActionType.DELETE)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
path = self.read_str("*** Add File: ")
|
|
129
|
+
if path:
|
|
130
|
+
if path in self.patch.actions:
|
|
131
|
+
raise DiffError(f"Add File Error: Duplicate Path: {path}")
|
|
132
|
+
self.patch.actions[path] = self.parse_add_file()
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
raise DiffError(f"Unknown Line: {self.lines[self.index]}")
|
|
136
|
+
|
|
137
|
+
if self.index >= len(self.lines) or not self.startswith("*** End Patch"):
|
|
138
|
+
raise DiffError("Missing End Patch")
|
|
139
|
+
self.index += 1
|
|
140
|
+
|
|
141
|
+
def parse_update_file(self, text: str) -> PatchAction:
|
|
142
|
+
action = PatchAction(type=ActionType.UPDATE)
|
|
143
|
+
lines = text.split("\n")
|
|
144
|
+
index = 0
|
|
145
|
+
|
|
146
|
+
while not self.is_done(
|
|
147
|
+
(
|
|
148
|
+
"*** End Patch",
|
|
149
|
+
"*** Update File:",
|
|
150
|
+
"*** Delete File:",
|
|
151
|
+
"*** Add File:",
|
|
152
|
+
"*** End of File",
|
|
153
|
+
)
|
|
154
|
+
):
|
|
155
|
+
def_str = self.read_str("@@ ")
|
|
156
|
+
section_str = ""
|
|
157
|
+
if not def_str and self.lines[self.index] == "@@":
|
|
158
|
+
section_str = self.lines[self.index]
|
|
159
|
+
self.index += 1
|
|
160
|
+
|
|
161
|
+
if not (def_str or section_str or index == 0):
|
|
162
|
+
raise DiffError(f"Invalid Line:\n{self.lines[self.index]}")
|
|
163
|
+
|
|
164
|
+
if def_str.strip():
|
|
165
|
+
found = False
|
|
166
|
+
if not [s for s in lines[:index] if s == def_str]:
|
|
167
|
+
for i, s in enumerate(lines[index:], index):
|
|
168
|
+
if s == def_str:
|
|
169
|
+
index = i + 1
|
|
170
|
+
found = True
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
if not found and not [s for s in lines[:index] if s.strip() == def_str.strip()]:
|
|
174
|
+
for i, s in enumerate(lines[index:], index):
|
|
175
|
+
if s.strip() == def_str.strip():
|
|
176
|
+
index = i + 1
|
|
177
|
+
self.fuzz += 1
|
|
178
|
+
found = True
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
next_chunk_context, chunks, end_patch_index, eof = self._peek_next_section()
|
|
182
|
+
next_chunk_text = "\n".join(next_chunk_context)
|
|
183
|
+
new_index, fuzz = _find_context(lines, next_chunk_context, index, eof)
|
|
184
|
+
|
|
185
|
+
if new_index == -1:
|
|
186
|
+
if eof:
|
|
187
|
+
raise DiffError(f"Invalid EOF Context {index}:\n{next_chunk_text}")
|
|
188
|
+
else:
|
|
189
|
+
raise DiffError(f"Invalid Context {index}:\n{next_chunk_text}")
|
|
190
|
+
|
|
191
|
+
self.fuzz += fuzz
|
|
192
|
+
|
|
193
|
+
for ch in chunks:
|
|
194
|
+
ch.orig_index += new_index
|
|
195
|
+
action.chunks.append(ch)
|
|
196
|
+
|
|
197
|
+
index = new_index + len(next_chunk_context)
|
|
198
|
+
self.index = end_patch_index
|
|
199
|
+
|
|
200
|
+
return action
|
|
201
|
+
|
|
202
|
+
def parse_add_file(self) -> PatchAction:
|
|
203
|
+
lines = []
|
|
204
|
+
while not self.is_done(
|
|
205
|
+
("*** End Patch", "*** Update File:", "*** Delete File:", "*** Add File:")
|
|
206
|
+
):
|
|
207
|
+
s = self.read_str()
|
|
208
|
+
if not s.startswith("+"):
|
|
209
|
+
raise DiffError(f"Invalid Add File Line: {s}")
|
|
210
|
+
s = s[1:]
|
|
211
|
+
lines.append(s)
|
|
212
|
+
return PatchAction(type=ActionType.ADD, new_file="\n".join(lines))
|
|
213
|
+
|
|
214
|
+
def _peek_next_section(self) -> tuple[list[str], list[Chunk], int, bool]:
|
|
215
|
+
old: list[str] = []
|
|
216
|
+
del_lines: list[str] = []
|
|
217
|
+
ins_lines: list[str] = []
|
|
218
|
+
chunks: list[Chunk] = []
|
|
219
|
+
mode = "keep"
|
|
220
|
+
orig_index = self.index
|
|
221
|
+
index = self.index
|
|
222
|
+
|
|
223
|
+
while index < len(self.lines):
|
|
224
|
+
s = self.lines[index]
|
|
225
|
+
if s.startswith(
|
|
226
|
+
(
|
|
227
|
+
"@@",
|
|
228
|
+
"*** End Patch",
|
|
229
|
+
"*** Update File:",
|
|
230
|
+
"*** Delete File:",
|
|
231
|
+
"*** Add File:",
|
|
232
|
+
"*** End of File",
|
|
233
|
+
)
|
|
234
|
+
):
|
|
235
|
+
break
|
|
236
|
+
if s == "***":
|
|
237
|
+
break
|
|
238
|
+
elif s.startswith("***"):
|
|
239
|
+
raise DiffError(f"Invalid Line: {s}")
|
|
240
|
+
|
|
241
|
+
index += 1
|
|
242
|
+
last_mode = mode
|
|
243
|
+
|
|
244
|
+
if s == "":
|
|
245
|
+
s = " "
|
|
246
|
+
|
|
247
|
+
if s[0] == "+":
|
|
248
|
+
mode = "add"
|
|
249
|
+
elif s[0] == "-":
|
|
250
|
+
mode = "delete"
|
|
251
|
+
elif s[0] == " ":
|
|
252
|
+
mode = "keep"
|
|
253
|
+
else:
|
|
254
|
+
raise DiffError(f"Invalid Line: {s}")
|
|
255
|
+
|
|
256
|
+
s = s[1:]
|
|
257
|
+
|
|
258
|
+
if mode == "keep" and last_mode != mode:
|
|
259
|
+
if ins_lines or del_lines:
|
|
260
|
+
chunks.append(
|
|
261
|
+
Chunk(
|
|
262
|
+
orig_index=len(old) - len(del_lines),
|
|
263
|
+
del_lines=del_lines,
|
|
264
|
+
ins_lines=ins_lines,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
del_lines = []
|
|
268
|
+
ins_lines = []
|
|
269
|
+
|
|
270
|
+
if mode == "delete":
|
|
271
|
+
del_lines.append(s)
|
|
272
|
+
old.append(s)
|
|
273
|
+
elif mode == "add":
|
|
274
|
+
ins_lines.append(s)
|
|
275
|
+
elif mode == "keep":
|
|
276
|
+
old.append(s)
|
|
277
|
+
|
|
278
|
+
if ins_lines or del_lines:
|
|
279
|
+
chunks.append(
|
|
280
|
+
Chunk(
|
|
281
|
+
orig_index=len(old) - len(del_lines),
|
|
282
|
+
del_lines=del_lines,
|
|
283
|
+
ins_lines=ins_lines,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if index < len(self.lines) and self.lines[index] == "*** End of File":
|
|
288
|
+
index += 1
|
|
289
|
+
return old, chunks, index, True
|
|
290
|
+
|
|
291
|
+
if index == orig_index:
|
|
292
|
+
raise DiffError(f"Nothing in this section - {index=} {self.lines[index]}")
|
|
293
|
+
|
|
294
|
+
return old, chunks, index, False
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _find_context_core(lines: list[str], context: list[str], start: int) -> tuple[int, int]:
|
|
298
|
+
if not context:
|
|
299
|
+
return start, 0
|
|
300
|
+
|
|
301
|
+
# Prefer identical
|
|
302
|
+
for i in range(start, len(lines)):
|
|
303
|
+
if lines[i : i + len(context)] == context:
|
|
304
|
+
return i, 0
|
|
305
|
+
|
|
306
|
+
# RStrip is ok
|
|
307
|
+
for i in range(start, len(lines)):
|
|
308
|
+
if [s.rstrip() for s in lines[i : i + len(context)]] == [s.rstrip() for s in context]:
|
|
309
|
+
return i, 1
|
|
310
|
+
|
|
311
|
+
# Fine, Strip is ok too
|
|
312
|
+
for i in range(start, len(lines)):
|
|
313
|
+
if [s.strip() for s in lines[i : i + len(context)]] == [s.strip() for s in context]:
|
|
314
|
+
return i, 100
|
|
315
|
+
|
|
316
|
+
return -1, 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _find_context(lines: list[str], context: list[str], start: int, eof: bool) -> tuple[int, int]:
|
|
320
|
+
if eof:
|
|
321
|
+
new_index, fuzz = _find_context_core(lines, context, len(lines) - len(context))
|
|
322
|
+
if new_index != -1:
|
|
323
|
+
return new_index, fuzz
|
|
324
|
+
new_index, fuzz = _find_context_core(lines, context, start)
|
|
325
|
+
return new_index, fuzz + 10000
|
|
326
|
+
return _find_context_core(lines, context, start)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _get_updated_file(text: str, action: PatchAction, path: str) -> str:
|
|
330
|
+
assert action.type == ActionType.UPDATE
|
|
331
|
+
orig_lines = text.split("\n")
|
|
332
|
+
dest_lines = []
|
|
333
|
+
orig_index = 0
|
|
334
|
+
|
|
335
|
+
for chunk in action.chunks:
|
|
336
|
+
if chunk.orig_index > len(orig_lines):
|
|
337
|
+
raise DiffError(
|
|
338
|
+
f"_get_updated_file: {path}: chunk.orig_index {chunk.orig_index} "
|
|
339
|
+
f"> len(lines) {len(orig_lines)}"
|
|
340
|
+
)
|
|
341
|
+
if orig_index > chunk.orig_index:
|
|
342
|
+
raise DiffError(
|
|
343
|
+
f"_get_updated_file: {path}: orig_index {orig_index} "
|
|
344
|
+
f"> chunk.orig_index {chunk.orig_index}"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
dest_lines.extend(orig_lines[orig_index : chunk.orig_index])
|
|
348
|
+
orig_index = chunk.orig_index
|
|
349
|
+
|
|
350
|
+
if chunk.ins_lines:
|
|
351
|
+
dest_lines.extend(chunk.ins_lines)
|
|
352
|
+
|
|
353
|
+
orig_index += len(chunk.del_lines)
|
|
354
|
+
|
|
355
|
+
dest_lines.extend(orig_lines[orig_index:])
|
|
356
|
+
return "\n".join(dest_lines)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _text_to_patch(text: str, orig: dict[str, str]) -> tuple[Patch, int]:
|
|
360
|
+
lines = text.strip().split("\n")
|
|
361
|
+
if len(lines) < 2 or not lines[0].startswith("*** Begin Patch") or lines[-1] != "*** End Patch":
|
|
362
|
+
raise DiffError("Invalid patch text")
|
|
363
|
+
|
|
364
|
+
parser = Parser(current_files=orig, lines=lines, index=1)
|
|
365
|
+
parser.parse()
|
|
366
|
+
return parser.patch, parser.fuzz
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _identify_files_needed(text: str) -> list[str]:
|
|
370
|
+
lines = text.strip().split("\n")
|
|
371
|
+
result = set()
|
|
372
|
+
for line in lines:
|
|
373
|
+
if line.startswith("*** Update File: "):
|
|
374
|
+
result.add(line[len("*** Update File: ") :])
|
|
375
|
+
if line.startswith("*** Delete File: "):
|
|
376
|
+
result.add(line[len("*** Delete File: ") :])
|
|
377
|
+
return list(result)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _patch_to_commit(patch: Patch, orig: dict[str, str]) -> Commit:
|
|
381
|
+
commit = Commit()
|
|
382
|
+
for path, action in patch.actions.items():
|
|
383
|
+
if action.type == ActionType.DELETE:
|
|
384
|
+
commit.changes[path] = FileChange(type=ActionType.DELETE, old_content=orig[path])
|
|
385
|
+
elif action.type == ActionType.ADD:
|
|
386
|
+
commit.changes[path] = FileChange(type=ActionType.ADD, new_content=action.new_file)
|
|
387
|
+
elif action.type == ActionType.UPDATE:
|
|
388
|
+
new_content = _get_updated_file(text=orig[path], action=action, path=path)
|
|
389
|
+
commit.changes[path] = FileChange(
|
|
390
|
+
type=ActionType.UPDATE,
|
|
391
|
+
old_content=orig[path],
|
|
392
|
+
new_content=new_content,
|
|
393
|
+
move_path=action.move_path,
|
|
394
|
+
)
|
|
395
|
+
return commit
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _apply_commit(commit: Commit, write_fn: Callable, remove_fn: Callable) -> None:
|
|
399
|
+
for path, change in commit.changes.items():
|
|
400
|
+
if change.type == ActionType.DELETE:
|
|
401
|
+
remove_fn(path)
|
|
402
|
+
elif change.type == ActionType.ADD:
|
|
403
|
+
write_fn(path, change.new_content)
|
|
404
|
+
elif change.type == ActionType.UPDATE:
|
|
405
|
+
if change.move_path:
|
|
406
|
+
write_fn(change.move_path, change.new_content)
|
|
407
|
+
remove_fn(path)
|
|
408
|
+
else:
|
|
409
|
+
write_fn(path, change.new_content)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class ApplyPatchTool:
|
|
413
|
+
"""
|
|
414
|
+
A tool that allows the agent to create, update, and delete files using structured diffs.
|
|
415
|
+
Conforms to OpenAI's apply_patch tool specification.
|
|
416
|
+
|
|
417
|
+
Features:
|
|
418
|
+
- Supports create_file, update_file, delete_file operations
|
|
419
|
+
- Parses V4A diff format
|
|
420
|
+
- Returns apply_patch_call_output format
|
|
421
|
+
- Path validation to prevent directory traversal
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
def __init__(self, base_path: str = ".") -> None:
|
|
425
|
+
"""
|
|
426
|
+
Initialize the apply patch tool.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
base_path: Base directory for file operations. Paths are relative to this.
|
|
430
|
+
"""
|
|
431
|
+
self.base_path = os.path.abspath(base_path)
|
|
432
|
+
|
|
433
|
+
def _validate_path(self, path: str) -> str:
|
|
434
|
+
"""Validate and resolve a path, preventing directory traversal."""
|
|
435
|
+
if path.startswith("/"):
|
|
436
|
+
raise DiffError(f"Absolute paths are not allowed: {path}")
|
|
437
|
+
|
|
438
|
+
# Normalize and resolve
|
|
439
|
+
full_path = os.path.normpath(os.path.join(self.base_path, path))
|
|
440
|
+
|
|
441
|
+
# Check for directory traversal
|
|
442
|
+
# Use base_path + os.sep to prevent sibling directory prefix bypass
|
|
443
|
+
# e.g., /tmp/myapp_sibling shouldn't match base_path /tmp/myapp
|
|
444
|
+
if full_path != self.base_path and not full_path.startswith(self.base_path + os.sep):
|
|
445
|
+
raise DiffError(f"Path traversal detected: {path}")
|
|
446
|
+
|
|
447
|
+
return full_path
|
|
448
|
+
|
|
449
|
+
def _open_file(self, path: str) -> str:
|
|
450
|
+
"""Read a file's contents."""
|
|
451
|
+
full_path = self._validate_path(path)
|
|
452
|
+
try:
|
|
453
|
+
with open(full_path) as f:
|
|
454
|
+
return f.read()
|
|
455
|
+
except FileNotFoundError:
|
|
456
|
+
raise DiffError(f"File not found: {path}") from None
|
|
457
|
+
except Exception as e:
|
|
458
|
+
raise DiffError(f"Error reading file {path}: {e}") from e
|
|
459
|
+
|
|
460
|
+
def _write_file(self, path: str, content: str) -> None:
|
|
461
|
+
"""Write content to a file, creating directories if needed."""
|
|
462
|
+
full_path = self._validate_path(path)
|
|
463
|
+
parent = os.path.dirname(full_path)
|
|
464
|
+
if parent:
|
|
465
|
+
os.makedirs(parent, exist_ok=True)
|
|
466
|
+
with open(full_path, "w") as f:
|
|
467
|
+
f.write(content)
|
|
468
|
+
|
|
469
|
+
def _remove_file(self, path: str) -> None:
|
|
470
|
+
"""Remove a file."""
|
|
471
|
+
full_path = self._validate_path(path)
|
|
472
|
+
os.remove(full_path)
|
|
473
|
+
|
|
474
|
+
def _load_files(self, paths: list[str]) -> dict[str, str]:
|
|
475
|
+
"""Load multiple files into a dictionary."""
|
|
476
|
+
orig = {}
|
|
477
|
+
for path in paths:
|
|
478
|
+
orig[path] = self._open_file(path)
|
|
479
|
+
return orig
|
|
480
|
+
|
|
481
|
+
def _process_v4a_diff(self, diff_text: str) -> str:
|
|
482
|
+
"""Process a V4A diff and apply it to files."""
|
|
483
|
+
if not diff_text.strip().startswith("*** Begin Patch"):
|
|
484
|
+
# Wrap in patch markers if not present
|
|
485
|
+
diff_text = f"*** Begin Patch\n{diff_text}\n*** End Patch"
|
|
486
|
+
|
|
487
|
+
paths = _identify_files_needed(diff_text)
|
|
488
|
+
orig = self._load_files(paths)
|
|
489
|
+
patch, _ = _text_to_patch(diff_text, orig)
|
|
490
|
+
commit = _patch_to_commit(patch, orig)
|
|
491
|
+
_apply_commit(commit, self._write_file, self._remove_file)
|
|
492
|
+
|
|
493
|
+
changed_files = list(commit.changes.keys())
|
|
494
|
+
return f"Applied patch to {len(changed_files)} file(s): {', '.join(changed_files)}"
|
|
495
|
+
|
|
496
|
+
async def __call__(
|
|
497
|
+
self,
|
|
498
|
+
type: str | None = None,
|
|
499
|
+
path: str | None = None,
|
|
500
|
+
diff: str | None = None,
|
|
501
|
+
**kwargs: object,
|
|
502
|
+
) -> ApplyPatchResult:
|
|
503
|
+
"""
|
|
504
|
+
Apply a patch operation.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
type: Operation type - "create_file", "update_file", or "delete_file"
|
|
508
|
+
path: The file path to operate on
|
|
509
|
+
diff: The V4A diff content (required for create_file and update_file)
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
ApplyPatchResult conforming to apply_patch_call_output format.
|
|
513
|
+
"""
|
|
514
|
+
op_type = type
|
|
515
|
+
|
|
516
|
+
if not op_type:
|
|
517
|
+
return ApplyPatchResult(
|
|
518
|
+
status="failed",
|
|
519
|
+
output="Error: Missing operation type",
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
if not path:
|
|
523
|
+
return ApplyPatchResult(
|
|
524
|
+
status="failed",
|
|
525
|
+
output="Error: Missing file path",
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
if op_type == "delete_file":
|
|
530
|
+
# Delete file operation
|
|
531
|
+
full_path = self._validate_path(path)
|
|
532
|
+
if not os.path.exists(full_path):
|
|
533
|
+
return ApplyPatchResult(
|
|
534
|
+
status="failed",
|
|
535
|
+
output=f"Error: File not found at path '{path}'",
|
|
536
|
+
)
|
|
537
|
+
self._remove_file(path)
|
|
538
|
+
return ApplyPatchResult(
|
|
539
|
+
status="completed",
|
|
540
|
+
output=f"Deleted {path}",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
elif op_type == "create_file":
|
|
544
|
+
# Create file operation
|
|
545
|
+
if not diff:
|
|
546
|
+
return ApplyPatchResult(
|
|
547
|
+
status="failed",
|
|
548
|
+
output="Error: Missing diff for create_file operation",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
full_path = self._validate_path(path)
|
|
552
|
+
if os.path.exists(full_path):
|
|
553
|
+
return ApplyPatchResult(
|
|
554
|
+
status="failed",
|
|
555
|
+
output=f"Error: File already exists at path '{path}'",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# For create_file, the diff should represent the full file content
|
|
559
|
+
# Parse the V4A diff format for new file
|
|
560
|
+
content = self._parse_create_diff(diff)
|
|
561
|
+
self._write_file(path, content)
|
|
562
|
+
return ApplyPatchResult(
|
|
563
|
+
status="completed",
|
|
564
|
+
output=f"Created {path}",
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
elif op_type == "update_file":
|
|
568
|
+
# Update file operation
|
|
569
|
+
if not diff:
|
|
570
|
+
return ApplyPatchResult(
|
|
571
|
+
status="failed",
|
|
572
|
+
output="Error: Missing diff for update_file operation",
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
full_path = self._validate_path(path)
|
|
576
|
+
if not os.path.exists(full_path):
|
|
577
|
+
return ApplyPatchResult(
|
|
578
|
+
status="failed",
|
|
579
|
+
output=f"Error: File not found at path '{path}'",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Apply the V4A diff
|
|
583
|
+
result = self._apply_update_diff(path, diff)
|
|
584
|
+
return ApplyPatchResult(
|
|
585
|
+
status="completed",
|
|
586
|
+
output=result,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
else:
|
|
590
|
+
return ApplyPatchResult(
|
|
591
|
+
status="failed",
|
|
592
|
+
output=f"Error: Unknown operation type '{op_type}'",
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
except DiffError as e:
|
|
596
|
+
return ApplyPatchResult(
|
|
597
|
+
status="failed",
|
|
598
|
+
output=f"Error: {e}",
|
|
599
|
+
)
|
|
600
|
+
except Exception as e:
|
|
601
|
+
return ApplyPatchResult(
|
|
602
|
+
status="failed",
|
|
603
|
+
output=f"Error: {e}",
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def _parse_create_diff(self, diff: str) -> str:
|
|
607
|
+
"""Parse a create diff and extract the file content."""
|
|
608
|
+
lines = diff.strip().split("\n")
|
|
609
|
+
content_lines = []
|
|
610
|
+
|
|
611
|
+
for line in lines:
|
|
612
|
+
# Skip empty lines at start
|
|
613
|
+
if not line and not content_lines:
|
|
614
|
+
continue
|
|
615
|
+
# Lines starting with + are additions (the file content)
|
|
616
|
+
if line.startswith("+"): # noqa: SIM114
|
|
617
|
+
content_lines.append(line[1:])
|
|
618
|
+
elif line.startswith(" "):
|
|
619
|
+
content_lines.append(line[1:])
|
|
620
|
+
elif line == "":
|
|
621
|
+
content_lines.append("")
|
|
622
|
+
|
|
623
|
+
return "\n".join(content_lines)
|
|
624
|
+
|
|
625
|
+
def _apply_update_diff(self, path: str, diff: str) -> str:
|
|
626
|
+
"""Apply an update diff to an existing file."""
|
|
627
|
+
# Read current content
|
|
628
|
+
current_content = self._open_file(path)
|
|
629
|
+
|
|
630
|
+
# Construct full patch text
|
|
631
|
+
patch_text = f"*** Begin Patch\n*** Update File: {path}\n{diff}\n*** End Patch"
|
|
632
|
+
|
|
633
|
+
# Parse and apply
|
|
634
|
+
orig = {path: current_content}
|
|
635
|
+
patch, fuzz = _text_to_patch(patch_text, orig)
|
|
636
|
+
commit = _patch_to_commit(patch, orig)
|
|
637
|
+
_apply_commit(commit, self._write_file, self._remove_file)
|
|
638
|
+
|
|
639
|
+
return f"Updated {path}" + (f" (fuzz: {fuzz})" if fuzz > 0 else "")
|