gencode-agent 1.0.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.
- gencode_agent-1.0.0/PKG-INFO +41 -0
- gencode_agent-1.0.0/README.md +34 -0
- gencode_agent-1.0.0/pyproject.toml +17 -0
- gencode_agent-1.0.0/src/gencode/__init__.py +0 -0
- gencode_agent-1.0.0/src/gencode/__main__.py +3 -0
- gencode_agent-1.0.0/src/gencode/agent/__init__.py +0 -0
- gencode_agent-1.0.0/src/gencode/agent/loop.py +70 -0
- gencode_agent-1.0.0/src/gencode/main.py +117 -0
- gencode_agent-1.0.0/src/gencode/providers/__init__.py +17 -0
- gencode_agent-1.0.0/src/gencode/providers/ollama.py +142 -0
- gencode_agent-1.0.0/src/gencode/providers/openrouter.py +141 -0
- gencode_agent-1.0.0/src/gencode/tools/__init__.py +12 -0
- gencode_agent-1.0.0/src/gencode/tools/bash.py +93 -0
- gencode_agent-1.0.0/src/gencode/tools/find.py +67 -0
- gencode_agent-1.0.0/src/gencode/tools/fsutil.py +72 -0
- gencode_agent-1.0.0/src/gencode/tools/grep.py +68 -0
- gencode_agent-1.0.0/src/gencode/tools/ls.py +42 -0
- gencode_agent-1.0.0/src/gencode/tools/read.py +23 -0
- gencode_agent-1.0.0/src/gencode/tools/stat.py +53 -0
- gencode_agent-1.0.0/src/gencode/tools/write.py +32 -0
- gencode_agent-1.0.0/src/gencode/types.py +89 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: gencode-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A terminal coding agent (run `gencode`) that works in your current folder using Ollama or OpenRouter
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# gencode
|
|
9
|
+
|
|
10
|
+
A terminal coding agent. Open any folder, run `gencode`, and it reads, writes and runs commands in that folder
|
|
11
|
+
using either a local **Ollama** model or an **OpenRouter** hosted model.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
uv tool install gencode-agent # or: pipx install gencode-agent / pip install gencode-agent
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
cd my-project
|
|
23
|
+
gencode
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
1. Choose a provider: `ollama` or `openrouter`.
|
|
27
|
+
2. `ollama`: enter the model name (Ollama must be running locally on `http://localhost:11434`).
|
|
28
|
+
`openrouter`: enter your API key (hidden), then the model name (e.g. `openai/gpt-4o-mini`).
|
|
29
|
+
3. Type prompts at `gencode>`. After each task you get the prompt back. Type `exit` to quit.
|
|
30
|
+
|
|
31
|
+
Provider, key and model are kept only in memory for that terminal session; nothing is saved to disk.
|
|
32
|
+
Each new terminal asks again.
|
|
33
|
+
|
|
34
|
+
## Optional environment variables
|
|
35
|
+
|
|
36
|
+
- `OLLAMA_BASE_URL`: default `http://localhost:11434`
|
|
37
|
+
- `GENCODE_NUM_CTX`: Ollama context window, default `32768`
|
|
38
|
+
|
|
39
|
+
## Warning
|
|
40
|
+
|
|
41
|
+
The agent can run any shell command in the current folder without confirmation. Only use it in folders you can afford to change.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# gencode
|
|
2
|
+
|
|
3
|
+
A terminal coding agent. Open any folder, run `gencode`, and it reads, writes and runs commands in that folder
|
|
4
|
+
using either a local **Ollama** model or an **OpenRouter** hosted model.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
uv tool install gencode-agent # or: pipx install gencode-agent / pip install gencode-agent
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
cd my-project
|
|
16
|
+
gencode
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
1. Choose a provider: `ollama` or `openrouter`.
|
|
20
|
+
2. `ollama`: enter the model name (Ollama must be running locally on `http://localhost:11434`).
|
|
21
|
+
`openrouter`: enter your API key (hidden), then the model name (e.g. `openai/gpt-4o-mini`).
|
|
22
|
+
3. Type prompts at `gencode>`. After each task you get the prompt back. Type `exit` to quit.
|
|
23
|
+
|
|
24
|
+
Provider, key and model are kept only in memory for that terminal session; nothing is saved to disk.
|
|
25
|
+
Each new terminal asks again.
|
|
26
|
+
|
|
27
|
+
## Optional environment variables
|
|
28
|
+
|
|
29
|
+
- `OLLAMA_BASE_URL`: default `http://localhost:11434`
|
|
30
|
+
- `GENCODE_NUM_CTX`: Ollama context window, default `32768`
|
|
31
|
+
|
|
32
|
+
## Warning
|
|
33
|
+
|
|
34
|
+
The agent can run any shell command in the current folder without confirmation. Only use it in folders you can afford to change.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gencode-agent"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "A terminal coding agent (run `gencode`) that works in your current folder using Ollama or OpenRouter"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
gencode = "gencode.main:main"
|
|
11
|
+
|
|
12
|
+
[tool.uv.build-backend]
|
|
13
|
+
module-name = "gencode"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11.0,<0.12"]
|
|
17
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from ..types import (
|
|
7
|
+
AssistantMessage,
|
|
8
|
+
Done,
|
|
9
|
+
Message,
|
|
10
|
+
Provider,
|
|
11
|
+
TextDelta,
|
|
12
|
+
Tool,
|
|
13
|
+
ToolCallBlock,
|
|
14
|
+
ToolResultMessage,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# events handed to on_event: dicts with a "type" key
|
|
19
|
+
# {"type": "text", "delta": str}
|
|
20
|
+
# {"type": "tool_start", "call": ToolCallBlock}
|
|
21
|
+
# {"type": "tool_end", "call": ToolCallBlock, "result": str, "is_error": bool}
|
|
22
|
+
# {"type": "turn_end", "message": AssistantMessage}
|
|
23
|
+
@dataclass
|
|
24
|
+
class AgentOptions:
|
|
25
|
+
provider: Provider
|
|
26
|
+
model: str
|
|
27
|
+
tools: list[Tool]
|
|
28
|
+
messages: list[Message]
|
|
29
|
+
on_event: Callable[[dict], None]
|
|
30
|
+
system: str | None = None
|
|
31
|
+
max_turns: int = 20
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_agent(opts: AgentOptions) -> None:
|
|
35
|
+
for _ in range(opts.max_turns):
|
|
36
|
+
assistant: AssistantMessage | None = None
|
|
37
|
+
for event in opts.provider.stream(opts.messages, opts.model, opts.system, opts.tools):
|
|
38
|
+
if isinstance(event, TextDelta):
|
|
39
|
+
opts.on_event({"type": "text", "delta": event.delta})
|
|
40
|
+
elif isinstance(event, Done):
|
|
41
|
+
assistant = event.message
|
|
42
|
+
if assistant is None:
|
|
43
|
+
raise RuntimeError("stream ended without a done event")
|
|
44
|
+
|
|
45
|
+
opts.messages.append(assistant)
|
|
46
|
+
opts.on_event({"type": "turn_end", "message": assistant})
|
|
47
|
+
|
|
48
|
+
if assistant.stop_reason != "toolUse":
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
for call in assistant.content:
|
|
52
|
+
if not isinstance(call, ToolCallBlock):
|
|
53
|
+
continue
|
|
54
|
+
opts.on_event({"type": "tool_start", "call": call})
|
|
55
|
+
is_error = False
|
|
56
|
+
try:
|
|
57
|
+
if "__parse_error__" in call.arguments:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"tool arguments were not valid JSON: {call.arguments['__parse_error__']!r}"
|
|
60
|
+
)
|
|
61
|
+
tool = next((t for t in opts.tools if t.name == call.name), None)
|
|
62
|
+
if tool is None:
|
|
63
|
+
raise ValueError(f"unknown tool name {call.name}")
|
|
64
|
+
result = tool.execute(call.arguments)
|
|
65
|
+
except Exception as e: # tool errors go back to the model, not up the stack
|
|
66
|
+
result = f"Error: {e}"
|
|
67
|
+
is_error = True
|
|
68
|
+
opts.on_event({"type": "tool_end", "call": call, "result": result, "is_error": is_error})
|
|
69
|
+
opts.messages.append(ToolResultMessage(call.id, call.name, result, is_error))
|
|
70
|
+
raise RuntimeError(f"stopped after {opts.max_turns} turns")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .agent.loop import AgentOptions, run_agent
|
|
8
|
+
from .providers import PROVIDERS, get_provider
|
|
9
|
+
from .tools import tools
|
|
10
|
+
from .types import Message, Provider, UserMessage
|
|
11
|
+
|
|
12
|
+
SYSTEM_PROMPT = (
|
|
13
|
+
"You are a coding agent running in the user's current folder. "
|
|
14
|
+
"Use the tools to inspect and change files: `write` to create files, `read`, `ls`, `find` and `grep` to explore, "
|
|
15
|
+
"and `bash` to run commands (use `uv` for Python projects). "
|
|
16
|
+
"All paths are relative to the current folder. Look at the folder before you change anything, "
|
|
17
|
+
"make the files real by calling `write` instead of only printing code, and finish with a short summary of what you did."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
EXIT_WORDS = {"exit", "quit"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ask(label: str, secret: bool = False) -> str:
|
|
24
|
+
# keep asking until the user types something; Ctrl+C / Ctrl+D at a setup prompt leaves gencode
|
|
25
|
+
while True:
|
|
26
|
+
try:
|
|
27
|
+
value = (getpass.getpass(label) if secret else input(label)).strip()
|
|
28
|
+
except (EOFError, KeyboardInterrupt):
|
|
29
|
+
print()
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
if value.lower() in EXIT_WORDS:
|
|
32
|
+
sys.exit(0)
|
|
33
|
+
if value:
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setup() -> tuple[Provider, str]:
|
|
38
|
+
# nothing is saved: provider, key and model live only in this process, i.e. this terminal session
|
|
39
|
+
print("Select a provider:")
|
|
40
|
+
for i, name in enumerate(PROVIDERS, 1):
|
|
41
|
+
print(f" {i}. {name}")
|
|
42
|
+
while True:
|
|
43
|
+
choice = ask("Provider (name or number): ").lower()
|
|
44
|
+
if choice.isdigit() and 1 <= int(choice) <= len(PROVIDERS):
|
|
45
|
+
choice = PROVIDERS[int(choice) - 1]
|
|
46
|
+
if choice in PROVIDERS:
|
|
47
|
+
break
|
|
48
|
+
print(f" pick one of: {', '.join(PROVIDERS)}")
|
|
49
|
+
|
|
50
|
+
api_key = ask("OpenRouter API key (hidden): ", secret=True) if choice == "openrouter" else None
|
|
51
|
+
model = ask("Model name: ")
|
|
52
|
+
return get_provider(choice, api_key), model
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def make_on_event(provider: Provider, model: str):
|
|
56
|
+
def on_event(event: dict) -> None:
|
|
57
|
+
kind = event["type"]
|
|
58
|
+
if kind == "text":
|
|
59
|
+
print(event["delta"], end="", flush=True)
|
|
60
|
+
elif kind == "tool_start":
|
|
61
|
+
call = event["call"]
|
|
62
|
+
print(f"\n> {call.name} {call.arguments if call.name != 'write' else call.arguments.get('path', '')}", flush=True)
|
|
63
|
+
elif kind == "tool_end":
|
|
64
|
+
shown = event["result"] if event["is_error"] else f"{len(event['result'].splitlines())} lines"
|
|
65
|
+
print(f" {shown}", flush=True)
|
|
66
|
+
elif kind == "turn_end":
|
|
67
|
+
m = event["message"]
|
|
68
|
+
print(f"\n[{provider.name} | {model} | in={m.usage.input} out={m.usage.output} | {m.stop_reason}]", flush=True)
|
|
69
|
+
|
|
70
|
+
return on_event
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> None:
|
|
74
|
+
# keep Windows consoles from choking on non-ASCII model output
|
|
75
|
+
for stream in (sys.stdout, sys.stderr):
|
|
76
|
+
if hasattr(stream, "reconfigure"):
|
|
77
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
78
|
+
|
|
79
|
+
print(f"gencode - coding agent working in {os.getcwd()}\n(type 'exit' to quit)\n")
|
|
80
|
+
provider, model = setup()
|
|
81
|
+
print(f"\nUsing {provider.name}/{model}. Ask me to do something.\n")
|
|
82
|
+
|
|
83
|
+
messages: list[Message] = [] # kept across prompts so follow-ups have context
|
|
84
|
+
on_event = make_on_event(provider, model)
|
|
85
|
+
|
|
86
|
+
while True:
|
|
87
|
+
try:
|
|
88
|
+
prompt = input("gencode> ").strip()
|
|
89
|
+
except (EOFError, KeyboardInterrupt):
|
|
90
|
+
print()
|
|
91
|
+
break
|
|
92
|
+
if not prompt:
|
|
93
|
+
continue
|
|
94
|
+
if prompt.lower() in EXIT_WORDS:
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
messages.append(UserMessage(prompt))
|
|
98
|
+
try:
|
|
99
|
+
run_agent(
|
|
100
|
+
AgentOptions(
|
|
101
|
+
provider=provider,
|
|
102
|
+
model=model,
|
|
103
|
+
tools=tools,
|
|
104
|
+
messages=messages,
|
|
105
|
+
system=SYSTEM_PROMPT,
|
|
106
|
+
on_event=on_event,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
print("\n(interrupted)")
|
|
111
|
+
except Exception as e: # a failed task must not end the session
|
|
112
|
+
print(f"\nerror: {e}", file=sys.stderr)
|
|
113
|
+
print()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..types import Provider
|
|
4
|
+
from .ollama import create_ollama
|
|
5
|
+
from .openrouter import create_openrouter
|
|
6
|
+
|
|
7
|
+
PROVIDERS = ("ollama", "openrouter")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_provider(name: str, api_key: str | None = None) -> Provider:
|
|
11
|
+
if name == "ollama":
|
|
12
|
+
return create_ollama()
|
|
13
|
+
if name == "openrouter":
|
|
14
|
+
if not api_key:
|
|
15
|
+
raise ValueError("openrouter needs an API key")
|
|
16
|
+
return create_openrouter(api_key)
|
|
17
|
+
raise ValueError(f"unknown provider '{name}', available: {', '.join(PROVIDERS)}")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
AssistantMessage,
|
|
11
|
+
ContentBlock,
|
|
12
|
+
Done,
|
|
13
|
+
Message,
|
|
14
|
+
StopReason,
|
|
15
|
+
TextBlock,
|
|
16
|
+
TextDelta,
|
|
17
|
+
Tool,
|
|
18
|
+
ToolCallBlock,
|
|
19
|
+
Usage,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
23
|
+
DEFAULT_NUM_CTX = 32768 # override with GENCODE_NUM_CTX; Ollama's own default is only 4096
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# our messages -> Ollama native /api/chat format
|
|
27
|
+
def to_ollama(messages: list[Message]) -> list[dict[str, Any]]:
|
|
28
|
+
out: list[dict[str, Any]] = []
|
|
29
|
+
for m in messages:
|
|
30
|
+
if m.role == "user":
|
|
31
|
+
out.append({"role": "user", "content": m.content})
|
|
32
|
+
elif m.role == "assistant":
|
|
33
|
+
text = "".join(b.text for b in m.content if isinstance(b, TextBlock))
|
|
34
|
+
calls = [b for b in m.content if isinstance(b, ToolCallBlock)]
|
|
35
|
+
msg: dict[str, Any] = {"role": "assistant", "content": text}
|
|
36
|
+
if calls:
|
|
37
|
+
# unlike OpenAI, the native API takes arguments as an object, not a JSON string
|
|
38
|
+
msg["tool_calls"] = [
|
|
39
|
+
{"function": {"name": c.name, "arguments": c.arguments}} for c in calls
|
|
40
|
+
]
|
|
41
|
+
out.append(msg)
|
|
42
|
+
else: # toolResult -> its own message with role "tool"
|
|
43
|
+
out.append({"role": "tool", "tool_name": m.tool_name, "content": m.content})
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OllamaProvider:
|
|
48
|
+
name = "ollama"
|
|
49
|
+
|
|
50
|
+
def __init__(self, base_url: str, num_ctx: int) -> None:
|
|
51
|
+
# accept an old-style ".../v1" URL too; the native API lives at the root
|
|
52
|
+
base_url = base_url.rstrip("/")
|
|
53
|
+
if base_url.endswith("/v1"):
|
|
54
|
+
base_url = base_url[: -len("/v1")]
|
|
55
|
+
self.chat_url = f"{base_url}/api/chat"
|
|
56
|
+
self.num_ctx = num_ctx
|
|
57
|
+
|
|
58
|
+
def stream(
|
|
59
|
+
self,
|
|
60
|
+
messages: list[Message],
|
|
61
|
+
model: str,
|
|
62
|
+
system: str | None = None,
|
|
63
|
+
tools: list[Tool] | None = None,
|
|
64
|
+
) -> Iterator[Any]:
|
|
65
|
+
tools = tools or []
|
|
66
|
+
chat = to_ollama(messages)
|
|
67
|
+
if system:
|
|
68
|
+
chat = [{"role": "system", "content": system}, *chat]
|
|
69
|
+
|
|
70
|
+
body: dict[str, Any] = {
|
|
71
|
+
"model": model,
|
|
72
|
+
"messages": chat,
|
|
73
|
+
"stream": True,
|
|
74
|
+
"options": {"num_ctx": self.num_ctx},
|
|
75
|
+
}
|
|
76
|
+
if tools:
|
|
77
|
+
body["tools"] = [
|
|
78
|
+
{
|
|
79
|
+
"type": "function",
|
|
80
|
+
"function": {"name": t.name, "description": t.description, "parameters": t.parameters},
|
|
81
|
+
}
|
|
82
|
+
for t in tools
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
req = urllib.request.Request(
|
|
86
|
+
self.chat_url,
|
|
87
|
+
data=json.dumps(body).encode("utf-8"),
|
|
88
|
+
headers={"Content-Type": "application/json"},
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
resp = urllib.request.urlopen(req)
|
|
92
|
+
except urllib.error.HTTPError as e:
|
|
93
|
+
raise RuntimeError(f"Ollama error {e.code}: {e.read().decode('utf-8', 'replace')}") from e
|
|
94
|
+
|
|
95
|
+
text = ""
|
|
96
|
+
calls: list[ToolCallBlock] = []
|
|
97
|
+
usage = Usage()
|
|
98
|
+
stop_reason: StopReason = "stop"
|
|
99
|
+
|
|
100
|
+
# the response is NDJSON: one JSON object per line
|
|
101
|
+
with resp:
|
|
102
|
+
for line in resp:
|
|
103
|
+
if not line.strip():
|
|
104
|
+
continue
|
|
105
|
+
chunk = json.loads(line)
|
|
106
|
+
if "error" in chunk:
|
|
107
|
+
raise RuntimeError(f"Ollama error: {chunk['error']}")
|
|
108
|
+
msg = chunk.get("message") or {}
|
|
109
|
+
if msg.get("content"):
|
|
110
|
+
text += msg["content"]
|
|
111
|
+
yield TextDelta(msg["content"])
|
|
112
|
+
# tool calls arrive whole (arguments already parsed), not in pieces
|
|
113
|
+
for tc in msg.get("tool_calls") or []:
|
|
114
|
+
fn = tc.get("function") or {}
|
|
115
|
+
args = fn.get("arguments") or {}
|
|
116
|
+
if isinstance(args, str):
|
|
117
|
+
try:
|
|
118
|
+
args = json.loads(args) if args else {}
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
# small local models sometimes emit broken JSON; let the loop report it to the model
|
|
121
|
+
args = {"__parse_error__": args}
|
|
122
|
+
calls.append(
|
|
123
|
+
ToolCallBlock(id=tc.get("id") or f"call_{len(calls)}", name=fn.get("name", ""), arguments=args)
|
|
124
|
+
)
|
|
125
|
+
if chunk.get("done"):
|
|
126
|
+
if chunk.get("done_reason") == "length":
|
|
127
|
+
stop_reason = "length"
|
|
128
|
+
usage = Usage(chunk.get("prompt_eval_count", 0), chunk.get("eval_count", 0))
|
|
129
|
+
|
|
130
|
+
content: list[ContentBlock] = [TextBlock(text)] if text else []
|
|
131
|
+
content.extend(calls)
|
|
132
|
+
if calls:
|
|
133
|
+
stop_reason = "toolUse"
|
|
134
|
+
|
|
135
|
+
yield Done(AssistantMessage(content=content, usage=usage, stop_reason=stop_reason))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def create_ollama() -> OllamaProvider:
|
|
139
|
+
return OllamaProvider(
|
|
140
|
+
base_url=os.environ.get("OLLAMA_BASE_URL", DEFAULT_BASE_URL),
|
|
141
|
+
num_ctx=int(os.environ.get("GENCODE_NUM_CTX", DEFAULT_NUM_CTX)),
|
|
142
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
AssistantMessage,
|
|
11
|
+
ContentBlock,
|
|
12
|
+
Done,
|
|
13
|
+
Message,
|
|
14
|
+
StopReason,
|
|
15
|
+
TextBlock,
|
|
16
|
+
TextDelta,
|
|
17
|
+
Tool,
|
|
18
|
+
ToolCallBlock,
|
|
19
|
+
Usage,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# our messages -> OpenAI-style chat format (what OpenRouter speaks)
|
|
26
|
+
def to_openai(messages: list[Message]) -> list[dict[str, Any]]:
|
|
27
|
+
out: list[dict[str, Any]] = []
|
|
28
|
+
for m in messages:
|
|
29
|
+
if m.role == "user":
|
|
30
|
+
out.append({"role": "user", "content": m.content})
|
|
31
|
+
elif m.role == "assistant":
|
|
32
|
+
text = "".join(b.text for b in m.content if isinstance(b, TextBlock))
|
|
33
|
+
calls = [b for b in m.content if isinstance(b, ToolCallBlock)]
|
|
34
|
+
msg: dict[str, Any] = {"role": "assistant", "content": text or None}
|
|
35
|
+
if calls:
|
|
36
|
+
# OpenAI wants arguments as a JSON string
|
|
37
|
+
msg["tool_calls"] = [
|
|
38
|
+
{"id": c.id, "type": "function", "function": {"name": c.name, "arguments": json.dumps(c.arguments)}}
|
|
39
|
+
for c in calls
|
|
40
|
+
]
|
|
41
|
+
out.append(msg)
|
|
42
|
+
else: # toolResult -> role "tool", linked by tool_call_id
|
|
43
|
+
out.append({"role": "tool", "tool_call_id": m.tool_call_id, "content": m.content})
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OpenRouterProvider:
|
|
48
|
+
name = "openrouter"
|
|
49
|
+
|
|
50
|
+
def __init__(self, base_url: str, api_key: str) -> None:
|
|
51
|
+
self.chat_url = f"{base_url.rstrip('/')}/chat/completions"
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
|
|
54
|
+
def stream(
|
|
55
|
+
self,
|
|
56
|
+
messages: list[Message],
|
|
57
|
+
model: str,
|
|
58
|
+
system: str | None = None,
|
|
59
|
+
tools: list[Tool] | None = None,
|
|
60
|
+
) -> Iterator[Any]:
|
|
61
|
+
tools = tools or []
|
|
62
|
+
chat = to_openai(messages)
|
|
63
|
+
if system:
|
|
64
|
+
chat = [{"role": "system", "content": system}, *chat]
|
|
65
|
+
|
|
66
|
+
body: dict[str, Any] = {"model": model, "messages": chat, "stream": True}
|
|
67
|
+
if tools:
|
|
68
|
+
body["tools"] = [
|
|
69
|
+
{
|
|
70
|
+
"type": "function",
|
|
71
|
+
"function": {"name": t.name, "description": t.description, "parameters": t.parameters},
|
|
72
|
+
}
|
|
73
|
+
for t in tools
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
req = urllib.request.Request(
|
|
77
|
+
self.chat_url,
|
|
78
|
+
data=json.dumps(body).encode("utf-8"),
|
|
79
|
+
headers={
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
82
|
+
"X-Title": "gencode",
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
resp = urllib.request.urlopen(req)
|
|
87
|
+
except urllib.error.HTTPError as e:
|
|
88
|
+
raise RuntimeError(f"OpenRouter error {e.code}: {e.read().decode('utf-8', 'replace')}") from e
|
|
89
|
+
|
|
90
|
+
text = ""
|
|
91
|
+
partial: dict[int, dict[str, str]] = {} # tool call index -> pieces, arguments arrive in fragments
|
|
92
|
+
usage = Usage()
|
|
93
|
+
stop_reason: StopReason = "stop"
|
|
94
|
+
|
|
95
|
+
# the response is server-sent events: "data: {json}" lines, ending with "data: [DONE]"
|
|
96
|
+
with resp:
|
|
97
|
+
for raw in resp:
|
|
98
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
99
|
+
if not line.startswith("data:"):
|
|
100
|
+
continue # blank lines and ": OPENROUTER PROCESSING" keep-alives
|
|
101
|
+
payload = line[len("data:") :].strip()
|
|
102
|
+
if payload == "[DONE]":
|
|
103
|
+
break
|
|
104
|
+
chunk = json.loads(payload)
|
|
105
|
+
if "error" in chunk:
|
|
106
|
+
raise RuntimeError(f"OpenRouter error: {chunk['error']}")
|
|
107
|
+
if chunk.get("usage"):
|
|
108
|
+
usage = Usage(chunk["usage"].get("prompt_tokens", 0), chunk["usage"].get("completion_tokens", 0))
|
|
109
|
+
for choice in chunk.get("choices") or []:
|
|
110
|
+
delta = choice.get("delta") or {}
|
|
111
|
+
if delta.get("content"):
|
|
112
|
+
text += delta["content"]
|
|
113
|
+
yield TextDelta(delta["content"])
|
|
114
|
+
for tc in delta.get("tool_calls") or []:
|
|
115
|
+
slot = partial.setdefault(tc.get("index", 0), {"id": "", "name": "", "args": ""})
|
|
116
|
+
fn = tc.get("function") or {}
|
|
117
|
+
slot["id"] = tc.get("id") or slot["id"]
|
|
118
|
+
slot["name"] += fn.get("name") or ""
|
|
119
|
+
slot["args"] += fn.get("arguments") or ""
|
|
120
|
+
if choice.get("finish_reason") == "length":
|
|
121
|
+
stop_reason = "length"
|
|
122
|
+
|
|
123
|
+
calls: list[ToolCallBlock] = []
|
|
124
|
+
for i, slot in sorted(partial.items()):
|
|
125
|
+
try:
|
|
126
|
+
args = json.loads(slot["args"]) if slot["args"] else {}
|
|
127
|
+
except json.JSONDecodeError:
|
|
128
|
+
# let the loop report the broken JSON back to the model
|
|
129
|
+
args = {"__parse_error__": slot["args"]}
|
|
130
|
+
calls.append(ToolCallBlock(id=slot["id"] or f"call_{i}", name=slot["name"], arguments=args))
|
|
131
|
+
|
|
132
|
+
content: list[ContentBlock] = [TextBlock(text)] if text else []
|
|
133
|
+
content.extend(calls)
|
|
134
|
+
if calls:
|
|
135
|
+
stop_reason = "toolUse"
|
|
136
|
+
|
|
137
|
+
yield Done(AssistantMessage(content=content, usage=usage, stop_reason=stop_reason))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_openrouter(api_key: str) -> OpenRouterProvider:
|
|
141
|
+
return OpenRouterProvider(base_url=os.environ.get("OPENROUTER_BASE_URL", DEFAULT_BASE_URL), api_key=api_key)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..types import Tool
|
|
4
|
+
from .bash import bash_tool
|
|
5
|
+
from .find import find_tool
|
|
6
|
+
from .grep import grep_tool
|
|
7
|
+
from .ls import ls_tool
|
|
8
|
+
from .read import read_tool
|
|
9
|
+
from .stat import stat_tool
|
|
10
|
+
from .write import write_tool
|
|
11
|
+
|
|
12
|
+
tools: list[Tool] = [bash_tool, read_tool, write_tool, ls_tool, grep_tool, stat_tool, find_tool]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ..types import Tool
|
|
9
|
+
|
|
10
|
+
MAX_LINES = 2000
|
|
11
|
+
MAX_BYTES = 50 * 1024
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_bash() -> str:
|
|
15
|
+
"""Prefer Git Bash on Windows; plain 'bash' can be the WSL launcher, which sees another file system."""
|
|
16
|
+
if sys.platform != "win32":
|
|
17
|
+
return "bash"
|
|
18
|
+
candidates = [
|
|
19
|
+
os.path.join(os.environ.get("ProgramFiles", ""), "Git", "bin", "bash.exe"),
|
|
20
|
+
os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Git", "bin", "bash.exe"),
|
|
21
|
+
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Git", "bin", "bash.exe"),
|
|
22
|
+
]
|
|
23
|
+
for c in candidates:
|
|
24
|
+
if os.path.exists(c):
|
|
25
|
+
return c
|
|
26
|
+
for d in os.environ.get("PATH", "").split(os.pathsep):
|
|
27
|
+
if not d or d.rstrip("\\/").lower().endswith("system32"):
|
|
28
|
+
continue
|
|
29
|
+
c = os.path.join(d, "bash.exe")
|
|
30
|
+
if os.path.exists(c):
|
|
31
|
+
return c
|
|
32
|
+
return shutil.which("bash") or "bash"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def truncate_tail(text: str, max_lines: int = MAX_LINES, max_bytes: int = MAX_BYTES) -> str:
|
|
36
|
+
"""Keep the END of the output (errors/results are at the bottom) and say what was cut."""
|
|
37
|
+
lines = text.split("\n")
|
|
38
|
+
kept = lines[-max_lines:]
|
|
39
|
+
while len(kept) > 1 and len("\n".join(kept).encode()) > max_bytes:
|
|
40
|
+
kept.pop(0)
|
|
41
|
+
out = "\n".join(kept)
|
|
42
|
+
if len(out.encode()) > max_bytes:
|
|
43
|
+
out = out[-max_bytes:] # one giant line
|
|
44
|
+
if len(kept) == len(lines) and len(out) == len(text):
|
|
45
|
+
return text
|
|
46
|
+
return f"[truncated: showing the last {len(kept)} of {len(lines)} lines]\n{out}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_bash(command: str, timeout_s: float) -> str:
|
|
50
|
+
try:
|
|
51
|
+
proc = subprocess.Popen(
|
|
52
|
+
[find_bash(), "-c", command],
|
|
53
|
+
cwd=os.getcwd(),
|
|
54
|
+
stdout=subprocess.PIPE,
|
|
55
|
+
stderr=subprocess.STDOUT,
|
|
56
|
+
text=True,
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
errors="replace",
|
|
59
|
+
)
|
|
60
|
+
except OSError as e:
|
|
61
|
+
return f"failed to start bash: {e}"
|
|
62
|
+
try:
|
|
63
|
+
out, _ = proc.communicate(timeout=timeout_s)
|
|
64
|
+
return f"{out}\n[exit code: {proc.returncode}]"
|
|
65
|
+
except subprocess.TimeoutExpired:
|
|
66
|
+
# proc.kill() does not kill grandchildren on Windows; taskkill /T kills the whole tree
|
|
67
|
+
if sys.platform == "win32":
|
|
68
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
|
|
69
|
+
else:
|
|
70
|
+
proc.kill()
|
|
71
|
+
out, _ = proc.communicate()
|
|
72
|
+
return f"{out}\n[timed out after {timeout_s:g}s]"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _execute(args: dict) -> str:
|
|
76
|
+
timeout = args.get("timeout")
|
|
77
|
+
seconds = timeout if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) else 30
|
|
78
|
+
return truncate_tail(run_bash(str(args["command"]), seconds))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
bash_tool = Tool(
|
|
82
|
+
name="bash",
|
|
83
|
+
description="Run a bash command in the current folder. Returns stdout, stderr and the exit code.",
|
|
84
|
+
parameters={
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {
|
|
87
|
+
"command": {"type": "string", "description": "The bash command to run"},
|
|
88
|
+
"timeout": {"type": "number", "description": "Timeout in seconds (default 30)"},
|
|
89
|
+
},
|
|
90
|
+
"required": ["command"],
|
|
91
|
+
},
|
|
92
|
+
execute=_execute,
|
|
93
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ..types import Tool
|
|
6
|
+
from .fsutil import glob_to_regex, resolve_path, walk
|
|
7
|
+
|
|
8
|
+
LIMIT = 2000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _execute(args: dict) -> str:
|
|
12
|
+
name = str(args["name"]) if args.get("name") else None
|
|
13
|
+
glob_pattern = str(args["glob"]) if args.get("glob") else None
|
|
14
|
+
regex_pattern = str(args["regex"]) if args.get("regex") else None
|
|
15
|
+
type_filter = str(args["type"]) if args.get("type") else None
|
|
16
|
+
|
|
17
|
+
if not (name or glob_pattern or regex_pattern):
|
|
18
|
+
return "error: provide at least one of 'name', 'glob', or 'regex'"
|
|
19
|
+
|
|
20
|
+
glob_re = glob_to_regex(glob_pattern) if glob_pattern else None
|
|
21
|
+
regex_re = None
|
|
22
|
+
if regex_pattern:
|
|
23
|
+
try:
|
|
24
|
+
regex_re = re.compile(regex_pattern)
|
|
25
|
+
except re.error as e:
|
|
26
|
+
return f"invalid regex: {e}"
|
|
27
|
+
|
|
28
|
+
root = resolve_path(args.get("path") or ".")
|
|
29
|
+
results: list[str] = []
|
|
30
|
+
for entry in walk(root, include_hidden=bool(args.get("includeHidden"))):
|
|
31
|
+
if type_filter == "file" and entry.is_directory:
|
|
32
|
+
continue
|
|
33
|
+
if type_filter == "dir" and not entry.is_directory:
|
|
34
|
+
continue
|
|
35
|
+
base = entry.rel_path.split("/")[-1]
|
|
36
|
+
if name and name.lower() not in base.lower():
|
|
37
|
+
continue
|
|
38
|
+
if glob_re and not glob_re.match(entry.rel_path):
|
|
39
|
+
continue
|
|
40
|
+
if regex_re and not regex_re.search(entry.rel_path):
|
|
41
|
+
continue
|
|
42
|
+
results.append(entry.rel_path + "/" if entry.is_directory else entry.rel_path)
|
|
43
|
+
if len(results) >= LIMIT:
|
|
44
|
+
results.append(f"[truncated at {LIMIT} results]")
|
|
45
|
+
break
|
|
46
|
+
return "\n".join(results) if results else "(no matches)"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
find_tool = Tool(
|
|
50
|
+
name="find",
|
|
51
|
+
description=(
|
|
52
|
+
"Find files/directories by name, matching a substring, glob pattern (e.g. '**/*.test.py') "
|
|
53
|
+
"or regex, recursively under a path."
|
|
54
|
+
),
|
|
55
|
+
parameters={
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"name": {"type": "string", "description": "Substring to match against file/directory names"},
|
|
59
|
+
"glob": {"type": "string", "description": "Glob pattern to match against the relative path, e.g. '**/*.py'"},
|
|
60
|
+
"regex": {"type": "string", "description": "Regex to match against the relative path"},
|
|
61
|
+
"path": {"type": "string", "description": "Directory to search under, relative to the current folder (default '.')"},
|
|
62
|
+
"type": {"type": "string", "description": "Filter by 'file' or 'dir' (default: both)"},
|
|
63
|
+
"includeHidden": {"type": "boolean", "description": "Include dotfiles/dot-directories (default false)"},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
execute=_execute,
|
|
67
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
DEFAULT_IGNORES = [".git", "node_modules", "dist", "build", ".next", "coverage", ".venv", "__pycache__"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
12
|
+
"""Turn a glob (supports *, ?, **) into a regex for relative, forward-slash paths."""
|
|
13
|
+
p = pattern.replace("\\", "/")
|
|
14
|
+
out = ""
|
|
15
|
+
i = 0
|
|
16
|
+
while i < len(p):
|
|
17
|
+
c = p[i]
|
|
18
|
+
if c == "*":
|
|
19
|
+
if p[i + 1 : i + 2] == "*":
|
|
20
|
+
# "**" matches across path segments (including none)
|
|
21
|
+
i += 1
|
|
22
|
+
if p[i + 1 : i + 2] == "/":
|
|
23
|
+
i += 1
|
|
24
|
+
out += "(?:.*/)?"
|
|
25
|
+
else:
|
|
26
|
+
out += "[^/]*"
|
|
27
|
+
elif c == "?":
|
|
28
|
+
out += "[^/]"
|
|
29
|
+
else:
|
|
30
|
+
out += re.escape(c)
|
|
31
|
+
i += 1
|
|
32
|
+
return re.compile(f"^{out}$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class WalkEntry:
|
|
37
|
+
abs_path: str
|
|
38
|
+
rel_path: str
|
|
39
|
+
is_directory: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def walk(
|
|
43
|
+
root: str,
|
|
44
|
+
max_depth: float = float("inf"),
|
|
45
|
+
include_hidden: bool = False,
|
|
46
|
+
ignores: list[str] | None = None,
|
|
47
|
+
) -> Iterator[WalkEntry]:
|
|
48
|
+
"""Depth-first walk yielding files and directories, sorted by name."""
|
|
49
|
+
ignores = DEFAULT_IGNORES if ignores is None else ignores
|
|
50
|
+
|
|
51
|
+
def recurse(directory: str, depth: int) -> Iterator[WalkEntry]:
|
|
52
|
+
try:
|
|
53
|
+
entries = sorted(os.scandir(directory), key=lambda e: e.name.lower())
|
|
54
|
+
except OSError:
|
|
55
|
+
return
|
|
56
|
+
for entry in entries:
|
|
57
|
+
if entry.name in ignores:
|
|
58
|
+
continue
|
|
59
|
+
if not include_hidden and entry.name.startswith("."):
|
|
60
|
+
continue
|
|
61
|
+
abs_path = os.path.join(directory, entry.name)
|
|
62
|
+
rel_path = os.path.relpath(abs_path, root).replace(os.sep, "/")
|
|
63
|
+
is_dir = entry.is_dir()
|
|
64
|
+
yield WalkEntry(abs_path, rel_path, is_dir)
|
|
65
|
+
if is_dir and depth < max_depth:
|
|
66
|
+
yield from recurse(abs_path, depth + 1)
|
|
67
|
+
|
|
68
|
+
yield from recurse(root, 1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_path(p: object) -> str:
|
|
72
|
+
return os.path.abspath(os.path.join(os.getcwd(), str(p)))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ..types import Tool
|
|
7
|
+
from .fsutil import glob_to_regex, resolve_path, walk
|
|
8
|
+
|
|
9
|
+
MAX_MATCHES = 500
|
|
10
|
+
MAX_FILE_BYTES = 5 * 1024 * 1024 # skip files bigger than 5MB
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _execute(args: dict) -> str:
|
|
14
|
+
root = resolve_path(args.get("path") or ".")
|
|
15
|
+
max_matches = args["maxMatches"] if isinstance(args.get("maxMatches"), (int, float)) else MAX_MATCHES
|
|
16
|
+
glob_re = glob_to_regex(str(args["glob"])) if args.get("glob") else None
|
|
17
|
+
try:
|
|
18
|
+
regex = re.compile(str(args["pattern"]), re.IGNORECASE if args.get("ignoreCase") else 0)
|
|
19
|
+
except re.error as e:
|
|
20
|
+
return f"invalid regex: {e}"
|
|
21
|
+
|
|
22
|
+
# a single file is searched directly
|
|
23
|
+
if os.path.isfile(root):
|
|
24
|
+
entries = [(root, os.path.basename(root))]
|
|
25
|
+
else:
|
|
26
|
+
entries = ((e.abs_path, e.rel_path) for e in walk(root) if not e.is_directory)
|
|
27
|
+
|
|
28
|
+
results: list[str] = []
|
|
29
|
+
for abs_path, rel_path in entries:
|
|
30
|
+
if glob_re and not glob_re.match(rel_path):
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
if os.path.getsize(abs_path) > MAX_FILE_BYTES:
|
|
34
|
+
continue
|
|
35
|
+
with open(abs_path, "rb") as f:
|
|
36
|
+
data = f.read()
|
|
37
|
+
except OSError:
|
|
38
|
+
continue
|
|
39
|
+
if b"\x00" in data: # skip binary files
|
|
40
|
+
continue
|
|
41
|
+
for i, line in enumerate(data.decode("utf-8", errors="replace").split("\n"), start=1):
|
|
42
|
+
if regex.search(line):
|
|
43
|
+
results.append(f"{rel_path}:{i}:{line.rstrip(chr(13))}")
|
|
44
|
+
if len(results) >= max_matches:
|
|
45
|
+
results.append(f"[truncated at {int(max_matches)} matches]")
|
|
46
|
+
return "\n".join(results)
|
|
47
|
+
return "\n".join(results) if results else "(no matches)"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
grep_tool = Tool(
|
|
51
|
+
name="grep",
|
|
52
|
+
description=(
|
|
53
|
+
"Search for a regex pattern inside files under a directory (recursively). "
|
|
54
|
+
"Returns matching file:line:text, like grep -rn."
|
|
55
|
+
),
|
|
56
|
+
parameters={
|
|
57
|
+
"type": "object",
|
|
58
|
+
"properties": {
|
|
59
|
+
"pattern": {"type": "string", "description": "Regex pattern to search for"},
|
|
60
|
+
"path": {"type": "string", "description": "Directory or file to search in, relative to the current folder (default '.')"},
|
|
61
|
+
"glob": {"type": "string", "description": "Only search files whose relative path matches this glob, e.g. '**/*.py'"},
|
|
62
|
+
"ignoreCase": {"type": "boolean", "description": "Case-insensitive search (default false)"},
|
|
63
|
+
"maxMatches": {"type": "number", "description": f"Max number of matches to return (default {MAX_MATCHES})"},
|
|
64
|
+
},
|
|
65
|
+
"required": ["pattern"],
|
|
66
|
+
},
|
|
67
|
+
execute=_execute,
|
|
68
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..types import Tool
|
|
4
|
+
from .fsutil import resolve_path, walk
|
|
5
|
+
|
|
6
|
+
LIMIT = 5000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _execute(args: dict) -> str:
|
|
10
|
+
root = resolve_path(args.get("path") or ".")
|
|
11
|
+
max_depth = args["maxDepth"] if isinstance(args.get("maxDepth"), (int, float)) else float("inf")
|
|
12
|
+
include_hidden = bool(args.get("includeHidden"))
|
|
13
|
+
dirs_only = bool(args.get("dirsOnly"))
|
|
14
|
+
|
|
15
|
+
lines: list[str] = []
|
|
16
|
+
for entry in walk(root, max_depth=max_depth, include_hidden=include_hidden):
|
|
17
|
+
if dirs_only and not entry.is_directory:
|
|
18
|
+
continue
|
|
19
|
+
lines.append(entry.rel_path + "/" if entry.is_directory else entry.rel_path)
|
|
20
|
+
if len(lines) >= LIMIT:
|
|
21
|
+
lines.append(f"[truncated at {LIMIT} entries]")
|
|
22
|
+
break
|
|
23
|
+
return "\n".join(lines) if lines else "(empty)"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ls_tool = Tool(
|
|
27
|
+
name="ls",
|
|
28
|
+
description=(
|
|
29
|
+
"List files and directories recursively under a path. "
|
|
30
|
+
"Skips common noise dirs (.git, node_modules, dist, build, .venv) and hidden files by default."
|
|
31
|
+
),
|
|
32
|
+
parameters={
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"path": {"type": "string", "description": "Directory to list, relative to the current folder (default '.')"},
|
|
36
|
+
"maxDepth": {"type": "number", "description": "How many levels deep to recurse (default: unlimited)"},
|
|
37
|
+
"includeHidden": {"type": "boolean", "description": "Include dotfiles/dot-directories (default false)"},
|
|
38
|
+
"dirsOnly": {"type": "boolean", "description": "Only list directories (default false)"},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
execute=_execute,
|
|
42
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..types import Tool
|
|
4
|
+
from .fsutil import resolve_path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _execute(args: dict) -> str:
|
|
8
|
+
with open(resolve_path(args["path"]), encoding="utf-8", errors="replace") as f:
|
|
9
|
+
return f.read()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
read_tool = Tool(
|
|
13
|
+
name="read",
|
|
14
|
+
description="Read a text file and returns its contents",
|
|
15
|
+
parameters={
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"path": {"type": "string", "description": "Path to the file, relative to the current folder"},
|
|
19
|
+
},
|
|
20
|
+
"required": ["path"],
|
|
21
|
+
},
|
|
22
|
+
execute=_execute,
|
|
23
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat as stat_mod
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from ..types import Tool
|
|
8
|
+
from .fsutil import resolve_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_bytes(n: float) -> str:
|
|
12
|
+
if n < 1024:
|
|
13
|
+
return f"{int(n)} B"
|
|
14
|
+
for unit in ("KB", "MB", "GB", "TB"):
|
|
15
|
+
n /= 1024
|
|
16
|
+
if n < 1024 or unit == "TB":
|
|
17
|
+
return f"{n:.2f} {unit}"
|
|
18
|
+
return f"{n:.2f} TB"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iso(ts: float) -> str:
|
|
22
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _execute(args: dict) -> str:
|
|
26
|
+
target = resolve_path(args["path"])
|
|
27
|
+
s = os.stat(target)
|
|
28
|
+
kind = "directory" if stat_mod.S_ISDIR(s.st_mode) else "file" if stat_mod.S_ISREG(s.st_mode) else "other"
|
|
29
|
+
return "\n".join(
|
|
30
|
+
[
|
|
31
|
+
f"path: {target}",
|
|
32
|
+
f"type: {kind}",
|
|
33
|
+
f"size: {s.st_size} bytes ({format_bytes(s.st_size)})",
|
|
34
|
+
f"created: {_iso(s.st_ctime)}",
|
|
35
|
+
f"modified: {_iso(s.st_mtime)}",
|
|
36
|
+
f"accessed: {_iso(s.st_atime)}",
|
|
37
|
+
f"mode: {oct(s.st_mode & 0o777)[2:]}",
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
stat_tool = Tool(
|
|
43
|
+
name="stat",
|
|
44
|
+
description="Get metadata for a file or directory: size, type, created/modified times, permissions.",
|
|
45
|
+
parameters={
|
|
46
|
+
"type": "object",
|
|
47
|
+
"properties": {
|
|
48
|
+
"path": {"type": "string", "description": "Path to the file or directory, relative to the current folder"},
|
|
49
|
+
},
|
|
50
|
+
"required": ["path"],
|
|
51
|
+
},
|
|
52
|
+
execute=_execute,
|
|
53
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from ..types import Tool
|
|
6
|
+
from .fsutil import resolve_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _execute(args: dict) -> str:
|
|
10
|
+
if "content" not in args:
|
|
11
|
+
raise ValueError("missing 'content'")
|
|
12
|
+
target = resolve_path(args["path"])
|
|
13
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
14
|
+
content = str(args["content"])
|
|
15
|
+
with open(target, "w", encoding="utf-8", newline="\n") as f:
|
|
16
|
+
f.write(content)
|
|
17
|
+
return f"wrote {len(content.encode())} bytes to {args['path']}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
write_tool = Tool(
|
|
21
|
+
name="write",
|
|
22
|
+
description="Create or overwrite a text file with the given content. Parent folders are created automatically.",
|
|
23
|
+
parameters={
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"path": {"type": "string", "description": "Path to the file, relative to the current folder"},
|
|
27
|
+
"content": {"type": "string", "description": "The full file content"},
|
|
28
|
+
},
|
|
29
|
+
"required": ["path", "content"],
|
|
30
|
+
},
|
|
31
|
+
execute=_execute,
|
|
32
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Callable, Iterator, Literal, Protocol, Union
|
|
5
|
+
|
|
6
|
+
StopReason = Literal["stop", "length", "toolUse"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Usage:
|
|
11
|
+
input: int = 0
|
|
12
|
+
output: int = 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TextBlock:
|
|
17
|
+
text: str
|
|
18
|
+
type: str = "text"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ToolCallBlock:
|
|
23
|
+
id: str
|
|
24
|
+
name: str
|
|
25
|
+
arguments: dict[str, Any]
|
|
26
|
+
type: str = "toolCall"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ContentBlock = Union[TextBlock, ToolCallBlock]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class UserMessage:
|
|
34
|
+
content: str
|
|
35
|
+
role: str = "user"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AssistantMessage:
|
|
40
|
+
content: list[ContentBlock]
|
|
41
|
+
usage: Usage
|
|
42
|
+
stop_reason: StopReason
|
|
43
|
+
role: str = "assistant"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ToolResultMessage:
|
|
48
|
+
tool_call_id: str
|
|
49
|
+
tool_name: str
|
|
50
|
+
content: str
|
|
51
|
+
is_error: bool
|
|
52
|
+
role: str = "toolResult"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Message = Union[UserMessage, AssistantMessage, ToolResultMessage]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Tool:
|
|
60
|
+
name: str
|
|
61
|
+
description: str
|
|
62
|
+
parameters: dict[str, Any]
|
|
63
|
+
execute: Callable[[dict[str, Any]], str]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# stream events
|
|
67
|
+
@dataclass
|
|
68
|
+
class TextDelta:
|
|
69
|
+
delta: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Done:
|
|
74
|
+
message: AssistantMessage
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
StreamEvent = Union[TextDelta, Done]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Provider(Protocol):
|
|
81
|
+
name: str
|
|
82
|
+
|
|
83
|
+
def stream(
|
|
84
|
+
self,
|
|
85
|
+
messages: list[Message],
|
|
86
|
+
model: str,
|
|
87
|
+
system: str | None = None,
|
|
88
|
+
tools: list[Tool] | None = None,
|
|
89
|
+
) -> Iterator[StreamEvent]: ...
|