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,200 @@
1
+ """Session tree and context reconstruction with optional JSONL persistence."""
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from threading import RLock
6
+ from uuid import uuid4
7
+
8
+ from ...ai.types import Message, UserMessage
9
+ from .projection import project_context_entries
10
+ from .storage import JsonlSessionStore, SessionFormatError
11
+ from .types import (
12
+ ModelChangeEntry,
13
+ CompactionEntry,
14
+ ContextMessageEntry,
15
+ SessionContext,
16
+ SessionCursorEntry,
17
+ SessionEntry,
18
+ SessionHeader,
19
+ SessionLogEntry,
20
+ SessionMessageEntry,
21
+ )
22
+
23
+
24
+ class SessionManager:
25
+ def __init__(self, cwd: Path) -> None:
26
+ self._header = SessionHeader(
27
+ id=uuid4().hex,
28
+ timestamp=int(time.time() * 1000),
29
+ cwd=str(cwd.expanduser().resolve()),
30
+ )
31
+ self._entries: list[SessionEntry] = []
32
+ self._records: list[SessionLogEntry] = []
33
+ self._by_id: dict[str, SessionEntry] = {}
34
+ self._leaf_id: str | None = None
35
+ self._store: JsonlSessionStore | None = None
36
+ self._lock = RLock()
37
+
38
+ @classmethod
39
+ def create(cls, cwd: Path, file_path: Path) -> "SessionManager":
40
+ session = cls(cwd)
41
+ session.save(file_path)
42
+ return session
43
+
44
+ @classmethod
45
+ def load(cls, file_path: Path) -> "SessionManager":
46
+ store, header, records = JsonlSessionStore.load(file_path)
47
+ session = cls(Path(header.cwd))
48
+ session._header = header.model_copy(deep=True)
49
+
50
+ # Replay without attaching the store: reading must never append records.
51
+ for line_number, record in records:
52
+ try:
53
+ if isinstance(record, SessionCursorEntry):
54
+ if record.entry_id is not None and record.entry_id not in session._by_id:
55
+ raise ValueError(f"Unknown cursor target: {record.entry_id}")
56
+ session._leaf_id = record.entry_id
57
+ session._records.append(record.model_copy(deep=True))
58
+ else:
59
+ session._append_entry(record)
60
+ except ValueError as exc:
61
+ raise SessionFormatError(store.path, line_number, str(exc)) from exc
62
+
63
+ session._store = store
64
+ return session
65
+
66
+ @property
67
+ def header(self) -> SessionHeader:
68
+ with self._lock:
69
+ return self._header.model_copy(deep=True)
70
+
71
+ @property
72
+ def leaf_id(self) -> str | None:
73
+ with self._lock:
74
+ return self._leaf_id
75
+
76
+ @property
77
+ def file_path(self) -> Path | None:
78
+ with self._lock:
79
+ return self._store.path if self._store is not None else None
80
+
81
+ def save(self, file_path: Path) -> Path:
82
+ """Attach a new file; subsequent changes are appended automatically."""
83
+ path = file_path.expanduser().resolve()
84
+ with self._lock:
85
+ if self._store is not None:
86
+ if path != self._store.path:
87
+ raise ValueError("This session is already attached to another file")
88
+ # All committed changes have already been persisted.
89
+ return self._store.path
90
+ self._store = JsonlSessionStore.create(path, self._header, self._records)
91
+ return self._store.path
92
+
93
+ def get_entries(self) -> list[SessionEntry]:
94
+ with self._lock:
95
+ return [entry.model_copy(deep=True) for entry in self._entries]
96
+
97
+ def append_message(self, message: Message, *, task_start: bool = False) -> str:
98
+ with self._lock:
99
+ entry = SessionMessageEntry(
100
+ id=uuid4().hex,
101
+ parent_id=self._leaf_id,
102
+ timestamp=int(time.time() * 1000),
103
+ message=message,
104
+ task_start=task_start,
105
+ )
106
+ return self._append_entry(entry)
107
+
108
+ def append_model_change(self, provider: str, model_id: str) -> str:
109
+ with self._lock:
110
+ entry = ModelChangeEntry(
111
+ id=uuid4().hex,
112
+ parent_id=self._leaf_id,
113
+ timestamp=int(time.time() * 1000),
114
+ provider=provider,
115
+ model_id=model_id,
116
+ )
117
+ return self._append_entry(entry)
118
+
119
+ def append_compaction(self, entry: CompactionEntry) -> str:
120
+ with self._lock:
121
+ if entry.parent_id != self._leaf_id:
122
+ raise ValueError("Session changed while preparing compaction")
123
+ return self._append_entry(entry)
124
+
125
+ def _append_entry(self, entry: SessionEntry) -> str:
126
+ if entry.id in self._by_id:
127
+ raise ValueError(f"Duplicate session entry ID: {entry.id}")
128
+ if entry.parent_id is not None and entry.parent_id not in self._by_id:
129
+ raise ValueError(f"Parent entry does not exist: {entry.parent_id}")
130
+ if isinstance(entry, SessionMessageEntry) and entry.task_start:
131
+ if not isinstance(entry.message, UserMessage):
132
+ raise ValueError("Only a user message can start a task")
133
+ if isinstance(entry, CompactionEntry):
134
+ project_context_entries(self._get_branch(entry.parent_id) + [entry])
135
+
136
+ stored = entry.model_copy(deep=True)
137
+ # Commit to disk before reporting success or advancing the in-memory tip.
138
+ if self._store is not None:
139
+ self._store.append(stored)
140
+ self._entries.append(stored)
141
+ self._records.append(stored)
142
+ self._by_id[stored.id] = stored
143
+ self._leaf_id = stored.id
144
+ return stored.id
145
+
146
+ def checkout(self, entry_id: str | None) -> None:
147
+ with self._lock:
148
+ if entry_id is not None and entry_id not in self._by_id:
149
+ raise ValueError(f"Session entry does not exist: {entry_id}")
150
+ if entry_id == self._leaf_id:
151
+ return
152
+ record = SessionCursorEntry(
153
+ entry_id=entry_id,
154
+ timestamp=int(time.time() * 1000),
155
+ )
156
+ if self._store is not None:
157
+ self._store.append(record)
158
+ self._records.append(record)
159
+ self._leaf_id = entry_id
160
+
161
+ def get_branch(self) -> list[SessionEntry]:
162
+ with self._lock:
163
+ return self._get_branch(self._leaf_id)
164
+
165
+ def _get_branch(self, entry_id: str | None) -> list[SessionEntry]:
166
+ if entry_id is not None and entry_id not in self._by_id:
167
+ raise ValueError(f"Session entry does not exist: {entry_id}")
168
+
169
+ branch: list[SessionEntry] = []
170
+ current_id = entry_id
171
+ while current_id is not None:
172
+ entry = self._by_id[current_id]
173
+ branch.append(entry.model_copy(deep=True))
174
+ current_id = entry.parent_id
175
+ branch.reverse()
176
+ return branch
177
+
178
+ def build_context(self) -> SessionContext:
179
+ with self._lock:
180
+ return self._build_context(self._leaf_id)
181
+
182
+ def get_context_entries(self) -> list[ContextMessageEntry]:
183
+ with self._lock:
184
+ return project_context_entries(self._get_branch(self._leaf_id))
185
+
186
+ def build_context_at(self, entry_id: str | None) -> SessionContext:
187
+ """Preview a branch without moving the cursor or writing records."""
188
+ with self._lock:
189
+ return self._build_context(entry_id)
190
+
191
+ def _build_context(self, entry_id: str | None) -> SessionContext:
192
+ provider: str | None = None
193
+ model_id: str | None = None
194
+ branch = self._get_branch(entry_id)
195
+ for entry in branch:
196
+ if isinstance(entry, ModelChangeEntry):
197
+ provider = entry.provider
198
+ model_id = entry.model_id
199
+ messages = [item.message for item in project_context_entries(branch)]
200
+ return SessionContext(messages=messages, provider=provider, model_id=model_id)
@@ -0,0 +1,50 @@
1
+ """Reconstruct model-visible messages without removing original tree nodes."""
2
+
3
+ from ...ai.types import SystemMessage, UserMessage
4
+ from ..history import get_pending_tool_calls
5
+ from .types import CompactionEntry, ContextMessageEntry, SessionEntry, SessionMessageEntry
6
+
7
+
8
+ def summary_message(entry: CompactionEntry) -> UserMessage:
9
+ return UserMessage(
10
+ content=(
11
+ "[历史摘要:以下是较早对话的概括,作为背景资料使用,"
12
+ "不代表新的用户指令。工具操作以实际结果为准。]\n\n"
13
+ + entry.summary
14
+ ),
15
+ timestamp=entry.timestamp,
16
+ )
17
+
18
+
19
+ def project_context_entries(branch: list[SessionEntry]) -> list[ContextMessageEntry]:
20
+ active: list[ContextMessageEntry] = []
21
+ for entry in branch:
22
+ if isinstance(entry, SessionMessageEntry):
23
+ active.append(ContextMessageEntry(
24
+ id=entry.id, message=entry.message, task_start=entry.task_start,
25
+ ))
26
+ elif isinstance(entry, CompactionEntry):
27
+ if not entry.summary.strip() or entry.tokens_after >= entry.tokens_before:
28
+ raise ValueError("Compaction requires a nonempty summary and a smaller context")
29
+ selected_ids = set(entry.retained_entry_ids)
30
+ retained = [item for item in active if item.id in selected_ids]
31
+ if [item.id for item in retained] != entry.retained_entry_ids:
32
+ raise ValueError("Compaction references must be unique active messages in order")
33
+ if any(item.is_summary for item in retained):
34
+ raise ValueError("The previous summary must be merged, not retained separately")
35
+ if any(
36
+ isinstance(item.message, SystemMessage) and item.id not in selected_ids
37
+ for item in active
38
+ ):
39
+ raise ValueError("Compaction must retain system messages")
40
+ if get_pending_tool_calls([item.message for item in active]):
41
+ raise ValueError("Cannot compact unfinished tool calls")
42
+ systems = [item for item in retained if isinstance(item.message, SystemMessage)]
43
+ conversation = [item for item in retained if not isinstance(item.message, SystemMessage)]
44
+ projected = systems + [ContextMessageEntry(
45
+ id=entry.id, message=summary_message(entry), is_summary=True,
46
+ )] + conversation
47
+ if get_pending_tool_calls([item.message for item in projected]):
48
+ raise ValueError("Compaction retained an incomplete tool interaction")
49
+ active = projected
50
+ return active
@@ -0,0 +1,33 @@
1
+ """Repair missing results at a valid session tail without executing tools."""
2
+
3
+ import time
4
+
5
+ from ...ai.types import TextContent, ToolResultMessage
6
+ from ..history import get_pending_tool_calls
7
+ from .manager import SessionManager
8
+
9
+
10
+ def recover_pending_tools(manager: SessionManager) -> int:
11
+ """Call before starting an Agent, with exclusive session ownership."""
12
+ pending = get_pending_tool_calls(manager.build_context().messages)
13
+ for call in pending:
14
+ manager.append_message(
15
+ ToolResultMessage(
16
+ tool_call_id=call.id,
17
+ tool_name=call.name,
18
+ content=[
19
+ TextContent(
20
+ text=(
21
+ "The session was interrupted before a result was recorded "
22
+ "for this tool call. Its execution status is unknown: it "
23
+ "may not have started, may have partially run, or may have "
24
+ "completed. Inspect the actual state before deciding "
25
+ "whether to retry."
26
+ )
27
+ )
28
+ ],
29
+ is_error=True,
30
+ timestamp=int(time.time() * 1000),
31
+ )
32
+ )
33
+ return len(pending)
@@ -0,0 +1,138 @@
1
+ """Synchronous JSONL persistence, intended for one writer per session file."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from threading import RLock
7
+
8
+ from pydantic import TypeAdapter, ValidationError
9
+
10
+ from .types import (
11
+ SessionFileEntry,
12
+ SessionHeader,
13
+ SessionLogEntry,
14
+ )
15
+
16
+
17
+ _RECORD_ADAPTER = TypeAdapter(SessionFileEntry)
18
+ _IO_LOCK = RLock()
19
+
20
+
21
+ class SessionFormatError(ValueError):
22
+ def __init__(self, path: Path, line: int, reason: str) -> None:
23
+ super().__init__(f"Invalid session file {path}, line {line}: {reason}")
24
+
25
+
26
+ class SessionConflictError(RuntimeError):
27
+ """The stored file changed after this manager loaded or wrote it."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class _Revision:
32
+ device: int
33
+ inode: int
34
+ size: int
35
+ modified_ns: int
36
+
37
+ @classmethod
38
+ def from_stat(cls, stat: os.stat_result) -> "_Revision":
39
+ return cls(stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
40
+
41
+
42
+ class JsonlSessionStore:
43
+ def __init__(self, path: Path, revision: _Revision) -> None:
44
+ self.path = path
45
+ self._revision = revision
46
+ self._failed = False
47
+
48
+ @classmethod
49
+ def create(
50
+ cls,
51
+ path: Path,
52
+ header: SessionHeader,
53
+ records: list[SessionLogEntry],
54
+ ) -> "JsonlSessionStore":
55
+ path = path.expanduser().resolve()
56
+ with _IO_LOCK:
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ # Never overwrite an existing session, including a damaged file.
59
+ with path.open("xb") as file:
60
+ file.write(header.model_dump_json(by_alias=True).encode("utf-8") + b"\n")
61
+ for record in records:
62
+ file.write(record.model_dump_json(by_alias=True).encode("utf-8") + b"\n")
63
+ file.flush()
64
+ os.fsync(file.fileno())
65
+ revision = _Revision.from_stat(os.fstat(file.fileno()))
66
+ return cls(path, revision)
67
+
68
+ @classmethod
69
+ def load(
70
+ cls,
71
+ path: Path,
72
+ ) -> tuple["JsonlSessionStore", SessionHeader, list[tuple[int, SessionLogEntry]]]:
73
+ path = path.expanduser().resolve()
74
+ header: SessionHeader | None = None
75
+ records: list[tuple[int, SessionLogEntry]] = []
76
+
77
+ with _IO_LOCK:
78
+ with path.open("rb") as file:
79
+ before = _Revision.from_stat(os.fstat(file.fileno()))
80
+ for line_number, line in enumerate(file, start=1):
81
+ if not line.strip():
82
+ continue
83
+ try:
84
+ record = _RECORD_ADAPTER.validate_json(line)
85
+ except ValidationError as exc:
86
+ raise SessionFormatError(
87
+ path, line_number, "invalid JSON or record structure"
88
+ ) from exc
89
+
90
+ if header is None:
91
+ if not isinstance(record, SessionHeader):
92
+ raise SessionFormatError(path, line_number, "the first record must be a session header")
93
+ header = record
94
+ elif isinstance(record, SessionHeader):
95
+ raise SessionFormatError(path, line_number, "duplicate session header")
96
+ else:
97
+ records.append((line_number, record))
98
+
99
+ after = _Revision.from_stat(os.fstat(file.fileno()))
100
+ if before != after or after != _Revision.from_stat(path.stat()):
101
+ raise SessionConflictError(f"Session changed while loading: {path}")
102
+
103
+ if header is None:
104
+ raise SessionFormatError(path, 1, "missing session header")
105
+ return cls(path, after), header, records
106
+
107
+ def append(self, record: SessionLogEntry) -> None:
108
+ payload = record.model_dump_json(by_alias=True).encode("utf-8") + b"\n"
109
+ with _IO_LOCK:
110
+ if self._failed:
111
+ raise SessionConflictError(
112
+ "A previous write failed. Reload and inspect the session before writing again."
113
+ )
114
+ with self.path.open("r+b") as file:
115
+ current = _Revision.from_stat(os.fstat(file.fileno()))
116
+ if current != self._revision:
117
+ raise SessionConflictError(f"Session was changed by another writer: {self.path}")
118
+ if current != _Revision.from_stat(self.path.stat()):
119
+ raise SessionConflictError(f"Session path was replaced: {self.path}")
120
+
121
+ # Accept a valid final JSON record without LF, adding its separator
122
+ # before the new record. An incomplete record was rejected on load.
123
+ if current.size:
124
+ file.seek(-1, os.SEEK_END)
125
+ if file.read(1) != b"\n":
126
+ payload = b"\n" + payload
127
+ file.seek(0, os.SEEK_END)
128
+ try:
129
+ written = file.write(payload)
130
+ if written != len(payload):
131
+ raise OSError("Incomplete session write")
132
+ file.flush()
133
+ os.fsync(file.fileno())
134
+ self._revision = _Revision.from_stat(os.fstat(file.fileno()))
135
+ except BaseException:
136
+ # A partial tail is not silently truncated or skipped.
137
+ self._failed = True
138
+ raise
@@ -0,0 +1,108 @@
1
+ from typing import Annotated, Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from ...ai.types import Message
6
+
7
+
8
+ class SessionRecord(BaseModel):
9
+ model_config = ConfigDict(
10
+ extra="forbid",
11
+ strict=True,
12
+ populate_by_name=True,
13
+ )
14
+
15
+
16
+ class SessionHeader(SessionRecord):
17
+ type: Literal["session"] = "session"
18
+
19
+ version: Literal[1] = 1
20
+
21
+ id: str = Field(min_length=1)
22
+ timestamp: int = Field(ge=0)
23
+ cwd: str = Field(min_length=1)
24
+
25
+
26
+ class SessionEntryBase(SessionRecord):
27
+ id: str = Field(min_length=1)
28
+
29
+ parent_id: str | None = Field(
30
+ alias="parentId",
31
+ )
32
+
33
+ timestamp: int = Field(ge=0)
34
+
35
+
36
+ class SessionMessageEntry(SessionEntryBase):
37
+ type: Literal["message"] = "message"
38
+
39
+ message: Message
40
+ task_start: bool = Field(default=False, alias="taskStart")
41
+
42
+
43
+ class CompactionEntry(SessionEntryBase):
44
+ type: Literal["compaction"] = "compaction"
45
+ summary: str = Field(min_length=1)
46
+ retained_entry_ids: list[str] = Field(alias="retainedEntryIds", min_length=1)
47
+ tokens_before: int = Field(alias="tokensBefore", ge=0)
48
+ tokens_after: int = Field(alias="tokensAfter", ge=0)
49
+
50
+
51
+ class ContextMessageEntry(BaseModel):
52
+ """A message in the active projection, including a synthetic summary."""
53
+
54
+ model_config = ConfigDict(extra="forbid")
55
+ id: str
56
+ message: Message
57
+ task_start: bool = False
58
+ is_summary: bool = False
59
+
60
+
61
+ class ModelChangeEntry(SessionEntryBase):
62
+ type: Literal["model_change"] = "model_change"
63
+
64
+ provider: str = Field(min_length=1)
65
+
66
+ model_id: str = Field(
67
+ alias="modelId",
68
+ min_length=1,
69
+ )
70
+
71
+
72
+ class SessionCursorEntry(SessionRecord):
73
+ """Persist the selected branch tip without creating a new tree node."""
74
+
75
+ type: Literal["cursor"] = "cursor"
76
+ entry_id: str | None = Field(alias="entryId")
77
+ timestamp: int = Field(ge=0)
78
+
79
+
80
+ SessionEntry = Annotated[
81
+ SessionMessageEntry | ModelChangeEntry | CompactionEntry,
82
+ Field(discriminator="type"),
83
+ ]
84
+
85
+
86
+ SessionLogEntry = Annotated[
87
+ SessionMessageEntry | ModelChangeEntry | CompactionEntry | SessionCursorEntry,
88
+ Field(discriminator="type"),
89
+ ]
90
+
91
+
92
+ SessionFileEntry = Annotated[
93
+ SessionHeader
94
+ | SessionMessageEntry
95
+ | ModelChangeEntry
96
+ | CompactionEntry
97
+ | SessionCursorEntry,
98
+ Field(discriminator="type"),
99
+ ]
100
+
101
+
102
+ class SessionContext(BaseModel):
103
+ model_config = ConfigDict(extra="forbid")
104
+
105
+ messages: list[Message]
106
+
107
+ provider: str | None = None
108
+ model_id: str | None = None
@@ -0,0 +1,35 @@
1
+ from .bash import BashTool
2
+ from .edit import EditTool
3
+ from .factory import (
4
+ DEFAULT_CODING_TOOLS,
5
+ READ_ONLY_TOOLS,
6
+ create_all_tools,
7
+ create_coding_tools,
8
+ create_read_only_tools,
9
+ create_tool,
10
+ create_tools,
11
+ )
12
+ from .find import FindTool
13
+ from .grep import GrepTool
14
+ from .ls import LsTool
15
+ from .powershell import PowerShellTool
16
+ from .read import ReadTool
17
+ from .write import WriteTool
18
+
19
+ __all__ = [
20
+ "BashTool",
21
+ "DEFAULT_CODING_TOOLS",
22
+ "EditTool",
23
+ "FindTool",
24
+ "GrepTool",
25
+ "LsTool",
26
+ "PowerShellTool",
27
+ "READ_ONLY_TOOLS",
28
+ "ReadTool",
29
+ "WriteTool",
30
+ "create_all_tools",
31
+ "create_coding_tools",
32
+ "create_read_only_tools",
33
+ "create_tool",
34
+ "create_tools",
35
+ ]
@@ -0,0 +1,5 @@
1
+ from .shell import ShellTool
2
+
3
+
4
+ class BashTool(ShellTool):
5
+ name: str = "bash"
@@ -0,0 +1,101 @@
1
+ import asyncio
2
+ import difflib
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 TextContent, ToolDefinition
9
+ from .edit_text import TextEdit, apply_text_edits, normalize_to_lf
10
+ from .mutation import file_mutation
11
+ from .paths import resolve_to_cwd
12
+
13
+
14
+ class EditArguments(BaseModel):
15
+ model_config = ConfigDict(extra="forbid", strict=True)
16
+
17
+ path: str = Field(min_length=1, description="File to edit, relative or absolute.")
18
+ edits: list[TextEdit] = Field(
19
+ min_length=1,
20
+ description=(
21
+ "Targeted replacements matched against the original file. "
22
+ "Each oldText must be unique. Regions must not overlap; "
23
+ "merge nearby changes into one edit."
24
+ ),
25
+ )
26
+
27
+
28
+ class EditTool:
29
+ name: str = "edit"
30
+ description: str = (
31
+ "Edit one file using exact text replacement. Each edits[].oldText must "
32
+ "match a unique, non-overlapping region of the original file. "
33
+ "Use multiple edits for separate locations; merge overlapping changes."
34
+ )
35
+
36
+ def __init__(self, cwd: Path) -> None:
37
+ self._cwd = cwd.resolve()
38
+
39
+ def get_definition(self) -> ToolDefinition:
40
+ return ToolDefinition(
41
+ name=self.name,
42
+ description=self.description,
43
+ parameters=EditArguments.model_json_schema(by_alias=True),
44
+ )
45
+
46
+ async def execute(
47
+ self,
48
+ tool_call_id: str,
49
+ arguments: dict[str, JsonValue],
50
+ ) -> ToolExecutionResult:
51
+ args = EditArguments.model_validate(arguments)
52
+ path = resolve_to_cwd(args.path, self._cwd)
53
+ return await asyncio.to_thread(self._edit, path, args)
54
+
55
+ def _edit(self, path: Path, args: EditArguments) -> ToolExecutionResult:
56
+ with file_mutation(path):
57
+ raw = path.read_bytes().decode("utf-8")
58
+ bom = "\ufeff" if raw.startswith("\ufeff") else ""
59
+ text = raw[len(bom):]
60
+ first_newline = text.find("\n")
61
+ ending = (
62
+ "\r\n"
63
+ if first_newline > 0 and text[first_newline - 1] == "\r"
64
+ else "\n"
65
+ )
66
+ original = normalize_to_lf(text)
67
+ updated = apply_text_edits(original, args.edits, args.path)
68
+
69
+ old_lines = original.splitlines(keepends=True)
70
+ new_lines = updated.splitlines(keepends=True)
71
+ patch_parts: list[str] = []
72
+ for line in difflib.unified_diff(
73
+ old_lines, new_lines, fromfile=args.path, tofile=args.path, n=4
74
+ ):
75
+ patch_parts.append(line)
76
+ if not line.endswith("\n"):
77
+ patch_parts.append("\n\\n")
78
+ patch = "".join(patch_parts)
79
+
80
+ first_changed_line = 1
81
+ matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
82
+ for tag, _, _, new_start, _ in matcher.get_opcodes():
83
+ if tag != "equal":
84
+ first_changed_line = new_start + 1
85
+ break
86
+
87
+ # Match and prepare the report before touching the file.
88
+ final = bom + updated.replace("\n", ending)
89
+ path.write_bytes(final.encode("utf-8"))
90
+
91
+ return ToolExecutionResult(
92
+ content=[TextContent(
93
+ text=f"Successfully replaced {len(args.edits)} block(s) in {args.path}."
94
+ )],
95
+ details={
96
+ "path": str(path),
97
+ "diff": patch,
98
+ "patch": patch,
99
+ "firstChangedLine": first_changed_line,
100
+ },
101
+ )