koina 0.1.0__tar.gz
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.
- koina-0.1.0/LICENSE +21 -0
- koina-0.1.0/PKG-INFO +107 -0
- koina-0.1.0/README.md +80 -0
- koina-0.1.0/pyproject.toml +84 -0
- koina-0.1.0/src/koina/__init__.py +36 -0
- koina-0.1.0/src/koina/_ripgrep.py +19 -0
- koina-0.1.0/src/koina/adapters/__init__.py +0 -0
- koina-0.1.0/src/koina/adapters/anthropic.py +103 -0
- koina-0.1.0/src/koina/adapters/openai.py +76 -0
- koina-0.1.0/src/koina/calls.py +30 -0
- koina-0.1.0/src/koina/context.py +29 -0
- koina-0.1.0/src/koina/observability.py +90 -0
- koina-0.1.0/src/koina/py.typed +0 -0
- koina-0.1.0/src/koina/registry.py +103 -0
- koina-0.1.0/src/koina/tool.py +39 -0
- koina-0.1.0/src/koina/tools/__init__.py +0 -0
- koina-0.1.0/src/koina/tools/bash.py +160 -0
- koina-0.1.0/src/koina/tools/edit.py +71 -0
- koina-0.1.0/src/koina/tools/glob.py +48 -0
- koina-0.1.0/src/koina/tools/grep.py +106 -0
- koina-0.1.0/src/koina/tools/read.py +88 -0
- koina-0.1.0/src/koina/tools/write.py +40 -0
koina-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geoffrey Guéret
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
koina-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: koina
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An agentic toolset: provider-neutral tools and dispatch for building agents on low-level LLM SDKs
|
|
5
|
+
Keywords: agents,agentic,llm,tool-use,function-calling,ai
|
|
6
|
+
Author: Geoffrey Guéret
|
|
7
|
+
Author-email: Geoffrey Guéret <geoffrey@gueret.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: pydantic>=2
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Homepage, https://github.com/ggueret/koina
|
|
22
|
+
Project-URL: Repository, https://github.com/ggueret/koina.git
|
|
23
|
+
Project-URL: Documentation, https://github.com/ggueret/koina#readme
|
|
24
|
+
Project-URL: Issues, https://github.com/ggueret/koina/issues
|
|
25
|
+
Project-URL: Changelog, https://github.com/ggueret/koina/releases
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<h1 align="center">
|
|
29
|
+
<picture>
|
|
30
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/brand/wordmark-dark.svg">
|
|
31
|
+
<img src="assets/brand/wordmark.svg" alt="koina" width="240">
|
|
32
|
+
</picture>
|
|
33
|
+
</h1>
|
|
34
|
+
|
|
35
|
+
<p align="center"><em>An agentic toolset.</em></p>
|
|
36
|
+
|
|
37
|
+
<p align="center">
|
|
38
|
+
Reusable, provider-neutral building blocks for agents on low-level LLM SDKs:
|
|
39
|
+
the six core file/shell tools (Read, Write, Edit, Bash, Glob, Grep), a
|
|
40
|
+
never-raising <code>dispatch</code>, structured JSONL logging, and a thin
|
|
41
|
+
adapter per provider. koina gives you the tools and the dispatch; the agentic
|
|
42
|
+
loop stays in your code.
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Python 3.12+
|
|
48
|
+
- ripgrep (`rg`) on PATH (for Glob and Grep)
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv add koina
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The library depends only on `pydantic`. Provider SDKs (`anthropic`, `openai`)
|
|
57
|
+
are the caller's dependency, used in your loop, not by koina.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
`dispatch` and the tools are provider-neutral; an adapter translates a provider's
|
|
62
|
+
wire format to and from the neutral `ToolCall`/`ToolResult`. With the Anthropic
|
|
63
|
+
adapter:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from anthropic import AsyncAnthropic
|
|
68
|
+
from koina import default_registry, dispatch, ToolContext
|
|
69
|
+
from koina.adapters import anthropic as adapter
|
|
70
|
+
|
|
71
|
+
client = AsyncAnthropic()
|
|
72
|
+
reg = default_registry()
|
|
73
|
+
ctx = ToolContext(cwd=Path.cwd())
|
|
74
|
+
msgs = [{"role": "user", "content": "List the Python files."}]
|
|
75
|
+
|
|
76
|
+
while True:
|
|
77
|
+
resp = await client.messages.create(
|
|
78
|
+
model="claude-opus-4-8", max_tokens=4096,
|
|
79
|
+
messages=msgs, tools=adapter.tools_param(reg),
|
|
80
|
+
)
|
|
81
|
+
msgs.append({"role": "assistant", "content": resp.content})
|
|
82
|
+
calls = adapter.parse_tool_calls(resp.content)
|
|
83
|
+
if not calls:
|
|
84
|
+
break
|
|
85
|
+
results = [await dispatch(c, reg, ctx) for c in calls]
|
|
86
|
+
msgs.append(adapter.format_results(results))
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Swap `koina.adapters.anthropic` for `koina.adapters.openai` to run the same tools
|
|
90
|
+
against the OpenAI Chat Completions API (or any OpenAI-compatible server, e.g.
|
|
91
|
+
llama.cpp). See `examples/` for runnable read-only code-review scripts on both.
|
|
92
|
+
|
|
93
|
+
## What's in the box
|
|
94
|
+
|
|
95
|
+
- **Six core tools** (Read, Write, Edit, Bash, Glob, Grep), faithful to Claude
|
|
96
|
+
Code's observable behavior, headless (no permissions or hooks).
|
|
97
|
+
- **`dispatch` never raises**: it always returns a `ToolResult` (errors set
|
|
98
|
+
`is_error=True`).
|
|
99
|
+
- **Provider-neutral core** (`ToolCall`, `ToolResult`) with per-provider adapters
|
|
100
|
+
(`koina.adapters.anthropic`, `koina.adapters.openai`). The library never imports
|
|
101
|
+
a provider SDK at runtime.
|
|
102
|
+
- **Structured logging**: typed events (tool calls, model calls, token usage,
|
|
103
|
+
reasoning) emitted to a pluggable `EventSink` (`JsonlSink`/`NullSink`), so a run
|
|
104
|
+
reconstructs from a JSONL transcript. Off by default, near-zero overhead when
|
|
105
|
+
inactive.
|
|
106
|
+
|
|
107
|
+
Permissions, web tools, and concurrency orchestration are out of scope.
|
koina-0.1.0/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/brand/wordmark-dark.svg">
|
|
4
|
+
<img src="assets/brand/wordmark.svg" alt="koina" width="240">
|
|
5
|
+
</picture>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<p align="center"><em>An agentic toolset.</em></p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
Reusable, provider-neutral building blocks for agents on low-level LLM SDKs:
|
|
12
|
+
the six core file/shell tools (Read, Write, Edit, Bash, Glob, Grep), a
|
|
13
|
+
never-raising <code>dispatch</code>, structured JSONL logging, and a thin
|
|
14
|
+
adapter per provider. koina gives you the tools and the dispatch; the agentic
|
|
15
|
+
loop stays in your code.
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Python 3.12+
|
|
21
|
+
- ripgrep (`rg`) on PATH (for Glob and Grep)
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv add koina
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The library depends only on `pydantic`. Provider SDKs (`anthropic`, `openai`)
|
|
30
|
+
are the caller's dependency, used in your loop, not by koina.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
`dispatch` and the tools are provider-neutral; an adapter translates a provider's
|
|
35
|
+
wire format to and from the neutral `ToolCall`/`ToolResult`. With the Anthropic
|
|
36
|
+
adapter:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from anthropic import AsyncAnthropic
|
|
41
|
+
from koina import default_registry, dispatch, ToolContext
|
|
42
|
+
from koina.adapters import anthropic as adapter
|
|
43
|
+
|
|
44
|
+
client = AsyncAnthropic()
|
|
45
|
+
reg = default_registry()
|
|
46
|
+
ctx = ToolContext(cwd=Path.cwd())
|
|
47
|
+
msgs = [{"role": "user", "content": "List the Python files."}]
|
|
48
|
+
|
|
49
|
+
while True:
|
|
50
|
+
resp = await client.messages.create(
|
|
51
|
+
model="claude-opus-4-8", max_tokens=4096,
|
|
52
|
+
messages=msgs, tools=adapter.tools_param(reg),
|
|
53
|
+
)
|
|
54
|
+
msgs.append({"role": "assistant", "content": resp.content})
|
|
55
|
+
calls = adapter.parse_tool_calls(resp.content)
|
|
56
|
+
if not calls:
|
|
57
|
+
break
|
|
58
|
+
results = [await dispatch(c, reg, ctx) for c in calls]
|
|
59
|
+
msgs.append(adapter.format_results(results))
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Swap `koina.adapters.anthropic` for `koina.adapters.openai` to run the same tools
|
|
63
|
+
against the OpenAI Chat Completions API (or any OpenAI-compatible server, e.g.
|
|
64
|
+
llama.cpp). See `examples/` for runnable read-only code-review scripts on both.
|
|
65
|
+
|
|
66
|
+
## What's in the box
|
|
67
|
+
|
|
68
|
+
- **Six core tools** (Read, Write, Edit, Bash, Glob, Grep), faithful to Claude
|
|
69
|
+
Code's observable behavior, headless (no permissions or hooks).
|
|
70
|
+
- **`dispatch` never raises**: it always returns a `ToolResult` (errors set
|
|
71
|
+
`is_error=True`).
|
|
72
|
+
- **Provider-neutral core** (`ToolCall`, `ToolResult`) with per-provider adapters
|
|
73
|
+
(`koina.adapters.anthropic`, `koina.adapters.openai`). The library never imports
|
|
74
|
+
a provider SDK at runtime.
|
|
75
|
+
- **Structured logging**: typed events (tool calls, model calls, token usage,
|
|
76
|
+
reasoning) emitted to a pluggable `EventSink` (`JsonlSink`/`NullSink`), so a run
|
|
77
|
+
reconstructs from a JSONL transcript. Off by default, near-zero overhead when
|
|
78
|
+
inactive.
|
|
79
|
+
|
|
80
|
+
Permissions, web tools, and concurrency orchestration are out of scope.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "koina"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An agentic toolset: provider-neutral tools and dispatch for building agents on low-level LLM SDKs"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Geoffrey Guéret", email = "geoffrey@gueret.dev" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["agents", "agentic", "llm", "tool-use", "function-calling", "ai"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"pydantic>=2",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/ggueret/koina"
|
|
30
|
+
Repository = "https://github.com/ggueret/koina.git"
|
|
31
|
+
Documentation = "https://github.com/ggueret/koina#readme"
|
|
32
|
+
Issues = "https://github.com/ggueret/koina/issues"
|
|
33
|
+
Changelog = "https://github.com/ggueret/koina/releases"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.11.3,<0.12.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
examples = [
|
|
41
|
+
"anthropic>=0.105.2",
|
|
42
|
+
"openai>=1.0",
|
|
43
|
+
]
|
|
44
|
+
dev = [
|
|
45
|
+
"mypy>=2.1.0",
|
|
46
|
+
"pytest>=9.0.3",
|
|
47
|
+
"pytest-asyncio>=1.4.0",
|
|
48
|
+
"pytest-cov>=7.1.0",
|
|
49
|
+
"ruff>=0.15.15",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
cache_dir = ".cache/pytest"
|
|
56
|
+
addopts = "--cov --cov-report=term-missing"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 88
|
|
60
|
+
target-version = "py312"
|
|
61
|
+
cache-dir = ".cache/ruff"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "W", "I", "B", "UP", "RUF"]
|
|
65
|
+
ignore = ["E501"] # line length is enforced by the formatter
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
python_version = "3.12"
|
|
69
|
+
strict = true
|
|
70
|
+
cache_dir = ".cache/mypy"
|
|
71
|
+
|
|
72
|
+
[tool.coverage.run]
|
|
73
|
+
source = ["src/koina"]
|
|
74
|
+
branch = true
|
|
75
|
+
data_file = ".cache/coverage/.coverage"
|
|
76
|
+
|
|
77
|
+
[tool.coverage.report]
|
|
78
|
+
exclude_also = [
|
|
79
|
+
"if TYPE_CHECKING:",
|
|
80
|
+
"raise NotImplementedError",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
[tool.coverage.html]
|
|
84
|
+
directory = ".cache/coverage/html"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .calls import ToolCall, ToolResult
|
|
2
|
+
from .context import ReadLimits, ToolContext
|
|
3
|
+
from .observability import (
|
|
4
|
+
Event,
|
|
5
|
+
EventSink,
|
|
6
|
+
JsonlSink,
|
|
7
|
+
ModelResponse,
|
|
8
|
+
NullSink,
|
|
9
|
+
Thinking,
|
|
10
|
+
ToolEnd,
|
|
11
|
+
ToolStart,
|
|
12
|
+
Usage,
|
|
13
|
+
)
|
|
14
|
+
from .registry import ToolRegistry, default_registry, dispatch
|
|
15
|
+
from .tool import Tool, ToolError
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Event",
|
|
19
|
+
"EventSink",
|
|
20
|
+
"JsonlSink",
|
|
21
|
+
"ModelResponse",
|
|
22
|
+
"NullSink",
|
|
23
|
+
"ReadLimits",
|
|
24
|
+
"Thinking",
|
|
25
|
+
"Tool",
|
|
26
|
+
"ToolCall",
|
|
27
|
+
"ToolContext",
|
|
28
|
+
"ToolEnd",
|
|
29
|
+
"ToolError",
|
|
30
|
+
"ToolRegistry",
|
|
31
|
+
"ToolResult",
|
|
32
|
+
"ToolStart",
|
|
33
|
+
"Usage",
|
|
34
|
+
"default_registry",
|
|
35
|
+
"dispatch",
|
|
36
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
async def run_rg(args: list[str], cwd: str) -> tuple[int, str]:
|
|
5
|
+
"""Run ripgrep, returning (returncode, stdout). rg exits 1 when no matches."""
|
|
6
|
+
proc = await asyncio.create_subprocess_exec(
|
|
7
|
+
"rg",
|
|
8
|
+
*args,
|
|
9
|
+
cwd=cwd,
|
|
10
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
11
|
+
stdout=asyncio.subprocess.PIPE,
|
|
12
|
+
stderr=asyncio.subprocess.PIPE,
|
|
13
|
+
)
|
|
14
|
+
stdout, stderr = await proc.communicate()
|
|
15
|
+
if proc.returncode not in (0, 1):
|
|
16
|
+
raise RuntimeError(
|
|
17
|
+
stderr.decode("utf-8", errors="replace").strip() or "rg failed"
|
|
18
|
+
)
|
|
19
|
+
return proc.returncode or 0, stdout.decode("utf-8", errors="replace")
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Anthropic Messages API adapter.
|
|
2
|
+
|
|
3
|
+
Maps koina's neutral tool types to and from Anthropic wire shapes: `tools_param`
|
|
4
|
+
(schema export), `parse_tool_calls` (tool_use blocks -> `ToolCall`),
|
|
5
|
+
`format_results` (`ToolResult` -> tool_result block, wrapping errors in
|
|
6
|
+
`<tool_use_error>`), plus `usage_event` / `thinking_events`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..calls import ToolCall, ToolResult
|
|
12
|
+
from ..observability import Thinking, Usage
|
|
13
|
+
from ..registry import ToolRegistry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def tools_param(registry: ToolRegistry) -> list[dict[str, object]]:
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
"name": tool.name,
|
|
20
|
+
"description": tool.description,
|
|
21
|
+
"input_schema": tool.input_json_schema(),
|
|
22
|
+
}
|
|
23
|
+
for tool in registry.tools()
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_tool_calls(content: Any) -> list[ToolCall]:
|
|
28
|
+
calls: list[ToolCall] = []
|
|
29
|
+
for block in content:
|
|
30
|
+
if getattr(block, "type", None) == "tool_use":
|
|
31
|
+
calls.append(
|
|
32
|
+
ToolCall(id=block.id, name=block.name, input=dict(block.input))
|
|
33
|
+
)
|
|
34
|
+
return calls
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_results(results: list[ToolResult]) -> dict[str, object]:
|
|
38
|
+
blocks: list[dict[str, object]] = []
|
|
39
|
+
for r in results:
|
|
40
|
+
# Claude Code wraps tool errors in this marker; it is Anthropic-specific,
|
|
41
|
+
# so it is applied here, not baked into the neutral ToolResult.content.
|
|
42
|
+
content = (
|
|
43
|
+
f"<tool_use_error>{r.content}</tool_use_error>" if r.is_error else r.content
|
|
44
|
+
)
|
|
45
|
+
block: dict[str, object] = {
|
|
46
|
+
"type": "tool_result",
|
|
47
|
+
"tool_use_id": r.id,
|
|
48
|
+
"content": content,
|
|
49
|
+
}
|
|
50
|
+
if r.is_error:
|
|
51
|
+
block["is_error"] = True
|
|
52
|
+
blocks.append(block)
|
|
53
|
+
return {"role": "user", "content": blocks}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def usage_event(
|
|
57
|
+
resp: Any, *, turn: int | None = None, parent_id: str | None = None
|
|
58
|
+
) -> Usage:
|
|
59
|
+
u = resp.usage
|
|
60
|
+
cache_read = getattr(u, "cache_read_input_tokens", 0) or 0
|
|
61
|
+
cache_creation = getattr(u, "cache_creation_input_tokens", 0) or 0
|
|
62
|
+
extra: dict[str, int] = {}
|
|
63
|
+
if cache_creation:
|
|
64
|
+
# Anthropic-only counter (cache-write premium); kept out of the neutral
|
|
65
|
+
# fields so the schema does not bias toward one provider.
|
|
66
|
+
extra["cache_creation_input_tokens"] = cache_creation
|
|
67
|
+
return Usage(
|
|
68
|
+
response_id=getattr(resp, "id", None),
|
|
69
|
+
input_tokens=u.input_tokens,
|
|
70
|
+
output_tokens=u.output_tokens,
|
|
71
|
+
cached_input_tokens=cache_read,
|
|
72
|
+
reasoning_tokens=0, # Anthropic folds thinking into output_tokens
|
|
73
|
+
extra=extra,
|
|
74
|
+
turn=turn,
|
|
75
|
+
parent_id=parent_id,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def thinking_events(
|
|
80
|
+
content: Any, *, turn: int | None = None, parent_id: str | None = None
|
|
81
|
+
) -> list[Thinking]:
|
|
82
|
+
events: list[Thinking] = []
|
|
83
|
+
for block in content:
|
|
84
|
+
btype = getattr(block, "type", None)
|
|
85
|
+
if btype == "thinking":
|
|
86
|
+
signature = getattr(block, "signature", None)
|
|
87
|
+
extra: dict[str, object] = {}
|
|
88
|
+
if signature is not None:
|
|
89
|
+
# Anthropic-only thinking-block signature; out of the neutral core.
|
|
90
|
+
extra["signature"] = signature
|
|
91
|
+
events.append(
|
|
92
|
+
Thinking(
|
|
93
|
+
thinking=getattr(block, "thinking", ""),
|
|
94
|
+
extra=extra,
|
|
95
|
+
turn=turn,
|
|
96
|
+
parent_id=parent_id,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
elif btype == "redacted_thinking":
|
|
100
|
+
events.append(
|
|
101
|
+
Thinking(thinking="", redacted=True, turn=turn, parent_id=parent_id)
|
|
102
|
+
)
|
|
103
|
+
return events
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""OpenAI Chat Completions adapter (and OpenAI-compatible servers).
|
|
2
|
+
|
|
3
|
+
Maps koina's neutral tool types to and from OpenAI wire shapes: `tools_param`
|
|
4
|
+
(schema export as function tools), `parse_tool_calls` (tool_calls -> `ToolCall`,
|
|
5
|
+
tolerant of malformed JSON arguments), `format_results` (`ToolResult` -> tool
|
|
6
|
+
message), plus `usage_event` / `thinking_events`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..calls import ToolCall, ToolResult
|
|
13
|
+
from ..observability import Thinking, Usage
|
|
14
|
+
from ..registry import ToolRegistry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def tools_param(registry: ToolRegistry) -> list[dict[str, object]]:
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
"type": "function",
|
|
21
|
+
"function": {
|
|
22
|
+
"name": tool.name,
|
|
23
|
+
"description": tool.description,
|
|
24
|
+
"parameters": tool.input_json_schema(),
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
for tool in registry.tools()
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_tool_calls(message: Any) -> list[ToolCall]:
|
|
32
|
+
calls: list[ToolCall] = []
|
|
33
|
+
for tc in getattr(message, "tool_calls", None) or []:
|
|
34
|
+
fn = tc.function
|
|
35
|
+
try:
|
|
36
|
+
args = json.loads(fn.arguments or "{}")
|
|
37
|
+
except (ValueError, TypeError):
|
|
38
|
+
args = {}
|
|
39
|
+
if not isinstance(args, dict):
|
|
40
|
+
args = {}
|
|
41
|
+
calls.append(ToolCall(id=tc.id, name=fn.name, input=args))
|
|
42
|
+
return calls
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_results(results: list[ToolResult]) -> list[dict[str, object]]:
|
|
46
|
+
return [
|
|
47
|
+
{"role": "tool", "tool_call_id": r.id, "content": r.content} for r in results
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def usage_event(
|
|
52
|
+
resp: Any, *, turn: int | None = None, parent_id: str | None = None
|
|
53
|
+
) -> Usage:
|
|
54
|
+
u = resp.usage
|
|
55
|
+
prompt_details = getattr(u, "prompt_tokens_details", None)
|
|
56
|
+
completion_details = getattr(u, "completion_tokens_details", None)
|
|
57
|
+
cached = getattr(prompt_details, "cached_tokens", 0) or 0
|
|
58
|
+
reasoning = getattr(completion_details, "reasoning_tokens", 0) or 0
|
|
59
|
+
return Usage(
|
|
60
|
+
response_id=getattr(resp, "id", None),
|
|
61
|
+
input_tokens=u.prompt_tokens,
|
|
62
|
+
output_tokens=u.completion_tokens,
|
|
63
|
+
cached_input_tokens=cached,
|
|
64
|
+
reasoning_tokens=reasoning,
|
|
65
|
+
turn=turn,
|
|
66
|
+
parent_id=parent_id,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def thinking_events(
|
|
71
|
+
message: Any, *, turn: int | None = None, parent_id: str | None = None
|
|
72
|
+
) -> list[Thinking]:
|
|
73
|
+
reasoning = getattr(message, "reasoning_content", None)
|
|
74
|
+
if not reasoning:
|
|
75
|
+
return []
|
|
76
|
+
return [Thinking(thinking=reasoning, turn=turn, parent_id=parent_id)]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class ToolCall:
|
|
6
|
+
"""A request to run a tool, decoded from a provider response.
|
|
7
|
+
|
|
8
|
+
`input` is the raw argument mapping; `dispatch` validates it against the
|
|
9
|
+
tool's `Input` model.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
input: dict[str, object]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ToolResult:
|
|
19
|
+
"""The outcome of a tool call, ready to be formatted back for a provider.
|
|
20
|
+
|
|
21
|
+
`content` is the rendered, provider-neutral text; `is_error` marks a failure
|
|
22
|
+
(an adapter may decorate error content for its provider).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
# name is carried for adapters that format results by function name
|
|
27
|
+
# (e.g. Gemini's functionResponse); the Anthropic adapter matches by id only.
|
|
28
|
+
name: str
|
|
29
|
+
content: str
|
|
30
|
+
is_error: bool = False
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .observability import EventSink, NullSink
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ReadLimits:
|
|
9
|
+
"""Caps applied by `Read`: it keeps at most
|
|
10
|
+
``min(max_bytes, max_tokens * 4)`` bytes of a file."""
|
|
11
|
+
|
|
12
|
+
max_tokens: int = 25_000
|
|
13
|
+
max_bytes: int = 256 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ToolContext:
|
|
18
|
+
"""State shared across tool calls, passed to every `run()`.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
cwd: Working directory used to resolve relative paths. `Bash` updates it,
|
|
22
|
+
so a ``cd`` persists across calls.
|
|
23
|
+
read_limits: Byte/token caps applied by `Read`.
|
|
24
|
+
events: Observability sink; defaults to `NullSink` (no-op).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
cwd: Path
|
|
28
|
+
read_limits: ReadLimits = field(default_factory=ReadLimits)
|
|
29
|
+
events: EventSink = field(default_factory=NullSink)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Literal, Protocol
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _Event(BaseModel):
|
|
10
|
+
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
11
|
+
ts: float = Field(default_factory=time.time)
|
|
12
|
+
turn: int | None = None
|
|
13
|
+
parent_id: str | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ToolStart(_Event):
|
|
17
|
+
type: Literal["tool_start"] = "tool_start"
|
|
18
|
+
tool: str
|
|
19
|
+
tool_call_id: str
|
|
20
|
+
input: dict[str, object]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolEnd(_Event):
|
|
24
|
+
type: Literal["tool_end"] = "tool_end"
|
|
25
|
+
tool: str
|
|
26
|
+
tool_call_id: str
|
|
27
|
+
duration_ms: float
|
|
28
|
+
is_error: bool
|
|
29
|
+
output_bytes: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ModelResponse(_Event):
|
|
33
|
+
type: Literal["model_response"] = "model_response"
|
|
34
|
+
response_id: str
|
|
35
|
+
model: str
|
|
36
|
+
stop_reason: str | None = None
|
|
37
|
+
tool_call_ids: list[str] = Field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Thinking(_Event):
|
|
41
|
+
type: Literal["thinking"] = "thinking"
|
|
42
|
+
thinking: str
|
|
43
|
+
redacted: bool = False
|
|
44
|
+
extra: dict[str, object] = Field(default_factory=dict) # provider-specific
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Usage(_Event):
|
|
48
|
+
type: Literal["usage"] = "usage"
|
|
49
|
+
response_id: str | None = None
|
|
50
|
+
input_tokens: int
|
|
51
|
+
output_tokens: int
|
|
52
|
+
cached_input_tokens: int = 0 # cache read, present in every provider
|
|
53
|
+
reasoning_tokens: int = 0 # OpenAI/Gemini; 0 on Anthropic (folded in output)
|
|
54
|
+
extra: dict[str, int] = Field(default_factory=dict) # provider-specific
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
Event = Annotated[
|
|
58
|
+
ToolStart | ToolEnd | ModelResponse | Thinking | Usage,
|
|
59
|
+
Field(discriminator="type"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EventSink(Protocol):
|
|
64
|
+
def emit(self, event: Event) -> None: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NullSink:
|
|
68
|
+
def emit(self, event: Event) -> None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class JsonlSink:
|
|
73
|
+
def __init__(self, path: str | Path) -> None:
|
|
74
|
+
# buffering=1 -> line-buffered: each emit flushes one terminated line.
|
|
75
|
+
self._fh = open(path, "a", encoding="utf-8", buffering=1)
|
|
76
|
+
|
|
77
|
+
def emit(self, event: Event) -> None:
|
|
78
|
+
try:
|
|
79
|
+
self._fh.write(event.model_dump_json() + "\n")
|
|
80
|
+
except Exception:
|
|
81
|
+
pass # emit must never raise; logging is best-effort
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
self._fh.close()
|
|
85
|
+
|
|
86
|
+
def __enter__(self) -> "JsonlSink":
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *exc: object) -> None:
|
|
90
|
+
self.close()
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from .calls import ToolCall, ToolResult
|
|
7
|
+
from .context import ToolContext
|
|
8
|
+
from .observability import Event, ToolEnd, ToolStart
|
|
9
|
+
from .tool import Tool, ToolError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ToolRegistry:
|
|
13
|
+
def __init__(
|
|
14
|
+
self, tools: tuple[Tool[Any, Any], ...] | list[Tool[Any, Any]] = ()
|
|
15
|
+
) -> None:
|
|
16
|
+
self._by_name: dict[str, Tool[Any, Any]] = {}
|
|
17
|
+
for tool in tools:
|
|
18
|
+
self.register(tool)
|
|
19
|
+
|
|
20
|
+
def register(self, tool: Tool[Any, Any]) -> None:
|
|
21
|
+
self._by_name[tool.name] = tool
|
|
22
|
+
for alias in tool.aliases:
|
|
23
|
+
self._by_name[alias] = tool
|
|
24
|
+
|
|
25
|
+
def get(self, name: str) -> Tool[Any, Any] | None:
|
|
26
|
+
return self._by_name.get(name)
|
|
27
|
+
|
|
28
|
+
def tools(self) -> list[Tool[Any, Any]]:
|
|
29
|
+
unique: dict[str, Tool[Any, Any]] = {}
|
|
30
|
+
for tool in self._by_name.values():
|
|
31
|
+
unique[tool.name] = tool
|
|
32
|
+
return list(unique.values())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _error(call: ToolCall, message: str) -> ToolResult:
|
|
36
|
+
return ToolResult(id=call.id, name=call.name, content=message, is_error=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _execute(
|
|
40
|
+
call: ToolCall, registry: ToolRegistry, ctx: ToolContext
|
|
41
|
+
) -> ToolResult:
|
|
42
|
+
tool = registry.get(call.name)
|
|
43
|
+
if tool is None:
|
|
44
|
+
return _error(call, f"No such tool available: {call.name}")
|
|
45
|
+
try:
|
|
46
|
+
parsed = tool.Input.model_validate(call.input)
|
|
47
|
+
except ValidationError as exc:
|
|
48
|
+
return _error(call, f"InputValidationError: {exc}")
|
|
49
|
+
try:
|
|
50
|
+
output = await tool.run(parsed, ctx)
|
|
51
|
+
return ToolResult(
|
|
52
|
+
id=call.id, name=call.name, content=tool.render_result(output)
|
|
53
|
+
)
|
|
54
|
+
except ToolError as exc:
|
|
55
|
+
return _error(call, str(exc))
|
|
56
|
+
except Exception as exc: # dispatch must never raise
|
|
57
|
+
return _error(call, f"{type(exc).__name__}: {exc}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _safe_emit(ctx: ToolContext, event: Event) -> None:
|
|
61
|
+
try:
|
|
62
|
+
ctx.events.emit(event)
|
|
63
|
+
except Exception: # logging is best-effort; never break dispatch
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def dispatch(
|
|
68
|
+
call: ToolCall, registry: ToolRegistry, ctx: ToolContext
|
|
69
|
+
) -> ToolResult:
|
|
70
|
+
"""Run a tool call and return its result.
|
|
71
|
+
|
|
72
|
+
Never raises: an unknown tool, an input that fails validation, a `ToolError`,
|
|
73
|
+
or any unexpected exception from the tool is converted into a
|
|
74
|
+
`ToolResult(is_error=True)`. A `ToolStart`/`ToolEnd` pair is emitted to
|
|
75
|
+
`ctx.events` around execution.
|
|
76
|
+
"""
|
|
77
|
+
start = ToolStart(tool=call.name, tool_call_id=call.id, input=call.input)
|
|
78
|
+
_safe_emit(ctx, start)
|
|
79
|
+
t0 = time.monotonic()
|
|
80
|
+
result = await _execute(call, registry, ctx)
|
|
81
|
+
_safe_emit(
|
|
82
|
+
ctx,
|
|
83
|
+
ToolEnd(
|
|
84
|
+
tool=call.name,
|
|
85
|
+
tool_call_id=call.id,
|
|
86
|
+
duration_ms=(time.monotonic() - t0) * 1000,
|
|
87
|
+
is_error=result.is_error,
|
|
88
|
+
output_bytes=len(result.content.encode("utf-8")),
|
|
89
|
+
parent_id=start.id,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def default_registry() -> ToolRegistry:
|
|
96
|
+
from .tools.bash import Bash
|
|
97
|
+
from .tools.edit import Edit
|
|
98
|
+
from .tools.glob import Glob
|
|
99
|
+
from .tools.grep import Grep
|
|
100
|
+
from .tools.read import Read
|
|
101
|
+
from .tools.write import Write
|
|
102
|
+
|
|
103
|
+
return ToolRegistry([Read(), Write(), Edit(), Bash(), Glob(), Grep()])
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from .context import ToolContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolError(Exception):
|
|
10
|
+
"""Raise inside `run()` to signal a user-facing tool failure.
|
|
11
|
+
|
|
12
|
+
`dispatch` catches it and returns a `ToolResult(is_error=True)`; it never
|
|
13
|
+
propagates out of `dispatch`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Tool[I: BaseModel, O](ABC):
|
|
18
|
+
"""Base class for a tool.
|
|
19
|
+
|
|
20
|
+
Parameterize it with the pydantic input model and the output type, e.g.
|
|
21
|
+
``class Read(Tool[ReadInput, ReadOutput])``, so that `run` and
|
|
22
|
+
`render_result` are type-checked against each other. `name`, `description`
|
|
23
|
+
and `Input` are required class attributes; `aliases` is optional.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: ClassVar[str]
|
|
27
|
+
aliases: ClassVar[tuple[str, ...]] = ()
|
|
28
|
+
description: ClassVar[str]
|
|
29
|
+
Input: ClassVar[type[BaseModel]]
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def run(self, input: I, ctx: ToolContext) -> O: ...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def render_result(self, output: O) -> str: ...
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def input_json_schema(cls) -> dict[str, object]:
|
|
39
|
+
return cls.Input.model_json_schema()
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from ..context import ToolContext
|
|
10
|
+
from ..tool import Tool
|
|
11
|
+
|
|
12
|
+
DEFAULT_TIMEOUT_MS = 120_000
|
|
13
|
+
MAX_TIMEOUT_MS = 600_000
|
|
14
|
+
MAX_OUTPUT_CHARS = 30_000
|
|
15
|
+
_MARKER = "__KOINA_CWD__:"
|
|
16
|
+
_READ_CHUNK = 65_536
|
|
17
|
+
# Bytes kept from the end of stdout so the trailing CWD marker survives truncation.
|
|
18
|
+
_TAIL_BYTES = 8_192
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _drain_capped(
|
|
22
|
+
stream: asyncio.StreamReader, cap: int, keep_tail: bool
|
|
23
|
+
) -> tuple[bytes, bytes, bool]:
|
|
24
|
+
"""Read a stream to EOF while bounding memory.
|
|
25
|
+
|
|
26
|
+
Keeps at most ``cap`` bytes from the head and, when ``keep_tail``, the last
|
|
27
|
+
``_TAIL_BYTES`` bytes. Returns ``(head, tail, overflowed)``. Peak memory is
|
|
28
|
+
``cap + _TAIL_BYTES`` regardless of how much the command emits.
|
|
29
|
+
"""
|
|
30
|
+
head = bytearray()
|
|
31
|
+
tail = bytearray()
|
|
32
|
+
overflowed = False
|
|
33
|
+
while True:
|
|
34
|
+
chunk = await stream.read(_READ_CHUNK)
|
|
35
|
+
if not chunk:
|
|
36
|
+
break
|
|
37
|
+
room = cap - len(head)
|
|
38
|
+
if room > 0:
|
|
39
|
+
head += chunk[:room]
|
|
40
|
+
if len(chunk) > room:
|
|
41
|
+
overflowed = True
|
|
42
|
+
if keep_tail:
|
|
43
|
+
tail += chunk
|
|
44
|
+
if len(tail) > _TAIL_BYTES:
|
|
45
|
+
del tail[: len(tail) - _TAIL_BYTES]
|
|
46
|
+
return bytes(head), bytes(tail), overflowed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _strip_marker_prefix(text: str) -> str:
|
|
50
|
+
"""Drop a partial ``_MARKER`` prefix left at the end of a truncated head."""
|
|
51
|
+
for k in range(min(len(_MARKER), len(text)), 0, -1):
|
|
52
|
+
if text.endswith(_MARKER[:k]):
|
|
53
|
+
return text[:-k]
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BashInput(BaseModel):
|
|
58
|
+
model_config = ConfigDict(extra="forbid")
|
|
59
|
+
command: str = Field(description="The command to execute")
|
|
60
|
+
timeout: int | None = Field(
|
|
61
|
+
default=None, description="Optional timeout in milliseconds"
|
|
62
|
+
)
|
|
63
|
+
description: str | None = Field(
|
|
64
|
+
default=None, description="Advisory description, no effect"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class BashOutput:
|
|
70
|
+
stdout: str
|
|
71
|
+
stderr: str
|
|
72
|
+
exit_code: int
|
|
73
|
+
timed_out: bool
|
|
74
|
+
truncated: bool
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Bash(Tool[BashInput, BashOutput]):
|
|
78
|
+
name = "Bash"
|
|
79
|
+
description = (
|
|
80
|
+
"Execute a bash command. The working directory persists between calls."
|
|
81
|
+
)
|
|
82
|
+
Input = BashInput
|
|
83
|
+
|
|
84
|
+
async def run(self, input: BashInput, ctx: ToolContext) -> BashOutput:
|
|
85
|
+
timeout_ms = min(input.timeout or DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)
|
|
86
|
+
script = (
|
|
87
|
+
f"{input.command}\n__rc=$?\nprintf '\\n{_MARKER}%s' \"$PWD\"\nexit $__rc"
|
|
88
|
+
)
|
|
89
|
+
proc = await asyncio.create_subprocess_exec(
|
|
90
|
+
"bash",
|
|
91
|
+
"-c",
|
|
92
|
+
script,
|
|
93
|
+
cwd=str(ctx.cwd),
|
|
94
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
95
|
+
stdout=asyncio.subprocess.PIPE,
|
|
96
|
+
stderr=asyncio.subprocess.PIPE,
|
|
97
|
+
start_new_session=True,
|
|
98
|
+
)
|
|
99
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
100
|
+
try:
|
|
101
|
+
(
|
|
102
|
+
(out_head, out_tail, out_over),
|
|
103
|
+
(err_head, _, err_over),
|
|
104
|
+
) = await asyncio.wait_for(
|
|
105
|
+
asyncio.gather(
|
|
106
|
+
_drain_capped(proc.stdout, MAX_OUTPUT_CHARS, keep_tail=True),
|
|
107
|
+
_drain_capped(proc.stderr, MAX_OUTPUT_CHARS, keep_tail=False),
|
|
108
|
+
),
|
|
109
|
+
timeout=timeout_ms / 1000,
|
|
110
|
+
)
|
|
111
|
+
await proc.wait()
|
|
112
|
+
except TimeoutError:
|
|
113
|
+
try:
|
|
114
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
115
|
+
except (ProcessLookupError, PermissionError):
|
|
116
|
+
proc.kill()
|
|
117
|
+
await proc.wait()
|
|
118
|
+
return BashOutput(
|
|
119
|
+
stdout="", stderr="", exit_code=124, timed_out=True, truncated=False
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# The CWD marker is printed last. If stdout overflowed the cap it lives in
|
|
123
|
+
# the tail; otherwise the whole output (marker included) is in the head.
|
|
124
|
+
marker_blob = (out_tail if out_over else out_head).decode(
|
|
125
|
+
"utf-8", errors="replace"
|
|
126
|
+
)
|
|
127
|
+
marker_index = marker_blob.rfind(_MARKER)
|
|
128
|
+
if marker_index != -1:
|
|
129
|
+
new_cwd = marker_blob[marker_index + len(_MARKER) :].strip()
|
|
130
|
+
if new_cwd:
|
|
131
|
+
ctx.cwd = Path(new_cwd)
|
|
132
|
+
|
|
133
|
+
stdout = out_head.decode("utf-8", errors="replace")
|
|
134
|
+
if out_over:
|
|
135
|
+
stdout = _strip_marker_prefix(stdout).rstrip("\n") + "\n(output truncated)"
|
|
136
|
+
elif marker_index != -1:
|
|
137
|
+
stdout = stdout[: stdout.rfind(_MARKER)].rstrip("\n")
|
|
138
|
+
|
|
139
|
+
stderr = err_head.decode("utf-8", errors="replace")
|
|
140
|
+
if err_over:
|
|
141
|
+
stderr = stderr.rstrip("\n") + "\n(output truncated)"
|
|
142
|
+
|
|
143
|
+
return BashOutput(
|
|
144
|
+
stdout=stdout,
|
|
145
|
+
stderr=stderr,
|
|
146
|
+
exit_code=proc.returncode or 0,
|
|
147
|
+
timed_out=False,
|
|
148
|
+
truncated=out_over or err_over,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def render_result(self, output: BashOutput) -> str:
|
|
152
|
+
if output.timed_out:
|
|
153
|
+
return "Command timed out"
|
|
154
|
+
parts = [output.stdout]
|
|
155
|
+
if output.stderr.strip():
|
|
156
|
+
parts.append(output.stderr)
|
|
157
|
+
content = "\n".join(p for p in parts if p) or "(no output)"
|
|
158
|
+
if output.exit_code != 0:
|
|
159
|
+
content += f"\nExit code: {output.exit_code}"
|
|
160
|
+
return content
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
from ..context import ToolContext
|
|
7
|
+
from ..tool import Tool, ToolError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EditInput(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
file_path: str = Field(description="The absolute path to the file to modify")
|
|
13
|
+
old_string: str = Field(description="The text to replace")
|
|
14
|
+
new_string: str = Field(description="The text to replace it with")
|
|
15
|
+
replace_all: bool = Field(default=False, description="Replace all occurrences")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class EditOutput:
|
|
20
|
+
file_path: str
|
|
21
|
+
replace_all: bool
|
|
22
|
+
was_created: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Edit(Tool[EditInput, EditOutput]):
|
|
26
|
+
name = "Edit"
|
|
27
|
+
description = "Perform an exact string replacement in a file."
|
|
28
|
+
Input = EditInput
|
|
29
|
+
|
|
30
|
+
async def run(self, input: EditInput, ctx: ToolContext) -> EditOutput:
|
|
31
|
+
path = Path(input.file_path)
|
|
32
|
+
if not path.is_absolute():
|
|
33
|
+
path = ctx.cwd / path
|
|
34
|
+
if path.suffix == ".ipynb":
|
|
35
|
+
raise ToolError("Use a notebook editor for .ipynb files")
|
|
36
|
+
|
|
37
|
+
if input.old_string == "" and not path.exists():
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(input.new_string, encoding="utf-8", newline="\n")
|
|
40
|
+
return EditOutput(
|
|
41
|
+
file_path=str(path), replace_all=input.replace_all, was_created=True
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if input.old_string == "" and path.exists():
|
|
45
|
+
raise ToolError(
|
|
46
|
+
f"File already exists: {input.file_path}; provide a non-empty old_string to edit it"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if not path.exists():
|
|
50
|
+
raise ToolError(f"File does not exist: {input.file_path}")
|
|
51
|
+
|
|
52
|
+
text = path.read_text(encoding="utf-8")
|
|
53
|
+
count = text.count(input.old_string)
|
|
54
|
+
if count == 0:
|
|
55
|
+
raise ToolError(f"old_string not found in {input.file_path}")
|
|
56
|
+
if count > 1 and not input.replace_all:
|
|
57
|
+
raise ToolError(
|
|
58
|
+
f"Found {count} matches but replace_all is false; make old_string unique"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
new_text = text.replace(
|
|
62
|
+
input.old_string, input.new_string, -1 if input.replace_all else 1
|
|
63
|
+
)
|
|
64
|
+
path.write_text(new_text, encoding="utf-8", newline="\n")
|
|
65
|
+
return EditOutput(file_path=str(path), replace_all=input.replace_all)
|
|
66
|
+
|
|
67
|
+
def render_result(self, output: EditOutput) -> str:
|
|
68
|
+
if output.was_created:
|
|
69
|
+
return f"File created: {output.file_path}"
|
|
70
|
+
suffix = " (all occurrences replaced)" if output.replace_all else ""
|
|
71
|
+
return f"File updated: {output.file_path}{suffix}"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
from .._ripgrep import run_rg
|
|
7
|
+
from ..context import ToolContext
|
|
8
|
+
from ..tool import Tool
|
|
9
|
+
|
|
10
|
+
GLOB_LIMIT = 100
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GlobInput(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid")
|
|
15
|
+
pattern: str = Field(description="The glob pattern to match files against")
|
|
16
|
+
path: str | None = Field(default=None, description="Directory to search in")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class GlobOutput:
|
|
21
|
+
filenames: list[str]
|
|
22
|
+
truncated: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Glob(Tool[GlobInput, GlobOutput]):
|
|
26
|
+
name = "Glob"
|
|
27
|
+
description = "Find files matching a glob pattern, sorted by modification time."
|
|
28
|
+
Input = GlobInput
|
|
29
|
+
|
|
30
|
+
async def run(self, input: GlobInput, ctx: ToolContext) -> GlobOutput:
|
|
31
|
+
base = Path(input.path) if input.path else ctx.cwd
|
|
32
|
+
if not base.is_absolute():
|
|
33
|
+
base = ctx.cwd / base
|
|
34
|
+
_, stdout = await run_rg(
|
|
35
|
+
["--files", "--hidden", "--glob", input.pattern, "--sortr", "modified"],
|
|
36
|
+
cwd=str(base),
|
|
37
|
+
)
|
|
38
|
+
names = [line for line in stdout.splitlines() if line]
|
|
39
|
+
truncated = len(names) > GLOB_LIMIT
|
|
40
|
+
return GlobOutput(filenames=names[:GLOB_LIMIT], truncated=truncated)
|
|
41
|
+
|
|
42
|
+
def render_result(self, output: GlobOutput) -> str:
|
|
43
|
+
if not output.filenames:
|
|
44
|
+
return "No files found"
|
|
45
|
+
content = "\n".join(output.filenames)
|
|
46
|
+
if output.truncated:
|
|
47
|
+
content += "\n(results truncated; use a more specific pattern)"
|
|
48
|
+
return content
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from .._ripgrep import run_rg
|
|
8
|
+
from ..context import ToolContext
|
|
9
|
+
from ..tool import Tool
|
|
10
|
+
|
|
11
|
+
DEFAULT_HEAD_LIMIT = 250
|
|
12
|
+
MAX_COLUMNS = 500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GrepInput(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
17
|
+
pattern: str = Field(description="The regular expression to search for")
|
|
18
|
+
path: str | None = Field(default=None, description="File or directory to search")
|
|
19
|
+
glob: str | None = Field(default=None, description="Glob to filter files")
|
|
20
|
+
output_mode: Literal["content", "files_with_matches", "count"] | None = Field(
|
|
21
|
+
default=None, description="Output mode"
|
|
22
|
+
)
|
|
23
|
+
after: int | None = Field(default=None, alias="-A", description="Lines after match")
|
|
24
|
+
before: int | None = Field(
|
|
25
|
+
default=None, alias="-B", description="Lines before match"
|
|
26
|
+
)
|
|
27
|
+
context: int | None = Field(
|
|
28
|
+
default=None, alias="-C", description="Lines around match"
|
|
29
|
+
)
|
|
30
|
+
line_numbers: bool | None = Field(
|
|
31
|
+
default=None, alias="-n", description="Show line numbers"
|
|
32
|
+
)
|
|
33
|
+
ignore_case: bool | None = Field(
|
|
34
|
+
default=None, alias="-i", description="Case insensitive"
|
|
35
|
+
)
|
|
36
|
+
type: str | None = Field(default=None, description="File type filter")
|
|
37
|
+
head_limit: int | None = Field(default=None, description="Limit results")
|
|
38
|
+
offset: int | None = Field(default=None, description="Skip the first N results")
|
|
39
|
+
multiline: bool | None = Field(default=None, description="Multiline mode")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class GrepOutput:
|
|
44
|
+
mode: str
|
|
45
|
+
filenames: list[str]
|
|
46
|
+
content: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Grep(Tool[GrepInput, GrepOutput]):
|
|
50
|
+
name = "Grep"
|
|
51
|
+
description = "Search file contents with ripgrep."
|
|
52
|
+
Input = GrepInput
|
|
53
|
+
|
|
54
|
+
async def run(self, input: GrepInput, ctx: ToolContext) -> GrepOutput:
|
|
55
|
+
mode = input.output_mode or "files_with_matches"
|
|
56
|
+
args: list[str] = []
|
|
57
|
+
if input.ignore_case:
|
|
58
|
+
args.append("-i")
|
|
59
|
+
if input.multiline:
|
|
60
|
+
args += ["-U", "--multiline-dotall"]
|
|
61
|
+
if input.glob:
|
|
62
|
+
args += ["--glob", input.glob]
|
|
63
|
+
if input.type:
|
|
64
|
+
args += ["--type", input.type]
|
|
65
|
+
|
|
66
|
+
if mode == "files_with_matches":
|
|
67
|
+
args.append("--files-with-matches")
|
|
68
|
+
elif mode == "count":
|
|
69
|
+
args.append("--count")
|
|
70
|
+
else:
|
|
71
|
+
args += [
|
|
72
|
+
"--line-number"
|
|
73
|
+
if input.line_numbers is not False
|
|
74
|
+
else "--no-line-number"
|
|
75
|
+
]
|
|
76
|
+
args += ["--max-columns", str(MAX_COLUMNS)]
|
|
77
|
+
if input.context is not None:
|
|
78
|
+
args += ["-C", str(input.context)]
|
|
79
|
+
else:
|
|
80
|
+
if input.after is not None:
|
|
81
|
+
args += ["-A", str(input.after)]
|
|
82
|
+
if input.before is not None:
|
|
83
|
+
args += ["-B", str(input.before)]
|
|
84
|
+
|
|
85
|
+
base = Path(input.path) if input.path else ctx.cwd
|
|
86
|
+
if not base.is_absolute():
|
|
87
|
+
base = ctx.cwd / base
|
|
88
|
+
# Run with base as cwd so ripgrep reports paths relative to it (like
|
|
89
|
+
# Glob), instead of basenames that collide across subdirectories.
|
|
90
|
+
args += ["--", input.pattern]
|
|
91
|
+
|
|
92
|
+
_, stdout = await run_rg(args, cwd=str(base))
|
|
93
|
+
limit = DEFAULT_HEAD_LIMIT if input.head_limit is None else input.head_limit
|
|
94
|
+
offset = input.offset or 0
|
|
95
|
+
lines = [line for line in stdout.splitlines() if line]
|
|
96
|
+
if offset > 0:
|
|
97
|
+
lines = lines[offset:]
|
|
98
|
+
if limit > 0:
|
|
99
|
+
lines = lines[:limit]
|
|
100
|
+
|
|
101
|
+
if mode == "files_with_matches":
|
|
102
|
+
return GrepOutput(mode=mode, filenames=lines, content="\n".join(lines))
|
|
103
|
+
return GrepOutput(mode=mode, filenames=[], content="\n".join(lines))
|
|
104
|
+
|
|
105
|
+
def render_result(self, output: GrepOutput) -> str:
|
|
106
|
+
return output.content or "No matches found"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
from ..context import ToolContext
|
|
7
|
+
from ..tool import Tool, ToolError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReadInput(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
file_path: str = Field(description="The absolute path to the file to read")
|
|
13
|
+
offset: int | None = Field(default=None, ge=1, description="1-based start line")
|
|
14
|
+
limit: int | None = Field(default=None, ge=1, description="Number of lines to read")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ReadOutput:
|
|
19
|
+
content: str
|
|
20
|
+
start_line: int
|
|
21
|
+
num_lines: int
|
|
22
|
+
truncated: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Read(Tool[ReadInput, ReadOutput]):
|
|
26
|
+
name = "Read"
|
|
27
|
+
description = (
|
|
28
|
+
"Read a text file from the local filesystem. Lines are returned numbered."
|
|
29
|
+
)
|
|
30
|
+
Input = ReadInput
|
|
31
|
+
|
|
32
|
+
async def run(self, input: ReadInput, ctx: ToolContext) -> ReadOutput:
|
|
33
|
+
path = Path(input.file_path)
|
|
34
|
+
if not path.is_absolute():
|
|
35
|
+
path = ctx.cwd / path
|
|
36
|
+
if not path.exists():
|
|
37
|
+
raise ToolError(f"File does not exist: {input.file_path}")
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
raise ToolError(f"Not a regular file: {input.file_path}")
|
|
40
|
+
|
|
41
|
+
byte_budget = min(ctx.read_limits.max_bytes, ctx.read_limits.max_tokens * 4)
|
|
42
|
+
with path.open("rb") as fh:
|
|
43
|
+
data = fh.read(byte_budget + 1)
|
|
44
|
+
truncated = len(data) > byte_budget
|
|
45
|
+
if truncated:
|
|
46
|
+
data = data[:byte_budget]
|
|
47
|
+
|
|
48
|
+
text = data.decode("utf-8", errors="replace")
|
|
49
|
+
lines = text.split("\n")
|
|
50
|
+
if lines and lines[-1] == "":
|
|
51
|
+
lines = lines[:-1]
|
|
52
|
+
|
|
53
|
+
if len(lines) == 0:
|
|
54
|
+
return ReadOutput(
|
|
55
|
+
content="(file is empty)",
|
|
56
|
+
start_line=1,
|
|
57
|
+
num_lines=0,
|
|
58
|
+
truncated=truncated,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
start = input.offset if input.offset and input.offset > 0 else 1
|
|
62
|
+
if start > len(lines):
|
|
63
|
+
return ReadOutput(
|
|
64
|
+
content=f"(offset {start} is beyond end of file: {len(lines)} lines)",
|
|
65
|
+
start_line=start,
|
|
66
|
+
num_lines=0,
|
|
67
|
+
truncated=truncated,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
end = (
|
|
71
|
+
len(lines)
|
|
72
|
+
if input.limit is None
|
|
73
|
+
else min(len(lines), start - 1 + input.limit)
|
|
74
|
+
)
|
|
75
|
+
selected = lines[start - 1 : end]
|
|
76
|
+
numbered = "\n".join(f"{start + i}\t{line}" for i, line in enumerate(selected))
|
|
77
|
+
return ReadOutput(
|
|
78
|
+
content=numbered,
|
|
79
|
+
start_line=start,
|
|
80
|
+
num_lines=len(selected),
|
|
81
|
+
truncated=truncated,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def render_result(self, output: ReadOutput) -> str:
|
|
85
|
+
content = output.content
|
|
86
|
+
if output.truncated:
|
|
87
|
+
content += "\n(file truncated: exceeded max_bytes)"
|
|
88
|
+
return content
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from ..context import ToolContext
|
|
8
|
+
from ..tool import Tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WriteInput(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid")
|
|
13
|
+
file_path: str = Field(description="The absolute path to the file to write")
|
|
14
|
+
content: str = Field(description="The content to write to the file")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class WriteOutput:
|
|
19
|
+
kind: Literal["create", "update"]
|
|
20
|
+
file_path: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Write(Tool[WriteInput, WriteOutput]):
|
|
24
|
+
name = "Write"
|
|
25
|
+
description = "Write a file to the local filesystem, overwriting if it exists."
|
|
26
|
+
Input = WriteInput
|
|
27
|
+
|
|
28
|
+
async def run(self, input: WriteInput, ctx: ToolContext) -> WriteOutput:
|
|
29
|
+
path = Path(input.file_path)
|
|
30
|
+
if not path.is_absolute():
|
|
31
|
+
path = ctx.cwd / path
|
|
32
|
+
kind: Literal["create", "update"] = "update" if path.exists() else "create"
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
normalized = input.content.replace("\r\n", "\n").replace("\r", "\n")
|
|
35
|
+
path.write_text(normalized, encoding="utf-8", newline="\n")
|
|
36
|
+
return WriteOutput(kind=kind, file_path=str(path))
|
|
37
|
+
|
|
38
|
+
def render_result(self, output: WriteOutput) -> str:
|
|
39
|
+
verb = "created successfully at:" if output.kind == "create" else "updated:"
|
|
40
|
+
return f"File {verb} {output.file_path}"
|