Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,4 @@ __marimo__/

DEVELOPMENT_WORKFLOW.md
docs/
toolcalling.txt
108 changes: 108 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
- **Seamless model switching** — capabilities update instantly when you switch models mid-conversation
- **Chain-of-thought reasoning** for models that support it (e.g. `qwen3`, `deepseek-r1`, `deepseek-v3.1`, `gpt-oss`)
- **Tool calling** and a full agent loop for multi-step model actions
- **Custom coding tools** (`read`, `grep`, `glob`, `ls`, `write`, `edit`, `multiedit`, `apply_patch`, `bash`, `batch`, planning/todo/task tools, and more)
- **Web search** via Ollama's built-in tools (requires an Ollama API key)
- **Vision / image attachments** for vision-capable models (e.g. `gemma3`, `llava`)
- **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
Expand Down Expand Up @@ -278,6 +279,20 @@ enabled = false
directory = "~/.local/state/ollamaterm/conversations"
metadata_path = "~/.local/state/ollamaterm/conversations/index.json"

[tools]
# Enable schema-first custom coding tools
enabled = true
# Base root for file/search/edit tools
workspace_root = "."
# Allow temporary external roots via external-directory tool
allow_external_directories = false
command_timeout_seconds = 30
max_output_lines = 200
max_output_bytes = 50000
max_read_bytes = 200000
max_search_results = 200
default_external_directories = []

[capabilities]
# Show the model's reasoning trace inside the assistant bubble.
# Thinking support itself is auto-detected — this controls only the UI display.
Expand Down Expand Up @@ -346,6 +361,99 @@ The agent loop allows the model to invoke tools multiple times before producing
a final answer. Control the upper bound with `max_tool_iterations` in
`[capabilities]`.

In addition to Ollama web tools, OllamaTerm now ships a schema-first local
coding toolset designed for agentic workflows:

- File and search tools: `read`, `ls`, `glob`, `grep`, `codesearch`
- Editing tools: `write`, `edit`, `multiedit`, `apply_patch`
- Runtime tools: `bash`, `batch`, `external-directory`
- Planning/state tools: `plan-enter`, `plan-exit`, `plan`, `todo`, `todoread`, `todowrite`, `task`, `question`
- Introspection tools: `registry`, `tool`, `truncation`, `invalid`

These tools are controlled by the `[tools]` config section and are constrained
by workspace-root path checks, command timeouts, and output truncation limits.

#### Function tools with Ollama (alpha/experimental)

OllamaTerm passes tools to the Ollama Python SDK in two forms:

- JSON function tools generated from the schema-first tool specs (the majority of tools below)
- Python callables for built-in Ollama integrations when enabled (e.g. `web_search`, `web_fetch`)

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.

> 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.

##### Available tools (names and key parameters)

- Files & search
- `list` (built-in) — List files and directories.
- `path?: string` (default: workspace root)
- `ls` (custom) — Alternate directory listing with tree-style output.
- `path?: string`, `ignore?: string[]`
- `read` — Read a file window.
- `path: string`, `offset?: int`, `limit?: int`
- `glob` — Find files by glob.
- `pattern: string`, `path?: string`, `max_results?: int`
- `grep` / `codesearch` — Search file contents.
- `query: string`, `path?: string`, `case_sensitive?: bool`, `fixed_strings?: bool`, `max_results?: int`

- Editing
- `write` — Atomic full-file write.
- `path: string`, `content: string`, `overwrite?: bool`, `create_dirs?: bool`
- `edit` — Single snippet replace.
- `path: string`, `old_text: string`, `new_text: string`, `replace_all?: bool`
- `multiedit` — Multiple snippet edits atomically.
- `path: string`, `edits: { old_text, new_text, replace_all? }[]`
- `apply_patch` — Apply structured patch hunks.
- `path: string`, `hunks: { old_text, new_text, replace_all? }[]`

- Runtime
- `bash` — Run a shell command (capped by time/output limits).
- `command: string`, `cwd?: string`
- `batch` — Run a sequence of tool calls.
- `calls: { name: string, arguments: object }[]`, `continue_on_error?: bool`
- `external-directory` — Manage temporary external directory allowlist for this session.
- `action: string`, `path?: string`

- Planning & state
- `plan-enter` | `plan-exit` | `plan`
- `plan-enter: { goal?: string }`
- `plan: { action?: string, content?: string }`
- `todo` | `todoread` | `todowrite` | `task`
- `todo: { item: string }`
- `todowrite: { items: string[], mode?: "append"|"replace" }`
- `task: { action?: string, name?: string, status?: string }`
- `question` — Emit a structured clarification question.
- `prompt: string`, `context?: string`

- Introspection & utility
- `registry` — List available tools.
- `tool` — Inspect a tool definition.
- `truncation` — Show output truncation limits.
- `invalid` — Always fails (for error-path testing).

- Web (requires tool-capable model; `web_search_enabled = true` and an API key)
- `websearch` — Perform a web search via Ollama integration.
- `query: string`, `max_results?: int`
- `webfetch` — Fetch a URL via Ollama integration.
- `url: string`

Notes:

- 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.
- File and command tools will prompt for permission. Paths are restricted to the configured workspace by default.
- Large outputs are truncated. Use `offset`/`limit` (for `read`) and `max_results` (for `grep`/`glob`) to scope results.

##### Quick examples

```text
List files here → Call tool: list { "path": "." }
Search for a string → Call tool: grep { "query": "TODO", "path": "." }
Read a file window → Call tool: read { "path": "src/main.py", "offset": 1, "limit": 120 }
Make an edit → Call tool: edit { "path": "README.md", "old_text": "foo", "new_text": "bar", "replace_all": true }
```

### Web search

Set `web_search_enabled = true` in `[capabilities]` and provide an Ollama API
Expand Down
16 changes: 16 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ enabled = false
directory = "~/.local/state/ollamaterm/conversations"
metadata_path = "~/.local/state/ollamaterm/conversations/index.json"

[tools]
# Enable schema-first custom coding tools (read/write/search/edit/bash/plan/todo/etc.)
enabled = true
# Base root for relative paths in file/search/edit tools.
workspace_root = "."
# Allow adding temporary external roots via external-directory tool.
allow_external_directories = false
# Safety/runtime limits.
command_timeout_seconds = 30
max_output_lines = 200
max_output_bytes = 50000
max_read_bytes = 200000
max_search_results = 200
# Optional always-allowed external roots.
default_external_directories = []

[capabilities]
# Whether to render the model's reasoning trace in the assistant bubble.
# The trace is shown only when the active model supports thinking (auto-detected).
Expand Down
2 changes: 1 addition & 1 deletion src/ollama_chat/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from __future__ import annotations

import argparse
from collections.abc import Sequence
from importlib import metadata
from typing import Sequence

from .app import OllamaChatApp
from .config import ensure_config_dir
Expand Down
41 changes: 31 additions & 10 deletions src/ollama_chat/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable
from datetime import datetime
import inspect
import logging
import os
import sys
from pathlib import Path
import random
import shutil
import subprocess
from pathlib import Path
from collections.abc import Awaitable, Callable
import sys
from typing import Any
from urllib.parse import urlparse

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

from .capabilities import AttachmentState, CapabilityContext, SearchState
from .chat import CapabilityReport, OllamaChat, ChatSendOptions
from .chat import CapabilityReport, ChatSendOptions, OllamaChat
from .commands import parse_inline_directives
from .config import load_config
from .exceptions import (
Expand All @@ -46,11 +46,11 @@
from .state import ConnectionState, ConversationState, StateManager
from .stream_handler import StreamHandler
from .task_manager import TaskManager
from .tools import ToolRegistry, build_registry, ToolRegistryOptions
from .tooling import ToolRegistry, ToolRegistryOptions, ToolRuntimeOptions, build_registry
from .widgets.activity_bar import ActivityBar
from .widgets.conversation import ConversationView
from .widgets.input_box import InputBox
from .widgets.message import MessageBubble
from .widgets.activity_bar import ActivityBar
from .widgets.status_bar import StatusBar

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

# --- zenity ---
Expand All @@ -137,7 +137,7 @@ async def _open_native_file_dialog(
path = stdout.decode().strip()
if path:
return path
except (asyncio.TimeoutError, OSError):
except (TimeoutError, OSError):
pass

# --- kdialog ---
Expand All @@ -158,7 +158,7 @@ async def _open_native_file_dialog(
path = stdout.decode().strip()
if path:
return path
except (asyncio.TimeoutError, OSError):
except (TimeoutError, OSError):
pass

return None
Expand Down Expand Up @@ -520,13 +520,34 @@ def __init__(self) -> None:
# used is gated at call time by _effective_caps.tools_enabled. This
# ensures the registry is ready when the first tool-capable model loads.
try:
tools_cfg = self.config.get("tools", {})
options = (
ToolRegistryOptions(
web_search_api_key=(
self.capabilities.web_search_api_key
if self.capabilities.web_search_enabled
else None
)
),
enable_custom_tools=bool(tools_cfg.get("enabled", True)),
runtime_options=ToolRuntimeOptions(
enabled=bool(tools_cfg.get("enabled", True)),
workspace_root=str(tools_cfg.get("workspace_root", ".")),
allow_external_directories=bool(
tools_cfg.get("allow_external_directories", False)
),
command_timeout_seconds=int(
tools_cfg.get("command_timeout_seconds", 30)
),
max_output_lines=int(tools_cfg.get("max_output_lines", 200)),
max_output_bytes=int(tools_cfg.get("max_output_bytes", 50_000)),
max_read_bytes=int(tools_cfg.get("max_read_bytes", 200_000)),
max_search_results=int(tools_cfg.get("max_search_results", 200)),
default_external_directories=tuple(
str(item)
for item in tools_cfg.get("default_external_directories", [])
if str(item).strip()
),
),
)
)
self._tool_registry: ToolRegistry | None = build_registry(options)
Expand Down
75 changes: 72 additions & 3 deletions src/ollama_chat/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import asyncio
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
import logging
import inspect
import json
import logging
import re
from typing import Any, Literal

from .exceptions import (
Expand Down Expand Up @@ -201,7 +203,7 @@ async def list_models(self) -> list[str]:
names: list[str] = []
models: Any = None
if hasattr(response, "models"):
models = getattr(response, "models")
models = response.models
elif isinstance(response, dict):
models = response.get("models")
elif hasattr(response, "model_dump"):
Expand Down Expand Up @@ -308,7 +310,7 @@ async def show_model_capabilities(

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

@staticmethod
def _parse_inline_tool_call_from_content(
content: str, allowed_names: set[str]
) -> list[dict[str, Any]]:
"""Parse a tool call embedded as JSON in content code blocks.

Some models emit a JSON object like {"name": "ls", "arguments": {}}
instead of structured tool_calls. Convert it to a minimal tool_call
dict only when the name is in allowed_names.
"""
text = (content or "").strip()
if not text:
return []

# Prefer ```json code blocks if present.
match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", text, re.IGNORECASE)
candidate = match.group(1) if match else text

try:
parsed = json.loads(candidate)
except Exception:
return []

def _as_call(obj: dict[str, Any]) -> list[dict[str, Any]]:
if not isinstance(obj, dict):
return []
if isinstance(obj.get("function"), dict):
fn = obj["function"]
name = str(fn.get("name", ""))
if name and name in allowed_names:
args = fn.get("arguments", {})
if not isinstance(args, dict):
args = {}
return [{"function": {"name": name, "arguments": args}}]
return []
name = str(obj.get("name", ""))
if name and name in allowed_names:
args = obj.get("arguments", {})
if not isinstance(args, dict):
args = {}
return [{"function": {"name": name, "arguments": args}}]
return []

if isinstance(parsed, list):
for item in parsed:
calls = _as_call(item)
if calls:
return calls
return []
if isinstance(parsed, dict):
return _as_call(parsed)
return []

def _map_exception(self, exc: Exception) -> OllamaChatError:
if isinstance(exc, OllamaChatError):
return exc
Expand Down Expand Up @@ -504,6 +559,20 @@ async def _stream_once_with_capabilities(
yield ChatChunk(kind="content", text=content_text)

chunk_tool_calls = self._extract_chunk_tool_calls(chunk)

# If the model printed a JSON tool call in content (no structured field),
# parse it and treat it as a tool_call so the agent loop can proceed.
if not chunk_tool_calls and tools and content_text:
allowed: set[str] = set()
for t in tools:
if isinstance(t, dict):
fn = t.get("function", {})
if isinstance(fn, dict):
n = fn.get("name")
if isinstance(n, str) and n:
allowed.add(n)
for tc in self._parse_inline_tool_call_from_content(content_text, allowed):
chunk_tool_calls.append(tc)
for tc in chunk_tool_calls:
name, args, index = self._parse_tool_call(tc)
if name:
Expand Down
1 change: 1 addition & 0 deletions src/ollama_chat/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from dataclasses import dataclass
import os
import re

from .capabilities import CapabilityContext

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