From 54adf9f2ba28054e71622fea4546f45b1e1957f5 Mon Sep 17 00:00:00 2001 From: Dowling_Wong <80257366+Dowling7@users.noreply.github.com> Date: Sun, 5 Apr 2026 02:16:31 +0200 Subject: [PATCH] added local support for OLLAMA qwen2.5_code, and able to call basic harness tools --- .../08ddbda2cc60476f80da155e5eeddbc4.json | 8 + .../0ed1f18234e14682a6d949cbfdd6ce3f.json | 9 + .../0f302419a56e48969cf34e994786f9be.json | 9 + .../1a63144d276847f6ab25199b8f53e91b.json | 9 + .../250be3d09a7a4440a8ea5f6401262a64.json | 9 + .../2b29247c302243999986b5c54e190182.json | 9 + .../2fa54297429e4e028409846e1f894702.json | 9 + .../36e5e3d416554020806ef4206f66aa77.json | 8 + .../39fe80c5372542a883e38c4d8c32c0ed.json | 8 + .../3d0e8026689b4d7fbde88f9625ab1252.json | 8 + .../3db5e35d58ee42de8ee8b31fde9bec41.json | 9 + .../42746973a4df45cd90ba9368d65bced1.json | 9 + .../42d0893f63694c0e8b36fced30febb7c.json | 9 + .../440c5c06b3f845f094b3e752863ef9d6.json | 8 + .../4c7176c1674d4c1490c7fab6a52884ca.json | 9 + .../697bb82bafcb48fb9aa1ba20eb1a1ed7.json | 9 + .../6a88bbb345ed4389b9305ab0ba72f92c.json | 9 + .../71e1b2dfb4844b4b8c257ff8b3bd520e.json | 9 + .../8dde908596f74b728c433eb018964cd0.json | 9 + .../9a88809c13b24f1d8922542110b0cb15.json | 9 + .../a9370872b5874d5082c3042ecccf851f.json | 8 + .../b83066e18a0a414886b142d1aef248ab.json | 9 + .../f22cc816662749c3a305a5a9fc76114f.json | 9 + .../f85a08b0f4254476a1245809cbee7bd3.json | 9 + AGENTS.md | 21 ++ OLLAMA_INTEGRATION.md | 331 ++++++++++++++++++ README.md | 11 + src/agent_loop.py | 264 ++++++++++++++ src/llm_backend.py | 74 ++++ src/main.py | 67 ++++ src/models.py | 6 + src/runtime_tools.py | 304 ++++++++++++++++ src/task.py | 37 +- tests/test_porting_workspace.py | 166 +++++++++ 34 files changed, 1489 insertions(+), 2 deletions(-) create mode 100644 .port_sessions/08ddbda2cc60476f80da155e5eeddbc4.json create mode 100644 .port_sessions/0ed1f18234e14682a6d949cbfdd6ce3f.json create mode 100644 .port_sessions/0f302419a56e48969cf34e994786f9be.json create mode 100644 .port_sessions/1a63144d276847f6ab25199b8f53e91b.json create mode 100644 .port_sessions/250be3d09a7a4440a8ea5f6401262a64.json create mode 100644 .port_sessions/2b29247c302243999986b5c54e190182.json create mode 100644 .port_sessions/2fa54297429e4e028409846e1f894702.json create mode 100644 .port_sessions/36e5e3d416554020806ef4206f66aa77.json create mode 100644 .port_sessions/39fe80c5372542a883e38c4d8c32c0ed.json create mode 100644 .port_sessions/3d0e8026689b4d7fbde88f9625ab1252.json create mode 100644 .port_sessions/3db5e35d58ee42de8ee8b31fde9bec41.json create mode 100644 .port_sessions/42746973a4df45cd90ba9368d65bced1.json create mode 100644 .port_sessions/42d0893f63694c0e8b36fced30febb7c.json create mode 100644 .port_sessions/440c5c06b3f845f094b3e752863ef9d6.json create mode 100644 .port_sessions/4c7176c1674d4c1490c7fab6a52884ca.json create mode 100644 .port_sessions/697bb82bafcb48fb9aa1ba20eb1a1ed7.json create mode 100644 .port_sessions/6a88bbb345ed4389b9305ab0ba72f92c.json create mode 100644 .port_sessions/71e1b2dfb4844b4b8c257ff8b3bd520e.json create mode 100644 .port_sessions/8dde908596f74b728c433eb018964cd0.json create mode 100644 .port_sessions/9a88809c13b24f1d8922542110b0cb15.json create mode 100644 .port_sessions/a9370872b5874d5082c3042ecccf851f.json create mode 100644 .port_sessions/b83066e18a0a414886b142d1aef248ab.json create mode 100644 .port_sessions/f22cc816662749c3a305a5a9fc76114f.json create mode 100644 .port_sessions/f85a08b0f4254476a1245809cbee7bd3.json create mode 100644 AGENTS.md create mode 100644 OLLAMA_INTEGRATION.md create mode 100644 src/agent_loop.py create mode 100644 src/llm_backend.py create mode 100644 src/runtime_tools.py diff --git a/.port_sessions/08ddbda2cc60476f80da155e5eeddbc4.json b/.port_sessions/08ddbda2cc60476f80da155e5eeddbc4.json new file mode 100644 index 00000000..f5036878 --- /dev/null +++ b/.port_sessions/08ddbda2cc60476f80da155e5eeddbc4.json @@ -0,0 +1,8 @@ +{ + "session_id": "08ddbda2cc60476f80da155e5eeddbc4", + "messages": [ + "review MCP tool" + ], + "input_tokens": 3, + "output_tokens": 13 +} \ No newline at end of file diff --git a/.port_sessions/0ed1f18234e14682a6d949cbfdd6ce3f.json b/.port_sessions/0ed1f18234e14682a6d949cbfdd6ce3f.json new file mode 100644 index 00000000..706d0fa6 --- /dev/null +++ b/.port_sessions/0ed1f18234e14682a6d949cbfdd6ce3f.json @@ -0,0 +1,9 @@ +{ + "session_id": "0ed1f18234e14682a6d949cbfdd6ce3f", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/0f302419a56e48969cf34e994786f9be.json b/.port_sessions/0f302419a56e48969cf34e994786f9be.json new file mode 100644 index 00000000..e7b8ff16 --- /dev/null +++ b/.port_sessions/0f302419a56e48969cf34e994786f9be.json @@ -0,0 +1,9 @@ +{ + "session_id": "0f302419a56e48969cf34e994786f9be", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/1a63144d276847f6ab25199b8f53e91b.json b/.port_sessions/1a63144d276847f6ab25199b8f53e91b.json new file mode 100644 index 00000000..369c997a --- /dev/null +++ b/.port_sessions/1a63144d276847f6ab25199b8f53e91b.json @@ -0,0 +1,9 @@ +{ + "session_id": "1a63144d276847f6ab25199b8f53e91b", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/250be3d09a7a4440a8ea5f6401262a64.json b/.port_sessions/250be3d09a7a4440a8ea5f6401262a64.json new file mode 100644 index 00000000..2de6dba2 --- /dev/null +++ b/.port_sessions/250be3d09a7a4440a8ea5f6401262a64.json @@ -0,0 +1,9 @@ +{ + "session_id": "250be3d09a7a4440a8ea5f6401262a64", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/2b29247c302243999986b5c54e190182.json b/.port_sessions/2b29247c302243999986b5c54e190182.json new file mode 100644 index 00000000..78f0ee6d --- /dev/null +++ b/.port_sessions/2b29247c302243999986b5c54e190182.json @@ -0,0 +1,9 @@ +{ + "session_id": "2b29247c302243999986b5c54e190182", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/2fa54297429e4e028409846e1f894702.json b/.port_sessions/2fa54297429e4e028409846e1f894702.json new file mode 100644 index 00000000..8c843eb1 --- /dev/null +++ b/.port_sessions/2fa54297429e4e028409846e1f894702.json @@ -0,0 +1,9 @@ +{ + "session_id": "2fa54297429e4e028409846e1f894702", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/36e5e3d416554020806ef4206f66aa77.json b/.port_sessions/36e5e3d416554020806ef4206f66aa77.json new file mode 100644 index 00000000..8e8b8b53 --- /dev/null +++ b/.port_sessions/36e5e3d416554020806ef4206f66aa77.json @@ -0,0 +1,8 @@ +{ + "session_id": "36e5e3d416554020806ef4206f66aa77", + "messages": [ + "review MCP tool" + ], + "input_tokens": 3, + "output_tokens": 13 +} \ No newline at end of file diff --git a/.port_sessions/39fe80c5372542a883e38c4d8c32c0ed.json b/.port_sessions/39fe80c5372542a883e38c4d8c32c0ed.json new file mode 100644 index 00000000..01b4aafe --- /dev/null +++ b/.port_sessions/39fe80c5372542a883e38c4d8c32c0ed.json @@ -0,0 +1,8 @@ +{ + "session_id": "39fe80c5372542a883e38c4d8c32c0ed", + "messages": [ + "review MCP tool" + ], + "input_tokens": 3, + "output_tokens": 13 +} \ No newline at end of file diff --git a/.port_sessions/3d0e8026689b4d7fbde88f9625ab1252.json b/.port_sessions/3d0e8026689b4d7fbde88f9625ab1252.json new file mode 100644 index 00000000..e16ccf75 --- /dev/null +++ b/.port_sessions/3d0e8026689b4d7fbde88f9625ab1252.json @@ -0,0 +1,8 @@ +{ + "session_id": "3d0e8026689b4d7fbde88f9625ab1252", + "messages": [ + "review MCP tool" + ], + "input_tokens": 3, + "output_tokens": 13 +} \ No newline at end of file diff --git a/.port_sessions/3db5e35d58ee42de8ee8b31fde9bec41.json b/.port_sessions/3db5e35d58ee42de8ee8b31fde9bec41.json new file mode 100644 index 00000000..3e3f8196 --- /dev/null +++ b/.port_sessions/3db5e35d58ee42de8ee8b31fde9bec41.json @@ -0,0 +1,9 @@ +{ + "session_id": "3db5e35d58ee42de8ee8b31fde9bec41", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/42746973a4df45cd90ba9368d65bced1.json b/.port_sessions/42746973a4df45cd90ba9368d65bced1.json new file mode 100644 index 00000000..b1c669b7 --- /dev/null +++ b/.port_sessions/42746973a4df45cd90ba9368d65bced1.json @@ -0,0 +1,9 @@ +{ + "session_id": "42746973a4df45cd90ba9368d65bced1", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/42d0893f63694c0e8b36fced30febb7c.json b/.port_sessions/42d0893f63694c0e8b36fced30febb7c.json new file mode 100644 index 00000000..2932d8d7 --- /dev/null +++ b/.port_sessions/42d0893f63694c0e8b36fced30febb7c.json @@ -0,0 +1,9 @@ +{ + "session_id": "42d0893f63694c0e8b36fced30febb7c", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/440c5c06b3f845f094b3e752863ef9d6.json b/.port_sessions/440c5c06b3f845f094b3e752863ef9d6.json new file mode 100644 index 00000000..689bc0b6 --- /dev/null +++ b/.port_sessions/440c5c06b3f845f094b3e752863ef9d6.json @@ -0,0 +1,8 @@ +{ + "session_id": "440c5c06b3f845f094b3e752863ef9d6", + "messages": [ + "review MCP tool" + ], + "input_tokens": 3, + "output_tokens": 13 +} \ No newline at end of file diff --git a/.port_sessions/4c7176c1674d4c1490c7fab6a52884ca.json b/.port_sessions/4c7176c1674d4c1490c7fab6a52884ca.json new file mode 100644 index 00000000..ba75b892 --- /dev/null +++ b/.port_sessions/4c7176c1674d4c1490c7fab6a52884ca.json @@ -0,0 +1,9 @@ +{ + "session_id": "4c7176c1674d4c1490c7fab6a52884ca", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/697bb82bafcb48fb9aa1ba20eb1a1ed7.json b/.port_sessions/697bb82bafcb48fb9aa1ba20eb1a1ed7.json new file mode 100644 index 00000000..18e4f8c4 --- /dev/null +++ b/.port_sessions/697bb82bafcb48fb9aa1ba20eb1a1ed7.json @@ -0,0 +1,9 @@ +{ + "session_id": "697bb82bafcb48fb9aa1ba20eb1a1ed7", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/6a88bbb345ed4389b9305ab0ba72f92c.json b/.port_sessions/6a88bbb345ed4389b9305ab0ba72f92c.json new file mode 100644 index 00000000..1e8f2835 --- /dev/null +++ b/.port_sessions/6a88bbb345ed4389b9305ab0ba72f92c.json @@ -0,0 +1,9 @@ +{ + "session_id": "6a88bbb345ed4389b9305ab0ba72f92c", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/71e1b2dfb4844b4b8c257ff8b3bd520e.json b/.port_sessions/71e1b2dfb4844b4b8c257ff8b3bd520e.json new file mode 100644 index 00000000..bdf8c059 --- /dev/null +++ b/.port_sessions/71e1b2dfb4844b4b8c257ff8b3bd520e.json @@ -0,0 +1,9 @@ +{ + "session_id": "71e1b2dfb4844b4b8c257ff8b3bd520e", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/8dde908596f74b728c433eb018964cd0.json b/.port_sessions/8dde908596f74b728c433eb018964cd0.json new file mode 100644 index 00000000..da069450 --- /dev/null +++ b/.port_sessions/8dde908596f74b728c433eb018964cd0.json @@ -0,0 +1,9 @@ +{ + "session_id": "8dde908596f74b728c433eb018964cd0", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/9a88809c13b24f1d8922542110b0cb15.json b/.port_sessions/9a88809c13b24f1d8922542110b0cb15.json new file mode 100644 index 00000000..498cc70b --- /dev/null +++ b/.port_sessions/9a88809c13b24f1d8922542110b0cb15.json @@ -0,0 +1,9 @@ +{ + "session_id": "9a88809c13b24f1d8922542110b0cb15", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/a9370872b5874d5082c3042ecccf851f.json b/.port_sessions/a9370872b5874d5082c3042ecccf851f.json new file mode 100644 index 00000000..e842111c --- /dev/null +++ b/.port_sessions/a9370872b5874d5082c3042ecccf851f.json @@ -0,0 +1,8 @@ +{ + "session_id": "a9370872b5874d5082c3042ecccf851f", + "messages": [ + "review MCP tool" + ], + "input_tokens": 3, + "output_tokens": 13 +} \ No newline at end of file diff --git a/.port_sessions/b83066e18a0a414886b142d1aef248ab.json b/.port_sessions/b83066e18a0a414886b142d1aef248ab.json new file mode 100644 index 00000000..cc8daa8c --- /dev/null +++ b/.port_sessions/b83066e18a0a414886b142d1aef248ab.json @@ -0,0 +1,9 @@ +{ + "session_id": "b83066e18a0a414886b142d1aef248ab", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/f22cc816662749c3a305a5a9fc76114f.json b/.port_sessions/f22cc816662749c3a305a5a9fc76114f.json new file mode 100644 index 00000000..370adcaf --- /dev/null +++ b/.port_sessions/f22cc816662749c3a305a5a9fc76114f.json @@ -0,0 +1,9 @@ +{ + "session_id": "f22cc816662749c3a305a5a9fc76114f", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/.port_sessions/f85a08b0f4254476a1245809cbee7bd3.json b/.port_sessions/f85a08b0f4254476a1245809cbee7bd3.json new file mode 100644 index 00000000..f484c8bb --- /dev/null +++ b/.port_sessions/f85a08b0f4254476a1245809cbee7bd3.json @@ -0,0 +1,9 @@ +{ + "session_id": "f85a08b0f4254476a1245809cbee7bd3", + "messages": [ + "review MCP tool", + "review MCP tool" + ], + "input_tokens": 6, + "output_tokens": 32 +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1d0533e2 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/OLLAMA_INTEGRATION.md b/OLLAMA_INTEGRATION.md new file mode 100644 index 00000000..84f3087e --- /dev/null +++ b/OLLAMA_INTEGRATION.md @@ -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. diff --git a/README.md b/README.md index d8b7039a..caae91a0 100644 --- a/README.md +++ b/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. diff --git a/src/agent_loop.py b/src/agent_loop.py new file mode 100644 index 00000000..bb88f718 --- /dev/null +++ b/src/agent_loop.py @@ -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[\w./-]+)(?:\s+lines?\s+(?P\d+)\s*(?:to|-)\s*(?P\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.+?)\s+for\s+['\"](?P[^'\"]+)['\"]", + 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[\w./-]+)\s+containing\s+(?P.+)', + 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())}) diff --git a/src/llm_backend.py b/src/llm_backend.py new file mode 100644 index 00000000..f880aac7 --- /dev/null +++ b/src/llm_backend.py @@ -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) diff --git a/src/main.py b/src/main.py index e1fa9eda..21c91315 100644 --- a/src/main.py +++ b/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 diff --git a/src/models.py b/src/models.py index d9fc2773..614335ce 100644 --- a/src/models.py +++ b/src/models.py @@ -37,6 +37,12 @@ class UsageSummary: ) +@dataclass(frozen=True) +class AssistantResponse: + content: str + raw: dict | None = None + + @dataclass class PortingBacklog: title: str diff --git a/src/runtime_tools.py b/src/runtime_tools.py new file mode 100644 index 00000000..f577d691 --- /dev/null +++ b/src/runtime_tools.py @@ -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]' diff --git a/src/task.py b/src/task.py index 1676d629..27b5ad10 100644 --- a/src/task.py +++ b/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'] diff --git a/tests/test_porting_workspace.py b/tests/test_porting_workspace.py index b332467d..b0bb9fd3 100644 --- a/tests/test_porting_workspace.py +++ b/tests/test_porting_workspace.py @@ -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()