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 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}"