codetrail-cli 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The tool guard: the assistant may only read `source/`, with Read, Grep and Glob (design section 6.9).
|
|
2
|
+
|
|
3
|
+
It runs as a PreToolUse hook, which sees every tool call, read-only ones included, before it runs. Anything it can't
|
|
4
|
+
place inside `source/` is refused; every file Read opens is recorded, and becomes the page's `files`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
ALLOWED_TOOLS = ("Read", "Grep", "Glob")
|
|
14
|
+
# The channel structured answers come back through; it reads nothing and takes no path.
|
|
15
|
+
ANSWER_TOOL = "StructuredOutput"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolGuard:
|
|
19
|
+
def __init__(self, source_root: Path) -> None:
|
|
20
|
+
self.root = source_root.resolve()
|
|
21
|
+
self.files_read: list[str] = []
|
|
22
|
+
|
|
23
|
+
def decide(self, tool: str, arguments: Mapping[str, Any]) -> str | None:
|
|
24
|
+
"""None to allow the call, or the reason it is refused; any error refuses (fails closed)."""
|
|
25
|
+
try:
|
|
26
|
+
return self._decide(tool, arguments)
|
|
27
|
+
except Exception:
|
|
28
|
+
return "The request couldn't be checked, so it is refused."
|
|
29
|
+
|
|
30
|
+
def _decide(self, tool: str, arguments: Mapping[str, Any]) -> str | None:
|
|
31
|
+
if tool == ANSWER_TOOL:
|
|
32
|
+
return None
|
|
33
|
+
if tool not in ALLOWED_TOOLS:
|
|
34
|
+
return f"{tool} isn't available; only Read, Grep and Glob inside the repository are."
|
|
35
|
+
if tool == "Read":
|
|
36
|
+
path = arguments.get("file_path")
|
|
37
|
+
if not isinstance(path, str):
|
|
38
|
+
return "Read needs a file path."
|
|
39
|
+
inside = self._inside(path)
|
|
40
|
+
if inside is None or not inside.is_file():
|
|
41
|
+
return f"{path} isn't a file in the repository."
|
|
42
|
+
relative = inside.relative_to(self.root).as_posix()
|
|
43
|
+
if relative not in self.files_read:
|
|
44
|
+
self.files_read.append(relative)
|
|
45
|
+
return None
|
|
46
|
+
folder = arguments.get("path")
|
|
47
|
+
if folder is not None and (not isinstance(folder, str) or self._inside(folder) is None):
|
|
48
|
+
return f"{folder} isn't inside the repository."
|
|
49
|
+
for key in ("pattern", "glob") if tool == "Glob" else ("glob",):
|
|
50
|
+
pattern = arguments.get(key)
|
|
51
|
+
# Braces could expand to .. or an absolute path before the pattern is resolved; refuse them outright.
|
|
52
|
+
if isinstance(pattern, str) and (
|
|
53
|
+
pattern.startswith(("/", "~")) or ".." in pattern or any(char in pattern for char in "{}\\\0")
|
|
54
|
+
):
|
|
55
|
+
return f"The pattern {pattern} could reach outside the repository."
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def _inside(self, path: str) -> Path | None:
|
|
59
|
+
if "\0" in path or path.startswith("~"):
|
|
60
|
+
return None
|
|
61
|
+
candidate = Path(path)
|
|
62
|
+
resolved = (candidate if candidate.is_absolute() else self.root / candidate).resolve()
|
|
63
|
+
return resolved if resolved == self.root or resolved.is_relative_to(self.root) else None
|
|
64
|
+
|
|
65
|
+
async def hook(self, input_data: Mapping[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
66
|
+
arguments = input_data.get("tool_input")
|
|
67
|
+
if not isinstance(arguments, Mapping):
|
|
68
|
+
arguments = {"invalid": True}
|
|
69
|
+
reason = self.decide(str(input_data.get("tool_name", "")), arguments)
|
|
70
|
+
if reason is None and arguments.get("invalid") is True and len(arguments) == 1:
|
|
71
|
+
reason = "The request couldn't be checked, so it is refused."
|
|
72
|
+
if reason is None:
|
|
73
|
+
return {}
|
|
74
|
+
return {
|
|
75
|
+
"hookSpecificOutput": {
|
|
76
|
+
"hookEventName": "PreToolUse",
|
|
77
|
+
"permissionDecision": "deny",
|
|
78
|
+
"permissionDecisionReason": reason,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The tool guard as Claude Code's PreToolUse hook: `python -m codetrail.assistant.guard_hook <source> <read-log>`.
|
|
2
|
+
|
|
3
|
+
Claude Code runs it before every tool call, with the call as JSON on stdin. A refusal is printed as the hook's deny
|
|
4
|
+
decision; each allowed Read's path is appended to the read log, which becomes the page's `files`. Claude Code runs a
|
|
5
|
+
tool when a hook fails in any way other than exit code 2, so every error of the hook's own exits with code 2, which
|
|
6
|
+
refuses the call (design section 15.1). Claude Code's permission rules confine reads independently of this hook.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(arguments: list[str]) -> int:
|
|
18
|
+
from codetrail.assistant.guard import ToolGuard
|
|
19
|
+
|
|
20
|
+
source, log = Path(arguments[0]), Path(arguments[1])
|
|
21
|
+
payload = json.loads(sys.stdin.read())
|
|
22
|
+
guard = ToolGuard(source)
|
|
23
|
+
if not isinstance(payload, Mapping):
|
|
24
|
+
raise ValueError("The hook's input isn't a JSON object.")
|
|
25
|
+
tool_input = payload.get("tool_input")
|
|
26
|
+
if not isinstance(tool_input, Mapping):
|
|
27
|
+
raise ValueError("The tool's input isn't a JSON object.")
|
|
28
|
+
reason = guard.decide(str(payload.get("tool_name", "")), tool_input)
|
|
29
|
+
if reason is not None:
|
|
30
|
+
print(
|
|
31
|
+
json.dumps(
|
|
32
|
+
{
|
|
33
|
+
"hookSpecificOutput": {
|
|
34
|
+
"hookEventName": "PreToolUse",
|
|
35
|
+
"permissionDecision": "deny",
|
|
36
|
+
"permissionDecisionReason": reason,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
return 0
|
|
42
|
+
if guard.files_read:
|
|
43
|
+
with log.open("a", encoding="utf-8") as handle:
|
|
44
|
+
handle.writelines(f"{path}\n" for path in guard.files_read)
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
try:
|
|
50
|
+
sys.exit(main(sys.argv[1:]))
|
|
51
|
+
except BaseException as error: # any failure must refuse the call, never let it through
|
|
52
|
+
if isinstance(error, SystemExit) and error.code == 0:
|
|
53
|
+
raise
|
|
54
|
+
print(
|
|
55
|
+
f"Codetrail's tool guard couldn't check this call ({type(error).__name__}), so it is refused.",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
58
|
+
sys.exit(2)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""The local adapter: a model on the reader's machine, through Codetrail's own read-only tools (design 15.1, 15.5).
|
|
2
|
+
|
|
3
|
+
It talks to an OpenAI-compatible chat endpoint on loopback (Ollama or LM Studio) with httpx, ignoring proxy settings
|
|
4
|
+
and never following redirects. The model may call Read, Grep and Glob, which Codetrail runs over `source/` through the
|
|
5
|
+
tool guard. A structured answer must be JSON with the schema's keys; one retry is given with the errors. Turns,
|
|
6
|
+
tokens and time are limited by configuration. Answers are buffered, so the bridge can scan them before the reader sees
|
|
7
|
+
them. Local models cost nothing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import AsyncIterator, Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Final
|
|
17
|
+
|
|
18
|
+
import anyio
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from codetrail.assistant import (
|
|
22
|
+
AnswerChunk,
|
|
23
|
+
AssistantError,
|
|
24
|
+
DigestDraft,
|
|
25
|
+
DigestRequest,
|
|
26
|
+
GradeRequest,
|
|
27
|
+
PageDraft,
|
|
28
|
+
PageRequest,
|
|
29
|
+
PlanDraft,
|
|
30
|
+
PlanRequest,
|
|
31
|
+
QuestionRequest,
|
|
32
|
+
Usage,
|
|
33
|
+
Verdict,
|
|
34
|
+
)
|
|
35
|
+
from codetrail.assistant.local_tools import SCHEMAS, LocalTools
|
|
36
|
+
from codetrail.assistant.prompts import (
|
|
37
|
+
ANSWER_RULES,
|
|
38
|
+
DIGEST_SCHEMA,
|
|
39
|
+
GRADE_RULES,
|
|
40
|
+
GRADE_SCHEMA,
|
|
41
|
+
GROUND_RULES,
|
|
42
|
+
PAGE_SYNTAX,
|
|
43
|
+
PLAN_SCHEMA,
|
|
44
|
+
answer_prompt,
|
|
45
|
+
digest_prompt,
|
|
46
|
+
grade_prompt,
|
|
47
|
+
neutralize,
|
|
48
|
+
page_task,
|
|
49
|
+
plan_prompt,
|
|
50
|
+
)
|
|
51
|
+
from codetrail.config import GenerationSettings, LocalSettings
|
|
52
|
+
|
|
53
|
+
PROVIDER: Final = "local"
|
|
54
|
+
MAX_TOOL_CALLS_PER_TURN = 20
|
|
55
|
+
TOOL_RULES = "Use the read, grep and glob tools to look at the repository's files before you answer."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class _Result:
|
|
60
|
+
text: str
|
|
61
|
+
files_read: list[str]
|
|
62
|
+
usage: Usage
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LocalAssistant:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
source_root: Path,
|
|
69
|
+
models: Mapping[str, str],
|
|
70
|
+
settings: LocalSettings,
|
|
71
|
+
limits: GenerationSettings,
|
|
72
|
+
client: httpx.AsyncClient | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.root = source_root.resolve()
|
|
75
|
+
self.models = dict(models)
|
|
76
|
+
self.settings = settings
|
|
77
|
+
self.limits = limits
|
|
78
|
+
self.client = client or httpx.AsyncClient(
|
|
79
|
+
base_url=settings.base_url,
|
|
80
|
+
trust_env=False,
|
|
81
|
+
follow_redirects=False,
|
|
82
|
+
timeout=httpx.Timeout(settings.timeout_seconds),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def plan(self, request: PlanRequest) -> PlanDraft:
|
|
86
|
+
data, result = await self._structured("plan", GROUND_RULES, plan_prompt(request), PLAN_SCHEMA)
|
|
87
|
+
return PlanDraft(list(data.get("pages", [])), list(data.get("paths", [])), result.files_read, 0.0, result.usage)
|
|
88
|
+
|
|
89
|
+
async def write_page(self, request: PageRequest) -> PageDraft:
|
|
90
|
+
prompt, schema = page_task(request) # a new page, or a revision's sections
|
|
91
|
+
data, result = await self._structured("write", GROUND_RULES + "\n" + PAGE_SYNTAX, prompt, schema)
|
|
92
|
+
return PageDraft(str(data.get("body", "")), list(data.get("checks", [])), result.files_read, 0.0, result.usage,
|
|
93
|
+
list(data.get("sections", [])), str(data.get("brief", ""))) # fmt: skip
|
|
94
|
+
|
|
95
|
+
async def write_digest(self, request: DigestRequest) -> DigestDraft:
|
|
96
|
+
data, result = await self._structured(
|
|
97
|
+
"digest", GROUND_RULES + "\n" + PAGE_SYNTAX, digest_prompt(request), DIGEST_SCHEMA
|
|
98
|
+
)
|
|
99
|
+
return DigestDraft(str(data.get("title", "")), str(data.get("body", "")), result.files_read, 0.0, result.usage)
|
|
100
|
+
|
|
101
|
+
async def grade(self, request: GradeRequest) -> Verdict:
|
|
102
|
+
data, result = await self._structured("grade", GRADE_RULES, grade_prompt(request), GRADE_SCHEMA, tools=False)
|
|
103
|
+
missed = [str(item) for item in data.get("missed", [])]
|
|
104
|
+
return Verdict(str(data.get("verdict", "")), missed, str(data.get("feedback", "")), 0.0, result.usage)
|
|
105
|
+
|
|
106
|
+
async def answer(self, request: QuestionRequest) -> AsyncIterator[AnswerChunk]:
|
|
107
|
+
rules = GROUND_RULES + "\n" + PAGE_SYNTAX + "\n" + ANSWER_RULES
|
|
108
|
+
result = await self._loop("answer", rules, answer_prompt(request), None)
|
|
109
|
+
# One final chunk: the bridge shows it only after scanning it (design section 15.5).
|
|
110
|
+
yield AnswerChunk(result.text, True, result.files_read, usage=result.usage)
|
|
111
|
+
|
|
112
|
+
async def _structured(
|
|
113
|
+
self, kind: str, rules: str, prompt: str, schema: dict[str, Any], tools: bool = True
|
|
114
|
+
) -> tuple[dict[str, Any], _Result]:
|
|
115
|
+
result = await self._loop(kind, rules, prompt, schema, tools)
|
|
116
|
+
data = _parse(result.text, schema)
|
|
117
|
+
if isinstance(data, str):
|
|
118
|
+
raise AssistantError(f"The local model's answer wasn't usable: {data}")
|
|
119
|
+
return data, result
|
|
120
|
+
|
|
121
|
+
async def _loop(
|
|
122
|
+
self, kind: str, rules: str, prompt: str, schema: dict[str, Any] | None, tools: bool = True
|
|
123
|
+
) -> _Result:
|
|
124
|
+
model = self.models.get(kind, "")
|
|
125
|
+
files = LocalTools(self.root, self.settings.max_read_bytes)
|
|
126
|
+
system = rules
|
|
127
|
+
if tools:
|
|
128
|
+
system += "\n" + TOOL_RULES
|
|
129
|
+
if schema is not None:
|
|
130
|
+
system += "\nFinish with only a JSON object that follows this schema, and nothing else:\n" + json.dumps(
|
|
131
|
+
schema
|
|
132
|
+
)
|
|
133
|
+
messages: list[dict[str, Any]] = [
|
|
134
|
+
{"role": "system", "content": system},
|
|
135
|
+
{"role": "user", "content": neutralize(prompt)},
|
|
136
|
+
]
|
|
137
|
+
tokens_in = tokens_out = 0
|
|
138
|
+
retried = False
|
|
139
|
+
with anyio.fail_after(self.settings.timeout_seconds):
|
|
140
|
+
for _turn in range(self.settings.max_turns):
|
|
141
|
+
body: dict[str, Any] = {"model": model, "messages": messages, "temperature": 0}
|
|
142
|
+
if tools:
|
|
143
|
+
body["tools"] = SCHEMAS
|
|
144
|
+
reply = await self._chat(body)
|
|
145
|
+
usage = reply.get("usage") or {}
|
|
146
|
+
tokens_in += _count(usage, "prompt_tokens")
|
|
147
|
+
tokens_out += _count(usage, "completion_tokens")
|
|
148
|
+
if tokens_in + tokens_out > self.settings.max_tokens_per_call:
|
|
149
|
+
raise AssistantError(
|
|
150
|
+
f"The local model passed its token limit ({self.settings.max_tokens_per_call})."
|
|
151
|
+
)
|
|
152
|
+
message = _message(reply)
|
|
153
|
+
calls = message.get("tool_calls") or []
|
|
154
|
+
if calls and tools:
|
|
155
|
+
messages.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": calls})
|
|
156
|
+
for index, call in enumerate(calls):
|
|
157
|
+
content = (
|
|
158
|
+
await _run_tool(files, call)
|
|
159
|
+
if index < MAX_TOOL_CALLS_PER_TURN
|
|
160
|
+
else f"Refused: too many tool calls in one turn (at most {MAX_TOOL_CALLS_PER_TURN})."
|
|
161
|
+
)
|
|
162
|
+
messages.append({"role": "tool", "tool_call_id": str(call.get("id", "")), "content": content})
|
|
163
|
+
continue
|
|
164
|
+
text = str(message.get("content") or "")
|
|
165
|
+
if schema is not None and isinstance(problem := _parse(text, schema), str) and not retried:
|
|
166
|
+
retried = True
|
|
167
|
+
messages += [
|
|
168
|
+
{"role": "assistant", "content": text},
|
|
169
|
+
{
|
|
170
|
+
"role": "user",
|
|
171
|
+
"content": f"That wasn't usable: {problem} Answer again with only the JSON object.",
|
|
172
|
+
},
|
|
173
|
+
]
|
|
174
|
+
continue
|
|
175
|
+
usage_record = Usage(PROVIDER, model, tokens_in, 0, tokens_out, 0.0)
|
|
176
|
+
return _Result(text, list(files.files_read), usage_record)
|
|
177
|
+
raise AssistantError(f"The local model didn't finish within {self.settings.max_turns} turns.")
|
|
178
|
+
|
|
179
|
+
async def _chat(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
180
|
+
try:
|
|
181
|
+
response = await self.client.post("chat/completions", json=body)
|
|
182
|
+
except httpx.HTTPError as error:
|
|
183
|
+
raise AssistantError(
|
|
184
|
+
f"The local model at {self.settings.base_url} couldn't be reached ({type(error).__name__})."
|
|
185
|
+
) from error
|
|
186
|
+
if response.status_code != 200:
|
|
187
|
+
raise AssistantError(f"The local model answered with HTTP {response.status_code}.")
|
|
188
|
+
try:
|
|
189
|
+
reply = response.json()
|
|
190
|
+
except ValueError as error:
|
|
191
|
+
raise AssistantError("The local model's reply wasn't JSON.") from error
|
|
192
|
+
if not isinstance(reply, dict):
|
|
193
|
+
raise AssistantError("The local model's reply wasn't a JSON object.")
|
|
194
|
+
return reply
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def _run_tool(files: LocalTools, call: Mapping[str, Any]) -> str:
|
|
198
|
+
function = call.get("function") or {}
|
|
199
|
+
try:
|
|
200
|
+
arguments = json.loads(function.get("arguments") or "{}")
|
|
201
|
+
except ValueError:
|
|
202
|
+
return "Refused: the tool's arguments weren't JSON."
|
|
203
|
+
if not isinstance(arguments, dict):
|
|
204
|
+
return "Refused: the tool's arguments weren't a JSON object."
|
|
205
|
+
# In a worker thread, so a slow search can't block the event loop or outlast the time limit.
|
|
206
|
+
return await anyio.to_thread.run_sync(files.run, str(function.get("name", "")), arguments, abandon_on_cancel=True)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _message(reply: Mapping[str, Any]) -> dict[str, Any]:
|
|
210
|
+
choices = reply.get("choices")
|
|
211
|
+
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
|
|
212
|
+
raise AssistantError("The local model's reply had no message.")
|
|
213
|
+
message = choices[0].get("message")
|
|
214
|
+
if not isinstance(message, dict):
|
|
215
|
+
raise AssistantError("The local model's reply had no message.")
|
|
216
|
+
return message
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _unfenced(text: str) -> str:
|
|
220
|
+
"""The text inside a ```json fence, if the whole answer is one; plain string work, so it stays linear."""
|
|
221
|
+
stripped = text.strip()
|
|
222
|
+
if stripped.startswith("```") and stripped.endswith("```") and len(stripped) >= 6:
|
|
223
|
+
inner = stripped[3:-3]
|
|
224
|
+
first_line, _newline, rest = inner.partition("\n")
|
|
225
|
+
return rest if first_line.strip() in ("", "json") else inner
|
|
226
|
+
return stripped
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse(text: str, schema: Mapping[str, Any]) -> dict[str, Any] | str:
|
|
230
|
+
"""The answer as a JSON object with the schema's required keys, or what is wrong with it."""
|
|
231
|
+
try:
|
|
232
|
+
data = json.loads(_unfenced(text))
|
|
233
|
+
except ValueError:
|
|
234
|
+
return "it isn't valid JSON."
|
|
235
|
+
if not isinstance(data, dict):
|
|
236
|
+
return "it isn't a JSON object."
|
|
237
|
+
missing = [key for key in schema.get("required", []) if key not in data]
|
|
238
|
+
if missing:
|
|
239
|
+
return f"it lacks {', '.join(missing)}."
|
|
240
|
+
return data
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _count(usage: Mapping[str, Any], key: str) -> int:
|
|
244
|
+
value = usage.get(key, 0)
|
|
245
|
+
return int(value) if isinstance(value, int | float) else 0
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""The local provider's file tools, implemented by Codetrail: Read, Grep and Glob, only inside `source/` (design 15.1).
|
|
2
|
+
|
|
3
|
+
Every path goes through the tool guard. Grep searches for plain text, never a regular expression, so no pattern from
|
|
4
|
+
the model can run away; Glob patterns are short, with few wildcards, for the same reason. A refusal comes back to the
|
|
5
|
+
model as text starting with "Refused:", so it can try something else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from codetrail.assistant.guard import ToolGuard
|
|
16
|
+
|
|
17
|
+
MAX_GREP_LINES = 200
|
|
18
|
+
MAX_GLOB_PATHS = 500
|
|
19
|
+
MAX_PATTERN_CHARS = 200
|
|
20
|
+
MAX_WILDCARDS = 10
|
|
21
|
+
MAX_LINE_CHARS = 300
|
|
22
|
+
|
|
23
|
+
SCHEMAS: list[dict[str, Any]] = [
|
|
24
|
+
{
|
|
25
|
+
"type": "function",
|
|
26
|
+
"function": {
|
|
27
|
+
"name": "read",
|
|
28
|
+
"description": "Read a text file in the repository, with line numbers.",
|
|
29
|
+
"parameters": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["path"],
|
|
32
|
+
"properties": {
|
|
33
|
+
"path": {"type": "string", "description": "A path relative to the repository's root."},
|
|
34
|
+
"offset": {"type": "integer", "description": "The first line to read, from 1."},
|
|
35
|
+
"limit": {"type": "integer", "description": "How many lines to read."},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"type": "function",
|
|
42
|
+
"function": {
|
|
43
|
+
"name": "grep",
|
|
44
|
+
"description": "Find lines containing some text (plain text, not case-sensitive).",
|
|
45
|
+
"parameters": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"required": ["text"],
|
|
48
|
+
"properties": {
|
|
49
|
+
"text": {"type": "string"},
|
|
50
|
+
"path": {"type": "string", "description": "A folder to search in; the root by default."},
|
|
51
|
+
"glob": {"type": "string", "description": "Only files whose path matches this pattern, like *.py."},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "function",
|
|
58
|
+
"function": {
|
|
59
|
+
"name": "glob",
|
|
60
|
+
"description": "List the files whose paths match a pattern, like **/*.py.",
|
|
61
|
+
"parameters": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"required": ["pattern"],
|
|
64
|
+
"properties": {
|
|
65
|
+
"pattern": {"type": "string"},
|
|
66
|
+
"path": {"type": "string", "description": "A folder to search in; the root by default."},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Refused(Exception):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LocalTools:
|
|
79
|
+
def __init__(self, source_root: Path, max_read_bytes: int) -> None:
|
|
80
|
+
self.guard = ToolGuard(source_root)
|
|
81
|
+
self.root = self.guard.root
|
|
82
|
+
self.max_read_bytes = max_read_bytes
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def files_read(self) -> list[str]:
|
|
86
|
+
return self.guard.files_read
|
|
87
|
+
|
|
88
|
+
def run(self, name: str, arguments: Mapping[str, Any]) -> str:
|
|
89
|
+
try:
|
|
90
|
+
if name == "read":
|
|
91
|
+
return self._read(
|
|
92
|
+
_text(arguments, "path"), _number(arguments, "offset", 1), _number(arguments, "limit", 2000)
|
|
93
|
+
)
|
|
94
|
+
if name == "grep":
|
|
95
|
+
return self._grep(_text(arguments, "text"), _text(arguments, "path", "."), arguments.get("glob"))
|
|
96
|
+
if name == "glob":
|
|
97
|
+
return self._glob(_text(arguments, "pattern"), _text(arguments, "path", "."))
|
|
98
|
+
raise _Refused(f"{name} isn't a tool here; only read, grep and glob are.")
|
|
99
|
+
except _Refused as refusal:
|
|
100
|
+
return f"Refused: {refusal}"
|
|
101
|
+
|
|
102
|
+
def _read(self, path: str, offset: int, limit: int) -> str:
|
|
103
|
+
reason = self.guard.decide("Read", {"file_path": path})
|
|
104
|
+
if reason is not None:
|
|
105
|
+
raise _Refused(reason)
|
|
106
|
+
with (self.root / path).open("rb") as handle:
|
|
107
|
+
data = handle.read(self.max_read_bytes)
|
|
108
|
+
lines = data.decode("utf-8", "replace").splitlines()
|
|
109
|
+
start = max(offset, 1)
|
|
110
|
+
shown = lines[start - 1 : start - 1 + max(limit, 1)]
|
|
111
|
+
return "\n".join(f"{start + index}\t{line}" for index, line in enumerate(shown))
|
|
112
|
+
|
|
113
|
+
def _grep(self, text: str, path: str, glob: object) -> str:
|
|
114
|
+
if not text:
|
|
115
|
+
raise _Refused("grep needs some text to find.")
|
|
116
|
+
folder = self._folder(path)
|
|
117
|
+
pattern = _pattern(glob) if glob is not None else None
|
|
118
|
+
needle = text.lower()
|
|
119
|
+
found: list[str] = []
|
|
120
|
+
for file in sorted(folder.rglob("*")):
|
|
121
|
+
if not self._plain_file(file) or (pattern and not fnmatch.fnmatch(file.name, pattern)):
|
|
122
|
+
continue
|
|
123
|
+
relative = file.relative_to(self.root).as_posix()
|
|
124
|
+
with file.open("rb") as handle:
|
|
125
|
+
content = handle.read(self.max_read_bytes).decode("utf-8", "replace")
|
|
126
|
+
for number, line in enumerate(content.splitlines(), start=1):
|
|
127
|
+
if needle in line.lower():
|
|
128
|
+
found.append(f"{relative}:{number}: {line.strip()[:MAX_LINE_CHARS]}")
|
|
129
|
+
if len(found) >= MAX_GREP_LINES:
|
|
130
|
+
return "\n".join(found)
|
|
131
|
+
return "\n".join(found) or "No matches."
|
|
132
|
+
|
|
133
|
+
def _glob(self, pattern: str, path: str) -> str:
|
|
134
|
+
reason = self.guard.decide("Glob", {"pattern": _pattern(pattern), "path": path})
|
|
135
|
+
if reason is not None:
|
|
136
|
+
raise _Refused(reason)
|
|
137
|
+
folder = self._folder(path)
|
|
138
|
+
paths = sorted(
|
|
139
|
+
file.relative_to(self.root).as_posix() for file in folder.glob(pattern) if self._plain_file(file)
|
|
140
|
+
)
|
|
141
|
+
return "\n".join(paths[:MAX_GLOB_PATHS]) or "No matches."
|
|
142
|
+
|
|
143
|
+
def _plain_file(self, file: Path) -> bool:
|
|
144
|
+
"""A regular file inside the root, reached through no symlink: source/ never holds one, but don't rely on it."""
|
|
145
|
+
if file.is_symlink() or not file.is_file():
|
|
146
|
+
return False
|
|
147
|
+
resolved = file.resolve()
|
|
148
|
+
return resolved.is_relative_to(self.root) and resolved == file.absolute()
|
|
149
|
+
|
|
150
|
+
def _folder(self, path: str) -> Path:
|
|
151
|
+
reason = self.guard.decide("Grep", {"path": path})
|
|
152
|
+
folder = (self.root / path).resolve()
|
|
153
|
+
if reason is not None or not folder.is_dir():
|
|
154
|
+
raise _Refused(reason or f"{path} isn't a folder in the repository.")
|
|
155
|
+
return folder
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _pattern(value: object) -> str:
|
|
159
|
+
if not isinstance(value, str) or not value:
|
|
160
|
+
raise _Refused("A pattern must be some text.")
|
|
161
|
+
if len(value) > MAX_PATTERN_CHARS or value.count("*") + value.count("?") > MAX_WILDCARDS:
|
|
162
|
+
raise _Refused(f"Patterns are limited to {MAX_PATTERN_CHARS} characters and {MAX_WILDCARDS} wildcards.")
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _text(arguments: Mapping[str, Any], key: str, default: str | None = None) -> str:
|
|
167
|
+
value = arguments.get(key, default)
|
|
168
|
+
if not isinstance(value, str):
|
|
169
|
+
raise _Refused(f"{key} must be text.")
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _number(arguments: Mapping[str, Any], key: str, default: int) -> int:
|
|
174
|
+
value = arguments.get(key, default)
|
|
175
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
176
|
+
raise _Refused(f"{key} must be a whole number.")
|
|
177
|
+
return value
|