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.
Files changed (70) hide show
  1. citrus_code/__init__.py +0 -0
  2. citrus_code/__main__.py +5 -0
  3. citrus_code/agent/__init__.py +0 -0
  4. citrus_code/agent/agent.py +255 -0
  5. citrus_code/agent/cancellation.py +66 -0
  6. citrus_code/agent/context.py +20 -0
  7. citrus_code/agent/events.py +97 -0
  8. citrus_code/agent/hooks.py +37 -0
  9. citrus_code/agent/loop.py +429 -0
  10. citrus_code/agent/message_queue.py +44 -0
  11. citrus_code/agent/state.py +13 -0
  12. citrus_code/agent/tool_runner.py +161 -0
  13. citrus_code/agent/types.py +34 -0
  14. citrus_code/ai/__init__.py +0 -0
  15. citrus_code/ai/completion.py +30 -0
  16. citrus_code/ai/events.py +78 -0
  17. citrus_code/ai/provider.py +20 -0
  18. citrus_code/ai/providers/__init__.py +0 -0
  19. citrus_code/ai/providers/chat_completions.py +503 -0
  20. citrus_code/ai/providers/chat_stream_types.py +47 -0
  21. citrus_code/ai/providers/deepseek.py +104 -0
  22. citrus_code/ai/providers/fake.py +101 -0
  23. citrus_code/ai/providers/tool_call_buffer.py +73 -0
  24. citrus_code/ai/registry.py +40 -0
  25. citrus_code/ai/types.py +142 -0
  26. citrus_code/cli/__init__.py +5 -0
  27. citrus_code/cli/main.py +258 -0
  28. citrus_code/cli/preview.py +55 -0
  29. citrus_code/cli/prompts.py +52 -0
  30. citrus_code/cli/render.py +306 -0
  31. citrus_code/cli/session_files.py +75 -0
  32. citrus_code/cli/terminal.py +280 -0
  33. citrus_code/cli/theme.py +51 -0
  34. citrus_code/coding/__init__.py +0 -0
  35. citrus_code/coding/coding_session.py +287 -0
  36. citrus_code/coding/compaction/__init__.py +5 -0
  37. citrus_code/coding/compaction/controller.py +168 -0
  38. citrus_code/coding/compaction/summarize.py +125 -0
  39. citrus_code/coding/compaction/tokens.py +48 -0
  40. citrus_code/coding/context_hooks.py +41 -0
  41. citrus_code/coding/history.py +47 -0
  42. citrus_code/coding/project_context.py +44 -0
  43. citrus_code/coding/session/__init__.py +30 -0
  44. citrus_code/coding/session/manager.py +200 -0
  45. citrus_code/coding/session/projection.py +50 -0
  46. citrus_code/coding/session/recovery.py +33 -0
  47. citrus_code/coding/session/storage.py +138 -0
  48. citrus_code/coding/session/types.py +108 -0
  49. citrus_code/coding/tools/__init__.py +35 -0
  50. citrus_code/coding/tools/bash.py +5 -0
  51. citrus_code/coding/tools/edit.py +101 -0
  52. citrus_code/coding/tools/edit_text.py +172 -0
  53. citrus_code/coding/tools/factory.py +57 -0
  54. citrus_code/coding/tools/find.py +86 -0
  55. citrus_code/coding/tools/grep.py +151 -0
  56. citrus_code/coding/tools/ls.py +61 -0
  57. citrus_code/coding/tools/mutation.py +39 -0
  58. citrus_code/coding/tools/paths.py +11 -0
  59. citrus_code/coding/tools/powershell.py +5 -0
  60. citrus_code/coding/tools/processes.py +103 -0
  61. citrus_code/coding/tools/read.py +133 -0
  62. citrus_code/coding/tools/shell.py +218 -0
  63. citrus_code/coding/tools/truncate.py +87 -0
  64. citrus_code/coding/tools/write.py +94 -0
  65. citrus_code/config.py +34 -0
  66. citrus_code-0.1.0.dist-info/METADATA +412 -0
  67. citrus_code-0.1.0.dist-info/RECORD +70 -0
  68. citrus_code-0.1.0.dist-info/WHEEL +4 -0
  69. citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
  70. 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
+ )