Skip to content

Commit 00492bb

Browse files
authored
Merge pull request #2 from Web-Dev-Codi/refactor
Refactor
2 parents e095f71 + 8ab0c1b commit 00492bb

57 files changed

Lines changed: 4930 additions & 248 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
- **Seamless model switching** — capabilities update instantly when you switch models mid-conversation
106106
- **Chain-of-thought reasoning** for models that support it (e.g. `qwen3`, `deepseek-r1`, `deepseek-v3.1`, `gpt-oss`)
107107
- **Tool calling** and a full agent loop for multi-step model actions
108+
- **Custom coding tools** (`read`, `grep`, `glob`, `ls`, `write`, `edit`, `multiedit`, `apply_patch`, `bash`, `batch`, planning/todo/task tools, and more)
108109
- **Web search** via Ollama's built-in tools (requires an Ollama API key)
109110
- **Vision / image attachments** for vision-capable models (e.g. `gemma3`, `llava`)
110111
- **Context window alignment**`max_context_tokens` is forwarded to Ollama as `options.num_ctx` so the server-side context window always matches the client-side trim budget
@@ -278,6 +279,20 @@ enabled = false
278279
directory = "~/.local/state/ollamaterm/conversations"
279280
metadata_path = "~/.local/state/ollamaterm/conversations/index.json"
280281

282+
[tools]
283+
# Enable schema-first custom coding tools
284+
enabled = true
285+
# Base root for file/search/edit tools
286+
workspace_root = "."
287+
# Allow temporary external roots via external-directory tool
288+
allow_external_directories = false
289+
command_timeout_seconds = 30
290+
max_output_lines = 200
291+
max_output_bytes = 50000
292+
max_read_bytes = 200000
293+
max_search_results = 200
294+
default_external_directories = []
295+
281296
[capabilities]
282297
# Show the model's reasoning trace inside the assistant bubble.
283298
# Thinking support itself is auto-detected — this controls only the UI display.
@@ -346,6 +361,99 @@ The agent loop allows the model to invoke tools multiple times before producing
346361
a final answer. Control the upper bound with `max_tool_iterations` in
347362
`[capabilities]`.
348363

364+
In addition to Ollama web tools, OllamaTerm now ships a schema-first local
365+
coding toolset designed for agentic workflows:
366+
367+
- File and search tools: `read`, `ls`, `glob`, `grep`, `codesearch`
368+
- Editing tools: `write`, `edit`, `multiedit`, `apply_patch`
369+
- Runtime tools: `bash`, `batch`, `external-directory`
370+
- Planning/state tools: `plan-enter`, `plan-exit`, `plan`, `todo`, `todoread`, `todowrite`, `task`, `question`
371+
- Introspection tools: `registry`, `tool`, `truncation`, `invalid`
372+
373+
These tools are controlled by the `[tools]` config section and are constrained
374+
by workspace-root path checks, command timeouts, and output truncation limits.
375+
376+
#### Function tools with Ollama (alpha/experimental)
377+
378+
OllamaTerm passes tools to the Ollama Python SDK in two forms:
379+
380+
- JSON function tools generated from the schema-first tool specs (the majority of tools below)
381+
- Python callables for built-in Ollama integrations when enabled (e.g. `web_search`, `web_fetch`)
382+
383+
The model emits `tool_calls`, the app executes them, appends a `tool` role message with the result, and continues the loop until the assistant returns a final answer.
384+
385+
> Warning: This tool suite is experimental. Most tools are untested and may be buggy or missing edge-case handling. Use with caution and review changes carefully, especially file edits. Outputs may be truncated according to configured limits.
386+
387+
##### Available tools (names and key parameters)
388+
389+
- Files & search
390+
- `list` (built-in) — List files and directories.
391+
- `path?: string` (default: workspace root)
392+
- `ls` (custom) — Alternate directory listing with tree-style output.
393+
- `path?: string`, `ignore?: string[]`
394+
- `read` — Read a file window.
395+
- `path: string`, `offset?: int`, `limit?: int`
396+
- `glob` — Find files by glob.
397+
- `pattern: string`, `path?: string`, `max_results?: int`
398+
- `grep` / `codesearch` — Search file contents.
399+
- `query: string`, `path?: string`, `case_sensitive?: bool`, `fixed_strings?: bool`, `max_results?: int`
400+
401+
- Editing
402+
- `write` — Atomic full-file write.
403+
- `path: string`, `content: string`, `overwrite?: bool`, `create_dirs?: bool`
404+
- `edit` — Single snippet replace.
405+
- `path: string`, `old_text: string`, `new_text: string`, `replace_all?: bool`
406+
- `multiedit` — Multiple snippet edits atomically.
407+
- `path: string`, `edits: { old_text, new_text, replace_all? }[]`
408+
- `apply_patch` — Apply structured patch hunks.
409+
- `path: string`, `hunks: { old_text, new_text, replace_all? }[]`
410+
411+
- Runtime
412+
- `bash` — Run a shell command (capped by time/output limits).
413+
- `command: string`, `cwd?: string`
414+
- `batch` — Run a sequence of tool calls.
415+
- `calls: { name: string, arguments: object }[]`, `continue_on_error?: bool`
416+
- `external-directory` — Manage temporary external directory allowlist for this session.
417+
- `action: string`, `path?: string`
418+
419+
- Planning & state
420+
- `plan-enter` | `plan-exit` | `plan`
421+
- `plan-enter: { goal?: string }`
422+
- `plan: { action?: string, content?: string }`
423+
- `todo` | `todoread` | `todowrite` | `task`
424+
- `todo: { item: string }`
425+
- `todowrite: { items: string[], mode?: "append"|"replace" }`
426+
- `task: { action?: string, name?: string, status?: string }`
427+
- `question` — Emit a structured clarification question.
428+
- `prompt: string`, `context?: string`
429+
430+
- Introspection & utility
431+
- `registry` — List available tools.
432+
- `tool` — Inspect a tool definition.
433+
- `truncation` — Show output truncation limits.
434+
- `invalid` — Always fails (for error-path testing).
435+
436+
- Web (requires tool-capable model; `web_search_enabled = true` and an API key)
437+
- `websearch` — Perform a web search via Ollama integration.
438+
- `query: string`, `max_results?: int`
439+
- `webfetch` — Fetch a URL via Ollama integration.
440+
- `url: string`
441+
442+
Notes:
443+
444+
- Directory listing may appear as `list` (built-in) or `ls` (custom) depending on which tool set is active. Both list files; prefer `list` when available.
445+
- File and command tools will prompt for permission. Paths are restricted to the configured workspace by default.
446+
- Large outputs are truncated. Use `offset`/`limit` (for `read`) and `max_results` (for `grep`/`glob`) to scope results.
447+
448+
##### Quick examples
449+
450+
```text
451+
List files here → Call tool: list { "path": "." }
452+
Search for a string → Call tool: grep { "query": "TODO", "path": "." }
453+
Read a file window → Call tool: read { "path": "src/main.py", "offset": 1, "limit": 120 }
454+
Make an edit → Call tool: edit { "path": "README.md", "old_text": "foo", "new_text": "bar", "replace_all": true }
455+
```
456+
349457
### Web search
350458

351459
Set `web_search_enabled = true` in `[capabilities]` and provide an Ollama API

config.example.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ enabled = false
5151
directory = "~/.local/state/ollamaterm/conversations"
5252
metadata_path = "~/.local/state/ollamaterm/conversations/index.json"
5353

54+
[tools]
55+
# Enable schema-first custom coding tools (read/write/search/edit/bash/plan/todo/etc.)
56+
enabled = true
57+
# Base root for relative paths in file/search/edit tools.
58+
workspace_root = "."
59+
# Allow adding temporary external roots via external-directory tool.
60+
allow_external_directories = false
61+
# Safety/runtime limits.
62+
command_timeout_seconds = 30
63+
max_output_lines = 200
64+
max_output_bytes = 50000
65+
max_read_bytes = 200000
66+
max_search_results = 200
67+
# Optional always-allowed external roots.
68+
default_external_directories = []
69+
5470
[capabilities]
5571
# Whether to render the model's reasoning trace in the assistant bubble.
5672
# The trace is shown only when the active model supports thinking (auto-detected).

src/ollama_chat/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from __future__ import annotations
44

55
import argparse
6+
from collections.abc import Sequence
67
from importlib import metadata
7-
from typing import Sequence
88

99
from .app import OllamaChatApp
1010
from .config import ensure_config_dir

src/ollama_chat/app.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
from __future__ import annotations
44

55
import asyncio
6+
from collections.abc import Awaitable, Callable
67
from datetime import datetime
78
import inspect
89
import logging
910
import os
10-
import sys
11+
from pathlib import Path
1112
import random
1213
import shutil
1314
import subprocess
14-
from pathlib import Path
15-
from collections.abc import Awaitable, Callable
15+
import sys
1616
from typing import Any
1717
from urllib.parse import urlparse
1818

@@ -24,7 +24,7 @@
2424
from textual.widgets import Button, Footer, Header, Input, OptionList, Static
2525

2626
from .capabilities import AttachmentState, CapabilityContext, SearchState
27-
from .chat import CapabilityReport, OllamaChat, ChatSendOptions
27+
from .chat import CapabilityReport, ChatSendOptions, OllamaChat
2828
from .commands import parse_inline_directives
2929
from .config import load_config
3030
from .exceptions import (
@@ -46,11 +46,11 @@
4646
from .state import ConnectionState, ConversationState, StateManager
4747
from .stream_handler import StreamHandler
4848
from .task_manager import TaskManager
49-
from .tools import ToolRegistry, build_registry, ToolRegistryOptions
49+
from .tooling import ToolRegistry, ToolRegistryOptions, ToolRuntimeOptions, build_registry
50+
from .widgets.activity_bar import ActivityBar
5051
from .widgets.conversation import ConversationView
5152
from .widgets.input_box import InputBox
5253
from .widgets.message import MessageBubble
53-
from .widgets.activity_bar import ActivityBar
5454
from .widgets.status_bar import StatusBar
5555

5656
LOGGER = logging.getLogger(__name__)
@@ -116,7 +116,7 @@ async def _open_native_file_dialog(
116116
cleaned = token.strip("',()><[]")
117117
if cleaned.startswith("file://"):
118118
return urllib.parse.unquote(cleaned[len("file://") :])
119-
except (asyncio.TimeoutError, OSError):
119+
except (TimeoutError, OSError):
120120
pass
121121

122122
# --- zenity ---
@@ -137,7 +137,7 @@ async def _open_native_file_dialog(
137137
path = stdout.decode().strip()
138138
if path:
139139
return path
140-
except (asyncio.TimeoutError, OSError):
140+
except (TimeoutError, OSError):
141141
pass
142142

143143
# --- kdialog ---
@@ -158,7 +158,7 @@ async def _open_native_file_dialog(
158158
path = stdout.decode().strip()
159159
if path:
160160
return path
161-
except (asyncio.TimeoutError, OSError):
161+
except (TimeoutError, OSError):
162162
pass
163163

164164
return None
@@ -520,13 +520,34 @@ def __init__(self) -> None:
520520
# used is gated at call time by _effective_caps.tools_enabled. This
521521
# ensures the registry is ready when the first tool-capable model loads.
522522
try:
523+
tools_cfg = self.config.get("tools", {})
523524
options = (
524525
ToolRegistryOptions(
525526
web_search_api_key=(
526527
self.capabilities.web_search_api_key
527528
if self.capabilities.web_search_enabled
528529
else None
529-
)
530+
),
531+
enable_custom_tools=bool(tools_cfg.get("enabled", True)),
532+
runtime_options=ToolRuntimeOptions(
533+
enabled=bool(tools_cfg.get("enabled", True)),
534+
workspace_root=str(tools_cfg.get("workspace_root", ".")),
535+
allow_external_directories=bool(
536+
tools_cfg.get("allow_external_directories", False)
537+
),
538+
command_timeout_seconds=int(
539+
tools_cfg.get("command_timeout_seconds", 30)
540+
),
541+
max_output_lines=int(tools_cfg.get("max_output_lines", 200)),
542+
max_output_bytes=int(tools_cfg.get("max_output_bytes", 50_000)),
543+
max_read_bytes=int(tools_cfg.get("max_read_bytes", 200_000)),
544+
max_search_results=int(tools_cfg.get("max_search_results", 200)),
545+
default_external_directories=tuple(
546+
str(item)
547+
for item in tools_cfg.get("default_external_directories", [])
548+
if str(item).strip()
549+
),
550+
),
530551
)
531552
)
532553
self._tool_registry: ToolRegistry | None = build_registry(options)

src/ollama_chat/chat.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import asyncio
66
from collections.abc import AsyncGenerator
77
from dataclasses import dataclass, field
8-
import logging
98
import inspect
9+
import json
10+
import logging
11+
import re
1012
from typing import Any, Literal
1113

1214
from .exceptions import (
@@ -201,7 +203,7 @@ async def list_models(self) -> list[str]:
201203
names: list[str] = []
202204
models: Any = None
203205
if hasattr(response, "models"):
204-
models = getattr(response, "models")
206+
models = response.models
205207
elif isinstance(response, dict):
206208
models = response.get("models")
207209
elif hasattr(response, "model_dump"):
@@ -308,7 +310,7 @@ async def show_model_capabilities(
308310

309311
# SDK object path.
310312
if hasattr(response, "capabilities"):
311-
caps_raw = getattr(response, "capabilities")
313+
caps_raw = response.capabilities
312314
# Treat explicit None as unknown; anything else counts as present.
313315
if caps_raw is not None:
314316
caps_known = True
@@ -419,6 +421,59 @@ def _extract_chunk_tool_calls(cls, chunk: Any) -> list[Any]:
419421
value = cls._extract_from_chunk(chunk, "tool_calls")
420422
return value if isinstance(value, list) else []
421423

424+
@staticmethod
425+
def _parse_inline_tool_call_from_content(
426+
content: str, allowed_names: set[str]
427+
) -> list[dict[str, Any]]:
428+
"""Parse a tool call embedded as JSON in content code blocks.
429+
430+
Some models emit a JSON object like {"name": "ls", "arguments": {}}
431+
instead of structured tool_calls. Convert it to a minimal tool_call
432+
dict only when the name is in allowed_names.
433+
"""
434+
text = (content or "").strip()
435+
if not text:
436+
return []
437+
438+
# Prefer ```json code blocks if present.
439+
match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", text, re.IGNORECASE)
440+
candidate = match.group(1) if match else text
441+
442+
try:
443+
parsed = json.loads(candidate)
444+
except Exception:
445+
return []
446+
447+
def _as_call(obj: dict[str, Any]) -> list[dict[str, Any]]:
448+
if not isinstance(obj, dict):
449+
return []
450+
if isinstance(obj.get("function"), dict):
451+
fn = obj["function"]
452+
name = str(fn.get("name", ""))
453+
if name and name in allowed_names:
454+
args = fn.get("arguments", {})
455+
if not isinstance(args, dict):
456+
args = {}
457+
return [{"function": {"name": name, "arguments": args}}]
458+
return []
459+
name = str(obj.get("name", ""))
460+
if name and name in allowed_names:
461+
args = obj.get("arguments", {})
462+
if not isinstance(args, dict):
463+
args = {}
464+
return [{"function": {"name": name, "arguments": args}}]
465+
return []
466+
467+
if isinstance(parsed, list):
468+
for item in parsed:
469+
calls = _as_call(item)
470+
if calls:
471+
return calls
472+
return []
473+
if isinstance(parsed, dict):
474+
return _as_call(parsed)
475+
return []
476+
422477
def _map_exception(self, exc: Exception) -> OllamaChatError:
423478
if isinstance(exc, OllamaChatError):
424479
return exc
@@ -504,6 +559,20 @@ async def _stream_once_with_capabilities(
504559
yield ChatChunk(kind="content", text=content_text)
505560

506561
chunk_tool_calls = self._extract_chunk_tool_calls(chunk)
562+
563+
# If the model printed a JSON tool call in content (no structured field),
564+
# parse it and treat it as a tool_call so the agent loop can proceed.
565+
if not chunk_tool_calls and tools and content_text:
566+
allowed: set[str] = set()
567+
for t in tools:
568+
if isinstance(t, dict):
569+
fn = t.get("function", {})
570+
if isinstance(fn, dict):
571+
n = fn.get("name")
572+
if isinstance(n, str) and n:
573+
allowed.add(n)
574+
for tc in self._parse_inline_tool_call_from_content(content_text, allowed):
575+
chunk_tool_calls.append(tc)
507576
for tc in chunk_tool_calls:
508577
name, args, index = self._parse_tool_call(tc)
509578
if name:

src/ollama_chat/commands.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import dataclass
66
import os
77
import re
8+
89
from .capabilities import CapabilityContext
910

1011
_IMAGE_PREFIX_RE = re.compile(r"(?:^|\s)/image\s+(\S+)")

0 commit comments

Comments
 (0)