added local support for OLLAMA qwen2.5_code, and able to call basic harness tools
This commit is contained in:
parent
1ceb077e40
commit
54adf9f2ba
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "08ddbda2cc60476f80da155e5eeddbc4",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "0ed1f18234e14682a6d949cbfdd6ce3f",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "0f302419a56e48969cf34e994786f9be",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "1a63144d276847f6ab25199b8f53e91b",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "250be3d09a7a4440a8ea5f6401262a64",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "2b29247c302243999986b5c54e190182",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "2fa54297429e4e028409846e1f894702",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "36e5e3d416554020806ef4206f66aa77",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "39fe80c5372542a883e38c4d8c32c0ed",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "3d0e8026689b4d7fbde88f9625ab1252",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "3db5e35d58ee42de8ee8b31fde9bec41",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "42746973a4df45cd90ba9368d65bced1",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "42d0893f63694c0e8b36fced30febb7c",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "440c5c06b3f845f094b3e752863ef9d6",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "4c7176c1674d4c1490c7fab6a52884ca",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "697bb82bafcb48fb9aa1ba20eb1a1ed7",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "6a88bbb345ed4389b9305ab0ba72f92c",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "71e1b2dfb4844b4b8c257ff8b3bd520e",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "8dde908596f74b728c433eb018964cd0",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "9a88809c13b24f1d8922542110b0cb15",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "a9370872b5874d5082c3042ecccf851f",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "b83066e18a0a414886b142d1aef248ab",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "f22cc816662749c3a305a5a9fc76114f",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"session_id": "f85a08b0f4254476a1245809cbee7bd3",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
11
README.md
11
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())})
|
||||
|
|
@ -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)
|
||||
67
src/main.py
67
src/main.py
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ class UsageSummary:
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantResponse:
|
||||
content: str
|
||||
raw: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortingBacklog:
|
||||
title: str
|
||||
|
|
|
|||
|
|
@ -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]'
|
||||
37
src/task.py
37
src/task.py
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue