caudate-cli 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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""EditImage — modify an existing image with FLUX img2img or Kontext.
|
|
2
|
+
|
|
3
|
+
Two modes selected by the LLM (or auto-routed):
|
|
4
|
+
|
|
5
|
+
- **`reimagine`** (FLUX-schnell img2img): preserves composition,
|
|
6
|
+
restyles content. Use for "watercolor version", "anime style",
|
|
7
|
+
"more vibrant colors", "blurry photo of the same scene".
|
|
8
|
+
Strength controls how much to deviate (0=identity, 1=fresh).
|
|
9
|
+
|
|
10
|
+
- **`edit`** (FLUX.1-Kontext-dev): instruction-driven editing.
|
|
11
|
+
Use for "make the wings blue", "remove the flower", "add a
|
|
12
|
+
forest behind", "change the time to sunset". The model edits
|
|
13
|
+
only what the instruction names; the rest of the image stays.
|
|
14
|
+
|
|
15
|
+
- **`auto`** (default): keyword heuristic picks one.
|
|
16
|
+
Imperative-edit verbs ("change", "make", "remove", "add",
|
|
17
|
+
"replace", "turn") → `edit`. Otherwise → `reimagine`.
|
|
18
|
+
|
|
19
|
+
Source can be a local path, a `file://` URI, or a `files/<id>` /
|
|
20
|
+
`/files/<id>/content` reference resolved via FileStore. The output
|
|
21
|
+
is persisted to FileStore exactly like Draw — returns a markdown
|
|
22
|
+
image link the chat UI renders inline.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
import urllib.parse
|
|
30
|
+
import urllib.request
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from core.image import ImageEdit, edit_to_file_store, make_image_edit
|
|
35
|
+
from core.schemas import ToolResult
|
|
36
|
+
from execution.tools.base import BaseTool
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Keyword sets for the auto-routing heuristic. Brittle by design —
|
|
42
|
+
# the LLM can override by passing `mode` explicitly. We bias toward
|
|
43
|
+
# `edit` (Kontext) because in practice it preserves source content
|
|
44
|
+
# far better than FLUX-schnell img2img, even for stylistic prompts.
|
|
45
|
+
_EDIT_VERBS = {
|
|
46
|
+
"change", "make", "remove", "add", "replace", "turn",
|
|
47
|
+
"delete", "erase", "swap", "convert", "transform", "recolor",
|
|
48
|
+
"set", "put", "insert", "give", "fix", "edit",
|
|
49
|
+
}
|
|
50
|
+
_STYLE_KEYWORDS = {
|
|
51
|
+
"cartoon", "comic", "anime", "manga", "watercolor", "oil painting",
|
|
52
|
+
"pencil sketch", "pixel art", "cel shaded", "ghibli", "pop art",
|
|
53
|
+
"vintage", "noir",
|
|
54
|
+
}
|
|
55
|
+
# Only reach for img2img when the prompt is a *very mild* tweak —
|
|
56
|
+
# Kontext doesn't lose source identity at high stylization strength.
|
|
57
|
+
_REIMAGINE_HINTS_STRONG = {"slightly", "subtle", "soft tweak", "minor"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _detect_mode(prompt: str) -> str:
|
|
61
|
+
p = prompt.lower().strip()
|
|
62
|
+
if any(h in p for h in _REIMAGINE_HINTS_STRONG):
|
|
63
|
+
return "reimagine"
|
|
64
|
+
# Style-transfer keywords or imperative-edit verbs both go to
|
|
65
|
+
# Kontext — it handles photos-with-faces correctly.
|
|
66
|
+
if any(k in p for k in _STYLE_KEYWORDS):
|
|
67
|
+
return "edit"
|
|
68
|
+
first = re.split(r"\s+", p, maxsplit=1)[0]
|
|
69
|
+
if first in _EDIT_VERBS:
|
|
70
|
+
return "edit"
|
|
71
|
+
return "edit" # default — Kontext is safer than FLUX-schnell img2img
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class EditImageTool(BaseTool):
|
|
75
|
+
mutates = True
|
|
76
|
+
name = "EditImage"
|
|
77
|
+
description = (
|
|
78
|
+
"Modify an existing image. Two modes: `reimagine` (FLUX-schnell "
|
|
79
|
+
"img2img — restyle/transform while preserving composition; pass "
|
|
80
|
+
"`strength` 0-1) or `edit` (FLUX.1-Kontext — instruction edit "
|
|
81
|
+
"like 'make the wings blue' or 'remove the flower'). Use `mode` "
|
|
82
|
+
"to pick explicitly; default `auto` routes by prompt verbs. "
|
|
83
|
+
"Source: local path or 'files/<id>' from a previous Draw / "
|
|
84
|
+
"DescribeImage / upload. Output: markdown image link the chat "
|
|
85
|
+
"UI renders inline.\n\n"
|
|
86
|
+
"STRONGLY PREFER `mode=edit` (Kontext) when:\n"
|
|
87
|
+
" - The source contains people/faces you want to preserve\n"
|
|
88
|
+
" - You want a STRONG style change (cartoon/anime/watercolor/oil-painting/comic) on a photo\n"
|
|
89
|
+
" - You want a targeted change ('make X blue', 'remove Y')\n"
|
|
90
|
+
"Use `mode=reimagine` only for mild stylistic tweaks where some "
|
|
91
|
+
"drift is OK. FLUX-schnell img2img has a narrow strength sweet "
|
|
92
|
+
"spot; on photos with faces it usually fabricates rather than "
|
|
93
|
+
"transforming.\n\n"
|
|
94
|
+
"NOTE: Kontext is non-commercial license — for commercial use, "
|
|
95
|
+
"fall back to `mode=reimagine`."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
file_store: Any | None = None,
|
|
101
|
+
backend: str = "diffusers",
|
|
102
|
+
backend_kwargs: dict | None = None,
|
|
103
|
+
):
|
|
104
|
+
self._file_store = file_store
|
|
105
|
+
self._backend_name = backend
|
|
106
|
+
self._backend_kwargs = backend_kwargs or {}
|
|
107
|
+
self._backend: ImageEdit | None = None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def input_schema(self) -> dict:
|
|
111
|
+
return {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"properties": {
|
|
114
|
+
"image": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"description": (
|
|
117
|
+
"Source image: absolute path, 'files/<file_id>', "
|
|
118
|
+
"or a /files/<id>/content URL from a prior tool."
|
|
119
|
+
),
|
|
120
|
+
},
|
|
121
|
+
"prompt": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"description": (
|
|
124
|
+
"Instruction or restyling prompt. Examples: "
|
|
125
|
+
"'make the butterfly wings blue' (edit), "
|
|
126
|
+
"'watercolor version of this scene' (reimagine)."
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
"mode": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"enum": ["auto", "reimagine", "edit"],
|
|
132
|
+
"description": (
|
|
133
|
+
"auto = keyword heuristic; reimagine = "
|
|
134
|
+
"FLUX-schnell img2img; edit = FLUX-Kontext "
|
|
135
|
+
"instruction edit. Default auto."
|
|
136
|
+
),
|
|
137
|
+
"default": "auto",
|
|
138
|
+
},
|
|
139
|
+
"strength": {
|
|
140
|
+
"type": "number",
|
|
141
|
+
"description": (
|
|
142
|
+
"img2img only (mode=reimagine): how much to "
|
|
143
|
+
"deviate from source. 0=identity, 1=fresh. "
|
|
144
|
+
"Default 0.75."
|
|
145
|
+
),
|
|
146
|
+
"default": 0.75,
|
|
147
|
+
},
|
|
148
|
+
"size": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"description": "Output dims, e.g. '1024x1024'. Default uses source size.",
|
|
151
|
+
},
|
|
152
|
+
"seed": {
|
|
153
|
+
"type": "integer",
|
|
154
|
+
"description": "Seed for reproducibility. Omit for random.",
|
|
155
|
+
},
|
|
156
|
+
"steps": {
|
|
157
|
+
"type": "integer",
|
|
158
|
+
"description": "Override inference steps. Defaults: 4 for reimagine, 28 for edit.",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
"required": ["image", "prompt"],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
def set_file_store(self, file_store: Any) -> None:
|
|
165
|
+
self._file_store = file_store
|
|
166
|
+
|
|
167
|
+
def _get_backend(self) -> ImageEdit:
|
|
168
|
+
if self._backend is None:
|
|
169
|
+
self._backend = make_image_edit(self._backend_name, **self._backend_kwargs)
|
|
170
|
+
return self._backend
|
|
171
|
+
|
|
172
|
+
def _resolve_image(self, ref: str) -> bytes:
|
|
173
|
+
"""Resolve an image reference to raw bytes."""
|
|
174
|
+
ref = ref.strip()
|
|
175
|
+
# /files/<id>/content URL or files/<id> shorthand
|
|
176
|
+
m = re.search(r"/?files/([0-9a-f-]{8,})(?:/content)?(?:[?#].*)?$", ref)
|
|
177
|
+
if m and self._file_store is not None:
|
|
178
|
+
rec = self._file_store.get(m.group(1))
|
|
179
|
+
if rec is None:
|
|
180
|
+
raise FileNotFoundError(f"file not found in store: {m.group(1)}")
|
|
181
|
+
return Path(rec.path).read_bytes()
|
|
182
|
+
# http(s) URL — fetch
|
|
183
|
+
if ref.startswith(("http://", "https://")):
|
|
184
|
+
with urllib.request.urlopen(ref, timeout=30) as r:
|
|
185
|
+
return r.read()
|
|
186
|
+
# file:// URI
|
|
187
|
+
if ref.startswith("file://"):
|
|
188
|
+
ref = urllib.parse.urlparse(ref).path
|
|
189
|
+
# Plain path
|
|
190
|
+
path = Path(ref).expanduser().resolve()
|
|
191
|
+
if not path.exists() or not path.is_file():
|
|
192
|
+
raise FileNotFoundError(f"image file not found: {path}")
|
|
193
|
+
return path.read_bytes()
|
|
194
|
+
|
|
195
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
196
|
+
image_ref = (kwargs.get("image") or "").strip()
|
|
197
|
+
prompt = (kwargs.get("prompt") or "").strip()
|
|
198
|
+
if not image_ref or not prompt:
|
|
199
|
+
return ToolResult(
|
|
200
|
+
tool_name=self.name, status="error",
|
|
201
|
+
error="`image` and `prompt` are both required",
|
|
202
|
+
)
|
|
203
|
+
if self._file_store is None:
|
|
204
|
+
return ToolResult(
|
|
205
|
+
tool_name=self.name, status="error",
|
|
206
|
+
error="EditImage is not wired to a FileStore.",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
mode = (kwargs.get("mode") or "auto").lower()
|
|
210
|
+
if mode == "auto":
|
|
211
|
+
mode = _detect_mode(prompt)
|
|
212
|
+
logger.info(f"EditImage auto-routed to mode={mode!r}")
|
|
213
|
+
if mode not in ("reimagine", "edit"):
|
|
214
|
+
return ToolResult(
|
|
215
|
+
tool_name=self.name, status="error",
|
|
216
|
+
error=f"unknown mode {mode!r}; use 'auto' / 'reimagine' / 'edit'",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
image_bytes = self._resolve_image(image_ref)
|
|
221
|
+
except (FileNotFoundError, OSError, urllib.error.URLError) as e:
|
|
222
|
+
return ToolResult(
|
|
223
|
+
tool_name=self.name, status="error",
|
|
224
|
+
error=f"could not read source image: {e}",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
edit_kwargs: dict[str, Any] = {}
|
|
228
|
+
if "strength" in kwargs and kwargs["strength"] is not None:
|
|
229
|
+
edit_kwargs["strength"] = float(kwargs["strength"])
|
|
230
|
+
if kwargs.get("size"):
|
|
231
|
+
edit_kwargs["size"] = kwargs["size"]
|
|
232
|
+
if kwargs.get("seed") is not None:
|
|
233
|
+
edit_kwargs["seed"] = int(kwargs["seed"])
|
|
234
|
+
if kwargs.get("steps") is not None:
|
|
235
|
+
edit_kwargs["steps"] = int(kwargs["steps"])
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
backend = self._get_backend()
|
|
239
|
+
record = await edit_to_file_store(
|
|
240
|
+
backend, self._file_store, image_bytes, prompt,
|
|
241
|
+
mode=mode, **edit_kwargs,
|
|
242
|
+
)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.exception("EditImage failed")
|
|
245
|
+
return ToolResult(
|
|
246
|
+
tool_name=self.name, status="error",
|
|
247
|
+
error=f"edit failed: {e}",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
url = f"http://127.0.0.1:8000/files/{record.id}/content"
|
|
251
|
+
viewer = f"http://127.0.0.1:8000/artifact/{record.id}"
|
|
252
|
+
md = f"![{prompt[:120]}]({url})\n\n[📄 Open in viewer]({viewer})"
|
|
253
|
+
return ToolResult(
|
|
254
|
+
tool_name=self.name, status="success",
|
|
255
|
+
output=md,
|
|
256
|
+
metadata={
|
|
257
|
+
"file_id": record.id,
|
|
258
|
+
"filename": record.filename,
|
|
259
|
+
"mode": mode,
|
|
260
|
+
"prompt": prompt,
|
|
261
|
+
},
|
|
262
|
+
)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Edit tool — surgical string replacement in a file.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
|
|
5
|
+
- **Normal:** `old_string` → `new_string`, single or all occurrences.
|
|
6
|
+
|
|
7
|
+
- **FIM gap-fill:** when `new_string` contains the literal marker
|
|
8
|
+
`<|FIM|>`, the tool splits `new_string` on the marker, calls the
|
|
9
|
+
project's FIM model with the surrounding file context, and fills
|
|
10
|
+
the gap before applying the replacement. Lets the agent write
|
|
11
|
+
half a function and let a code-model fill the body without
|
|
12
|
+
re-emitting the whole edit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from core.schemas import ToolResult
|
|
21
|
+
from execution.tools.base import BaseTool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FIM_MARKER = "<|FIM|>"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EditTool(BaseTool):
|
|
28
|
+
mutates = True
|
|
29
|
+
name = "Edit"
|
|
30
|
+
description = (
|
|
31
|
+
"Replace an exact string in a file with a new string. The old_string "
|
|
32
|
+
"must appear EXACTLY once in the file unless replace_all is true. "
|
|
33
|
+
"Preserves all surrounding content.\n\n"
|
|
34
|
+
f"FIM mode: include the literal marker `{FIM_MARKER}` once in "
|
|
35
|
+
"new_string to have a code-model fill that gap using the "
|
|
36
|
+
"surrounding file context. Pass `fim_model` to override the "
|
|
37
|
+
"default (qwen2.5-coder:1.5b)."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def input_schema(self) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"path": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Path to the file to edit",
|
|
48
|
+
},
|
|
49
|
+
"old_string": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Exact text to replace",
|
|
52
|
+
},
|
|
53
|
+
"new_string": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": (
|
|
56
|
+
"Replacement text (must differ from old_string). "
|
|
57
|
+
f"Include `{FIM_MARKER}` once to trigger FIM gap-fill."
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
"replace_all": {
|
|
61
|
+
"type": "boolean",
|
|
62
|
+
"description": "If true, replace every occurrence. Default false.",
|
|
63
|
+
"default": False,
|
|
64
|
+
},
|
|
65
|
+
"fim_model": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": (
|
|
68
|
+
"Override FIM model id (e.g. ollama/qwen3-coder-next:q4_K_M). "
|
|
69
|
+
"Only used when new_string contains the FIM marker."
|
|
70
|
+
),
|
|
71
|
+
},
|
|
72
|
+
"fim_max_tokens": {
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"description": "Max tokens for the FIM completion (default 128).",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
"required": ["path", "old_string", "new_string"],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
81
|
+
path = kwargs.get("path", "")
|
|
82
|
+
old = kwargs.get("old_string", "")
|
|
83
|
+
new = kwargs.get("new_string", "")
|
|
84
|
+
replace_all = kwargs.get("replace_all", False)
|
|
85
|
+
fim_model = kwargs.get("fim_model")
|
|
86
|
+
fim_max_tokens = kwargs.get("fim_max_tokens")
|
|
87
|
+
|
|
88
|
+
if not path:
|
|
89
|
+
return self._error("No path provided")
|
|
90
|
+
if not old:
|
|
91
|
+
return self._error("old_string cannot be empty")
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
p = Path(path)
|
|
95
|
+
content = p.read_text()
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
return self._error(f"File not found: {path}")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
return self._error(str(e))
|
|
100
|
+
|
|
101
|
+
count = content.count(old)
|
|
102
|
+
if count == 0:
|
|
103
|
+
return self._error(f"old_string not found in {path}")
|
|
104
|
+
if count > 1 and not replace_all:
|
|
105
|
+
return self._error(
|
|
106
|
+
f"old_string matches {count} places in {path}; "
|
|
107
|
+
"set replace_all=true or provide a more specific string"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# FIM gap-fill mode: marker in new_string → call code-model
|
|
111
|
+
# with the surrounding file context, splice the result in.
|
|
112
|
+
# Only one marker supported per call; replace_all+FIM is
|
|
113
|
+
# rejected because filling the same gap N times would loop the
|
|
114
|
+
# same completion into every spot.
|
|
115
|
+
fim_used = False
|
|
116
|
+
if FIM_MARKER in new:
|
|
117
|
+
if new.count(FIM_MARKER) > 1:
|
|
118
|
+
return self._error(
|
|
119
|
+
f"new_string contains {new.count(FIM_MARKER)} FIM "
|
|
120
|
+
"markers; only one is supported per call"
|
|
121
|
+
)
|
|
122
|
+
if replace_all:
|
|
123
|
+
return self._error(
|
|
124
|
+
"replace_all=true is incompatible with FIM gap-fill"
|
|
125
|
+
)
|
|
126
|
+
local_prefix, local_suffix = new.split(FIM_MARKER, 1)
|
|
127
|
+
# Locate the first occurrence in the file (we've verified count >= 1).
|
|
128
|
+
idx = content.find(old)
|
|
129
|
+
file_before = content[:idx]
|
|
130
|
+
file_after = content[idx + len(old):]
|
|
131
|
+
fim_prefix = file_before + local_prefix
|
|
132
|
+
fim_suffix = local_suffix + file_after
|
|
133
|
+
try:
|
|
134
|
+
from llm.provider import fim_complete, DEFAULT_FIM_MODEL
|
|
135
|
+
gap = await fim_complete(
|
|
136
|
+
prefix=fim_prefix,
|
|
137
|
+
suffix=fim_suffix,
|
|
138
|
+
model=fim_model or DEFAULT_FIM_MODEL,
|
|
139
|
+
max_tokens=fim_max_tokens,
|
|
140
|
+
)
|
|
141
|
+
except ValueError as e:
|
|
142
|
+
return self._error(f"FIM model rejected: {e}")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
return self._error(f"FIM call failed: {e}")
|
|
145
|
+
if old == local_prefix + gap + local_suffix:
|
|
146
|
+
return self._error(
|
|
147
|
+
"FIM produced text identical to old_string — nothing to change"
|
|
148
|
+
)
|
|
149
|
+
new = local_prefix + gap + local_suffix
|
|
150
|
+
fim_used = True
|
|
151
|
+
|
|
152
|
+
if old == new:
|
|
153
|
+
return self._error("old_string and new_string are identical")
|
|
154
|
+
|
|
155
|
+
new_content = content.replace(old, new) if replace_all else content.replace(old, new, 1)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
p.write_text(new_content)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return self._error(str(e))
|
|
161
|
+
|
|
162
|
+
n = count if replace_all else 1
|
|
163
|
+
msg = f"Replaced {n} occurrence(s) in {path}"
|
|
164
|
+
if fim_used:
|
|
165
|
+
msg += " (FIM gap-fill)"
|
|
166
|
+
return self._success(msg)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class FillInMiddleTool(BaseTool):
|
|
170
|
+
"""Standalone FIM tool — routable target for Caudate's tool head.
|
|
171
|
+
|
|
172
|
+
Edit already supports FIM via the marker mode, but exposing FIM as
|
|
173
|
+
its own named tool lets Caudate's contrastive head learn to route
|
|
174
|
+
to it directly (e.g. for cases where the agent just wants the
|
|
175
|
+
completion string without applying an edit). Read-only: returns
|
|
176
|
+
the generated text, doesn't touch the filesystem.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
mutates = False
|
|
180
|
+
name = "FillInMiddle"
|
|
181
|
+
description = (
|
|
182
|
+
"Generate the text that should fill the gap between `prefix` "
|
|
183
|
+
"and `suffix` using a FIM-trained code model. Returns the "
|
|
184
|
+
"completion string only — does not write to any file. Use for "
|
|
185
|
+
"code-completion lookups, structural gap-fills, or when you "
|
|
186
|
+
"want to inspect the model's suggestion before applying it. "
|
|
187
|
+
"Default model: qwen2.5-coder:1.5b (low-latency); override "
|
|
188
|
+
"with `model` for heavier completions."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def input_schema(self) -> dict:
|
|
193
|
+
return {
|
|
194
|
+
"type": "object",
|
|
195
|
+
"properties": {
|
|
196
|
+
"prefix": {
|
|
197
|
+
"type": "string",
|
|
198
|
+
"description": "Text before the gap.",
|
|
199
|
+
},
|
|
200
|
+
"suffix": {
|
|
201
|
+
"type": "string",
|
|
202
|
+
"description": "Text after the gap.",
|
|
203
|
+
"default": "",
|
|
204
|
+
},
|
|
205
|
+
"model": {
|
|
206
|
+
"type": "string",
|
|
207
|
+
"description": (
|
|
208
|
+
"Override model id (e.g. ollama/qwen3-coder-next:q4_K_M). "
|
|
209
|
+
"Must be a FIM-trained code model on Ollama."
|
|
210
|
+
),
|
|
211
|
+
},
|
|
212
|
+
"max_tokens": {
|
|
213
|
+
"type": "integer",
|
|
214
|
+
"description": "Max tokens for the completion (default 128).",
|
|
215
|
+
},
|
|
216
|
+
"temperature": {
|
|
217
|
+
"type": "number",
|
|
218
|
+
"description": "Sampling temperature (default 0.1).",
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
"required": ["prefix"],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
225
|
+
prefix = kwargs.get("prefix", "")
|
|
226
|
+
if not prefix:
|
|
227
|
+
return self._error("prefix is required")
|
|
228
|
+
suffix = kwargs.get("suffix", "")
|
|
229
|
+
model = kwargs.get("model")
|
|
230
|
+
max_tokens = kwargs.get("max_tokens")
|
|
231
|
+
temperature = kwargs.get("temperature")
|
|
232
|
+
try:
|
|
233
|
+
from llm.provider import fim_complete, DEFAULT_FIM_MODEL
|
|
234
|
+
completion = await fim_complete(
|
|
235
|
+
prefix=prefix,
|
|
236
|
+
suffix=suffix,
|
|
237
|
+
model=model or DEFAULT_FIM_MODEL,
|
|
238
|
+
max_tokens=max_tokens,
|
|
239
|
+
temperature=temperature,
|
|
240
|
+
)
|
|
241
|
+
except ValueError as e:
|
|
242
|
+
return self._error(str(e))
|
|
243
|
+
except Exception as e:
|
|
244
|
+
return self._error(f"FIM call failed: {e}")
|
|
245
|
+
return self._success(completion)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""File tools — read and write files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from core.schemas import ToolResult
|
|
9
|
+
from execution.tools.base import BaseTool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileReadTool(BaseTool):
|
|
13
|
+
name = "Read"
|
|
14
|
+
description = "Read the contents of a file. Returns the file text. Supports offset and limit for large files."
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def input_schema(self) -> dict:
|
|
18
|
+
return {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"path": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Absolute or relative path to the file to read",
|
|
24
|
+
},
|
|
25
|
+
"offset": {
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"description": "Line number to start reading from (1-based)",
|
|
28
|
+
},
|
|
29
|
+
"limit": {
|
|
30
|
+
"type": "integer",
|
|
31
|
+
"description": "Maximum number of lines to read",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
"required": ["path"],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
38
|
+
path = kwargs.get("path", "")
|
|
39
|
+
if not path:
|
|
40
|
+
return self._error("No path provided")
|
|
41
|
+
offset = kwargs.get("offset")
|
|
42
|
+
limit = kwargs.get("limit")
|
|
43
|
+
try:
|
|
44
|
+
content = Path(path).read_text()
|
|
45
|
+
if offset or limit:
|
|
46
|
+
lines = content.splitlines(keepends=True)
|
|
47
|
+
start = (offset - 1) if offset and offset > 0 else 0
|
|
48
|
+
end = (start + limit) if limit else len(lines)
|
|
49
|
+
content = "".join(lines[start:end])
|
|
50
|
+
return self._success(content)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
return self._error(f"File not found: {path}")
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return self._error(str(e))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FileWriteTool(BaseTool):
|
|
58
|
+
mutates = True
|
|
59
|
+
name = "Write"
|
|
60
|
+
description = "Write content to a file. Creates parent directories if needed. Overwrites existing content."
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def input_schema(self) -> dict:
|
|
64
|
+
return {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"path": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "Path to the file to write",
|
|
70
|
+
},
|
|
71
|
+
"content": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Content to write to the file",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
"required": ["path", "content"],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
80
|
+
path = kwargs.get("path", "")
|
|
81
|
+
content = kwargs.get("content", "")
|
|
82
|
+
if not path:
|
|
83
|
+
return self._error("No path provided")
|
|
84
|
+
try:
|
|
85
|
+
p = Path(path)
|
|
86
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
p.write_text(content)
|
|
88
|
+
return self._success(f"Written {len(content)} chars to {path}")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
return self._error(str(e))
|