citrus-code 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.
- citrus_code/__init__.py +0 -0
- citrus_code/__main__.py +5 -0
- citrus_code/agent/__init__.py +0 -0
- citrus_code/agent/agent.py +255 -0
- citrus_code/agent/cancellation.py +66 -0
- citrus_code/agent/context.py +20 -0
- citrus_code/agent/events.py +97 -0
- citrus_code/agent/hooks.py +37 -0
- citrus_code/agent/loop.py +429 -0
- citrus_code/agent/message_queue.py +44 -0
- citrus_code/agent/state.py +13 -0
- citrus_code/agent/tool_runner.py +161 -0
- citrus_code/agent/types.py +34 -0
- citrus_code/ai/__init__.py +0 -0
- citrus_code/ai/completion.py +30 -0
- citrus_code/ai/events.py +78 -0
- citrus_code/ai/provider.py +20 -0
- citrus_code/ai/providers/__init__.py +0 -0
- citrus_code/ai/providers/chat_completions.py +503 -0
- citrus_code/ai/providers/chat_stream_types.py +47 -0
- citrus_code/ai/providers/deepseek.py +104 -0
- citrus_code/ai/providers/fake.py +101 -0
- citrus_code/ai/providers/tool_call_buffer.py +73 -0
- citrus_code/ai/registry.py +40 -0
- citrus_code/ai/types.py +142 -0
- citrus_code/cli/__init__.py +5 -0
- citrus_code/cli/main.py +258 -0
- citrus_code/cli/preview.py +55 -0
- citrus_code/cli/prompts.py +52 -0
- citrus_code/cli/render.py +306 -0
- citrus_code/cli/session_files.py +75 -0
- citrus_code/cli/terminal.py +280 -0
- citrus_code/cli/theme.py +51 -0
- citrus_code/coding/__init__.py +0 -0
- citrus_code/coding/coding_session.py +287 -0
- citrus_code/coding/compaction/__init__.py +5 -0
- citrus_code/coding/compaction/controller.py +168 -0
- citrus_code/coding/compaction/summarize.py +125 -0
- citrus_code/coding/compaction/tokens.py +48 -0
- citrus_code/coding/context_hooks.py +41 -0
- citrus_code/coding/history.py +47 -0
- citrus_code/coding/project_context.py +44 -0
- citrus_code/coding/session/__init__.py +30 -0
- citrus_code/coding/session/manager.py +200 -0
- citrus_code/coding/session/projection.py +50 -0
- citrus_code/coding/session/recovery.py +33 -0
- citrus_code/coding/session/storage.py +138 -0
- citrus_code/coding/session/types.py +108 -0
- citrus_code/coding/tools/__init__.py +35 -0
- citrus_code/coding/tools/bash.py +5 -0
- citrus_code/coding/tools/edit.py +101 -0
- citrus_code/coding/tools/edit_text.py +172 -0
- citrus_code/coding/tools/factory.py +57 -0
- citrus_code/coding/tools/find.py +86 -0
- citrus_code/coding/tools/grep.py +151 -0
- citrus_code/coding/tools/ls.py +61 -0
- citrus_code/coding/tools/mutation.py +39 -0
- citrus_code/coding/tools/paths.py +11 -0
- citrus_code/coding/tools/powershell.py +5 -0
- citrus_code/coding/tools/processes.py +103 -0
- citrus_code/coding/tools/read.py +133 -0
- citrus_code/coding/tools/shell.py +218 -0
- citrus_code/coding/tools/truncate.py +87 -0
- citrus_code/coding/tools/write.py +94 -0
- citrus_code/config.py +34 -0
- citrus_code-0.1.0.dist-info/METADATA +412 -0
- citrus_code-0.1.0.dist-info/RECORD +70 -0
- citrus_code-0.1.0.dist-info/WHEEL +4 -0
- citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
- citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
6
|
+
|
|
7
|
+
from ...agent.types import ToolExecutionResult
|
|
8
|
+
from ...ai.types import ImageContent, TextContent, ToolDefinition
|
|
9
|
+
from .paths import resolve_to_cwd
|
|
10
|
+
from .truncate import DEFAULT_MAX_BYTES, truncate_text
|
|
11
|
+
|
|
12
|
+
class ReadArguments(BaseModel):
|
|
13
|
+
model_config = ConfigDict(
|
|
14
|
+
extra="forbid",
|
|
15
|
+
strict=True
|
|
16
|
+
)
|
|
17
|
+
path: str = Field(
|
|
18
|
+
min_length=1,
|
|
19
|
+
description="The file path, absolute or relative to the working directory.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
offset: int = Field(
|
|
23
|
+
default=1,
|
|
24
|
+
ge=1,
|
|
25
|
+
description="The first line to read, starting from 1.",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
limit: int | None = Field(
|
|
29
|
+
default=None,
|
|
30
|
+
ge=1,
|
|
31
|
+
description="The maximum number of lines to read.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ReadTool:
|
|
36
|
+
name: str = "read"
|
|
37
|
+
description: str = (
|
|
38
|
+
"Read a UTF-8 text file or an image (PNG, JPEG, GIF, WebP, BMP). "
|
|
39
|
+
"Images are returned as attachments. Text is limited to 2000 lines or 50KB, "
|
|
40
|
+
"whichever is hit first. Use offset (1-based) and limit for large files; "
|
|
41
|
+
"continue with the suggested offset to read more."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def __init__(self, cwd: Path) -> None:
|
|
45
|
+
self._cwd = cwd.resolve()
|
|
46
|
+
|
|
47
|
+
def get_definition(self) -> ToolDefinition:
|
|
48
|
+
return ToolDefinition(
|
|
49
|
+
name=self.name,
|
|
50
|
+
description=self.description,
|
|
51
|
+
parameters=ReadArguments.model_json_schema(),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def execute(
|
|
55
|
+
self,
|
|
56
|
+
tool_call_id: str,
|
|
57
|
+
arguments: dict[str, JsonValue],
|
|
58
|
+
) -> ToolExecutionResult:
|
|
59
|
+
args = ReadArguments.model_validate(arguments)
|
|
60
|
+
|
|
61
|
+
path = resolve_to_cwd(args.path, self._cwd)
|
|
62
|
+
|
|
63
|
+
return await asyncio.to_thread(
|
|
64
|
+
self._read,
|
|
65
|
+
path,
|
|
66
|
+
args,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _read(
|
|
70
|
+
self,
|
|
71
|
+
path: Path,
|
|
72
|
+
args: ReadArguments,
|
|
73
|
+
) -> ToolExecutionResult:
|
|
74
|
+
buffer = path.read_bytes()
|
|
75
|
+
mime_type: str | None = None
|
|
76
|
+
if buffer.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
77
|
+
mime_type = "image/png"
|
|
78
|
+
elif buffer.startswith(b"\xff\xd8\xff"):
|
|
79
|
+
mime_type = "image/jpeg"
|
|
80
|
+
elif buffer.startswith((b"GIF87a", b"GIF89a")):
|
|
81
|
+
mime_type = "image/gif"
|
|
82
|
+
elif buffer.startswith(b"RIFF") and buffer[8:12] == b"WEBP":
|
|
83
|
+
mime_type = "image/webp"
|
|
84
|
+
elif buffer.startswith(b"BM"):
|
|
85
|
+
mime_type = "image/bmp"
|
|
86
|
+
if mime_type is not None:
|
|
87
|
+
return ToolExecutionResult(
|
|
88
|
+
content=[
|
|
89
|
+
TextContent(text=f"Read image file [{mime_type}]: {args.path}"),
|
|
90
|
+
ImageContent(data=base64.b64encode(buffer).decode("ascii"), mime_type=mime_type),
|
|
91
|
+
],
|
|
92
|
+
details={"path": str(path), "mimeType": mime_type, "bytes": len(buffer)},
|
|
93
|
+
)
|
|
94
|
+
text = buffer.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
|
|
95
|
+
# Split only LF: Unicode separators inside source literals are not lines.
|
|
96
|
+
parts = text.split("\n")
|
|
97
|
+
lines = [line + "\n" for line in parts[:-1]]
|
|
98
|
+
if parts[-1]:
|
|
99
|
+
lines.append(parts[-1])
|
|
100
|
+
start = args.offset - 1
|
|
101
|
+
if start >= len(lines) and (lines or start != 0):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Offset {args.offset} is beyond end of file ({len(lines)} lines total)"
|
|
104
|
+
)
|
|
105
|
+
stop = len(lines) if args.limit is None else start + args.limit
|
|
106
|
+
truncation = truncate_text("".join(lines[start:stop]))
|
|
107
|
+
text = truncation.content
|
|
108
|
+
has_more = start + truncation.output_lines < len(lines)
|
|
109
|
+
if truncation.first_line_exceeds_limit:
|
|
110
|
+
text = (
|
|
111
|
+
f"[Line {args.offset} exceeds {DEFAULT_MAX_BYTES} bytes. "
|
|
112
|
+
"Use bash or powershell to inspect a smaller portion of this line.]"
|
|
113
|
+
)
|
|
114
|
+
elif has_more:
|
|
115
|
+
next_offset = args.offset + truncation.output_lines
|
|
116
|
+
text += (
|
|
117
|
+
f"\n\n[Showing {truncation.output_lines} lines starting at {args.offset}. "
|
|
118
|
+
f"Use offset={next_offset} to continue.]"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
details: dict[str, JsonValue] = {
|
|
122
|
+
"path": str(path),
|
|
123
|
+
"offset": args.offset,
|
|
124
|
+
"lines_read": truncation.output_lines,
|
|
125
|
+
"has_more": has_more,
|
|
126
|
+
}
|
|
127
|
+
if truncation.truncated:
|
|
128
|
+
details["truncation"] = truncation.model_dump(mode="json", exclude={"content"})
|
|
129
|
+
|
|
130
|
+
return ToolExecutionResult(
|
|
131
|
+
content=[TextContent(text=text)],
|
|
132
|
+
details=details,
|
|
133
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
9
|
+
|
|
10
|
+
from ...agent.types import ToolExecutionResult
|
|
11
|
+
from ...ai.types import TextContent, ToolDefinition
|
|
12
|
+
from .processes import find_executable, spawn_process, stop_process
|
|
13
|
+
from .truncate import DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncate_text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ShellArguments(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
18
|
+
|
|
19
|
+
command: str = Field(min_length=1, description="Shell command to execute.")
|
|
20
|
+
timeout: float | None = Field(
|
|
21
|
+
default=None,
|
|
22
|
+
gt=0,
|
|
23
|
+
allow_inf_nan=False,
|
|
24
|
+
description="Timeout in seconds. Omit for no timeout.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ShellTool:
|
|
29
|
+
name: str = "bash"
|
|
30
|
+
description: str
|
|
31
|
+
|
|
32
|
+
def __init__(self, cwd: Path, shell_path: str | None = None) -> None:
|
|
33
|
+
self._cwd = cwd.resolve()
|
|
34
|
+
self._shell_path = shell_path
|
|
35
|
+
language = "PowerShell" if self.name == "powershell" else "Bash"
|
|
36
|
+
self.description = (
|
|
37
|
+
f"Execute a {language} command in the working directory. Returns stdout "
|
|
38
|
+
"and stderr. Keeps the last 2000 lines or 50KB; full truncated output "
|
|
39
|
+
"is saved to a temporary file. Optional timeout is in seconds."
|
|
40
|
+
)
|
|
41
|
+
if self.name == "powershell":
|
|
42
|
+
self.description += (
|
|
43
|
+
" Commands run in a fresh session; variables and cd do not persist. "
|
|
44
|
+
"Use Windows PowerShell 5.1-compatible syntax: no Bash heredocs, && or ||. "
|
|
45
|
+
"Backslash does not escape quotes in PowerShell; use single-quoted literals "
|
|
46
|
+
"or a PowerShell here-string. For complex Python/JavaScript, write a script "
|
|
47
|
+
"file first, then execute it instead of nesting code inside -c/-e."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def get_definition(self) -> ToolDefinition:
|
|
51
|
+
return ToolDefinition(
|
|
52
|
+
name=self.name,
|
|
53
|
+
description=self.description,
|
|
54
|
+
parameters=ShellArguments.model_json_schema(),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _command(self, command: str, script_path: Path | None) -> list[str]:
|
|
58
|
+
if self.name == "powershell":
|
|
59
|
+
executable = self._shell_path or shutil.which("pwsh") or find_executable("powershell")
|
|
60
|
+
if script_path is None:
|
|
61
|
+
raise ValueError("PowerShell requires a script file")
|
|
62
|
+
encoded_path = base64.b64encode(str(script_path).encode("utf-8")).decode("ascii")
|
|
63
|
+
# Only the short launcher crosses Windows argv parsing. Read and
|
|
64
|
+
# parse the original command after UTF-8 and error handling are set,
|
|
65
|
+
# so nested quotes, long commands and parser diagnostics survive.
|
|
66
|
+
launcher = (
|
|
67
|
+
"$OutputEncoding = [Console]::OutputEncoding = "
|
|
68
|
+
"[System.Text.UTF8Encoding]::new($false); "
|
|
69
|
+
"$ErrorActionPreference = 'Stop'; "
|
|
70
|
+
"$PSNativeCommandUseErrorActionPreference = $false; "
|
|
71
|
+
"try { "
|
|
72
|
+
"$citrusCodeScriptPath = [System.Text.Encoding]::UTF8.GetString("
|
|
73
|
+
f"[System.Convert]::FromBase64String('{encoded_path}')); "
|
|
74
|
+
"& ([scriptblock]::Create([System.IO.File]::ReadAllText("
|
|
75
|
+
"$citrusCodeScriptPath, [System.Text.Encoding]::UTF8))); "
|
|
76
|
+
"if (-not $?) { "
|
|
77
|
+
"if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; "
|
|
78
|
+
"exit 1 }; exit 0 "
|
|
79
|
+
"} catch { [Console]::Error.WriteLine(($_ | Out-String)); exit 1 }"
|
|
80
|
+
)
|
|
81
|
+
return [executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", launcher]
|
|
82
|
+
|
|
83
|
+
executable = self._shell_path
|
|
84
|
+
if executable is None and os.name == "nt":
|
|
85
|
+
# Prefer Git Bash to Windows' WSL launcher: the tool must execute
|
|
86
|
+
# Bash commands against the same Windows working directory.
|
|
87
|
+
candidates: list[Path] = []
|
|
88
|
+
git = shutil.which("git")
|
|
89
|
+
if git:
|
|
90
|
+
candidates.append(Path(git).resolve().parent.parent / "bin" / "bash.exe")
|
|
91
|
+
for variable in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
|
|
92
|
+
base = os.environ.get(variable)
|
|
93
|
+
if base:
|
|
94
|
+
candidates.append(Path(base) / "Git" / "bin" / "bash.exe")
|
|
95
|
+
executable = next((str(path) for path in candidates if path.is_file()), None)
|
|
96
|
+
if executable is None:
|
|
97
|
+
raise FileNotFoundError("Git Bash is required on Windows. Install Git Bash or set shell_path.")
|
|
98
|
+
return [executable or find_executable("bash"), "-c", command]
|
|
99
|
+
|
|
100
|
+
async def execute(
|
|
101
|
+
self,
|
|
102
|
+
tool_call_id: str,
|
|
103
|
+
arguments: dict[str, JsonValue],
|
|
104
|
+
) -> ToolExecutionResult:
|
|
105
|
+
args = ShellArguments.model_validate(arguments)
|
|
106
|
+
script_path: Path | None = None
|
|
107
|
+
output_path: Path | None = None
|
|
108
|
+
keep_output = False
|
|
109
|
+
try:
|
|
110
|
+
if self.name == "powershell":
|
|
111
|
+
with tempfile.NamedTemporaryFile(
|
|
112
|
+
prefix="citrus-code-powershell-", suffix=".ps1",
|
|
113
|
+
mode="w", encoding="utf-8", newline="\n", delete=False,
|
|
114
|
+
) as script:
|
|
115
|
+
script_path = Path(script.name)
|
|
116
|
+
script.write(args.command)
|
|
117
|
+
# Capture status inside the same script block as the final
|
|
118
|
+
# user command; invoking a block can reset $? in PowerShell.
|
|
119
|
+
script.write(
|
|
120
|
+
"\nif (-not $?) { "
|
|
121
|
+
"if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; "
|
|
122
|
+
"exit 1 }; exit 0\n"
|
|
123
|
+
)
|
|
124
|
+
command = self._command(args.command, script_path)
|
|
125
|
+
# A file sink bounds memory and avoids inherited pipe handles keeping
|
|
126
|
+
# a completed foreground shell blocked on stdout/stderr drainage.
|
|
127
|
+
with tempfile.NamedTemporaryFile(
|
|
128
|
+
prefix=f"citrus-code-{self.name}-", suffix=".log", delete=False
|
|
129
|
+
) as output:
|
|
130
|
+
output_path = Path(output.name)
|
|
131
|
+
process = await spawn_process(
|
|
132
|
+
command, self._cwd, merge_stderr=True, output=output.file
|
|
133
|
+
)
|
|
134
|
+
timed_out = False
|
|
135
|
+
try:
|
|
136
|
+
if args.timeout is None:
|
|
137
|
+
code = await process.wait()
|
|
138
|
+
else:
|
|
139
|
+
code = await asyncio.wait_for(process.wait(), args.timeout)
|
|
140
|
+
except TimeoutError:
|
|
141
|
+
timed_out = True
|
|
142
|
+
await stop_process(process)
|
|
143
|
+
code = await process.wait()
|
|
144
|
+
except BaseException:
|
|
145
|
+
await stop_process(process)
|
|
146
|
+
await process.wait()
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
format_task = asyncio.create_task(asyncio.to_thread(self._format_output, output_path))
|
|
150
|
+
try:
|
|
151
|
+
text, details = await asyncio.shield(format_task)
|
|
152
|
+
except asyncio.CancelledError:
|
|
153
|
+
# Do not remove an output file still being read by the worker.
|
|
154
|
+
await format_task
|
|
155
|
+
raise
|
|
156
|
+
keep_output = "fullOutputPath" in details
|
|
157
|
+
if timed_out:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
f"{text}\n\nCommand timed out after {args.timeout} seconds"
|
|
160
|
+
f"\nShell: {command[0]}"
|
|
161
|
+
)
|
|
162
|
+
if code != 0:
|
|
163
|
+
raise RuntimeError(f"{text}\n\nCommand exited with code {code}\nShell: {command[0]}")
|
|
164
|
+
details["exit_code"] = code
|
|
165
|
+
details["shell"] = command[0]
|
|
166
|
+
return ToolExecutionResult(content=[TextContent(text=text)], details=details)
|
|
167
|
+
finally:
|
|
168
|
+
if script_path is not None:
|
|
169
|
+
script_path.unlink(missing_ok=True)
|
|
170
|
+
if output_path is not None and not keep_output:
|
|
171
|
+
output_path.unlink(missing_ok=True)
|
|
172
|
+
|
|
173
|
+
def _format_output(self, path: Path) -> tuple[str, dict[str, JsonValue]]:
|
|
174
|
+
total_bytes = 0
|
|
175
|
+
newline_count = 0
|
|
176
|
+
last_byte = b""
|
|
177
|
+
suffix = bytearray()
|
|
178
|
+
with path.open("rb") as file:
|
|
179
|
+
while chunk := file.read(64 * 1024):
|
|
180
|
+
total_bytes += len(chunk)
|
|
181
|
+
newline_count += chunk.count(b"\n")
|
|
182
|
+
last_byte = chunk[-1:]
|
|
183
|
+
suffix.extend(chunk)
|
|
184
|
+
if len(suffix) > DEFAULT_MAX_BYTES:
|
|
185
|
+
del suffix[:-DEFAULT_MAX_BYTES]
|
|
186
|
+
|
|
187
|
+
byte_clipped = total_bytes > len(suffix)
|
|
188
|
+
partial_line = False
|
|
189
|
+
if byte_clipped:
|
|
190
|
+
first_break = suffix.find(b"\n")
|
|
191
|
+
if 0 <= first_break < len(suffix) - 1:
|
|
192
|
+
del suffix[:first_break + 1]
|
|
193
|
+
else:
|
|
194
|
+
partial_line = True
|
|
195
|
+
# Ignore only an incomplete leading UTF-8 code point introduced by clipping.
|
|
196
|
+
while suffix and byte_clipped and suffix[0] & 0xC0 == 0x80:
|
|
197
|
+
del suffix[0]
|
|
198
|
+
tail = bytes(suffix).decode("utf-8", errors="replace")
|
|
199
|
+
truncation = truncate_text(tail, tail=True)
|
|
200
|
+
truncation.total_bytes = total_bytes
|
|
201
|
+
truncation.total_lines = newline_count + int(total_bytes > 0 and last_byte != b"\n")
|
|
202
|
+
if byte_clipped:
|
|
203
|
+
truncation.truncated = True
|
|
204
|
+
if truncation.truncated_by is None:
|
|
205
|
+
truncation.truncated_by = "bytes"
|
|
206
|
+
truncation.last_line_partial = partial_line
|
|
207
|
+
|
|
208
|
+
text = truncation.content or "(no output)"
|
|
209
|
+
details: dict[str, JsonValue] = {}
|
|
210
|
+
if truncation.truncated:
|
|
211
|
+
details["truncation"] = truncation.model_dump(mode="json", exclude={"content"})
|
|
212
|
+
details["fullOutputPath"] = str(path)
|
|
213
|
+
text += (
|
|
214
|
+
f"\n\n[Showing the last {truncation.output_lines} lines "
|
|
215
|
+
f"({truncation.output_bytes} bytes); limits: {DEFAULT_MAX_LINES} lines / "
|
|
216
|
+
f"{DEFAULT_MAX_BYTES} bytes. Full output: {path}]"
|
|
217
|
+
)
|
|
218
|
+
return text, details
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DEFAULT_MAX_LINES = 2000
|
|
7
|
+
DEFAULT_MAX_BYTES = 50 * 1024
|
|
8
|
+
GREP_MAX_LINE_LENGTH = 500
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TruncationResult(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid")
|
|
13
|
+
|
|
14
|
+
content: str
|
|
15
|
+
truncated: bool
|
|
16
|
+
truncated_by: Literal["lines", "bytes"] | None
|
|
17
|
+
total_lines: int
|
|
18
|
+
total_bytes: int
|
|
19
|
+
output_lines: int
|
|
20
|
+
output_bytes: int
|
|
21
|
+
first_line_exceeds_limit: bool = False
|
|
22
|
+
last_line_partial: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def truncate_text(
|
|
26
|
+
content: str,
|
|
27
|
+
*,
|
|
28
|
+
tail: bool = False,
|
|
29
|
+
max_lines: int = DEFAULT_MAX_LINES,
|
|
30
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
31
|
+
) -> TruncationResult:
|
|
32
|
+
"""Keep complete lines, with a UTF-8-safe partial last line for shell tails."""
|
|
33
|
+
if max_lines < 1 or max_bytes < 1:
|
|
34
|
+
raise ValueError("Output limits must be positive")
|
|
35
|
+
|
|
36
|
+
lines = content.split("\n") if content else []
|
|
37
|
+
if content.endswith("\n"):
|
|
38
|
+
lines.pop()
|
|
39
|
+
total_bytes = len(content.encode("utf-8"))
|
|
40
|
+
if len(lines) <= max_lines and total_bytes <= max_bytes:
|
|
41
|
+
return TruncationResult(
|
|
42
|
+
content=content,
|
|
43
|
+
truncated=False,
|
|
44
|
+
truncated_by=None,
|
|
45
|
+
total_lines=len(lines),
|
|
46
|
+
total_bytes=total_bytes,
|
|
47
|
+
output_lines=len(lines),
|
|
48
|
+
output_bytes=total_bytes,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
selected: list[str] = []
|
|
52
|
+
size = 0
|
|
53
|
+
reason: Literal["lines", "bytes"] = "lines"
|
|
54
|
+
partial = False
|
|
55
|
+
first_too_large = False
|
|
56
|
+
candidates = reversed(lines) if tail else iter(lines)
|
|
57
|
+
for line in candidates:
|
|
58
|
+
if len(selected) >= max_lines:
|
|
59
|
+
break
|
|
60
|
+
line_bytes = line.encode("utf-8")
|
|
61
|
+
cost = len(line_bytes) + (1 if selected else 0)
|
|
62
|
+
if size + cost > max_bytes:
|
|
63
|
+
reason = "bytes"
|
|
64
|
+
if not selected:
|
|
65
|
+
if tail:
|
|
66
|
+
selected.append(line_bytes[-max_bytes:].decode("utf-8", errors="ignore"))
|
|
67
|
+
partial = True
|
|
68
|
+
else:
|
|
69
|
+
first_too_large = True
|
|
70
|
+
break
|
|
71
|
+
selected.append(line)
|
|
72
|
+
size += cost
|
|
73
|
+
|
|
74
|
+
if tail:
|
|
75
|
+
selected.reverse()
|
|
76
|
+
output = "\n".join(selected)
|
|
77
|
+
return TruncationResult(
|
|
78
|
+
content=output,
|
|
79
|
+
truncated=True,
|
|
80
|
+
truncated_by=reason,
|
|
81
|
+
total_lines=len(lines),
|
|
82
|
+
total_bytes=total_bytes,
|
|
83
|
+
output_lines=len(selected),
|
|
84
|
+
output_bytes=len(output.encode("utf-8")),
|
|
85
|
+
first_line_exceeds_limit=first_too_large,
|
|
86
|
+
last_line_partial=partial,
|
|
87
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
5
|
+
|
|
6
|
+
from ...agent.types import ToolExecutionResult
|
|
7
|
+
from ...ai.types import TextContent, ToolDefinition
|
|
8
|
+
from .mutation import file_mutation
|
|
9
|
+
from .paths import resolve_to_cwd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WriteArguments(BaseModel):
|
|
13
|
+
model_config = ConfigDict(
|
|
14
|
+
extra="forbid",
|
|
15
|
+
strict=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
path: str = Field(
|
|
19
|
+
min_length=1,
|
|
20
|
+
description=(
|
|
21
|
+
"The file path, absolute or relative "
|
|
22
|
+
"to the working directory."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
content: str = Field(
|
|
27
|
+
description="The complete text content to write.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WriteTool:
|
|
32
|
+
name: str = "write"
|
|
33
|
+
|
|
34
|
+
description: str = (
|
|
35
|
+
"Write UTF-8 text to a file. "
|
|
36
|
+
"Creates the file if it does not exist, "
|
|
37
|
+
"overwrites it if it does, "
|
|
38
|
+
"and automatically creates parent directories. "
|
|
39
|
+
"Use this tool for new files or complete rewrites."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __init__(self, cwd: Path) -> None:
|
|
43
|
+
self._cwd = cwd.resolve()
|
|
44
|
+
|
|
45
|
+
def get_definition(self) -> ToolDefinition:
|
|
46
|
+
return ToolDefinition(
|
|
47
|
+
name=self.name,
|
|
48
|
+
description=self.description,
|
|
49
|
+
parameters=WriteArguments.model_json_schema(),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def execute(
|
|
53
|
+
self,
|
|
54
|
+
tool_call_id: str,
|
|
55
|
+
arguments: dict[str, JsonValue],
|
|
56
|
+
) -> ToolExecutionResult:
|
|
57
|
+
args = WriteArguments.model_validate(arguments)
|
|
58
|
+
|
|
59
|
+
path = resolve_to_cwd(args.path, self._cwd)
|
|
60
|
+
|
|
61
|
+
return await asyncio.to_thread(
|
|
62
|
+
self._write,
|
|
63
|
+
path,
|
|
64
|
+
args,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _write(
|
|
68
|
+
self,
|
|
69
|
+
path: Path,
|
|
70
|
+
args: WriteArguments,
|
|
71
|
+
) -> ToolExecutionResult:
|
|
72
|
+
with file_mutation(path):
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
characters_written = path.write_text(
|
|
75
|
+
args.content,
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
newline="",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return ToolExecutionResult(
|
|
81
|
+
content=[
|
|
82
|
+
TextContent(
|
|
83
|
+
text=(
|
|
84
|
+
f"Successfully wrote "
|
|
85
|
+
f"{characters_written} characters "
|
|
86
|
+
f"to {args.path}."
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
],
|
|
90
|
+
details={
|
|
91
|
+
"path": str(path),
|
|
92
|
+
"characters_written": characters_written,
|
|
93
|
+
},
|
|
94
|
+
)
|
citrus_code/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModelConnectionConfig(BaseModel):
|
|
7
|
+
model_config = ConfigDict(extra="forbid")
|
|
8
|
+
|
|
9
|
+
provider_id: str = Field(min_length=1)
|
|
10
|
+
model_id: str = Field(min_length=1)
|
|
11
|
+
base_url: str = Field(min_length=1)
|
|
12
|
+
api_key: SecretStr
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_connection_config() -> ModelConnectionConfig:
|
|
16
|
+
api_key = os.getenv("DEEPSEEK_API_KEY")
|
|
17
|
+
|
|
18
|
+
if api_key is None or not api_key.strip():
|
|
19
|
+
raise ValueError(
|
|
20
|
+
"Please set the DEEPSEEK_API_KEY environment variable"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return ModelConnectionConfig(
|
|
24
|
+
provider_id="deepseek",
|
|
25
|
+
model_id=os.getenv(
|
|
26
|
+
"CITRUS_CODE_MODEL",
|
|
27
|
+
"deepseek-flash",
|
|
28
|
+
),
|
|
29
|
+
base_url=os.getenv(
|
|
30
|
+
"CITRUS_CODE_BASE_URL",
|
|
31
|
+
"https://api.deepseek.com",
|
|
32
|
+
),
|
|
33
|
+
api_key=SecretStr(api_key),
|
|
34
|
+
)
|