added local support for OLLAMA qwen2.5_code, and able to call basic harness tools

This commit is contained in:
Dowling_Wong 2026-04-05 02:16:31 +02:00
parent 1ceb077e40
commit 54adf9f2ba
34 changed files with 1489 additions and 2 deletions

View File

@ -0,0 +1,8 @@
{
"session_id": "08ddbda2cc60476f80da155e5eeddbc4",
"messages": [
"review MCP tool"
],
"input_tokens": 3,
"output_tokens": 13
}

View File

@ -0,0 +1,9 @@
{
"session_id": "0ed1f18234e14682a6d949cbfdd6ce3f",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "0f302419a56e48969cf34e994786f9be",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "1a63144d276847f6ab25199b8f53e91b",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "250be3d09a7a4440a8ea5f6401262a64",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "2b29247c302243999986b5c54e190182",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "2fa54297429e4e028409846e1f894702",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,8 @@
{
"session_id": "36e5e3d416554020806ef4206f66aa77",
"messages": [
"review MCP tool"
],
"input_tokens": 3,
"output_tokens": 13
}

View File

@ -0,0 +1,8 @@
{
"session_id": "39fe80c5372542a883e38c4d8c32c0ed",
"messages": [
"review MCP tool"
],
"input_tokens": 3,
"output_tokens": 13
}

View File

@ -0,0 +1,8 @@
{
"session_id": "3d0e8026689b4d7fbde88f9625ab1252",
"messages": [
"review MCP tool"
],
"input_tokens": 3,
"output_tokens": 13
}

View File

@ -0,0 +1,9 @@
{
"session_id": "3db5e35d58ee42de8ee8b31fde9bec41",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "42746973a4df45cd90ba9368d65bced1",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "42d0893f63694c0e8b36fced30febb7c",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,8 @@
{
"session_id": "440c5c06b3f845f094b3e752863ef9d6",
"messages": [
"review MCP tool"
],
"input_tokens": 3,
"output_tokens": 13
}

View File

@ -0,0 +1,9 @@
{
"session_id": "4c7176c1674d4c1490c7fab6a52884ca",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "697bb82bafcb48fb9aa1ba20eb1a1ed7",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "6a88bbb345ed4389b9305ab0ba72f92c",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "71e1b2dfb4844b4b8c257ff8b3bd520e",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "8dde908596f74b728c433eb018964cd0",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "9a88809c13b24f1d8922542110b0cb15",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,8 @@
{
"session_id": "a9370872b5874d5082c3042ecccf851f",
"messages": [
"review MCP tool"
],
"input_tokens": 3,
"output_tokens": 13
}

View File

@ -0,0 +1,9 @@
{
"session_id": "b83066e18a0a414886b142d1aef248ab",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "f22cc816662749c3a305a5a9fc76114f",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

View File

@ -0,0 +1,9 @@
{
"session_id": "f85a08b0f4254476a1245809cbee7bd3",
"messages": [
"review MCP tool",
"review MCP tool"
],
"input_tokens": 6,
"output_tokens": 32
}

21
AGENTS.md Normal file
View File

@ -0,0 +1,21 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Detected stack
- Languages: Rust.
- Frameworks: none detected from the supported starter markers.
## Verification
- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
## Repository shape
- `rust/` contains the Rust workspace and active CLI/runtime implementation.
- `src/` contains source files that should stay consistent with generated guidance and tests.
- `tests/` contains validation surfaces that should be reviewed alongside code changes.
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
- Keep shared defaults in `.Codex.json`; reserve `.Codex/settings.local.json` for machine-local overrides.
- Do not overwrite existing `AGENTS.md` content automatically; update it intentionally when repo workflows change.

331
OLLAMA_INTEGRATION.md Normal file
View File

@ -0,0 +1,331 @@
# Ollama Integration
This document describes the current local Ollama integration in the Python `claw-code` workspace, the features the harness uses today, and the commands to exercise them.
It is intended as a reference for future API cleanup, provider abstraction work, or deeper tool integrations.
## Scope
The current Ollama integration lives in the Python workspace:
- [`src/llm_backend.py`](./src/llm_backend.py): low-level `/api/chat` client
- [`src/task.py`](./src/task.py): one-shot local chat wrapper
- [`src/runtime_tools.py`](./src/runtime_tools.py): executable workspace tools
- [`src/agent_loop.py`](./src/agent_loop.py): multi-turn agent loop with tool use
- [`src/main.py`](./src/main.py): CLI entrypoints
## Default Configuration
- Host: `http://localhost:11434`
- Default model: `qwen2.5-coder:7b`
- Endpoint: `POST /api/chat`
- Transport: one non-streaming request per turn
## Ollama Features Currently Used
### 1. Chat messages
The harness sends standard Ollama chat messages:
```json
{
"model": "qwen2.5-coder:7b",
"messages": [
{"role": "system", "content": "..."},
{"role": "user", "content": "..."}
],
"stream": false
}
```
Used by:
- `python3 -m src.main chat ...`
- `python3 -m src.main agent ...`
### 2. Non-streaming responses
The harness currently uses `stream: false`.
That means:
- each turn waits for a full Ollama response
- the Python agent loop is simpler
- there is no token-by-token terminal rendering yet
This is fine for early harness integration, but future work could add streaming for better UX and partial tool-call parsing.
### 3. JSON response mode
The agent path asks Ollama for JSON output:
```json
{
"format": "json"
}
```
This is only used in agent mode. It makes the model return structured responses like:
```json
{"type":"tool_call","name":"read_file","arguments":{"path":"src/main.py"}}
```
or:
```json
{"type":"final","content":"..."}
```
### 4. Ollama options
The agent path currently sets:
```json
{
"options": {
"temperature": 0
}
}
```
Reason:
- lower randomness improves protocol adherence
- weaker local models are less likely to drift away from the JSON tool format
### 5. Multi-turn conversation state
The one-shot `chat` command sends a single system+user turn.
The `agent` command maintains a growing message list across turns:
- system instructions
- original user prompt
- assistant JSON tool call
- synthetic user `TOOL_RESULT ...` message
- final assistant answer
This is the core mechanism that lets Ollama use local harness tools.
## Tool Protocol
Agent mode expects exactly one JSON object from the model on each turn.
Valid shapes:
```json
{"type":"tool_call","name":"read_file","arguments":{"path":"src/main.py"}}
```
```json
{"type":"final","content":"Summary goes here."}
```
If the model returns invalid JSON or tries to answer directly without using a required tool, the harness will:
1. reject the response
2. ask the model to follow the protocol again
3. for obvious prompt shapes, synthesize a fallback first tool call
## Executable Tools Available Today
The current agent exposes these real tools:
### `read_file`
Arguments:
- `path`
- optional `start_line`
- optional `end_line`
Behavior:
- reads a file inside the workspace root
- returns numbered lines
- rejects paths outside the workspace
### `search_files`
Arguments:
- `pattern`
- optional `path`
- optional `max_results`
Behavior:
- uses `rg` when available
- falls back to a Python file scan otherwise
- returns `path:line:content` matches
### `edit_file`
Arguments:
- `path`
- `new_text`
- optional `old_text`
Behavior:
- creates a new file when the target does not exist and `old_text` is omitted
- replaces exactly one matching snippet in an existing file
- rejects ambiguous multi-match replacements
### `run_shell_command`
Arguments:
- `command`
- optional `timeout_seconds`
Behavior:
- runs inside the workspace root
- captures stdout, stderr, and exit code
- marks non-zero exit as failure
## CLI Commands
### One-shot local chat
Use this when you only want a plain local model response and do not need tool use.
```bash
python3 -m src.main chat "write a hello world in python"
```
Override model or host:
```bash
python3 -m src.main chat --model qwen2.5-coder:7b --host http://localhost:11434 "summarize this repo"
```
Pipe stdin:
```bash
printf 'summarize src/main.py' | python3 -m src.main chat
```
### Agent mode with tool use
Use this when the task requires reading files, searching code, editing files, or running commands.
```bash
python3 -m src.main agent "read src/main.py lines 1 to 40 and summarize the CLI"
```
Search the workspace:
```bash
python3 -m src.main agent "search the src tree for 'agent_parser' and tell me which file contains it"
```
Edit a file:
```bash
python3 -m src.main agent "create tmp_test.txt containing hello harness"
```
Run shell commands:
```bash
python3 -m src.main agent "run 'pwd' and 'ls src | head' and summarize the results"
```
### Agent trace mode
Use `--trace` to verify that the model actually called a harness tool.
```bash
python3 -m src.main agent --trace "read src/main.py lines 1 to 40 and quote the subcommands you find"
```
Expected stderr output looks like:
```text
[trace] assistant_raw[1]={"type":"tool_call","name":"read_file","arguments":{"path":"src/main.py","start_line":1,"end_line":40}}
[trace] tool_call[1]=read_file {"path": "src/main.py", "start_line": 1, "end_line": 40}
[trace] tool_result[1]=read_file success=True error=None
[trace] assistant_raw[2]={"type":"final","content":"..."}
```
### Deny tools
You can block specific tools or prefixes in agent mode:
```bash
python3 -m src.main agent --deny-tool run_shell_command "inspect this repo"
```
```bash
python3 -m src.main agent --deny-prefix read "inspect src/main.py"
```
## Prompt Shapes That Work Best
Local models follow the protocol more reliably when prompts are concrete.
Good:
- `read src/main.py lines 1 to 40 and list the subcommands`
- `search the src tree for 'agent_parser'`
- `run 'pwd' and 'ls src | head' and summarize the results`
- `create tmp_test.txt containing hello harness`
Weaker:
- `inspect the codebase`
- `look around and tell me what you think`
Reason:
- concrete prompts make it easier for the harness to infer the correct first tool call when the model hesitates
## Current Limitations
- no streaming responses yet
- no native Ollama function-calling schema; this is prompt+JSON protocol driven
- no patch-based editing tool yet, only exact text replacement or file creation
- shell execution is real but still minimal; there is no deep safety policy beyond workspace scoping and CLI deny lists
- the agent does not yet plan complex multi-step edits as well as a stronger hosted model
## Suggested Next API Improvements
If you extend this integration later, these are the next sensible upgrades:
1. Add streaming support to `/api/chat`
2. Separate model protocol logic from tool orchestration
3. Introduce a stronger typed schema for tool calls and tool results
4. Add patch-style file editing instead of exact-text replacement only
5. Add richer shell permission policies
6. Store traces and tool transcripts in sessions
7. Move Ollama into a provider abstraction so local and hosted models share one agent runtime
## Quick Verification Checklist
Before building on this integration, verify:
1. `ollama serve` is running
2. the target model is available locally
3. `python3 -m src.main chat "hello"` works
4. `python3 -m src.main agent --trace "read src/main.py lines 1 to 20"` shows a real `tool_call`
## Example End-to-End Session
Command:
```bash
python3 -m src.main agent --trace "search the src tree for 'agent_parser' and tell me which file contains it"
```
Observed behavior:
1. Ollama returns `{"type":"tool_call","name":"search_files",...}`
2. the harness executes `search_files`
3. the harness feeds back `TOOL_RESULT {...}`
4. Ollama returns `{"type":"final","content":"..."}`
That is the current definition of "real harness tool use" for the local Ollama path.

View File

@ -142,6 +142,17 @@ python3 -m src.main commands --limit 10
python3 -m src.main tools --limit 10
```
Run a local Ollama-backed chat prompt:
```bash
ollama run qwen2.5-coder:7b
python3 -m src.main chat "write a hello world in python"
```
The local chat path uses Ollama at `http://localhost:11434` by default and sends a single non-streaming `/api/chat` request to `qwen2.5-coder:7b`.
For the current local agent/tool integration, request features, and command examples, see [`OLLAMA_INTEGRATION.md`](./OLLAMA_INTEGRATION.md).
## Current Parity Checkpoint
The port now mirrors the archived root-entry file surface, top-level subsystem names, and command/tool inventories much more closely than before. However, it is **not yet** a full runtime-equivalent replacement for the original TypeScript system; the Python tree still contains fewer executable runtime slices than the archived source.

264
src/agent_loop.py Normal file
View File

@ -0,0 +1,264 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .llm_backend import DEFAULT_OLLAMA_HOST, DEFAULT_OLLAMA_MODEL, LLMBackendError, OllamaBackend
from .permissions import ToolPermissionContext
from .runtime_tools import RuntimeToolRegistry, render_tool_instructions
DEFAULT_AGENT_SYSTEM_PROMPT = (
'You are a local coding assistant running inside a minimal harness. '
'Use tools when needed. Respond with exactly one JSON object and no surrounding prose. '
'Valid outputs are {"type":"tool_call","name":"...","arguments":{...}} or '
'{"type":"final","content":"..."}. '
'Do not wrap JSON in markdown fences.'
)
@dataclass(frozen=True)
class AgentRunResult:
content: str
turns: int
tool_calls: tuple[str, ...]
trace_events: tuple[str, ...] = ()
class AgentProtocolError(RuntimeError):
"""Raised when the model does not follow the JSON tool-call protocol."""
def run_agent_task(
prompt: str,
model: str = DEFAULT_OLLAMA_MODEL,
host: str = DEFAULT_OLLAMA_HOST,
root: Path | None = None,
system_prompt: str = DEFAULT_AGENT_SYSTEM_PROMPT,
max_turns: int = 8,
permission_context: ToolPermissionContext | None = None,
trace: bool = False,
) -> AgentRunResult:
workspace_root = (root or Path.cwd()).resolve()
backend = OllamaBackend(model=model, host=host)
tools = RuntimeToolRegistry(root=workspace_root, permission_context=permission_context)
messages = build_agent_messages(prompt=prompt, workspace_root=workspace_root, system_prompt=system_prompt)
tool_calls: list[str] = []
trace_events: list[str] = []
tool_required = prompt_requires_tool(prompt)
fallback_used = False
for turn in range(1, max_turns + 1):
response = backend.chat(messages, response_format='json', options={'temperature': 0})
assistant_content = response.content.strip()
if trace:
trace_events.append(f'assistant_raw[{turn}]={assistant_content}')
messages.append({'role': 'assistant', 'content': assistant_content})
try:
payload = parse_agent_response(assistant_content)
except AgentProtocolError as error:
if trace:
trace_events.append(f'protocol_error[{turn}]={error}')
fallback = None
if tool_required and not tool_calls and not fallback_used:
fallback = infer_tool_call_from_prompt(prompt)
if fallback is not None:
fallback_used = True
if trace:
trace_events.append(f'fallback_tool[{turn}]={fallback["name"]} {json.dumps(fallback["arguments"], ensure_ascii=True)}')
_append_tool_result(messages, tools, fallback, tool_calls, trace_events if trace else None, turn)
continue
messages.append({'role': 'user', 'content': protocol_error_message(str(error))})
continue
if payload['type'] == 'final':
if tool_required and not tool_calls:
if trace:
trace_events.append(f'tool_required_reject[{turn}]={payload["content"]}')
fallback = None
if not fallback_used:
fallback = infer_tool_call_from_prompt(prompt)
if fallback is not None:
fallback_used = True
if trace:
trace_events.append(f'fallback_tool[{turn}]={fallback["name"]} {json.dumps(fallback["arguments"], ensure_ascii=True)}')
_append_tool_result(messages, tools, fallback, tool_calls, trace_events if trace else None, turn)
continue
messages.append({'role': 'user', 'content': tool_required_message()})
continue
return AgentRunResult(
content=payload['content'],
turns=turn,
tool_calls=tuple(tool_calls),
trace_events=tuple(trace_events),
)
_append_tool_result(messages, tools, payload, tool_calls, trace_events if trace else None, turn)
if trace:
trace_events.append(f'stopped=max_turns:{max_turns}')
raise LLMBackendError(f'Agent stopped after reaching max_turns={max_turns}.')
def build_agent_messages(
prompt: str,
workspace_root: Path,
system_prompt: str = DEFAULT_AGENT_SYSTEM_PROMPT,
) -> list[dict[str, str]]:
return [
{
'role': 'system',
'content': '\n\n'.join(
[
system_prompt,
f'Workspace root: {workspace_root}',
render_tool_instructions(),
'After every tool call you will receive a user message starting with TOOL_RESULT followed by JSON.',
'If the user asks to inspect workspace files, search code, edit files, or run shell commands, use a tool before giving a final answer.',
'Example tool call: {"type":"tool_call","name":"read_file","arguments":{"path":"src/main.py","start_line":1,"end_line":40}}',
'Example final: {"type":"final","content":"I found ..."}',
]
),
},
{'role': 'user', 'content': prompt},
]
def parse_agent_response(content: str) -> dict[str, Any]:
normalized = content.strip()
if normalized.startswith('```'):
lines = normalized.splitlines()
if lines and lines[0].startswith('```'):
lines = lines[1:]
if lines and lines[-1].startswith('```'):
lines = lines[:-1]
normalized = '\n'.join(lines).strip()
try:
payload = json.loads(normalized)
except json.JSONDecodeError as error:
raise AgentProtocolError('Response was not valid JSON.') from error
if not isinstance(payload, dict):
raise AgentProtocolError('Response JSON must be an object.')
response_type = payload.get('type')
if response_type == 'final':
content_value = payload.get('content')
if not isinstance(content_value, str) or not content_value:
raise AgentProtocolError('Final responses must include a non-empty string content field.')
return {'type': 'final', 'content': content_value}
if response_type == 'tool_call':
name = payload.get('name')
arguments = payload.get('arguments')
if not isinstance(name, str) or not name:
raise AgentProtocolError('Tool calls must include a non-empty string name field.')
if not isinstance(arguments, dict):
raise AgentProtocolError('Tool calls must include an arguments object.')
return {'type': 'tool_call', 'name': name, 'arguments': arguments}
raise AgentProtocolError('Response type must be either "tool_call" or "final".')
def tool_result_message(payload: dict[str, Any]) -> str:
return f'TOOL_RESULT {json.dumps(payload, ensure_ascii=True)}'
def protocol_error_message(error: str) -> str:
return (
'Your previous response did not follow the protocol. '
f'{error} '
'Respond again with exactly one JSON object.'
)
def tool_required_message() -> str:
return (
'You must use an appropriate tool before answering this request because it depends on workspace contents '
'or shell output. Respond with exactly one JSON tool_call object.'
)
def prompt_requires_tool(prompt: str) -> bool:
lowered = prompt.lower()
action_words = ('read', 'search', 'find', 'inspect', 'open', 'edit', 'modify', 'create', 'write', 'run', 'execute', 'quote')
target_hints = ('src/', '.py', 'file', 'files', 'workspace', 'tree', 'directory', 'lines', 'command', 'shell', 'pwd', 'ls ', 'rg ', 'grep ')
return any(word in lowered for word in action_words) and any(hint in lowered for hint in target_hints)
def infer_tool_call_from_prompt(prompt: str) -> dict[str, Any] | None:
read_match = re.search(
r'read\s+(?P<path>[\w./-]+)(?:\s+lines?\s+(?P<start>\d+)\s*(?:to|-)\s*(?P<end>\d+))?',
prompt,
flags=re.IGNORECASE,
)
if read_match:
arguments: dict[str, Any] = {'path': read_match.group('path')}
if read_match.group('start') and read_match.group('end'):
arguments['start_line'] = int(read_match.group('start'))
arguments['end_line'] = int(read_match.group('end'))
return {'name': 'read_file', 'arguments': arguments}
search_match = re.search(
r"search(?:\s+the)?\s+(?P<scope>.+?)\s+for\s+['\"](?P<pattern>[^'\"]+)['\"]",
prompt,
flags=re.IGNORECASE,
)
if search_match:
scope = search_match.group('scope').strip().lower()
path = '.'
if 'src' in scope:
path = 'src'
return {
'name': 'search_files',
'arguments': {'path': path, 'pattern': search_match.group('pattern')},
}
quoted_commands = re.findall(r"['\"]([^'\"]+)['\"]", prompt)
if any(word in prompt.lower() for word in ('run', 'execute', 'shell')) and quoted_commands:
return {
'name': 'run_shell_command',
'arguments': {'command': '\n'.join(quoted_commands)},
}
create_match = re.search(
r'create\s+(?P<path>[\w./-]+)\s+containing\s+(?P<content>.+)',
prompt,
flags=re.IGNORECASE,
)
if create_match:
return {
'name': 'edit_file',
'arguments': {
'path': create_match.group('path'),
'new_text': create_match.group('content').strip(),
},
}
return None
def _append_tool_result(
messages: list[dict[str, str]],
tools: RuntimeToolRegistry,
payload: dict[str, Any],
tool_calls: list[str],
trace_events: list[str] | None,
turn: int,
) -> None:
tool_name = payload['name']
arguments = payload['arguments']
tool_calls.append(tool_name)
if trace_events is not None:
trace_events.append(f'tool_call[{turn}]={tool_name} {json.dumps(arguments, ensure_ascii=True)}')
tool_result = tools.execute(tool_name, arguments)
if trace_events is not None:
trace_events.append(
f'tool_result[{turn}]={tool_name} success={tool_result.success} '
f'error={tool_result.error!r}'
)
messages.append({'role': 'user', 'content': tool_result_message(tool_result.to_payload())})

74
src/llm_backend.py Normal file
View File

@ -0,0 +1,74 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from .models import AssistantResponse
DEFAULT_OLLAMA_HOST = 'http://localhost:11434'
DEFAULT_OLLAMA_MODEL = 'qwen2.5-coder:7b'
class LLMBackendError(RuntimeError):
"""Raised when the local LLM backend cannot complete a request."""
@dataclass(frozen=True)
class OllamaBackend:
model: str = DEFAULT_OLLAMA_MODEL
host: str = DEFAULT_OLLAMA_HOST
def chat(
self,
messages: list[dict[str, str]],
response_format: str | dict[str, Any] | None = None,
options: dict[str, Any] | None = None,
) -> AssistantResponse:
payload: dict[str, Any] = {
'model': self.model,
'messages': messages,
'stream': False,
}
if response_format is not None:
payload['format'] = response_format
if options:
payload['options'] = options
body = json.dumps(payload).encode('utf-8')
request = Request(
url=f'{self.host.rstrip("/")}/api/chat',
data=body,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with urlopen(request) as response:
payload = json.loads(response.read().decode('utf-8'))
except HTTPError as error:
detail = error.read().decode('utf-8', errors='replace').strip()
message = detail or str(error)
raise LLMBackendError(f'Ollama request failed: {message}') from error
except URLError as error:
raise LLMBackendError(
f'Could not reach Ollama at {self.host}. '
'Make sure Ollama is running and listening on that host.'
) from error
except json.JSONDecodeError as error:
raise LLMBackendError('Ollama returned invalid JSON.') from error
return normalize_ollama_response(payload)
def normalize_ollama_response(payload: dict[str, Any]) -> AssistantResponse:
message = payload.get('message')
if not isinstance(message, dict):
raise LLMBackendError('Ollama response did not include a message object.')
content = message.get('content')
if not isinstance(content, str):
raise LLMBackendError('Ollama response did not include assistant content.')
return AssistantResponse(content=content, raw=payload)

View File

@ -1,10 +1,13 @@
from __future__ import annotations
import argparse
import sys
from .bootstrap_graph import build_bootstrap_graph
from .command_graph import build_command_graph
from .commands import execute_command, get_command, get_commands, render_command_index
from .agent_loop import DEFAULT_AGENT_SYSTEM_PROMPT, run_agent_task
from .llm_backend import DEFAULT_OLLAMA_HOST, DEFAULT_OLLAMA_MODEL, LLMBackendError
from .direct_modes import run_deep_link, run_direct_connect
from .parity_audit import run_parity_audit
from .permissions import ToolPermissionContext
@ -14,6 +17,7 @@ from .remote_runtime import run_remote_mode, run_ssh_mode, run_teleport_mode
from .runtime import PortRuntime
from .session_store import load_session
from .setup import run_setup
from .task import DEFAULT_SYSTEM_PROMPT, run_local_task
from .tool_pool import assemble_tool_pool
from .tools import execute_tool, get_tool, get_tools, render_tool_index
@ -88,9 +92,36 @@ def build_parser() -> argparse.ArgumentParser:
exec_tool_parser = subparsers.add_parser('exec-tool', help='execute a mirrored tool shim by exact name')
exec_tool_parser.add_argument('name')
exec_tool_parser.add_argument('payload')
chat_parser = subparsers.add_parser('chat', help='send a prompt to a local Ollama model')
chat_parser.add_argument('prompt', nargs='?')
chat_parser.add_argument('--model', default=DEFAULT_OLLAMA_MODEL)
chat_parser.add_argument('--host', default=DEFAULT_OLLAMA_HOST)
chat_parser.add_argument('--system')
agent_parser = subparsers.add_parser('agent', help='run the local Ollama agent loop with executable workspace tools')
agent_parser.add_argument('prompt', nargs='?')
agent_parser.add_argument('--model', default=DEFAULT_OLLAMA_MODEL)
agent_parser.add_argument('--host', default=DEFAULT_OLLAMA_HOST)
agent_parser.add_argument('--system')
agent_parser.add_argument('--max-turns', type=int, default=8)
agent_parser.add_argument('--deny-tool', action='append', default=[])
agent_parser.add_argument('--deny-prefix', action='append', default=[])
agent_parser.add_argument('--trace', action='store_true')
return parser
def resolve_chat_prompt(args: argparse.Namespace, parser: argparse.ArgumentParser) -> str:
if args.prompt:
return args.prompt
prompt = sys.stdin.read().strip()
if prompt:
return prompt
command_name = getattr(args, 'command', 'command')
parser.error(f'{command_name} requires a prompt argument or stdin input')
raise AssertionError('unreachable')
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
@ -205,6 +236,42 @@ def main(argv: list[str] | None = None) -> int:
result = execute_tool(args.name, args.payload)
print(result.message)
return 0 if result.handled else 1
if args.command == 'chat':
prompt = resolve_chat_prompt(args, parser)
try:
print(
run_local_task(
prompt,
model=args.model,
host=args.host,
system_prompt=args.system if args.system is not None else DEFAULT_SYSTEM_PROMPT,
)
)
except LLMBackendError as error:
print(error, file=sys.stderr)
return 1
return 0
if args.command == 'agent':
prompt = resolve_chat_prompt(args, parser)
permission_context = ToolPermissionContext.from_iterables(args.deny_tool, args.deny_prefix)
try:
result = run_agent_task(
prompt,
model=args.model,
host=args.host,
system_prompt=args.system if args.system is not None else DEFAULT_AGENT_SYSTEM_PROMPT,
max_turns=args.max_turns,
permission_context=permission_context,
trace=args.trace,
)
if args.trace:
for event in result.trace_events:
print(f'[trace] {event}', file=sys.stderr)
print(result.content)
except LLMBackendError as error:
print(error, file=sys.stderr)
return 1
return 0
parser.error(f'unknown command: {args.command}')
return 2

View File

@ -37,6 +37,12 @@ class UsageSummary:
)
@dataclass(frozen=True)
class AssistantResponse:
content: str
raw: dict | None = None
@dataclass
class PortingBacklog:
title: str

304
src/runtime_tools.py Normal file
View File

@ -0,0 +1,304 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from shutil import which
from typing import Any
from .permissions import ToolPermissionContext
MAX_TOOL_OUTPUT_CHARS = 12_000
class RuntimeToolError(RuntimeError):
"""Raised when a runtime tool cannot complete a request."""
@dataclass(frozen=True)
class RuntimeToolDefinition:
name: str
description: str
arguments: dict[str, str]
@dataclass(frozen=True)
class RuntimeToolResult:
name: str
success: bool
output: str
metadata: dict[str, Any] = field(default_factory=dict)
error: str | None = None
def to_payload(self) -> dict[str, Any]:
payload = {
'name': self.name,
'success': self.success,
'output': self.output,
'metadata': self.metadata,
}
if self.error is not None:
payload['error'] = self.error
return payload
TOOL_DEFINITIONS = (
RuntimeToolDefinition(
name='read_file',
description='Read a file from the workspace. Optionally limit the response to a line range.',
arguments={
'path': 'Workspace-relative file path.',
'start_line': 'Optional 1-based starting line number.',
'end_line': 'Optional 1-based ending line number.',
},
),
RuntimeToolDefinition(
name='search_files',
description='Search workspace files for a regular expression pattern and return matching lines.',
arguments={
'pattern': 'Regular expression to search for.',
'path': 'Optional workspace-relative directory to search from. Defaults to the workspace root.',
'max_results': 'Optional cap on returned matches. Defaults to 50.',
},
),
RuntimeToolDefinition(
name='edit_file',
description='Edit a workspace file by replacing an exact old_text snippet with new_text. Creates a new file when old_text is omitted and the file does not exist.',
arguments={
'path': 'Workspace-relative file path.',
'old_text': 'Exact existing text to replace. Required when editing an existing file.',
'new_text': 'Replacement text to write.',
},
),
RuntimeToolDefinition(
name='run_shell_command',
description='Run a shell command inside the workspace and capture stdout, stderr, and exit code.',
arguments={
'command': 'Shell command string to execute.',
'timeout_seconds': 'Optional timeout in seconds. Defaults to 20.',
},
),
)
def render_tool_instructions() -> str:
lines = [
'Available tools:',
]
for tool in TOOL_DEFINITIONS:
lines.append(f'- {tool.name}: {tool.description}')
for arg, detail in tool.arguments.items():
lines.append(f' - {arg}: {detail}')
return '\n'.join(lines)
@dataclass
class RuntimeToolRegistry:
root: Path
permission_context: ToolPermissionContext | None = None
shell: str = '/bin/zsh'
def __post_init__(self) -> None:
self.root = self.root.resolve()
def execute(self, name: str, arguments: dict[str, Any]) -> RuntimeToolResult:
if self.permission_context and self.permission_context.blocks(name):
return RuntimeToolResult(
name=name,
success=False,
output='',
error=f'Tool is blocked by permission policy: {name}',
)
handlers = {
'read_file': self._read_file,
'search_files': self._search_files,
'edit_file': self._edit_file,
'run_shell_command': self._run_shell_command,
}
handler = handlers.get(name)
if handler is None:
return RuntimeToolResult(
name=name,
success=False,
output='',
error=f'Unknown runtime tool: {name}',
)
try:
return handler(arguments)
except RuntimeToolError as error:
return RuntimeToolResult(name=name, success=False, output='', error=str(error))
def _read_file(self, arguments: dict[str, Any]) -> RuntimeToolResult:
path = self._resolve_path(self._require_string(arguments, 'path'))
if not path.exists():
raise RuntimeToolError(f'File not found: {path.relative_to(self.root)}')
if not path.is_file():
raise RuntimeToolError(f'Path is not a file: {path.relative_to(self.root)}')
text = path.read_text(encoding='utf-8')
lines = text.splitlines()
start_line = self._optional_int(arguments, 'start_line', minimum=1) or 1
end_line = self._optional_int(arguments, 'end_line', minimum=start_line) or len(lines) or 1
selected = lines[start_line - 1 : end_line]
numbered = '\n'.join(f'{index}\t{line}' for index, line in enumerate(selected, start=start_line))
return RuntimeToolResult(
name='read_file',
success=True,
output=self._truncate(numbered if numbered else ''),
metadata={
'path': str(path.relative_to(self.root)),
'start_line': start_line,
'end_line': min(end_line, len(lines)) if lines else 0,
'line_count': len(lines),
},
)
def _search_files(self, arguments: dict[str, Any]) -> RuntimeToolResult:
pattern = self._require_string(arguments, 'pattern')
search_root = self._resolve_path(arguments.get('path', '.'))
if not search_root.exists():
raise RuntimeToolError(f'Search path not found: {search_root.relative_to(self.root)}')
max_results = self._optional_int(arguments, 'max_results', minimum=1) or 50
if which('rg'):
command = [
'rg',
'-n',
'--no-heading',
'--color',
'never',
'--max-count',
str(max_results),
pattern,
str(search_root),
]
result = subprocess.run(command, capture_output=True, text=True, cwd=self.root, check=False)
if result.returncode not in (0, 1):
raise RuntimeToolError(result.stderr.strip() or 'ripgrep search failed')
output = result.stdout.strip()
else:
matches: list[str] = []
for path in sorted(search_root.rglob('*')):
if not path.is_file():
continue
try:
for line_number, line in enumerate(path.read_text(encoding='utf-8').splitlines(), start=1):
if pattern in line:
matches.append(f'{path.relative_to(self.root)}:{line_number}:{line}')
if len(matches) >= max_results:
break
if len(matches) >= max_results:
break
except UnicodeDecodeError:
continue
output = '\n'.join(matches)
return RuntimeToolResult(
name='search_files',
success=True,
output=self._truncate(output),
metadata={
'path': str(search_root.relative_to(self.root)),
'pattern': pattern,
'max_results': max_results,
},
)
def _edit_file(self, arguments: dict[str, Any]) -> RuntimeToolResult:
path = self._resolve_path(self._require_string(arguments, 'path'))
new_text = self._require_string(arguments, 'new_text')
old_text = arguments.get('old_text')
if old_text is not None and not isinstance(old_text, str):
raise RuntimeToolError('old_text must be a string when provided.')
if not path.exists():
if old_text:
raise RuntimeToolError(f'Cannot replace text in missing file: {path.relative_to(self.root)}')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(new_text, encoding='utf-8')
return RuntimeToolResult(
name='edit_file',
success=True,
output=f'Created {path.relative_to(self.root)}',
metadata={'path': str(path.relative_to(self.root)), 'created': True},
)
if not path.is_file():
raise RuntimeToolError(f'Path is not a file: {path.relative_to(self.root)}')
if not old_text:
raise RuntimeToolError('old_text is required when editing an existing file.')
current = path.read_text(encoding='utf-8')
match_count = current.count(old_text)
if match_count == 0:
raise RuntimeToolError('old_text was not found in the target file.')
if match_count > 1:
raise RuntimeToolError('old_text matched multiple locations; provide a more specific snippet.')
path.write_text(current.replace(old_text, new_text, 1), encoding='utf-8')
return RuntimeToolResult(
name='edit_file',
success=True,
output=f'Updated {path.relative_to(self.root)}',
metadata={'path': str(path.relative_to(self.root)), 'created': False},
)
def _run_shell_command(self, arguments: dict[str, Any]) -> RuntimeToolResult:
command = self._require_string(arguments, 'command')
timeout = self._optional_int(arguments, 'timeout_seconds', minimum=1) or 20
try:
result = subprocess.run(
[self.shell, '-lc', command],
capture_output=True,
text=True,
cwd=self.root,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as error:
raise RuntimeToolError(f'Shell command timed out after {timeout} seconds.') from error
combined = '\n'.join(
part for part in (result.stdout.strip(), result.stderr.strip()) if part
)
return RuntimeToolResult(
name='run_shell_command',
success=result.returncode == 0,
output=self._truncate(combined),
metadata={'exit_code': result.returncode, 'command': command, 'timeout_seconds': timeout},
error=None if result.returncode == 0 else f'Command exited with status {result.returncode}.',
)
def _resolve_path(self, raw_path: Any) -> Path:
if not isinstance(raw_path, str) or not raw_path.strip():
raise RuntimeToolError('path must be a non-empty string.')
candidate = Path(raw_path)
resolved = candidate.resolve() if candidate.is_absolute() else (self.root / candidate).resolve()
if not resolved.is_relative_to(self.root):
raise RuntimeToolError('path must stay inside the workspace root.')
return resolved
@staticmethod
def _require_string(arguments: dict[str, Any], key: str) -> str:
value = arguments.get(key)
if not isinstance(value, str) or not value:
raise RuntimeToolError(f'{key} must be a non-empty string.')
return value
@staticmethod
def _optional_int(arguments: dict[str, Any], key: str, minimum: int = 0) -> int | None:
if key not in arguments or arguments[key] is None:
return None
value = arguments[key]
if not isinstance(value, int) or value < minimum:
raise RuntimeToolError(f'{key} must be an integer >= {minimum}.')
return value
@staticmethod
def _truncate(text: str, limit: int = MAX_TOOL_OUTPUT_CHARS) -> str:
if len(text) <= limit:
return text
return f'{text[:limit]}\n...[truncated]'

View File

@ -1,5 +1,38 @@
from __future__ import annotations
from .task import PortingTask
from dataclasses import dataclass
__all__ = ['PortingTask']
from .llm_backend import DEFAULT_OLLAMA_HOST, DEFAULT_OLLAMA_MODEL, OllamaBackend
DEFAULT_SYSTEM_PROMPT = (
'You are a local coding assistant running inside a minimal harness. '
'Be concise, accurate, and explicit about uncertainty.'
)
@dataclass(frozen=True)
class PortingTask:
name: str
description: str
def build_chat_messages(prompt: str, system_prompt: str = DEFAULT_SYSTEM_PROMPT) -> list[dict[str, str]]:
messages: list[dict[str, str]] = []
if system_prompt:
messages.append({'role': 'system', 'content': system_prompt})
messages.append({'role': 'user', 'content': prompt})
return messages
def run_local_task(
prompt: str,
model: str = DEFAULT_OLLAMA_MODEL,
host: str = DEFAULT_OLLAMA_HOST,
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
) -> str:
backend = OllamaBackend(model=model, host=host)
response = backend.chat(build_chat_messages(prompt=prompt, system_prompt=system_prompt))
return response.content
__all__ = ['DEFAULT_SYSTEM_PROMPT', 'PortingTask', 'build_chat_messages', 'run_local_task']

View File

@ -1,14 +1,28 @@
from __future__ import annotations
import io
import subprocess
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from src.agent_loop import (
build_agent_messages,
infer_tool_call_from_prompt,
parse_agent_response,
prompt_requires_tool,
run_agent_task,
tool_result_message,
)
from src.commands import PORTED_COMMANDS
from src.llm_backend import normalize_ollama_response
from src.parity_audit import run_parity_audit
from src.port_manifest import build_port_manifest
from src.query_engine import QueryEnginePort
from src.runtime_tools import RuntimeToolRegistry
from src.task import DEFAULT_SYSTEM_PROMPT, build_chat_messages, run_local_task
from src.tools import PORTED_TOOLS
@ -243,6 +257,158 @@ class PortingWorkspaceTests(unittest.TestCase):
self.assertIn('mode=direct-connect', direct_result.stdout)
self.assertIn('mode=deep-link', deep_link_result.stdout)
def test_build_chat_messages_includes_default_system_prompt(self) -> None:
messages = build_chat_messages('Summarize this repo')
self.assertEqual(messages[0], {'role': 'system', 'content': DEFAULT_SYSTEM_PROMPT})
self.assertEqual(messages[1], {'role': 'user', 'content': 'Summarize this repo'})
def test_run_local_task_uses_backend_response_content(self) -> None:
with patch('src.task.OllamaBackend.chat', return_value=normalize_ollama_response({'message': {'content': 'local answer'}})) as chat_mock:
result = run_local_task('hello')
self.assertEqual(result, 'local answer')
sent_messages = chat_mock.call_args.args[0]
self.assertEqual(sent_messages[-1], {'role': 'user', 'content': 'hello'})
def test_normalize_ollama_response_returns_assistant_content(self) -> None:
response = normalize_ollama_response(
{
'model': 'qwen2.5-coder:7b',
'message': {'role': 'assistant', 'content': 'hello from ollama'},
}
)
self.assertEqual(response.content, 'hello from ollama')
self.assertEqual(response.raw['model'], 'qwen2.5-coder:7b')
def test_chat_cli_reads_prompt_from_stdin(self) -> None:
from src.main import build_parser, resolve_chat_prompt
parser = build_parser()
args = parser.parse_args(['chat'])
with patch('sys.stdin.read', return_value='stdin prompt'):
self.assertEqual(resolve_chat_prompt(args, parser), 'stdin prompt')
def test_runtime_tool_registry_reads_searches_edits_and_runs_shell(self) -> None:
with TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
target = root / 'notes.txt'
target.write_text('alpha\nbeta\n', encoding='utf-8')
registry = RuntimeToolRegistry(root=root)
read_result = registry.execute('read_file', {'path': 'notes.txt'})
self.assertTrue(read_result.success)
self.assertIn('1\talpha', read_result.output)
search_result = registry.execute('search_files', {'pattern': 'beta', 'path': '.'})
self.assertTrue(search_result.success)
self.assertIn('notes.txt:2:beta', search_result.output)
edit_result = registry.execute(
'edit_file',
{'path': 'notes.txt', 'old_text': 'beta\n', 'new_text': 'gamma\n'},
)
self.assertTrue(edit_result.success)
self.assertEqual(target.read_text(encoding='utf-8'), 'alpha\ngamma\n')
shell_result = registry.execute('run_shell_command', {'command': 'printf shell-ok'})
self.assertTrue(shell_result.success)
self.assertEqual(shell_result.output, 'shell-ok')
def test_parse_agent_response_supports_tool_calls_and_finals(self) -> None:
tool_call = parse_agent_response('{"type":"tool_call","name":"read_file","arguments":{"path":"src/main.py"}}')
final = parse_agent_response('{"type":"final","content":"done"}')
self.assertEqual(tool_call['name'], 'read_file')
self.assertEqual(final['content'], 'done')
def test_prompt_requires_tool_for_workspace_inspection_prompts(self) -> None:
self.assertTrue(prompt_requires_tool('read src/main.py lines 1 to 40'))
self.assertTrue(prompt_requires_tool("search the src tree for 'agent_parser'"))
self.assertFalse(prompt_requires_tool('say hello'))
def test_infer_tool_call_from_prompt_recognizes_read_and_search(self) -> None:
read_call = infer_tool_call_from_prompt('read src/main.py lines 1 to 40')
search_call = infer_tool_call_from_prompt("search the src tree for 'agent_parser'")
self.assertEqual(read_call, {'name': 'read_file', 'arguments': {'path': 'src/main.py', 'start_line': 1, 'end_line': 40}})
self.assertEqual(search_call, {'name': 'search_files', 'arguments': {'path': 'src', 'pattern': 'agent_parser'}})
def test_tool_result_message_is_json_prefixed(self) -> None:
message = tool_result_message({'name': 'read_file', 'success': True, 'output': 'ok'})
self.assertTrue(message.startswith('TOOL_RESULT '))
self.assertIn('"name": "read_file"', message)
def test_build_agent_messages_mentions_workspace_and_tools(self) -> None:
messages = build_agent_messages('inspect repo', Path('/tmp/workspace'))
self.assertEqual(messages[1], {'role': 'user', 'content': 'inspect repo'})
self.assertIn('Workspace root: /tmp/workspace', messages[0]['content'])
self.assertIn('read_file', messages[0]['content'])
def test_run_agent_task_executes_tools_end_to_end(self) -> None:
with TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / 'example.txt').write_text('hello world\n', encoding='utf-8')
responses = [
normalize_ollama_response(
{
'message': {
'content': '{"type":"tool_call","name":"read_file","arguments":{"path":"example.txt"}}'
}
}
),
normalize_ollama_response(
{
'message': {
'content': '{"type":"final","content":"Read complete."}'
}
}
),
]
with patch('src.agent_loop.OllamaBackend.chat', side_effect=responses) as chat_mock:
result = run_agent_task('inspect file', root=root, max_turns=3, trace=True)
self.assertEqual(result.content, 'Read complete.')
self.assertEqual(result.tool_calls, ('read_file',))
self.assertTrue(any(event.startswith('tool_call') for event in result.trace_events))
second_call_messages = chat_mock.call_args_list[1].args[0]
self.assertTrue(any(message['content'].startswith('TOOL_RESULT ') for message in second_call_messages if message['role'] == 'user'))
def test_run_agent_task_rejects_direct_final_when_tool_is_required(self) -> None:
with TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / 'example.txt').write_text('hello world\n', encoding='utf-8')
responses = [
normalize_ollama_response(
{
'message': {
'content': '{"type":"final","content":"No subcommands found."}'
}
}
),
normalize_ollama_response(
{
'message': {
'content': '{"type":"final","content":"Read complete after tool use."}'
}
}
),
]
with patch('src.agent_loop.OllamaBackend.chat', side_effect=responses):
result = run_agent_task('read example.txt lines 1 to 1', root=root, max_turns=3, trace=True)
self.assertEqual(result.content, 'Read complete after tool use.')
self.assertEqual(result.tool_calls, ('read_file',))
self.assertTrue(any('tool_required_reject' in event for event in result.trace_events))
def test_agent_cli_invokes_agent_loop(self) -> None:
from src.main import main
with patch('src.main.run_agent_task') as run_agent_task_mock:
run_agent_task_mock.return_value.content = 'agent output'
run_agent_task_mock.return_value.trace_events = ('tool_call[1]=read_file {"path":"src/main.py"}',)
with patch('sys.stdout', new=io.StringIO()):
with patch('sys.stderr', new=io.StringIO()):
exit_code = main(['agent', 'inspect repo', '--trace'])
self.assertEqual(exit_code, 0)
run_agent_task_mock.assert_called_once()
if __name__ == '__main__':
unittest.main()