From 052591281d59c3040fa6e9b5911eaba5170dcea4 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 9 Apr 2026 18:07:43 +0300 Subject: [PATCH] Initial clean version - Agent Maxim - Complete agent system with planning and execution - Bridge API with FastAPI server - WhatsApp-style mobile dashboard - Enhanced web search with query improvement - Security guards and policy checks - Comprehensive documentation --- .gitignore | 39 +++ README.md | 243 ++++++++++++++++ agent/README.md | 252 +++++++++++++++++ agent/core/__init__.py | 0 agent/core/agent_loop.py | 204 ++++++++++++++ agent/core/classifier.py | 47 ++++ agent/core/controller.py | 130 +++++++++ agent/core/resolver.py | 99 +++++++ agent/core/retry_policy.py | 35 +++ agent/core/state.py | 122 ++++++++ agent/main.py | 113 ++++++++ agent/planner/__init__.py | 0 agent/planner/planner.py | 165 +++++++++++ agent/planner/step_selector.py | 14 + agent/requirements.txt | 4 + agent/runtime/__init__.py | 0 agent/runtime/executor.py | 33 +++ agent/runtime/tool_registry.py | 32 +++ agent/runtime/tools/__init__.py | 0 agent/runtime/tools/file_ops.py | 69 +++++ agent/runtime/tools/flaky.py | 47 ++++ agent/runtime/tools/shell.py | 88 ++++++ agent/runtime/tools/web_fetch.py | 137 +++++++++ agent/runtime/tools/web_search.py | 189 +++++++++++++ agent/telemetry/__init__.py | 0 agent/telemetry/audit_logger.py | 43 +++ agent/tests/__init__.py | 0 agent/tests/test_sprint2.py | 119 ++++++++ bridge/README.md | 324 +++++++++++++++++++++ bridge/adapters/__init__.py | 28 ++ bridge/adapters/agent_adapter.py | 146 ++++++++++ bridge/adapters/base.py | 29 ++ bridge/adapters/cloudcode_adapter.py | 51 ++++ bridge/adapters/local_echo_adapter.py | 61 ++++ bridge/adapters/ollama_adapter.py | 219 +++++++++++++++ bridge/adapters/openai_adapter.py | 45 +++ bridge/adapters/stub_adapter.py | 25 ++ bridge/api/__init__.py | 0 bridge/api/chat_routes.py | 37 +++ bridge/api/controller_routes.py | 33 +++ bridge/api/routes.py | 117 ++++++++ bridge/app.py | 31 ++ bridge/chat/__init__.py | 0 bridge/chat/guard.py | 324 +++++++++++++++++++++ bridge/chat/responder.py | 161 +++++++++++ bridge/config.py | 31 ++ bridge/controller/__init__.py | 0 bridge/controller/controller_loop.py | 258 +++++++++++++++++ bridge/controller/controller_state.py | 62 ++++ bridge/controller/decision_model.py | 391 ++++++++++++++++++++++++++ bridge/controller/rewrite_rules.py | 48 ++++ bridge/core/__init__.py | 0 bridge/core/audit.py | 42 +++ bridge/core/instruction_builder.py | 30 ++ bridge/core/run_manager.py | 192 +++++++++++++ bridge/core/state_store.py | 30 ++ bridge/models/__init__.py | 0 bridge/models/schemas.py | 88 ++++++ bridge/policy/__init__.py | 0 bridge/policy/guard.py | 31 ++ bridge/test_agent_search.json | 4 + bridge/test_agent_search2.json | 4 + bridge/test_chat.json | 3 + bridge/test_request.json | 4 + bridge/test_simple.json | 4 + bridge/tests/__init__.py | 0 bridge/tests/test_sprint2.py | 134 +++++++++ requirements.txt | 4 + simple_dashboard.py | 341 ++++++++++++++++++++++ 69 files changed, 5556 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agent/README.md create mode 100644 agent/core/__init__.py create mode 100644 agent/core/agent_loop.py create mode 100644 agent/core/classifier.py create mode 100644 agent/core/controller.py create mode 100644 agent/core/resolver.py create mode 100644 agent/core/retry_policy.py create mode 100644 agent/core/state.py create mode 100644 agent/main.py create mode 100644 agent/planner/__init__.py create mode 100644 agent/planner/planner.py create mode 100644 agent/planner/step_selector.py create mode 100644 agent/requirements.txt create mode 100644 agent/runtime/__init__.py create mode 100644 agent/runtime/executor.py create mode 100644 agent/runtime/tool_registry.py create mode 100644 agent/runtime/tools/__init__.py create mode 100644 agent/runtime/tools/file_ops.py create mode 100644 agent/runtime/tools/flaky.py create mode 100644 agent/runtime/tools/shell.py create mode 100644 agent/runtime/tools/web_fetch.py create mode 100644 agent/runtime/tools/web_search.py create mode 100644 agent/telemetry/__init__.py create mode 100644 agent/telemetry/audit_logger.py create mode 100644 agent/tests/__init__.py create mode 100644 agent/tests/test_sprint2.py create mode 100644 bridge/README.md create mode 100644 bridge/adapters/__init__.py create mode 100644 bridge/adapters/agent_adapter.py create mode 100644 bridge/adapters/base.py create mode 100644 bridge/adapters/cloudcode_adapter.py create mode 100644 bridge/adapters/local_echo_adapter.py create mode 100644 bridge/adapters/ollama_adapter.py create mode 100644 bridge/adapters/openai_adapter.py create mode 100644 bridge/adapters/stub_adapter.py create mode 100644 bridge/api/__init__.py create mode 100644 bridge/api/chat_routes.py create mode 100644 bridge/api/controller_routes.py create mode 100644 bridge/api/routes.py create mode 100644 bridge/app.py create mode 100644 bridge/chat/__init__.py create mode 100644 bridge/chat/guard.py create mode 100644 bridge/chat/responder.py create mode 100644 bridge/config.py create mode 100644 bridge/controller/__init__.py create mode 100644 bridge/controller/controller_loop.py create mode 100644 bridge/controller/controller_state.py create mode 100644 bridge/controller/decision_model.py create mode 100644 bridge/controller/rewrite_rules.py create mode 100644 bridge/core/__init__.py create mode 100644 bridge/core/audit.py create mode 100644 bridge/core/instruction_builder.py create mode 100644 bridge/core/run_manager.py create mode 100644 bridge/core/state_store.py create mode 100644 bridge/models/__init__.py create mode 100644 bridge/models/schemas.py create mode 100644 bridge/policy/__init__.py create mode 100644 bridge/policy/guard.py create mode 100644 bridge/test_agent_search.json create mode 100644 bridge/test_agent_search2.json create mode 100644 bridge/test_chat.json create mode 100644 bridge/test_request.json create mode 100644 bridge/test_simple.json create mode 100644 bridge/tests/__init__.py create mode 100644 bridge/tests/test_sprint2.py create mode 100644 requirements.txt create mode 100644 simple_dashboard.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc0ed8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +agent/__pycache__/ +bridge/__pycache__/ +bridge/bridge.log +bridge/runs/ +agent/workspace/ +*.pyc +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo +*~ +.cache/ +.pytest_cache/ +.coverage +htmlcov/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb5030f --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# Agent Maxim + +AI-powered agent system with intelligent task planning, web search capabilities, and a modern mobile dashboard. + +## Overview + +Agent Maxim is an autonomous AI agent that can: +- **Understand complex tasks** using natural language processing +- **Plan multi-step solutions** with automatic tool selection +- **Execute web searches** with intelligent query enhancement +- **Process and analyze information** from multiple sources +- **Provide structured responses** through a clean chat interface + +## Architecture + +The system consists of three main components: + +### 1. Agent Core (`agent/`) +- **Planning System**: Breaks down complex goals into executable steps +- **Tool Registry**: Manages available tools (web_search, web_fetch, etc.) +- **Runtime Executor**: Handles tool execution with safety guards +- **State Management**: Tracks agent progress and decisions + +### 2. Bridge API (`bridge/`) +- **FastAPI Server**: RESTful API endpoint +- **Request Routing**: Forces agent-only execution (no direct LLM calls) +- **Policy Guards**: Security and safety checks +- **Run Management**: Persistent execution state + +### 3. Dashboard (`simple_dashboard.py`) +- **WhatsApp-style UI**: Modern mobile-friendly interface +- **Real-time Communication**: Direct agent interaction +- **Responsive Design**: Works on all screen sizes +- **Visual Feedback**: Loading states, formatted results + +## Installation + +### Prerequisites +- Python 3.10+ +- pip package manager + +### Setup +```bash +# Clone the repository +git clone +cd agent-maxim + +# Install dependencies +pip install -r requirements.txt +``` + +## Usage + +### Starting the Backend API +```bash +cd bridge +python app.py +``` + +The API will start on `http://localhost:8000` + +### Starting the Dashboard +```bash +python simple_dashboard.py +``` + +The dashboard will start on `http://localhost:8610` + +### Running the Agent Directly +```bash +cd agent +python main.py --goal "найди новости OpenAI" +python main.py --goal "inspect project" +python main.py --goal "create file hello.txt with text hello world" +``` + +## API Endpoints + +### POST /runs +Execute a one-shot agent run. + +**Request:** +```json +{ + "goal": "найди новости OpenAI", + "adapter": "agent" +} +``` + +**Response:** +```json +{ + "run_id": "unique-id", + "status": "completed", + "goal": "найди новости OpenAI", + "adapter": "agent", + "response": "Agent response..." +} +``` + +### GET /health +Check server status and adapter configuration. + +## Features + +### Intelligent Web Search +- **Query Enhancement**: Automatically improves search queries +- **Context Extraction**: Removes instruction prefixes +- **Source Filtering**: Focuses on quality tech sources +- **Result Formatting**: Clean presentation of search results + +### Planning System +- **Multi-step reasoning**: Breaks down complex tasks +- **Tool selection**: Chooses appropriate tools automatically +- **Fallback handling**: Graceful degradation on failures +- **State tracking**: Maintains execution context + +### Security & Safety +- **Workspace isolation**: File operations restricted to workspace +- **Command filtering**: Prevents dangerous operations +- **Policy guards**: Input validation and sanitization +- **Execution limits**: Timeout and resource constraints + +## Development + +### Project Structure +``` +agent-maxim/ +├── agent/ # Agent core system +│ ├── core/ # Planning and execution logic +│ ├── runtime/ # Tool execution environment +│ └── main.py # Direct agent entry point +├── bridge/ # API server +│ ├── api/ # REST endpoints +│ ├── adapters/ # Backend adapters +│ └── app.py # Server entry point +├── simple_dashboard.py # Mobile dashboard UI +└── requirements.txt # Python dependencies +``` + +### Testing +```bash +# Run all tests +pytest + +# Run specific tests +pytest tests/test_web_search.py +``` + +## Configuration + +### Environment Variables +- `OLLAMA_MODEL`: Default Ollama model (default: "qwen2.5:7b") +- `WORKSPACE_ROOT`: Workspace directory for file operations + +### Dashboard Configuration +The dashboard connects to the backend API at `/runs` endpoint. +Ensure the API server is running before starting the dashboard. + +## Deployment + +### Production Server +The system is currently deployed on: +- **API**: https://openclaw.kotkanagrilli.fi (port 8000) +- **Dashboard**: https://max-dashboard.kotkanagrilli.fi (port 8610) + +### Server Setup +```bash +# On production server +cd /home/anton/clear +# Start backend +cd bridge && python app.py +# Start dashboard (in separate terminal) +python simple_dashboard.py +``` + +## Example Use Cases + +### Web Search +``` +User: "найди новости OpenAI" +Agent: Performs enhanced search for latest OpenAI news + Returns formatted results with titles, snippets, and URLs +``` + +### Understanding Projects +``` +User: "inspect project" +Agent: Analyzes codebase structure + Provides overview of files and directories +``` + +### Task Planning +``` +User: "create a Python file that calculates factorial" +Agent: Plans steps: + 1. Create file structure + 2. Write factorial function + 3. Add test cases + Executes steps sequentially +``` + +## Troubleshooting + +### Common Issues + +**Dashboard not connecting to API** +- Ensure backend API is running on port 8000 +- Check firewall settings +- Verify API endpoint is accessible + +**Agent not responding** +- Check backend logs for errors +- Verify agent dependencies are installed +- Ensure workspace directory exists and is writable + +**Web search returning poor results** +- Check internet connectivity +- Verify search API is accessible +- Review query enhancement logs + +## Contributing + +Contributions are welcome! Please follow these guidelines: +- Use the existing code style (4 spaces, max 100 char lines) +- Add tests for new features +- Update documentation for API changes +- Follow security best practices + +## License + +This project is part of the Agent Maxim system. + +## Support + +For issues and questions: +- Check existing documentation +- Review code comments and docstrings +- Examine execution logs for debugging + +--- + +**Agent Maxim** - Intelligent AI Agent for Task Automation and Information Retrieval \ No newline at end of file diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..c1f4b27 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,252 @@ +# Minimal Viable Agent — Stage 1 + +Local-first agent with a deterministic planner. No LLM, no external dependencies. + +## What Is Implemented + +- Goal → Plan → Step selection → Execute → Observe → Update state → Finish +- Deterministic keyword-based planner (no LLM at Stage 1) +- Four step kinds: `shell`, `write_file`, `read_file`, `finish` +- Shell allowlist enforcement (`shell=False`, no raw strings) +- File ops path traversal guard (resolves + prefix check) +- State persistence per run (`workspace/runs//state.json`) +- Append-only JSONL audit log (`workspace/runs//audit.jsonl`) + +## Project Structure + +``` +agent/ + main.py CLI entry point + core/ + state.py Dataclass models: AgentState, Plan, PlanStep, StepResult + controller.py Builds state, inits AuditLogger/Executor, starts run + agent_loop.py Main select → execute → update loop + planner/ + planner.py Deterministic plan builder + step_selector.py Picks next PENDING step + runtime/ + executor.py Dispatches steps to tools, catches all exceptions + tool_registry.py Maps kind strings to handler functions + tools/ + shell.py Safe shell execution (allowlist, shell=False) + file_ops.py write_file, read_file, finish handlers + telemetry/ + audit_logger.py JSONL audit log writer + workspace/ + runs// + state.json Full run state (updated after each step) + audit.jsonl Append-only event log +``` + +## How to Run + +```bash +cd agent/ +python main.py --goal "inspect project" +python main.py --goal "create file hello.txt with text hello world" +python main.py --goal "проверь что тут есть" +python main.py --goal "anything else" # default plan +python main.py --goal "inspect project" --run-id my-run-01 +``` + +## Allowed Shell Commands + +`pwd`, `ls`, `echo`, `cat`, `python --version`, `git status` + +Blocked: `bash`, `sh`, `rm`, `sudo`, `curl`, `wget`, `chmod`, `chown`, `docker`, `systemctl` + +## Negative Test Scenarios + +```bash +# Shell security +python main.py --goal "test deny bash" # blocked: bash is in BLOCKED_COMMANDS +python main.py --goal "test deny rm" # blocked: rm is in BLOCKED_COMMANDS +python main.py --goal "test deny curl" # blocked: curl is in BLOCKED_COMMANDS +python main.py --goal "test forbidden command" # alias for test deny bash + +# File security +python main.py --goal "test traversal" # blocked: ../escape.txt outside workspace +python main.py --goal "test absolute path" # blocked: /tmp/test.txt outside workspace +``` + +All negative scenarios exit with code 1 and produce `status: failed` in state.json. +No blocked command or file is ever executed or created. + +## Final State Semantics + +| Scenario | `run.status` | `current_step_id` | `history` | +|---|---|---|---| +| All steps done | `done` | `null` | all steps | +| Step failed | `failed` | `null` | steps up to failure | +| MAX_STEPS exceeded | `failed` | `null` | all executed steps | + +`current_step_id`: +- Set to the step being executed **during** a step +- Reset to `null` when the run terminates (done or failed) + +## Security Boundaries + +**Shell:** +- `subprocess.run(..., shell=False)` — always, unconditionally +- BLOCKED_COMMANDS checked before ALLOWED_COMMANDS — explicit deny wins +- Commands not in ALLOWED_COMMANDS are denied even if not in BLOCKED_COMMANDS +- `python` and `git` have restricted sub-commands: only `--version` and `status` respectively + +**File ops:** +- All paths resolved with `Path.resolve()` before access +- Prefix check: `str(target).startswith(str(WORKSPACE_ROOT))` +- Blocks: `../` traversal, absolute paths, normalized mixed traversal +- WORKSPACE_ROOT = `agent/workspace/` (confirmed via `parents[2]`) + +## Audit Events (per run) + +Every run produces these events in order: +``` +run_started → plan_created → state_saved +→ (per step: step_started → step_finished → state_saved) +→ run_finished +``` + +## Run Directory Layout + +``` +workspace/runs// + audit.jsonl one JSON record per line, append-only + state.json full AgentState snapshot, overwritten after each step +``` + +## Stage 2A — Inter-Step Data Flow (Templates) + +Steps can reference outputs of previous steps via template expressions in `payload` values. + +### Supported expressions + +| Expression | Resolves to | +|---|---| +| `{{ last.output_text }}` | `output_text` of the most recently completed step | +| `{{ steps.step-1.output_text }}` | `output_text` of the step with `step_id = "step-1"` | + +### How it works + +1. Before each step executes, `has_templates(payload)` checks for `{{ ... }}` patterns +2. If found, `resolve_payload(payload, state)` substitutes all expressions +3. Executor receives the fully-resolved payload — tools never see raw templates +4. If any expression fails (step not in history, no output), the step is marked `FAILED` immediately, run terminates + +### Example plan + +``` +step-1: shell { command: "pwd" } +step-2: write_file { path: "report.txt", content: "{{ last.output_text }}" } +step-3: read_file { path: "report.txt" } +step-4: finish +``` + +step-2 payload is resolved to `{ path: "report.txt", content: "/workspace/agent\n" }` before writing. + +### Audit event + +```json +{ "event_type": "resolution_performed", "step_id": "step-2", + "status": "ok", "details": { "templates_found": true, "success": true } } +``` + +On failure: `"status": "failed"`, `"details": { "error": "..." }` + +### Flow test goals + +```bash +python main.py --goal "test flow pwd to file" # {{ last.output_text }} +python main.py --goal "test flow read to copy" # {{ last.output_text }} chained +python main.py --goal "test flow ls to file" # {{ steps.step-1.output_text }} +python main.py --goal "test flow bad template" # resolution fails → FAILED +``` + +## Stage 2B — Failure-Aware Retry + +### Failure classification + +| Class | Trigger | +|---|---| +| `security_denied` | "blocked by security policy" / "not in allowlist" | +| `template_error` | Template resolution failure (`{{ }}`) | +| `file_error` | File not found / path traversal / missing payload key | +| `execution_error` | Shell non-zero exit / timeout / `execution_error:` prefix | +| `unknown_error` | Everything else | + +### Retry policy + +| Class | Max attempts | Retry? | +|---|---|---| +| `security_denied` | 1 | Never | +| `template_error` | 1 | Never | +| `file_error` | 2 | 1 retry | +| `execution_error` | 2 | 1 retry | +| `unknown_error` | 1 | Never | + +Hard cap: 2 attempts per step maximum, regardless of policy. + +### Attempt tracking + +`PlanStep.attempts` — incremented before each execution attempt (0 = not yet started) +`StepResult.attempt` — which attempt produced this result (1-based) +`StepResult.failure_class` — set on FAILED results, null on DONE + +Both fields are persisted in `state.json`. + +### History model + +Multiple StepResult records with the same `step_id` indicate retry: +```json +{"step_id":"step-1","status":"failed","attempt":1,"failure_class":"execution_error"} +{"step_id":"step-1","status":"done","attempt":2,"failure_class":null} +``` + +### Audit events (retry path) + +``` +step_started → attempt 1 +step_finished → attempt 1 (failed) +step_failed → failure_class=execution_error, retry_scheduled=true +retry_scheduled → next_attempt=2 +retry_attempt_started → attempt 2 +retry_attempt_finished → attempt 2 (done or failed) +``` + +For non-retriable failures: +``` +step_started → step_finished(failed) → step_failed(retry_scheduled=false) → run terminates +``` + +### Recovery test goals + +```bash +python main.py --goal "test retry execution" # flaky step: fails once → retries → DONE +python main.py --goal "test deny bash" # security_denied → no retry → FAILED +python main.py --goal "test flow bad template" # template_error → no retry → FAILED +``` + +### New files + +| File | Purpose | +|---|---| +| `core/classifier.py` | Maps (kind, error) → FailureClass | +| `core/retry_policy.py` | Max attempts per FailureClass, should_retry() | +| `runtime/tools/flaky.py` | Deterministic fail-once tool for testing | + +## Stage 1 Limitations + +- Planner is deterministic (keyword matching), not LLM-driven +- Plans are always 3 steps (2 for file security tests) — no dynamic plan length +- ~~No inter-step data passing~~ — resolved in Stage 2A via template system +- Shell allowlist is hardcoded in `shell.py`, not loaded from config +- No retry logic on step failure +- No human-in-the-loop approval gate +- Windows only: `ls`/`pwd` work via Git bash; fails on pure Windows without Git + +## Stage 1.2 Changes (Consistency Fixes) + +- `current_step_id` → `null` after run terminates (done or failed) +- Added security test keywords: `test deny rm`, `test deny curl`, `test traversal`, `test absolute path` +- Fixed `WORKSPACE_ROOT` bug: was `parents[3]` (wrong), now `parents[2]` (correct) +- audit events now include: `run_started`, `plan_created`, `state_saved`, `step_started`, `step_finished`, `run_finished` diff --git a/agent/core/__init__.py b/agent/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/core/agent_loop.py b/agent/core/agent_loop.py new file mode 100644 index 0000000..2b8a830 --- /dev/null +++ b/agent/core/agent_loop.py @@ -0,0 +1,204 @@ +"""Main agent execution loop: select → resolve → execute → retry? → update → persist → repeat.""" +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +from core.classifier import FailureClass, classify +from core.resolver import has_templates, resolve_payload +from core.retry_policy import should_retry +from core.state import AgentState, RunStatus, StepResult, StepStatus +from planner.step_selector import select_next_step +from runtime.executor import Executor +from telemetry.audit_logger import AuditLogger + +MAX_STEPS = 20 + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def run_loop( + state: AgentState, + executor: Executor, + logger: AuditLogger, + runs_dir: Path, +) -> AgentState: + """Main loop: select → resolve → execute → retry-or-continue → save → repeat. + + Terminates when: + - no pending steps remain (DONE) + - template resolution fails and no retry allowed (FAILED) + - a step fails and no retry allowed (FAILED) + - a 'finish' step completes (DONE) + - MAX_STEPS is exceeded (FAILED) + """ + from core.controller import save_state # avoid circular import + + for _ in range(MAX_STEPS): + step = select_next_step(state.plan) + if step is None: + state.status = RunStatus.DONE + break + + # Track attempt number (0 = first time through) + is_retry = step.attempts > 0 + step.attempts += 1 + current_attempt = step.attempts + + # pending → running + step.status = StepStatus.RUNNING + state.current_step_id = step.step_id + state.updated_at = _now() + + if is_retry: + logger.log( + "retry_attempt_started", + step_id=step.step_id, + status="running", + details={"kind": step.kind, "attempt": current_attempt}, + ) + else: + logger.log( + "step_started", + step_id=step.step_id, + status="running", + details={"kind": step.kind, "description": step.description}, + ) + + # --- Template resolution (before executor) --- + if has_templates(step.payload): + resolved_payload, ok, err = resolve_payload(step.payload, state) + logger.log( + "resolution_performed", + step_id=step.step_id, + status="ok" if ok else "failed", + details={"templates_found": True, "success": ok, "error": err if not ok else None}, + ) + if not ok: + result = StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Template resolution failed: {err}", + started_at=_now(), + finished_at=_now(), + attempt=current_attempt, + failure_class=FailureClass.TEMPLATE_ERROR.value, + ) + step.status = StepStatus.FAILED + state.history.append(result) + state.updated_at = _now() + logger.log( + "retry_attempt_finished" if is_retry else "step_finished", + step_id=step.step_id, + status="failed", + details={"error": result.error, "attempt": current_attempt}, + ) + logger.log( + "step_failed", + step_id=step.step_id, + status="failed", + details={ + "failure_class": FailureClass.TEMPLATE_ERROR.value, + "attempt": current_attempt, + "retry_scheduled": False, + "reason": "template_error is never retried", + }, + ) + state.status = RunStatus.FAILED + state.current_step_id = None + save_state(state, runs_dir, logger) + break + step.payload = resolved_payload + else: + if step.kind not in ("finish",): + logger.log( + "resolution_performed", + step_id=step.step_id, + status="ok", + details={"templates_found": False, "success": True}, + ) + + # --- Execute --- + started = _now() + result = executor.execute(step) + result.started_at = started + result.finished_at = _now() + result.attempt = current_attempt + + logger.log( + "retry_attempt_finished" if is_retry else "step_finished", + step_id=step.step_id, + status=result.status.value, + details={ + "output_text": (result.output_text or "")[:300], + "error": result.error, + "attempt": current_attempt, + }, + ) + + # --- Retry / fail decision --- + if result.status == StepStatus.FAILED: + failure_class = classify(step.kind, result.error or "") + result.failure_class = failure_class.value + retry_ok = should_retry(failure_class, step.attempts) + + logger.log( + "step_failed", + step_id=step.step_id, + status="failed", + details={ + "failure_class": failure_class.value, + "attempt": current_attempt, + "retry_scheduled": retry_ok, + }, + ) + + if retry_ok: + step.status = StepStatus.PENDING # reset for retry + state.history.append(result) + state.updated_at = _now() + logger.log( + "retry_scheduled", + step_id=step.step_id, + status="ok", + details={ + "attempt_just_failed": current_attempt, + "next_attempt": current_attempt + 1, + "failure_class": failure_class.value, + }, + ) + save_state(state, runs_dir, logger) + # Loop continues — select_next_step will pick up PENDING step again + else: + step.status = StepStatus.FAILED + state.history.append(result) + state.updated_at = _now() + state.status = RunStatus.FAILED + state.current_step_id = None + save_state(state, runs_dir, logger) + break + + else: + # Step succeeded + step.status = StepStatus.DONE + state.history.append(result) + state.updated_at = _now() + save_state(state, runs_dir, logger) + + if step.kind == "finish": + state.status = RunStatus.DONE + state.current_step_id = None + break + + else: + state.status = RunStatus.FAILED + state.current_step_id = None + logger.log( + "run_aborted", + status="failed", + details={"reason": f"Exceeded MAX_STEPS ({MAX_STEPS})"}, + ) + + return state diff --git a/agent/core/classifier.py b/agent/core/classifier.py new file mode 100644 index 0000000..0f7ef20 --- /dev/null +++ b/agent/core/classifier.py @@ -0,0 +1,47 @@ +"""Failure classifier for agent step errors. + +Maps (step_kind, error_text) → FailureClass. +Used by retry_policy to decide whether and how to retry. +""" +from __future__ import annotations + +from enum import Enum + + +class FailureClass(str, Enum): + SECURITY_DENIED = "security_denied" # blocked by security policy + TEMPLATE_ERROR = "template_error" # {{ }} resolution failed + FILE_ERROR = "file_error" # file not found / path error + EXECUTION_ERROR = "execution_error" # subprocess failure / transient + UNKNOWN_ERROR = "unknown_error" # catch-all + + +def classify(step_kind: str, error: str) -> FailureClass: + """Classify a step failure from its kind and error message.""" + if not error: + return FailureClass.UNKNOWN_ERROR + + err = error.lower() + + # Security policy rejections — cannot be retried + if "blocked by security policy" in err or "is not in allowlist" in err: + return FailureClass.SECURITY_DENIED + + # Template resolution failures — logic errors, cannot be retried + if "template resolution failed" in err or "could not be resolved" in err: + return FailureClass.TEMPLATE_ERROR + + # File system errors — may be transient (e.g. write race), limited retry + if step_kind in ("write_file", "read_file"): + if any(kw in err for kw in ("not found", "path traversal", "missing 'path'", "no such file")): + return FailureClass.FILE_ERROR + + # Explicit execution_error tag (used by flaky test tool) + if err.startswith("execution_error"): + return FailureClass.EXECUTION_ERROR + + # Shell non-zero exit or binary not found + if step_kind == "shell" and any(kw in err for kw in ("non-zero exit code", "not found on path", "timed out")): + return FailureClass.EXECUTION_ERROR + + return FailureClass.UNKNOWN_ERROR diff --git a/agent/core/controller.py b/agent/core/controller.py new file mode 100644 index 0000000..86341cb --- /dev/null +++ b/agent/core/controller.py @@ -0,0 +1,130 @@ +"""Agent controller — builds initial state and starts the run. + +This is the single entry point used by main.py. +It owns: state construction, AuditLogger init, Executor init, final state save. +The loop itself lives in agent_loop.py. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +from core.agent_loop import run_loop +from core.state import AgentState, RunStatus +from planner.planner import PlannerError, build_plan +from runtime.executor import Executor +from runtime.tool_registry import ToolRegistry +from telemetry.audit_logger import AuditLogger + + +def run_agent(run_id: str, goal: str, workspace_root: Path, trace_id: str | None = None) -> AgentState: + """Orchestrate a full agent run from goal string to final AgentState. + + Flow: + build_plan → log plan_created → run_loop → save final state → log run_finished + """ + runs_dir = workspace_root / "runs" + logger = AuditLogger(run_id=run_id, runs_dir=runs_dir, trace_id=trace_id) + + try: + plan = build_plan(goal) + except PlannerError as exc: + now = datetime.now(timezone.utc).isoformat() + from core.state import Plan + failed_state = AgentState( + run_id=run_id, + goal=goal, + plan=Plan(steps=[]), + current_step_id=None, + history=[], + started_at=now, + updated_at=now, + status=RunStatus.FAILED, + trace_id=trace_id, + ) + runs_dir = workspace_root / "runs" + logger.log("run_failed", status="failed", details={"reason": str(exc)}) + save_state(failed_state, runs_dir, logger=None) + return failed_state + + now = datetime.now(timezone.utc).isoformat() + + state = AgentState( + run_id=run_id, + goal=goal, + plan=plan, + current_step_id=None, + history=[], + started_at=now, + updated_at=now, + status=RunStatus.RUNNING, + trace_id=trace_id, + ) + + logger.log( + "run_started", + status="running", + details={"goal": goal}, + ) + logger.log( + "plan_created", + status="ok", + details={"step_count": len(plan.steps), "steps": [s.to_dict() for s in plan.steps]}, + ) + + save_state(state, runs_dir, logger) + + state = run_loop(state, executor=Executor(registry=ToolRegistry()), logger=logger, runs_dir=runs_dir) + + # Final save — loop already saves after each step, this captures terminal run status + save_state(state, runs_dir, logger=None) # no event: loop already logged last state_saved + + # Validation: ensure search tasks included web_fetch + search_keywords = ["search", "найди", "поиск", "news", "новости", "найти информацию", "find"] + is_search_task = any(kw in goal.lower() for kw in search_keywords) + + if is_search_task and state.status == RunStatus.DONE: + has_web_search = any(s.kind == "web_search" for s in plan.steps) + has_web_fetch = any(s.kind == "web_fetch" for s in plan.steps) + + if has_web_search and not has_web_fetch: + # This shouldn't happen if planner is configured correctly + logger.log( + "validation_warning", + status="warning", + details={ + "reason": "search task completed without web_fetch", + "goal": goal, + "steps": [s.kind for s in plan.steps] + } + ) + + logger.log( + "run_finished", + status=state.status.value, + details={ + "total_steps": len(plan.steps), + "completed": sum(1 for s in plan.steps if s.status.value == "done"), + "failed": sum(1 for s in plan.steps if s.status.value == "failed"), + }, + ) + + return state + + +def save_state(state: AgentState, runs_dir: Path, logger: "Optional[AuditLogger]" = None) -> None: + """Persist current AgentState to state.json and emit state_saved event.""" + run_dir = runs_dir / state.run_id + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "state.json").write_text( + json.dumps(state.to_dict(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + if logger: + logger.log( + "state_saved", + step_id=state.current_step_id, + status="ok", + details={"run_status": state.status.value, "current_step_id": state.current_step_id}, + ) diff --git a/agent/core/resolver.py b/agent/core/resolver.py new file mode 100644 index 0000000..b73c307 --- /dev/null +++ b/agent/core/resolver.py @@ -0,0 +1,99 @@ +"""Template resolution for step payloads. + +Supported patterns: + {{ last.output_text }} — output_text of the most recent step in history + {{ steps.step-1.output_text }} — output_text of a specific step by step_id + +Resolution is called BEFORE executor.execute(step) in agent_loop. +Tools never see unresolved templates. +""" +from __future__ import annotations + +import re +from typing import Any, Optional + +from core.state import AgentState + +# Matches {{ expr }} with optional surrounding whitespace +_TMPL_RE = re.compile(r"\{\{\s*([\w.\-]+)\s*\}\}") + + +def resolve_payload( + payload: dict[str, Any], state: AgentState +) -> tuple[dict[str, Any], bool, str]: + """Resolve all template expressions in a step payload. + + Returns: + (resolved_payload, success, error_message) + On failure: ({}, False, "reason") + On success: (resolved_dict, True, "") + """ + resolved: dict[str, Any] = {} + for key, value in payload.items(): + if isinstance(value, str) and _TMPL_RE.search(value): + new_val, ok, err = _resolve_string(value, state) + if not ok: + return {}, False, err + resolved[key] = new_val + else: + resolved[key] = value + return resolved, True, "" + + +def has_templates(payload: dict[str, Any]) -> bool: + """Return True if any string value in payload contains a template.""" + return any( + isinstance(v, str) and bool(_TMPL_RE.search(v)) + for v in payload.values() + ) + + +def _resolve_string(s: str, state: AgentState) -> tuple[str, bool, str]: + """Replace all {{ expr }} occurrences in s. Returns (result, ok, error).""" + errors: list[str] = [] + + def replacer(m: re.Match) -> str: + expr = m.group(1).strip() + val, ok, err = _eval_expr(expr, state) + if not ok: + errors.append(err) + return "" + return val if val is not None else "" + + result = _TMPL_RE.sub(replacer, s) + if errors: + return "", False, errors[0] + return result, True, "" + + +def _eval_expr(expr: str, state: AgentState) -> tuple[Optional[str], bool, str]: + """Evaluate a single template expression against agent state.""" + + # {{ last.output_text }} + if expr == "last.output_text": + val = state.get_last_output() + if val is None: + return None, False, ( + "Template '{{ last.output_text }}' could not be resolved: " + "history is empty or last step produced no output" + ) + return val, True, "" + + # {{ steps..output_text }} + if expr.startswith("steps.") and expr.endswith("..output_text"): + # "steps.step-1.output_text" → split on first and last dot + # Use rsplit to handle step-ids that might contain dots (unlikely but safe) + without_prefix = expr[len("steps."):] # "step-1.output_text" + if without_prefix.endswith(".output_text"): + step_id = without_prefix[: -len(".output_text")] # "step-1" + if not step_id: + return None, False, f"Template '{{{{ {expr} }}}}' has empty step_id" + val = state.get_step_output(step_id) + if val is None: + return None, False, ( + f"Template '{{{{ {expr} }}}}' could not be resolved: " + f"step '{step_id}' not found in history or has no output" + ) + return val, True, "" + + return None, False, f"Unknown template expression: '{{{{ {expr} }}}}'" diff --git a/agent/core/retry_policy.py b/agent/core/retry_policy.py new file mode 100644 index 0000000..eece8f8 --- /dev/null +++ b/agent/core/retry_policy.py @@ -0,0 +1,35 @@ +"""Retry policy for agent step failures. + +Rules (max_attempts = total attempts allowed, including first): + security_denied → 1 (no retry — policy decision is final) + template_error → 1 (no retry — logic error, retry won't help) + file_error → 2 (1 retry — may be transient write/read race) + execution_error → 2 (1 retry — subprocess may fail transiently) + unknown_error → 1 (no retry by default — unknown cause) + +Hard cap: MAX_ATTEMPTS_PER_STEP = 2 (enforced regardless of policy). +This prevents any single step from consuming more than 2 iterations. +""" +from __future__ import annotations + +from core.classifier import FailureClass + +MAX_ATTEMPTS_PER_STEP: int = 2 + +_POLICY: dict[str, int] = { + FailureClass.SECURITY_DENIED.value: 1, + FailureClass.TEMPLATE_ERROR.value: 1, + FailureClass.FILE_ERROR.value: 2, + FailureClass.EXECUTION_ERROR.value: 2, + FailureClass.UNKNOWN_ERROR.value: 1, +} + + +def max_attempts(failure_class: FailureClass) -> int: + """Return the maximum allowed attempts (including the first) for this failure class.""" + return min(_POLICY.get(failure_class.value, 1), MAX_ATTEMPTS_PER_STEP) + + +def should_retry(failure_class: FailureClass, attempts_so_far: int) -> bool: + """Return True if another attempt is allowed.""" + return attempts_so_far < max_attempts(failure_class) diff --git a/agent/core/state.py b/agent/core/state.py new file mode 100644 index 0000000..c374c0e --- /dev/null +++ b/agent/core/state.py @@ -0,0 +1,122 @@ +"""State models for the agent runtime.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any, Optional + + +class StepStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + + +class RunStatus(str, Enum): + RUNNING = "running" + DONE = "done" + FAILED = "failed" + + +@dataclass +class PlanStep: + """A single step within an agent plan.""" + + step_id: str + kind: str # "shell" | "write_file" | "read_file" | "finish" | "flaky" + description: str + payload: dict[str, Any] + status: StepStatus = StepStatus.PENDING + attempts: int = 0 # how many times this step has been attempted (0 = not yet started) + + def to_dict(self) -> dict[str, Any]: + d = asdict(self) + d["status"] = self.status.value + return d + + +@dataclass +class StepResult: + """Result of executing a single plan step.""" + + step_id: str + status: StepStatus + output_text: Optional[str] = None + error: Optional[str] = None + metadata: dict[str, Any] = field(default_factory=dict) + started_at: str = "" + finished_at: str = "" + attempt: int = 1 # which attempt produced this result (1-based) + failure_class: Optional[str] = None # set on FAILED results + + def to_dict(self) -> dict[str, Any]: + return { + "step_id": self.step_id, + "status": self.status.value, + "output_text": self.output_text, + "error": self.error, + "metadata": self.metadata, + "started_at": self.started_at, + "finished_at": self.finished_at, + "attempt": self.attempt, + "failure_class": self.failure_class, + } + + +@dataclass +class Plan: + """Ordered list of steps to accomplish a goal.""" + + steps: list[PlanStep] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return {"steps": [s.to_dict() for s in self.steps]} + + +@dataclass +class AgentState: + """Full runtime state of a single agent run.""" + + run_id: str + goal: str + plan: Plan + current_step_id: Optional[str] + history: list[StepResult] + started_at: str + updated_at: str + status: RunStatus + trace_id: Optional[str] = None + + # --- History access helpers --- + + def get_last_output(self) -> Optional[str]: + """Return output_text of the most recently completed step, or None.""" + for result in reversed(self.history): + if result.output_text is not None: + return result.output_text + return None + + def get_step_output(self, step_id: str) -> Optional[str]: + """Return output_text for a specific step_id, or None if not found.""" + for result in self.history: + if result.step_id == step_id: + return result.output_text + return None + + def list_outputs(self) -> list[tuple[str, Optional[str]]]: + """Return [(step_id, output_text)] for all history entries.""" + return [(r.step_id, r.output_text) for r in self.history] + + def to_dict(self) -> dict[str, Any]: + return { + "run_id": self.run_id, + "trace_id": self.trace_id, + "goal": self.goal, + "plan": self.plan.to_dict(), + "current_step_id": self.current_step_id, + "history": [r.to_dict() for r in self.history], + "started_at": self.started_at, + "updated_at": self.updated_at, + "status": self.status.value, + } diff --git a/agent/main.py b/agent/main.py new file mode 100644 index 0000000..73b5bfe --- /dev/null +++ b/agent/main.py @@ -0,0 +1,113 @@ +"""CLI entry point for the Minimal Viable Agent — Stage 1.""" +from __future__ import annotations + +import argparse +import sys +import uuid +from pathlib import Path + +# Ensure the agent/ directory is on sys.path so imports work +sys.path.insert(0, str(Path(__file__).parent)) + +from core.controller import run_agent +from core.state import RunStatus, StepStatus + +WORKSPACE_ROOT = Path(__file__).parent / "workspace" + + +def main() -> None: + import os + import sys + + parser = argparse.ArgumentParser( + description="Minimal Viable Agent — Stage 1", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +examples: + python main.py --goal "inspect project" + python main.py --goal "create file hello.txt with text hello world" + python main.py --goal "проверь что тут есть" + """, + ) + parser.add_argument("--goal", required=True, help="Goal for the agent to accomplish") + parser.add_argument("--run-id", default=None, help="Optional run ID (auto-generated if omitted)") + parser.add_argument("--trace-id", default=None, help="Trace ID for cross-layer observability") + args = parser.parse_args() + + run_id = args.run_id or str(uuid.uuid4())[:8] + + print(f"[agent] run_id : {run_id}") + print(f"[agent] workspace: {WORKSPACE_ROOT / 'runs' / run_id}") + print() + + # Safe printing with encoding fallback (defined early for logging) + def safe_print(text: str, indent: int = 0) -> None: + """Print text safely by replacing unencodable characters.""" + try: + print(f"{' ' * indent}{text}") + except UnicodeEncodeError: + # Replace characters that can't be encoded + cleaned = text.encode(sys.stdout.encoding or 'utf-8', errors='replace').decode(sys.stdout.encoding or 'utf-8') + print(f"{' ' * indent}{cleaned}") + + # === AGENT EXECUTION LOGGING START === + safe_print("=== AGENT START ===") + safe_print(f"GOAL: {args.goal}") + print() + + state = run_agent(run_id=run_id, goal=args.goal, workspace_root=WORKSPACE_ROOT, trace_id=args.trace_id) + + # === AFTER PLANNER LOGGING === + safe_print("=== AFTER PLANNER ===") + try: + print(f"Plan steps: {len(state.plan.steps)}") + for step in state.plan.steps: + print(f" - {step.step_id}: {step.kind} - {step.description}") + except Exception as e: + print(f"Error accessing plan: {e}") + print() + + # === STEPS EXECUTION LOGGING === + safe_print("=== STEPS EXECUTION ===") + try: + for step in state.plan.steps: + step_result = next((r for r in state.history if r.step_id == step.step_id), None) + if step_result: + status = "DONE" if step_result.status == StepStatus.DONE else "FAILED" + print(f" {step.step_id}: {step.kind} - {status}") + else: + print(f" {step.step_id}: {step.kind} - NOT EXECUTED") + except Exception as e: + print(f"Error accessing steps: {e}") + print() + + # === AGENT EXECUTION LOGGING END === + safe_print("=== AGENT END ===") + print() + + # Print results + total = len(state.plan.steps) + done = sum(1 for s in state.plan.steps if s.status == StepStatus.DONE) + failed = sum(1 for s in state.plan.steps if s.status == StepStatus.FAILED) + + print(f"[agent] status : {state.status.value.upper()}") + print(f"[agent] steps : {done} done, {failed} failed, {total} total") + print() + + for result in state.history: + step = next((s for s in state.plan.steps if s.step_id == result.step_id), None) + kind = step.kind if step else "?" + icon = "[ok]" if result.status == StepStatus.DONE else "[!]" + safe_print(f"{icon} {result.step_id} ({kind})") + if result.output_text: + for line in result.output_text.rstrip().splitlines(): + safe_print(line, indent=6) + if result.error: + safe_print(f"ERROR: {result.error}", indent=6) + + print() + sys.exit(0 if state.status == RunStatus.DONE else 1) + + +if __name__ == "__main__": + main() diff --git a/agent/planner/__init__.py b/agent/planner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/planner/planner.py b/agent/planner/planner.py new file mode 100644 index 0000000..789dc97 --- /dev/null +++ b/agent/planner/planner.py @@ -0,0 +1,165 @@ +"""Deterministic planner — Stage 1 (no LLM required).""" +from __future__ import annotations + +import os +import re + +from core.state import Plan, PlanStep + +TEST_MODE: bool = os.getenv("AGENT_TEST_MODE") == "1" + + +def build_plan(goal: str) -> Plan: + """Build a deterministic plan based on keywords in the goal. + + Security test paths (only when AGENT_TEST_MODE=1): + "test deny bash" → pwd + bash (blocked) + finish + "test deny rm" → pwd + rm (blocked) + finish + "test deny curl" → pwd + curl (blocked) + finish + "test forbidden command" → alias for test deny bash + "test traversal" → write_file with ../escape.txt (blocked) + "test absolute path" → write_file with /tmp/test.txt (blocked) + + Normal paths: + "inspect" / "analyze" / "посмотри" / "проверь" → pwd + ls + finish + "create file" / "создай файл" / "write file" → write_file + read_file + finish + (no match) → PlannerError + """ + goal_lower = goal.lower() + + # --- Security test paths (TEST_MODE only) --- + if TEST_MODE and "test deny rm" in goal_lower: + steps = [ + PlanStep("step-1", "shell", "Allowed: pwd", {"command": "pwd"}), + PlanStep("step-2", "shell", "BLOCKED: rm (security test)", {"command": "rm -rf /tmp"}), + PlanStep("step-3", "finish", "Task complete", {}), + ] + + elif TEST_MODE and "test deny curl" in goal_lower: + steps = [ + PlanStep("step-1", "shell", "Allowed: pwd", {"command": "pwd"}), + PlanStep("step-2", "shell", "BLOCKED: curl (security test)", {"command": "curl http://example.com"}), + PlanStep("step-3", "finish", "Task complete", {}), + ] + + elif TEST_MODE and any(kw in goal_lower for kw in ("test deny bash", "test forbidden command")): + steps = [ + PlanStep("step-1", "shell", "Allowed: pwd", {"command": "pwd"}), + PlanStep("step-2", "shell", "BLOCKED: bash (security test)", {"command": "bash -c echo hi"}), + PlanStep("step-3", "finish", "Task complete", {}), + ] + + elif TEST_MODE and "test traversal" in goal_lower: + steps = [ + PlanStep("step-1", "write_file", "BLOCKED: path traversal ../escape.txt", + {"path": "../escape.txt", "content": "should not exist"}), + PlanStep("step-2", "finish", "Task complete", {}), + ] + + elif TEST_MODE and "test absolute path" in goal_lower: + steps = [ + PlanStep("step-1", "write_file", "BLOCKED: absolute path /tmp/test.txt", + {"path": "/tmp/test.txt", "content": "should not exist"}), + PlanStep("step-2", "finish", "Task complete", {}), + ] + + # --- Stage 2B retry test paths --- + elif TEST_MODE and "test retry execution" in goal_lower: + # flaky step: fails once (execution_error), then succeeds on retry + steps = [ + PlanStep("step-1", "flaky", "Flaky step (fails once, then succeeds)", {}), + PlanStep("step-2", "finish", "Task complete", {}), + ] + + # --- Stage 2A flow test paths --- + elif TEST_MODE and "test flow pwd to file" in goal_lower: + steps = [ + PlanStep("step-1", "shell", "Get current directory", {"command": "pwd"}), + PlanStep("step-2", "write_file", "Write pwd output to report.txt", + {"path": "report.txt", "content": "{{ last.output_text }}"}), + PlanStep("step-3", "read_file", "Read back report.txt", {"path": "report.txt"}), + PlanStep("step-4", "finish", "Task complete", {}), + ] + + elif TEST_MODE and "test flow read to copy" in goal_lower: + steps = [ + PlanStep("step-1", "write_file", "Write source.txt", {"path": "source.txt", "content": "hello from source"}), + PlanStep("step-2", "read_file", "Read source.txt", {"path": "source.txt"}), + PlanStep("step-3", "write_file", "Copy content to copy.txt", + {"path": "copy.txt", "content": "{{ last.output_text }}"}), + PlanStep("step-4", "read_file", "Read back copy.txt", {"path": "copy.txt"}), + PlanStep("step-5", "finish", "Task complete", {}), + ] + + elif TEST_MODE and "test flow ls to file" in goal_lower: + steps = [ + PlanStep("step-1", "shell", "List files", {"command": "ls"}), + PlanStep("step-2", "write_file", "Save listing to listing.txt", + {"path": "listing.txt", "content": "{{ steps.step-1.output_text }}"}), + PlanStep("step-3", "finish", "Task complete", {}), + ] + + elif TEST_MODE and "test flow bad template" in goal_lower: + steps = [ + PlanStep("step-1", "write_file", "Write with unresolvable template", + {"path": "fail.txt", "content": "{{ steps.nonexistent.output_text }}"}), + PlanStep("step-2", "finish", "Task complete", {}), + ] + + # --- Normal paths --- + + # Search queries (HIGHEST PRIORITY - check FIRST) + search_keywords = [ + "найди", "поиск", "новости", "что нового", "посмотри что нового", + "search", "news", "latest", "find", "look up", "google", "bing" + ] + if any(word in goal_lower for word in search_keywords): + steps = [ + PlanStep("step-1", "web_search", "Search the web", {"query": goal}), + PlanStep("step-2", "finish", "Search complete", {}) + ] + + # Inspect/analyze commands + elif any(kw in goal_lower for kw in ("inspect", "analyze", "посмотри", "проверь")): + steps = [ + PlanStep("step-1", "shell", "Show current directory", {"command": "pwd"}), + PlanStep("step-2", "shell", "List files", {"command": "ls"}), + PlanStep("step-3", "finish", "Task complete", {}) + ] + + # File commands + elif any(kw in goal_lower for kw in ("create file", "создай файл", "write file")): + filename, content = _parse_file_goal(goal) + steps = [ + PlanStep("step-1", "write_file", f"Write {filename}", {"path": filename, "content": content}), + PlanStep("step-2", "read_file", f"Read back {filename}", {"path": filename}), + PlanStep("step-3", "finish", "Task complete", {}) + ] + + # FALLBACK: If nothing matches, default to web_search + else: + steps = [ + PlanStep("step-1", "web_search", "Search the web (default)", {"query": goal}), + PlanStep("step-2", "finish", "Search complete", {}) + ] + + return Plan(steps=steps) + + +class PlannerError(Exception): + """Raised when no plan can be built for the given goal.""" + + +def _parse_file_goal(goal: str) -> tuple[str, str]: + """Extract filename and content from a 'create file' goal string.""" + m = re.search( + r"(?:create file|write file|создай файл)\s+(\S+)" + r"(?:\s+with\s+(?:text|content)\s+(.+))?", + goal, + re.IGNORECASE, + ) + if m: + return m.group(1), (m.group(2) or "") + return "output.txt", "" + + diff --git a/agent/planner/step_selector.py b/agent/planner/step_selector.py new file mode 100644 index 0000000..f7917d8 --- /dev/null +++ b/agent/planner/step_selector.py @@ -0,0 +1,14 @@ +"""Step selector — picks the next pending step from a plan.""" +from __future__ import annotations + +from typing import Optional + +from core.state import Plan, PlanStep, StepStatus + + +def select_next_step(plan: Plan) -> Optional[PlanStep]: + """Return the first step with PENDING status, or None if all steps are done.""" + for step in plan.steps: + if step.status == StepStatus.PENDING: + return step + return None diff --git a/agent/requirements.txt b/agent/requirements.txt new file mode 100644 index 0000000..3e981b7 --- /dev/null +++ b/agent/requirements.txt @@ -0,0 +1,4 @@ +# Stage 1 uses only the Python standard library. +# External dependencies for web search tool: +requests>=2.31.0 +beautifulsoup4>=4.12.0 diff --git a/agent/runtime/__init__.py b/agent/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/runtime/executor.py b/agent/runtime/executor.py new file mode 100644 index 0000000..ac6d33b --- /dev/null +++ b/agent/runtime/executor.py @@ -0,0 +1,33 @@ +"""Executor — dispatches plan steps to tools, never raises.""" +from __future__ import annotations + +from core.state import PlanStep, StepResult, StepStatus +from runtime.tool_registry import ToolRegistry + + +class Executor: + """Executes a PlanStep by delegating to the appropriate tool handler.""" + + def __init__(self, registry: ToolRegistry) -> None: + self._registry = registry + + def execute(self, step: PlanStep) -> StepResult: + """ + Execute a step. All exceptions are caught and returned as + a FAILED StepResult — the caller never needs to handle exceptions. + """ + handler = self._registry.get(step.kind) + if handler is None: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"No handler registered for step kind '{step.kind}'", + ) + try: + return handler(step) + except Exception as exc: # noqa: BLE001 + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"{type(exc).__name__}: {exc}", + ) diff --git a/agent/runtime/tool_registry.py b/agent/runtime/tool_registry.py new file mode 100644 index 0000000..d885e41 --- /dev/null +++ b/agent/runtime/tool_registry.py @@ -0,0 +1,32 @@ +"""Registry mapping step kinds to handler functions.""" +from __future__ import annotations + +from typing import Callable, Optional + +from core.state import PlanStep, StepResult +from runtime.tools.file_ops import finish_step, read_file, write_file +from runtime.tools.flaky import run_flaky +from runtime.tools.shell import run_shell +from runtime.tools.web_fetch import web_fetch +from runtime.tools.web_search import web_search + +ToolHandler = Callable[[PlanStep], StepResult] + + +class ToolRegistry: + """Maps step kind strings to callable tool handlers.""" + + def __init__(self) -> None: + self._handlers: dict[str, ToolHandler] = { + "shell": run_shell, + "write_file": write_file, + "read_file": read_file, + "finish": finish_step, + "flaky": run_flaky, + "web_search": web_search, + "web_fetch": web_fetch, + } + + def get(self, kind: str) -> Optional[ToolHandler]: + """Return handler for the given kind, or None if unknown.""" + return self._handlers.get(kind) diff --git a/agent/runtime/tools/__init__.py b/agent/runtime/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/runtime/tools/file_ops.py b/agent/runtime/tools/file_ops.py new file mode 100644 index 0000000..651bcae --- /dev/null +++ b/agent/runtime/tools/file_ops.py @@ -0,0 +1,69 @@ +"""File operation tools — write_file, read_file, finish.""" +from __future__ import annotations + +from pathlib import Path + +from core.state import PlanStep, StepResult, StepStatus + +# All file ops are confined to agent/workspace/ +# parents: [0]=tools, [1]=runtime, [2]=agent → agent/workspace +WORKSPACE_ROOT: Path = Path(__file__).resolve().parents[2] / "workspace" + + +def write_file(step: PlanStep) -> StepResult: + """Write content to a file inside agent/workspace/. Path traversal is blocked.""" + rel_path: str = step.payload.get("path", "").strip() + content: str = step.payload.get("content", "") + + if not rel_path: + return StepResult(step_id=step.step_id, status=StepStatus.FAILED, error="payload missing 'path'") + + target = (WORKSPACE_ROOT / rel_path).resolve() + + if not str(target).startswith(str(WORKSPACE_ROOT)): + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Path traversal blocked: '{rel_path}' resolves outside workspace", + ) + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + return StepResult( + step_id=step.step_id, + status=StepStatus.DONE, + output_text=f"Written {len(content)} chars to {rel_path}", + ) + + +def read_file(step: PlanStep) -> StepResult: + """Read a file from inside agent/workspace/. Path traversal is blocked.""" + rel_path: str = step.payload.get("path", "").strip() + + if not rel_path: + return StepResult(step_id=step.step_id, status=StepStatus.FAILED, error="payload missing 'path'") + + target = (WORKSPACE_ROOT / rel_path).resolve() + + if not str(target).startswith(str(WORKSPACE_ROOT)): + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Path traversal blocked: '{rel_path}' resolves outside workspace", + ) + + if not target.exists(): + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"File not found: {rel_path}", + ) + + content = target.read_text(encoding="utf-8") + return StepResult(step_id=step.step_id, status=StepStatus.DONE, output_text=content) + + +def finish_step(step: PlanStep) -> StepResult: + """Terminal step — signals successful plan completion.""" + return StepResult(step_id=step.step_id, status=StepStatus.DONE, output_text="Agent task complete.") diff --git a/agent/runtime/tools/flaky.py b/agent/runtime/tools/flaky.py new file mode 100644 index 0000000..a14645d --- /dev/null +++ b/agent/runtime/tools/flaky.py @@ -0,0 +1,47 @@ +"""Flaky test tool — deterministic fail-once-then-succeed behavior. + +Used exclusively for testing retry logic (goal: "test retry execution"). + +Behavior: + Attempt 1: flag file does NOT exist → creates flag → returns FAILED + (failure_class: execution_error → eligible for 1 retry) + Attempt 2: flag file EXISTS → removes flag → returns DONE + +The flag file is workspace/.flaky_test_flag. +It is always cleaned up on the second attempt, so repeated test runs work correctly. + +Edge case: if the run is aborted between attempt 1 and attempt 2, the flag file +will persist. The next run will then succeed on attempt 1 (no retry exercised). +To reset: delete agent/workspace/.flaky_test_flag manually. +""" +from __future__ import annotations + +from pathlib import Path + +from core.state import PlanStep, StepResult, StepStatus + +# parents: [0]=tools, [1]=runtime, [2]=agent → agent/workspace +_WORKSPACE_ROOT: Path = Path(__file__).resolve().parents[2] / "workspace" +_FLAG: Path = _WORKSPACE_ROOT / ".flaky_test_flag" + + +def run_flaky(step: PlanStep) -> StepResult: + """Fail on first call, succeed on second (flag-based).""" + _WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True) + + if _FLAG.exists(): + # Second call: cleanup flag, succeed + _FLAG.unlink() + return StepResult( + step_id=step.step_id, + status=StepStatus.DONE, + output_text="flaky step succeeded on retry (flag removed)", + ) + else: + # First call: create flag, fail with execution_error prefix for classifier + _FLAG.touch() + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error="execution_error: simulated transient failure on attempt 1", + ) diff --git a/agent/runtime/tools/shell.py b/agent/runtime/tools/shell.py new file mode 100644 index 0000000..e4abfbf --- /dev/null +++ b/agent/runtime/tools/shell.py @@ -0,0 +1,88 @@ +"""Safe shell execution tool with allowlist enforcement.""" +from __future__ import annotations + +import subprocess + +from core.state import PlanStep, StepResult, StepStatus + +# Commands explicitly allowed +ALLOWED_COMMANDS: frozenset[str] = frozenset({"pwd", "ls", "echo", "cat", "python", "git"}) + +# Commands explicitly blocked (belt-and-suspenders) +BLOCKED_COMMANDS: frozenset[str] = frozenset({ + "bash", "sh", "rm", "sudo", "curl", "wget", + "chmod", "chown", "docker", "systemctl", +}) + +# For commands with restricted sub-commands +RESTRICTED_SUBCOMMANDS: dict[str, set[str]] = { + "python": {"--version"}, + "git": {"status"}, +} + + +def run_shell(step: PlanStep) -> StepResult: + """Execute a whitelisted shell command via subprocess (shell=False).""" + raw_command: str = step.payload.get("command", "").strip() + parts = raw_command.split() + + if not parts: + return StepResult(step_id=step.step_id, status=StepStatus.FAILED, error="Empty command") + + binary = parts[0] + + if binary in BLOCKED_COMMANDS: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Command '{binary}' is blocked by security policy", + ) + + if binary not in ALLOWED_COMMANDS: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Command '{binary}' is not in allowlist. Allowed: {sorted(ALLOWED_COMMANDS)}", + ) + + # Validate restricted sub-commands + if binary in RESTRICTED_SUBCOMMANDS: + allowed_sub = RESTRICTED_SUBCOMMANDS[binary] + sub = " ".join(parts[1:]) + if sub not in allowed_sub: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Only '{binary} {sorted(allowed_sub)}' is permitted, got: '{binary} {sub}'", + ) + + try: + proc = subprocess.run( + parts, + shell=False, + capture_output=True, + text=True, + timeout=10, + ) + output = proc.stdout + proc.stderr + if proc.returncode != 0: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + output_text=output, + error=f"Non-zero exit code: {proc.returncode}", + ) + return StepResult(step_id=step.step_id, status=StepStatus.DONE, output_text=output) + + except subprocess.TimeoutExpired: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error="Command timed out after 10s", + ) + except FileNotFoundError: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Binary '{binary}' not found on PATH", + ) diff --git a/agent/runtime/tools/web_fetch.py b/agent/runtime/tools/web_fetch.py new file mode 100644 index 0000000..129eb0a --- /dev/null +++ b/agent/runtime/tools/web_fetch.py @@ -0,0 +1,137 @@ +"""Web page fetch tool - reads content from URLs (safe and stable).""" +from __future__ import annotations + +import requests +from bs4 import BeautifulSoup +from core.state import PlanStep, StepResult, StepStatus + + +# Configuration limits +MAX_RESPONSE_SIZE = 500 * 1024 # 500KB +MAX_OUTPUT_LENGTH = 4000 # Characters +REQUEST_TIMEOUT = 5 # Seconds + + +def web_fetch(step: PlanStep) -> StepResult: + """Fetch and read content from a web page (safe and stable). + + Args: + step: PlanStep with payload containing 'url' + + Returns: + StepResult with extracted page content (max 4000 characters) + """ + url = step.payload.get("url", "").strip() + + # Validation: Basic URL check + if not url: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error="Fetch error: URL not provided" + ) + + if not url.startswith(("http://", "https://")): + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error="Fetch error: URL must start with http:// or https://" + ) + + try: + # Request configuration + headers = { + "User-Agent": "Mozilla/5.0 (compatible; AgentMaxim/1.0)" + } + + # Streaming response with size limit (follow redirects automatically) + response = requests.get( + url, + headers=headers, + timeout=REQUEST_TIMEOUT, + stream=True, + allow_redirects=True + ) + + # Content-type validation + content_type = response.headers.get("content-type", "") + if "text/html" not in content_type: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Fetch error: Invalid content-type: {content_type}" + ) + + # Read with size limit + content = b"" + for chunk in response.iter_content(chunk_size=8192): + content += chunk + if len(content) > MAX_RESPONSE_SIZE: + break + + if len(content) == 0: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error="Fetch error: Empty response" + ) + + # Parse HTML + soup = BeautifulSoup(content, "html.parser") + + # Smart content extraction + # Priority: main container → article → content divs → body + main_content = ( + soup.find("main") or + soup.find("article") or + soup.find("div", class_=["content", "main-content", "article-content"]) or + soup.body + ) + + if not main_content: + main_content = soup.find("body") + + # Extract text with cleaning + text = main_content.get_text(separator="\n", strip=True) + + # Remove excessive whitespace + import re + text = re.sub(r'\n{3,}', '\n\n', text) + text = re.sub(r' +', ' ', text) + + # Limit output length + if len(text) > MAX_OUTPUT_LENGTH: + text = text[:MAX_OUTPUT_LENGTH] + "\n\n... (truncated)" + + return StepResult( + step_id=step.step_id, + status=StepStatus.DONE, + output_text=text, + metadata={ + "source": url, + "content_type": content_type, + "original_size": len(content), + "output_size": len(text) + } + ) + + except requests.exceptions.Timeout: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Fetch error: Request timed out (>{REQUEST_TIMEOUT}s)" + ) + + except requests.exceptions.RequestException as e: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Fetch error: {str(e)}" + ) + + except Exception as e: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"Fetch error: {type(e).__name__}: {str(e)}" + ) diff --git a/agent/runtime/tools/web_search.py b/agent/runtime/tools/web_search.py new file mode 100644 index 0000000..3982d03 --- /dev/null +++ b/agent/runtime/tools/web_search.py @@ -0,0 +1,189 @@ +"""Web search tool using Bing with snippets.""" +from __future__ import annotations + +import re +import requests +from bs4 import BeautifulSoup +from datetime import datetime +from core.state import PlanStep, StepResult, StepStatus + + +def _enhance_search_query(query: str) -> str: + """Enhance search query for better AI/news results. + + Args: + query: Original search query (may contain instruction text) + + Returns: + Enhanced search query with AI/news focus + """ + # Clean up query - remove instruction text + query = query.strip() + + # Remove common instruction prefixes + instruction_patterns = [ + r'^Task:\s*', + r'^Please complete the task above\.?\s*', + r'^Respond with a concise result\.?\s*', + r'^IMPORTANT INSTRUCTIONS.*?(?=\n|$)', + r'^Use web search.*?(?=\n|$)', + ] + + for pattern in instruction_patterns: + query = re.sub(pattern, '', query, flags=re.IGNORECASE | re.DOTALL) + + # Take only first line or first 100 characters if multi-line + if '\n' in query: + query = query.split('\n')[0].strip() + elif len(query) > 100: + query = query[:100].strip() + + # Remove any remaining instruction-like text + query = re.sub(r'(Please|complete|task|result|instruction)s?\b.*$', '', query, flags=re.IGNORECASE) + query = query.strip() + + # If query is too short after cleaning, use generic AI news + if len(query) < 5: + query = "AI artificial intelligence news" + + current_year = datetime.now().year + query_lower = query.lower() + + # Check if query is about news/AI/OpenAI + news_keywords = ["новости", "news", "openai", "ai", "artificial intelligence", + "ml", "machine learning", "gpt", "llm", "chatgpt", "claude", + "developments", "latest", "updates", "breakthrough"] + + is_news_query = any(keyword in query_lower for keyword in news_keywords) + + if is_news_query: + # Add AI/news specific terms only if not already present + if not any(term in query_lower for term in ["latest", "news", "current"]): + query = f"{query} latest" + + if not any(term in query_lower for term in ["2026", str(current_year)]): + query = f"{query} {current_year}" + + # Add tech site restrictions for better results + tech_sites = ["site:techcrunch.com", "site:theverge.com", "site:arstechnica.com", + "site:wired.com", "site:openai.com", "site:anthropic.com"] + + # Use up to 2 tech sites to avoid overly restrictive queries + site_filter = " OR ".join(tech_sites[:2]) + query = f"{query} {site_filter}" + + # Add AI-specific terms if not already present + ai_terms = ["artificial intelligence", "machine learning", "AI technology"] + if not any(term in query_lower for term in ai_terms): + query = f"{query} artificial intelligence" + else: + # For non-news queries, just ensure basic quality + # Add relevant context if missing + if len(query) < 10: + query = f"{query} information" + + # Exclude common low-quality results + exclude_terms = ["tasks", "calendar", "todo", "reminder", "schedule", "app"] + exclusions = [] + for term in exclude_terms: + if term not in query_lower: + exclusions.append(f"-{term}") + + if exclusions: + query = f"{query} {' '.join(exclusions)}" + + return query + + +def web_search(step: PlanStep) -> StepResult: + """Search the web using Bing with informative snippets. + + Args: + step: PlanStep with payload containing 'query' + + Returns: + StepResult with search results (title + URL + snippet) + """ + query = step.payload.get("query", "").strip() + + if not query: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error="payload missing 'query'" + ) + + # Enhance query for better AI/news results + enhanced_query = _enhance_search_query(query) + + try: + url = "https://www.bing.com/search" + params = {"q": enhanced_query, "setlang": "en", "cc": "US"} + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept-Language": "en-US,en;q=0.9" + } + + resp = requests.get(url, params=params, headers=headers, timeout=10) + soup = BeautifulSoup(resp.text, "html.parser") + + results = [] + items = soup.select("li.b_algo")[:5] + + for i, item in enumerate(items, 1): + title_tag = item.select_one("h2, h3") + link_tag = item.select_one("a[href]") + snippet_tag = item.select_one("[data-bm], p") + + if not title_tag or not link_tag: + continue + + title = title_tag.get_text(strip=True) + link = link_tag.get("href", "") + + # Decode Bing redirect URLs to get actual URLs + if "bing.com/ck/a" in link: + try: + match = re.search(r'[?&]u=([a-zA-Z0-9_-]+)', link) + if match: + encoded_url = match.group(1) + # Remove 'a1' prefix if present + if encoded_url.startswith('a1'): + encoded_url = encoded_url[2:] + # Add proper padding and decode + padding = 4 - (len(encoded_url) % 4) + if padding != 4: + encoded_url += '=' * padding + import base64 + link = base64.b64decode(encoded_url).decode('utf-8') + except: + pass # Keep original URL if decoding fails + + snippet = "" + if snippet_tag: + snippet = snippet_tag.get_text(strip=True) + snippet = re.sub(r"\s+", " ", snippet) + snippet = snippet[:200] + + results.append(f"{i}.\nTitle: {title}\nURL: {link}\nSnippet: {snippet}\n") + + if not results: + return StepResult( + step_id=step.step_id, + status=StepStatus.DONE, + output_text=f"No results found for query: {enhanced_query}" + ) + + return StepResult( + step_id=step.step_id, + status=StepStatus.DONE, + output_text="\n".join(results), + metadata={"source": "bing", "results_count": len(results)} + ) + + except Exception as e: + return StepResult( + step_id=step.step_id, + status=StepStatus.FAILED, + error=f"search error: {str(e)}" + ) diff --git a/agent/telemetry/__init__.py b/agent/telemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/telemetry/audit_logger.py b/agent/telemetry/audit_logger.py new file mode 100644 index 0000000..a4ab132 --- /dev/null +++ b/agent/telemetry/audit_logger.py @@ -0,0 +1,43 @@ +"""Append-only JSONL audit logger.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + + +class AuditLogger: + """Writes structured JSONL audit records to workspace/runs//audit.jsonl. + + Required events: run_started, plan_created, step_started, + step_finished, state_saved, run_finished. + """ + + def __init__(self, run_id: str, runs_dir: Path, trace_id: Optional[str] = None) -> None: + self._run_id = run_id + self._trace_id = trace_id + self._run_dir = runs_dir / run_id + self._run_dir.mkdir(parents=True, exist_ok=True) + self._log_path = self._run_dir / "audit.jsonl" + + def log( + self, + event_type: str, + step_id: Optional[str] = None, + status: str = "ok", + details: Optional[dict[str, Any]] = None, + ) -> None: + """Append a single audit record to audit.jsonl.""" + record: dict[str, Any] = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "trace_id": self._trace_id, + "run_id": self._run_id, + "layer": "agent", + "event_type": event_type, + "step_id": step_id, + "status": status, + "details": details or {}, + } + with self._log_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") diff --git a/agent/tests/__init__.py b/agent/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/tests/test_sprint2.py b/agent/tests/test_sprint2.py new file mode 100644 index 0000000..1c7d668 --- /dev/null +++ b/agent/tests/test_sprint2.py @@ -0,0 +1,119 @@ +"""Regression tests for Hardening Sprint 2 — agent planner layer.""" +from __future__ import annotations + +import os +import sys +import importlib +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def _reload_planner(test_mode: str | None) -> object: + """Reload planner.planner with a specific AGENT_TEST_MODE value.""" + env = os.environ.copy() + if test_mode is None: + env.pop("AGENT_TEST_MODE", None) + else: + env["AGENT_TEST_MODE"] = test_mode + + old = os.environ.copy() + os.environ.clear() + os.environ.update(env) + try: + if "planner.planner" in sys.modules: + del sys.modules["planner.planner"] + import planner.planner as p + importlib.reload(p) + return p + finally: + os.environ.clear() + os.environ.update(old) + + +# --------------------------------------------------------------------------- +# 3. Planner — test branches disabled when AGENT_TEST_MODE != 1 +# --------------------------------------------------------------------------- + +TEST_GOALS = [ + "test deny bash", + "test deny rm", + "test deny curl", + "test forbidden command", + "test traversal", + "test absolute path", + "test retry execution", + "test flow pwd to file", + "test flow read to copy", + "test flow ls to file", + "test flow bad template", +] + +NORMAL_GOALS = [ + "inspect project", + "analyze codebase", + "create file hello.txt with text hi", +] + + +@pytest.mark.parametrize("goal", TEST_GOALS) +def test_test_goal_fails_without_test_mode(goal): + """AGENT_TEST_MODE unset → all test goals raise PlannerError.""" + p = _reload_planner(None) + assert p.TEST_MODE is False + with pytest.raises(p.PlannerError): + p.build_plan(goal) + + +@pytest.mark.parametrize("goal", TEST_GOALS) +def test_test_goal_fails_with_test_mode_zero(goal): + """AGENT_TEST_MODE=0 → still raises PlannerError.""" + p = _reload_planner("0") + assert p.TEST_MODE is False + with pytest.raises(p.PlannerError): + p.build_plan(goal) + + +# --------------------------------------------------------------------------- +# 4. Planner — test branches enabled when AGENT_TEST_MODE == 1 +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("goal", TEST_GOALS) +def test_test_goal_succeeds_with_test_mode_one(goal): + """AGENT_TEST_MODE=1 → test goals build a plan (no PlannerError).""" + p = _reload_planner("1") + assert p.TEST_MODE is True + plan = p.build_plan(goal) + assert len(plan.steps) >= 1 + + +# --------------------------------------------------------------------------- +# 5. Safety test — normal goals unaffected by TEST_MODE gating +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("goal", NORMAL_GOALS) +def test_normal_goal_works_without_test_mode(goal): + """Normal goals always produce a plan regardless of AGENT_TEST_MODE.""" + p = _reload_planner(None) + plan = p.build_plan(goal) + assert len(plan.steps) >= 1 + assert plan.steps[-1].kind == "finish" + + +@pytest.mark.parametrize("goal", NORMAL_GOALS) +def test_normal_goal_works_with_test_mode(goal): + """Normal goals still work when AGENT_TEST_MODE=1.""" + p = _reload_planner("1") + plan = p.build_plan(goal) + assert len(plan.steps) >= 1 + assert plan.steps[-1].kind == "finish" + + +def test_unknown_goal_raises_planner_error(): + """Unknown goals raise PlannerError in both modes.""" + for mode in (None, "0", "1"): + p = _reload_planner(mode) + with pytest.raises(p.PlannerError): + p.build_plan("run the test suite please") diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 0000000..33b4ee4 --- /dev/null +++ b/bridge/README.md @@ -0,0 +1,324 @@ +# Bridge V1.2 — Agent-Connected Orchestration + +One-shot orchestration with a real agent backend. `POST /runs` with `adapter="agent"` executes the full agent pipeline and returns the final result. + +## Run Lifecycle + +``` +POST /runs {"goal": "...", "adapter": "agent"} + │ + ├─ status: created → state.json written, run_created logged + ├─ instruction built → "Task: \n\nPlease complete..." + │ instruction_generated logged + ├─ policy check → blocked? → status=failed, return + ├─ status: running → task_sent logged + ├─ agent_adapter.execute(instruction, bridge_run_id=rid) + │ └─ subprocess: python agent/main.py --goal --run-id agent- + │ └─ reads agent/workspace/runs/agent-/state.json + │ response_received logged (mode="real", exit_code, agent_run_id) + └─ exit_code==0 → status=done | else → status=failed + run_finished logged (agent_final_status, agent_run_id) +``` + +Status transitions: `created → running → done | failed` +Status on return from `POST /runs`: always `done` or `failed`, never `running`. + +## Run ID Mapping + +``` +Bridge run_id : br-flow-01 +Agent run_id : agent-br-flow-01 + +Bridge state : bridge/runs/br-flow-01/state.json +Agent state : agent/workspace/runs/agent-br-flow-01/state.json +``` + +Every bridge run maps to exactly one agent run. Both are traceable by ID. + +## Project Structure + +``` +bridge/ + app.py FastAPI entry point (v1.2.0) + config.py Env config + api/routes.py POST /runs, GET /runs/{id}, GET /health + core/ + run_manager.py One-shot execute_run() with metadata extraction + instruction_builder.py Goal → instruction (deterministic) + state_store.py state.json read/write + audit.py JSONL append-only logger + adapters/ + __init__.py Registry: stub, local_echo, openai, cloudcode, agent + base.py AdapterResult(response,error,exit_code,mode,metadata) + stub_adapter.py Always-available stub + local_echo_adapter.py Real subprocess (Python child process) + openai_adapter.py Real if OPENAI_API_KEY, stub otherwise + cloudcode_adapter.py Stub — transport unavailable + agent_adapter.py REAL — subprocess call to agent/main.py + policy/guard.py Prompt validation + models/schemas.py Pydantic schemas (RunResponse includes agent_* fields) + runs// + state.json + audit.jsonl +``` + +## Agent Adapter Design + +```python +# bridge/adapters/agent_adapter.py + +_AGENT_MAIN = bridge/../agent/main.py + +def execute(prompt: str, bridge_run_id: str = None) -> AdapterResult: + agent_run_id = f"agent-{bridge_run_id}" + proc = subprocess.run( + [sys.executable, str(_AGENT_MAIN), + "--goal", prompt, "--run-id", agent_run_id], + capture_output=True, text=True, timeout=60, shell=False, + cwd=str(_AGENT_DIR), + ) + # Read agent/workspace/runs/{agent_run_id}/state.json + # Extract agent_final_status + return AdapterResult( + response=proc.stdout, + error=... if proc.returncode != 0 else None, + exit_code=proc.returncode, + mode="real", + metadata={"agent_run_id": ..., "agent_final_status": ..., "agent_state_path": ...} + ) +``` + +**Goal routing:** The instruction passed as `--goal` contains the original goal as a substring. The agent's deterministic planner uses `keyword in goal.lower()` matching, so all keywords resolve correctly through the wrapper text. + +## API + +### GET /health + +```json +{ + "status": "ok", + "adapters": { + "stub": "stub", + "local_echo": "real", + "openai": "stub", + "cloudcode": "stub", + "agent": "real" + } +} +``` + +### POST /runs + +```bash +curl -X POST http://localhost:8000/runs \ + -H "Content-Type: application/json" \ + -d '{"goal": "test flow pwd to file", "adapter": "agent"}' +``` + +Returns final state (status = done or failed): +```json +{ + "run_id": "br-flow-01", + "status": "done", + "adapter": "agent", + "adapter_mode": "real", + "exit_code": 0, + "agent_run_id": "agent-br-flow-01", + "agent_final_status": "done", + "agent_state_path": ".../agent/workspace/runs/agent-br-flow-01/state.json" +} +``` + +### GET /runs/{id} + +Returns persisted bridge state. + +## Adapter Modes + +| Adapter | Mode | Execution | +|---|---|---| +| `stub` | stub | Python string echo | +| `local_echo` | **real** | `subprocess.run([python, -c, echo_script])` | +| `openai` | real/stub | Real if `OPENAI_API_KEY` set, stub otherwise | +| `cloudcode` | stub | Transport unavailable in V1.2 | +| `agent` | **real** | `subprocess.run([python, agent/main.py, --goal, ...])` | + +## Storage Layout + +``` +bridge/runs//state.json fields: + run_id, goal, status, adapter, adapter_mode, + instruction, response, error, exit_code, + agent_run_id, agent_final_status, agent_state_path, + created_at, updated_at + +agent/workspace/runs//state.json fields: + run_id, goal, plan, history, status, current_step_id, + started_at, updated_at +``` + +## Audit Events (agent adapter) + +``` +run_created goal + adapter="agent" +instruction_generated instruction_len +task_sent adapter="agent", instruction_len +response_received mode="real", exit_code, response_len, agent_run_id +run_finished status, adapter_mode="real", agent_run_id, agent_final_status +``` + +## Controller Loop V1 (Bridge V1.3) + +### Architecture + +``` +POST /controller/runs {"goal": "..."} + │ + ├─ ControllerState created, controller_audit.jsonl opened + ├─ Loop (max 3 iterations): + │ ├─ decide(goal, iteration, last_result) + │ │ ├─ stub: deterministic rules + │ │ └─ real: OpenAI call → JSON parse → fallback to stub + │ ├─ if action == "stop": break + │ ├─ RunManager.execute_run(goal, adapter="agent") + │ │ └─ bridge run → agent subprocess → result + │ └─ observe result → update controller state + └─ final_status persisted, controller_finished logged +``` + +### Loop Stop Conditions + +1. `decision.action == "stop"` — decision layer says stop +2. `iteration >= MAX_ITERATIONS (3)` — hard iteration cap +3. `consecutive_failures >= 2` — repeated failure guard + +### Stub Decision Rules + +| Condition | Action | Reason | +|---|---|---| +| No previous run (iteration 0) | run_agent | First execution | +| Last run status=done + agent_done | stop | Success | +| Last run status=failed | stop | No controller-level retry | +| Iteration approaching max | stop | Safety cap | + +### Ollama Decision Rules (V1.3 — default) + +Ollama is the primary decision backend. If Ollama is unreachable at startup, falls back to stub automatically. + +**Decision flow:** +``` +decide(goal, iteration, last_result, recovery_attempted) + │ + ├─ DECISION_MODE=stub → _decide_stub() (Ollama unreachable at startup) + └─ DECISION_MODE=ollama → ollama_adapter.execute(prompt, temperature=0.1, max_tokens=150) + ├─ connection error / empty → ollama-fallback (stub logic used) + ├─ bad/truncated JSON → ollama-fallback (stub logic used) + ├─ invalid action/goal → ollama-fallback (stub logic used) + └─ valid JSON → Decision(mode="ollama", parsed_success=True) +``` + +**Prompt context sent:** +``` +goal, iteration/max_iterations, last_status, last_agent_status, recovery_attempted +``` + +**Expected JSON output:** +```json +{"action": "run_agent"|"stop", "goal": "...", "reason": "..."} +``` + +**Safety guard (before accepting):** +- `action` must be `"run_agent"` or `"stop"` +- `goal` must be a string ≤ 500 chars +- markdown fences and leading prose stripped automatically + +**Decision modes in state/audit:** +| mode | meaning | +|---|---| +| `ollama` | Ollama response parsed successfully | +| `ollama-fallback` | Ollama called but response invalid — stub used | +| `stub` | Ollama unreachable at startup — deterministic rules | + +**Ollama setup:** +```bash +# Install: https://ollama.com — already running on Windows +# Default model +ollama pull qwen2.5:7b +# Optional fallback +ollama pull llama3.2:3b +``` + +**Configuration (env vars):** +```bash +export OLLAMA_MODEL=qwen2.5:7b # default +export OLLAMA_HOST=... # auto-resolved (0.0.0.0 → http://localhost:11434) +``` + +**Health endpoint shows current mode:** +```json +{ + "status": "ok", + "decision_backend": "ollama", + "ollama_model": "qwen2.5:7b", + "adapters": {"ollama": "real", ...} +} +``` + +### Run ID Traceability + +``` +controller_run_id : ctrl-xxxx + └─ bridge run_id : bri-ctrl-xxxx-i0 (iteration 0) + └─ agent run_id : agent-bri-ctrl-xxxx-i0 + └─ bridge run_id : bri-ctrl-xxxx-i1 (iteration 1, if any) +``` + +### Storage + +``` +bridge/runs/ctrl-xxxx/ + controller_state.json — controller run, decisions, bridge results + controller_audit.jsonl — controller-level events + +bridge/runs/bri-ctrl-xxxx-i0/ + state.json — bridge run state + audit.jsonl — bridge run events + +agent/workspace/runs/agent-bri-ctrl-xxxx-i0/ + state.json — agent run with full step history + audit.jsonl — agent step events +``` + +### Controller Audit Events + +``` +controller_started goal, decision_mode, max_iterations +decision_made iteration, action, reason, mode, parsed_success, fallback_used +bridge_run_started iteration, bridge_run_id, goal +bridge_run_finished iteration, status, agent_final_status, exit_code +recovery_considered iteration, original_goal, recovery_applied, reason +recovery_applied iteration, original_goal, rewritten_goal, rule_reason +recovery_skipped iteration, original_goal, reason +controller_finished final_status, iterations, linked_runs +``` + +### API + +```bash +# Start controller run +POST /controller/runs {"goal": "test retry execution"} +→ 201 ControllerResponse (final state) + +# Get controller run +GET /controller/runs/{ctrl_id} +→ 200 ControllerResponse +``` + +## V1.2 Limitations (agent/bridge layer) + +- instruction_builder is a string wrapper — not an LLM planner +- agent planner is deterministic keyword-matching — not dynamic +- bridge sends full instruction as `--goal`; agent's `goal` field in state.json shows the wrapper text (not the raw goal) +- one step per bridge run (single agent invocation) +- cloudcode adapter is stub-only +- no authentication, no streaming, no retries diff --git a/bridge/adapters/__init__.py b/bridge/adapters/__init__.py new file mode 100644 index 0000000..6a25541 --- /dev/null +++ b/bridge/adapters/__init__.py @@ -0,0 +1,28 @@ +"""Adapter registry for Bridge V1.2.""" +from __future__ import annotations + +from adapters import ( + agent_adapter, + cloudcode_adapter, + local_echo_adapter, + ollama_adapter, + openai_adapter, + stub_adapter, +) + +REGISTRY: dict[str, object] = { + "stub": stub_adapter, + "local_echo": local_echo_adapter, + "ollama": ollama_adapter, + "openai": openai_adapter, + "cloudcode": cloudcode_adapter, + "agent": agent_adapter, +} + + +def get(name: str): + return REGISTRY.get(name) + + +def available() -> dict[str, str]: + return {name: mod.mode() for name, mod in REGISTRY.items()} # type: ignore[attr-defined] diff --git a/bridge/adapters/agent_adapter.py b/bridge/adapters/agent_adapter.py new file mode 100644 index 0000000..563774c --- /dev/null +++ b/bridge/adapters/agent_adapter.py @@ -0,0 +1,146 @@ +"""Agent adapter — executes the real local agent via subprocess. + +Calls agent/main.py with the given instruction as --goal. +The planner keyword-matches on goal text, so the instruction +(which contains the original goal as a substring) routes correctly. + +Run ID mapping: + bridge run_id : (e.g. "a1b2c3d4") + agent run_id : "agent-" (e.g. "agent-a1b2c3d4") + +This makes every bridge run traceable to a unique agent workspace dir: + agent/workspace/runs/agent-/state.json +""" +from __future__ import annotations + +import json +import subprocess +import sys +import time +from pathlib import Path +from typing import Callable, Optional + +from adapters.base import AdapterResult + +MODE = "real" + +# Resolve paths relative to this file: bridge/adapters/ → bridge/ → localagent/ → agent/ +_BRIDGE_DIR: Path = Path(__file__).resolve().parents[1] +_AGENT_DIR: Path = _BRIDGE_DIR.parent / "agent" +_AGENT_MAIN: Path = _AGENT_DIR / "main.py" + + +def execute(prompt: str, bridge_run_id: Optional[str] = None, trace_id: Optional[str] = None, is_cancelled: Optional[Callable[[], bool]] = None) -> AdapterResult: + """Run the agent with prompt as --goal, return its stdout + parsed state.""" + print("=== THIS ADAPTER IS USED: agent_adapter ===") + if not _AGENT_MAIN.exists(): + return AdapterResult( + response=None, + error=f"agent/main.py not found at {_AGENT_MAIN}", + exit_code=1, + mode=MODE, + ) + + agent_run_id = f"agent-{bridge_run_id}" if bridge_run_id else "agent-unlinked" + + # Check if run is cancelled via callback + if is_cancelled and is_cancelled(): + return AdapterResult( + response=None, + error="user_cancelled", + exit_code=130, # Exit code for cancelled + mode=MODE, + metadata={"agent_run_id": agent_run_id, "agent_final_status": "cancelled"}, + ) + + cmd = [sys.executable, str(_AGENT_MAIN), "--goal", prompt, "--run-id", agent_run_id] + if trace_id: + cmd += ["--trace-id", trace_id] + + _t_sub = time.time() + print("=== BEFORE AGENT SUBPROCESS ===") + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + shell=False, + cwd=str(_AGENT_DIR), + ) + except subprocess.TimeoutExpired: + print("=== AFTER AGENT SUBPROCESS ===") + print(f"[TIMING] subprocess: {time.time() - _t_sub:.3f}s [TIMEOUT]") + return AdapterResult( + response=None, + error="agent subprocess timed out after 60s", + exit_code=1, + mode=MODE, + metadata={"agent_run_id": agent_run_id}, + ) + except Exception as exc: # noqa: BLE001 + print("=== AFTER AGENT SUBPROCESS ===") + print(f"[TIMING] subprocess: {time.time() - _t_sub:.3f}s [ERROR: {exc}]") + return AdapterResult( + response=None, + error=f"{type(exc).__name__}: {exc}", + exit_code=1, + mode=MODE, + metadata={"agent_run_id": agent_run_id}, + ) + + print("=== AFTER AGENT SUBPROCESS ===") + print(f"[TIMING] subprocess: {time.time() - _t_sub:.3f}s") + stdout = proc.stdout.strip() + stderr = proc.stderr.strip() + print(f"[STDOUT]: {stdout[:300]}") + if stderr: + print(f"[STDERR]: {stderr[:300]}") + combined = stdout + ("\n" + stderr if stderr else "") + + # Read agent's persisted state.json for structured result + agent_state_path = _AGENT_DIR / "workspace" / "runs" / agent_run_id / "state.json" + agent_final_status: Optional[str] = None + if agent_state_path.exists(): + try: + agent_state = json.loads(agent_state_path.read_text(encoding="utf-8")) + agent_final_status = agent_state.get("status") + except Exception: # noqa: BLE001 + pass + + metadata = { + "agent_run_id": agent_run_id, + "agent_final_status": agent_final_status, + "agent_state_path": str(agent_state_path), + } + + if proc.returncode != 0: + return AdapterResult( + response=combined or None, + error=f"agent exited {proc.returncode}" + (f": {stderr}" if stderr else ""), + exit_code=proc.returncode, + mode=MODE, + metadata=metadata, + ) + + # exit_code=0 but agent state missing or unreadable → treat as failure + if agent_final_status is None: + return AdapterResult( + response=combined or None, + error="agent state missing or invalid (exit 0 but state.json unreadable)", + exit_code=1, + mode=MODE, + metadata=metadata, + ) + + return AdapterResult( + response=combined, + error=None, + exit_code=0, + mode=MODE, + metadata=metadata, + ) + + +def mode() -> str: + return MODE if _AGENT_MAIN.exists() else "unavailable" diff --git a/bridge/adapters/base.py b/bridge/adapters/base.py new file mode 100644 index 0000000..56835cd --- /dev/null +++ b/bridge/adapters/base.py @@ -0,0 +1,29 @@ +"""Unified adapter contract for Bridge V1.2. + +Every adapter must expose: + execute(prompt: str) -> AdapterResult + +AdapterResult fields: + response - string output on success, None on error + error - error message on failure, None on success + exit_code - 0 = success, non-zero = failure + mode - "real" or "stub" + metadata - adapter-specific extra data (e.g. agent_run_id) +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class AdapterResult: + response: Optional[str] + error: Optional[str] + exit_code: int + mode: str # "real" | "stub" + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def ok(self) -> bool: + return self.exit_code == 0 and self.error is None diff --git a/bridge/adapters/cloudcode_adapter.py b/bridge/adapters/cloudcode_adapter.py new file mode 100644 index 0000000..69c2541 --- /dev/null +++ b/bridge/adapters/cloudcode_adapter.py @@ -0,0 +1,51 @@ +"""CloudCode adapter — transport-ready stub for Bridge V1.1. + +WHY REAL MODE IS UNAVAILABLE: + Claude Code CLI requires an interactive terminal session and Anthropic API + credentials bound to a running Claude Code process. There is no stable + programmatic transport (HTTP/IPC) for local Claude Code invocation exposed + by the CLI in V1.1. A subprocess approach would require `claude` on PATH + with a valid session token, which cannot be assumed in this environment. + +STUB MODE: + Returns a deterministic response that describes what would be sent. + The execute() contract is identical to all other adapters, so this can be + swapped to a real implementation by replacing the body of execute() once + a transport is available (e.g. MCP server, HTTP sidecar, or subprocess). + +REAL MODE PREREQUISITES (future): + - `claude` binary on PATH with active session + - OR an HTTP MCP server exposing Claude Code + - OR Claude API key with claude-sonnet-4-6 and tool calls +""" +from __future__ import annotations + +from adapters.base import AdapterResult + +MODE = "stub" +_UNAVAILABLE_REASON = ( + "Claude Code transport not available: no stable programmatic API " + "for local Claude Code invocation in V1.1 environment" +) + + +def execute(prompt: str) -> AdapterResult: + print("=== THIS ADAPTER IS USED: cloudcode_adapter ===") + return AdapterResult( + response=( + f"[cloudcode-stub] Transport unavailable. " + f"Would send {len(prompt)} chars to Claude Code. " + f"Reason: {_UNAVAILABLE_REASON}" + ), + error=None, + exit_code=0, + mode=MODE, + ) + + +def mode() -> str: + return MODE + + +def unavailable_reason() -> str: + return _UNAVAILABLE_REASON diff --git a/bridge/adapters/local_echo_adapter.py b/bridge/adapters/local_echo_adapter.py new file mode 100644 index 0000000..8af609f --- /dev/null +++ b/bridge/adapters/local_echo_adapter.py @@ -0,0 +1,61 @@ +"""Local Echo adapter — REAL execution via subprocess. + +Runs a real subprocess (Python interpreter) that receives the prompt +via stdin and echoes it back. Proves orchestration works with an +actual child process, no external API required. + +This is the mandatory non-stub execution path for Bridge V1.1. +""" +from __future__ import annotations + +import subprocess +import sys + +from adapters.base import AdapterResult + +MODE = "real" + +# Script run inside the subprocess: reads stdin, prints back with metadata +_ECHO_SCRIPT = ( + "import sys; " + "data = sys.stdin.read(); " + "print(f'[local_echo] received {len(data)} chars'); " + "print(data.strip()[:500])" +) + + +def execute(prompt: str) -> AdapterResult: + """Run prompt through a real subprocess and return its stdout.""" + print("=== THIS ADAPTER IS USED: local_echo_adapter ===") + try: + proc = subprocess.run( + [sys.executable, "-c", _ECHO_SCRIPT], + input=prompt, + capture_output=True, + text=True, + timeout=10, + shell=False, + ) + if proc.returncode != 0: + return AdapterResult( + response=None, + error=f"subprocess exited {proc.returncode}: {proc.stderr.strip()}", + exit_code=proc.returncode, + mode=MODE, + ) + return AdapterResult( + response=proc.stdout.strip(), + error=None, + exit_code=0, + mode=MODE, + ) + except subprocess.TimeoutExpired: + return AdapterResult(response=None, error="subprocess timed out after 10s", + exit_code=1, mode=MODE) + except Exception as exc: # noqa: BLE001 + return AdapterResult(response=None, error=f"{type(exc).__name__}: {exc}", + exit_code=1, mode=MODE) + + +def mode() -> str: + return MODE diff --git a/bridge/adapters/ollama_adapter.py b/bridge/adapters/ollama_adapter.py new file mode 100644 index 0000000..fa97182 --- /dev/null +++ b/bridge/adapters/ollama_adapter.py @@ -0,0 +1,219 @@ +"""Ollama adapter — real local LLM via Ollama HTTP API.""" + +from __future__ import annotations + +import json +import os +import re +import time +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import URLError + +from adapters.base import AdapterResult + +_DIAG_DIR = Path(__file__).resolve().parents[3] / "data" / "diagnostics" + + +def _resolve_host() -> str: + """Normalize OLLAMA_HOST to a full http:// URL. + + Ollama on Windows sets OLLAMA_HOST=0.0.0.0 (bind address, no scheme). + We always talk to localhost:11434 for outbound connections. + """ + raw = os.environ.get("OLLAMA_HOST", "") + if not raw or raw in ("0.0.0.0", "[::]"): + return "http://localhost:11434" + if raw.startswith("http://") or raw.startswith("https://"): + return raw + return f"http://{raw}" + + +_HOST: str = _resolve_host() +# Use DECISION_MODEL if set, fall back to OLLAMA_MODEL for backwards compatibility +_MODEL: str = os.environ.get("DECISION_MODEL") or os.environ.get( + "OLLAMA_MODEL", "qwen2.5:7b" +) +_FALLBACK_MODEL: str = "llama3.2:3b" + +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") + + +def _scan_unicode(text: str, label: str) -> list[dict]: + """Scan text for CJK codepoints, return list of findings.""" + findings = [] + for m in _CJK_RE.finditer(text): + findings.append( + { + "pos": m.start(), + "char": m.group(), + "codepoint": f"U+{ord(m.group()):04X}", + "context": text[max(0, m.start() - 20) : m.end() + 20], + } + ) + if findings: + print(f"[CJK DETECTED] in {label}: {len(findings)} occurrence(s)") + for f in findings: + print(f" pos={f['pos']} {f['codepoint']} context={f['context']!r}") + return findings + + +def _save_diag(trace_id: str, kind: str, payload: dict) -> None: + """Save diagnostic JSON to data/diagnostics/.""" + try: + _DIAG_DIR.mkdir(parents=True, exist_ok=True) + path = _DIAG_DIR / f"llm_raw_{kind}_{trace_id}.json" + path.write_text( + json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8" + ) + except Exception as exc: + print(f"[DIAG] save failed: {exc}") + + +def execute( + prompt: str, + model: str | None = None, + temperature: float = 0.1, + max_tokens: int = 150, + timeout: float = 30.0, + trace_id: str | None = None, +) -> AdapterResult: + print("=== THIS ADAPTER IS USED: ollama_adapter ===") + m = model or _MODEL + call_id = ( + trace_id or f"notrace-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%f')}" + ) + + # Hard timeout 60s enforced at request layer + hard_timeout = 60.0 + + request_payload = { + "model": m, + "prompt": prompt, + "stream": False, + "options": { + "temperature": temperature, + "num_predict": max_tokens, + }, + } + + # --- Log + scan RAW REQUEST --- + print( + f"[RAW REQUEST] trace_id={call_id} model={m} temp={temperature} max_tokens={max_tokens}" + ) + print(f"[RAW REQUEST] prompt ({len(prompt)} chars):\n{prompt}") + req_cjk = _scan_unicode(prompt, "request/prompt") + _save_diag( + call_id, + "request", + { + "trace_id": call_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "model": m, + "temperature": temperature, + "max_tokens": max_tokens, + "prompt": prompt, + "prompt_len": len(prompt), + "cjk_in_request": req_cjk, + }, + ) + + encoded = json.dumps(request_payload).encode() + req = urllib.request.Request( + f"{_HOST}/api/generate", + data=encoded, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + _t = time.time() + try: + with urllib.request.urlopen(req, timeout=hard_timeout) as resp: + data = json.loads(resp.read()) + # RAW response text — before ANY processing + raw_text = data.get("response", "") + elapsed = time.time() - _t + tokens_in = data.get("prompt_eval_count", "?") + tokens_out = data.get("eval_count", "?") + print( + f"[TIMING] ollama_http: {elapsed:.3f}s tokens_in={tokens_in} tokens_out={tokens_out}" + ) + + # --- Log + scan RAW RESPONSE --- + print( + f"[RAW RESPONSE] trace_id={call_id} ({len(raw_text)} chars):\n{raw_text}" + ) + resp_cjk = _scan_unicode(raw_text, "response") + _save_diag( + call_id, + "response", + { + "trace_id": call_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "model": m, + "elapsed_s": round(elapsed, 3), + "tokens_in": tokens_in, + "tokens_out": tokens_out, + "raw_response": raw_text, + "raw_response_len": len(raw_text), + "cjk_in_response": resp_cjk, + "full_ollama_payload": { + k: v for k, v in data.items() if k != "response" + }, + }, + ) + + return AdapterResult( + response=raw_text, + error=None, + exit_code=0, + mode="real", + metadata={"model": m}, + ) + except Exception as exc: # noqa: BLE001 + elapsed = time.time() - _t + is_timeout = isinstance(exc, URLError) and "timed out" in str(exc).lower() + if is_timeout: + print(f"[OLLAMA TIMEOUT] trace_id={call_id} timeout=60s") + else: + print(f"[TIMING] ollama_http: {elapsed:.3f}s [ERROR]") + _save_diag( + call_id, + "response", + { + "trace_id": call_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "model": m, + "elapsed_s": round(elapsed, 3), + "error": f"{type(exc).__name__}: {exc}", + "timeout": is_timeout, + "timeout_seconds": 60 if is_timeout else None, + "raw_response": None, + "cjk_in_response": [], + }, + ) + return AdapterResult( + response=None, + error=f"{type(exc).__name__}: {exc}", + exit_code=1, + mode="real", + metadata={ + "model": m, + "timeout": is_timeout, + "timeout_seconds": 60 if is_timeout else None, + }, + ) + + +def available() -> bool: + """Quick reachability check (used at startup).""" + try: + urllib.request.urlopen(f"{_HOST}/api/tags", timeout=3.0) + return True + except (URLError, OSError): + return False + + +def mode() -> str: + return "real" if available() else "stub" diff --git a/bridge/adapters/openai_adapter.py b/bridge/adapters/openai_adapter.py new file mode 100644 index 0000000..bd82915 --- /dev/null +++ b/bridge/adapters/openai_adapter.py @@ -0,0 +1,45 @@ +"""OpenAI adapter — real if OPENAI_API_KEY is set, explicit stub otherwise.""" +from __future__ import annotations + +import os + +from adapters.base import AdapterResult + +_API_KEY: str = os.environ.get("OPENAI_API_KEY", "") +_MODEL: str = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") +_REAL: bool = bool(_API_KEY) + + +def execute( + prompt: str, + model: str = _MODEL, + temperature: float = 0.0, + max_tokens: int = 300, + timeout: float = 30.0, +) -> AdapterResult: + print("=== THIS ADAPTER IS USED: openai_adapter ===") + if not _REAL: + return AdapterResult( + response=f"[openai-stub] OPENAI_API_KEY not set. Would send {len(prompt)} chars to {model}.", + error=None, + exit_code=0, + mode="stub", + ) + try: + from openai import OpenAI # type: ignore + client = OpenAI(api_key=_API_KEY, timeout=timeout) + completion = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=temperature, + max_tokens=max_tokens, + ) + text = completion.choices[0].message.content or "" + return AdapterResult(response=text, error=None, exit_code=0, mode="real") + except Exception as exc: # noqa: BLE001 + return AdapterResult(response=None, error=f"{type(exc).__name__}: {exc}", + exit_code=1, mode="real") + + +def mode() -> str: + return "real" if _REAL else "stub" diff --git a/bridge/adapters/stub_adapter.py b/bridge/adapters/stub_adapter.py new file mode 100644 index 0000000..f3aa8ee --- /dev/null +++ b/bridge/adapters/stub_adapter.py @@ -0,0 +1,25 @@ +"""Stub adapter — always available, no external dependencies. + +Returns a deterministic echo response. Useful for testing the +orchestration pipeline without any real execution. +""" +from __future__ import annotations + +from adapters.base import AdapterResult + +MODE = "stub" + + +def execute(prompt: str) -> AdapterResult: + print("=== THIS ADAPTER IS USED: stub_adapter ===") + preview = prompt[:120] + ("..." if len(prompt) > 120 else "") + return AdapterResult( + response=f"[stub] received {len(prompt)} chars | {preview}", + error=None, + exit_code=0, + mode=MODE, + ) + + +def mode() -> str: + return MODE diff --git a/bridge/api/__init__.py b/bridge/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/api/chat_routes.py b/bridge/api/chat_routes.py new file mode 100644 index 0000000..a581395 --- /dev/null +++ b/bridge/api/chat_routes.py @@ -0,0 +1,37 @@ +"""Chat API routes — user-facing conversation endpoint. + +POST /chat + Accepts a user message, generates a CJK-safe Russian reply via Ollama. + This is the ONLY path through which LLM text reaches a user. + All output is hardened by chat.guard before being returned. +""" +from __future__ import annotations + +import uuid + +from fastapi import APIRouter + +from chat.responder import generate_reply +from models.schemas import ChatRequest, ChatResponse + +router = APIRouter(prefix="/chat", tags=["chat"]) + + +@router.post("", response_model=ChatResponse, status_code=200) +def chat(body: ChatRequest) -> ChatResponse: + """Generate a CJK-safe reply for the given user message. + + - LLM output is scanned for CJK before being returned. + - On detection: one retry with strict Russian-only instruction. + - On second failure: safe Russian fallback returned. + - Diagnostic artifact saved to data/diagnostics/chat_cjk_.json on any CJK incident. + """ + trace_id = body.trace_id or f"chat-{str(uuid.uuid4())[:8]}" + reply, meta = generate_reply(user_message=body.message, trace_id=trace_id) + return ChatResponse( + reply=reply, + cjk_detected=meta["cjk_detected"], + fallback_used=meta["fallback_used"], + model=meta["model"], + trace_id=trace_id, + ) diff --git a/bridge/api/controller_routes.py b/bridge/api/controller_routes.py new file mode 100644 index 0000000..f1e58e1 --- /dev/null +++ b/bridge/api/controller_routes.py @@ -0,0 +1,33 @@ +"""Controller API routes for Bridge V1.2.""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from controller.controller_loop import RUNS_DIR, run_controller +from controller.controller_state import load as load_state +from models.schemas import ControllerResponse, ControllerRunRequest + +router = APIRouter(prefix="/controller", tags=["controller"]) + + +@router.post("/runs", response_model=ControllerResponse, status_code=201) +def start_controller_run(body: ControllerRunRequest): + """Execute a full controller loop: goal → decision → agent → result → stop. + + Blocks until the controller loop completes (max 3 iterations). + Returns the final persisted controller state. + """ + try: + state = run_controller(goal=body.goal, run_id=body.run_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return ControllerResponse(**state.to_dict()) + + +@router.get("/runs/{ctrl_id}", response_model=ControllerResponse) +def get_controller_run(ctrl_id: str): + """Retrieve persisted controller state.""" + state = load_state(RUNS_DIR, ctrl_id) + if state is None: + raise HTTPException(status_code=404, detail=f"controller run '{ctrl_id}' not found") + return ControllerResponse(**state.to_dict()) diff --git a/bridge/api/routes.py b/bridge/api/routes.py new file mode 100644 index 0000000..06f6ae3 --- /dev/null +++ b/bridge/api/routes.py @@ -0,0 +1,117 @@ +"""FastAPI route definitions for Bridge V1.2 with UTF-8 support.""" +from __future__ import annotations + +import sys +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request + +import adapters as adapter_registry +from core.run_manager import RunManager +from models.schemas import CreateRunRequest, HealthResponse, RunResponse, RunStatus +from policy.guard import check as policy_check + +def safe_print(text): + """Print with UTF-8 encoding fallback for Windows CP1252.""" + try: + print(text) + except UnicodeEncodeError: + sys.stdout.buffer.write(text.encode('utf-8', errors='replace')) + sys.stdout.buffer.write(b'\n') + +RUNS_DIR = Path(__file__).resolve().parents[1] / "runs" +_manager = RunManager(RUNS_DIR) + +router = APIRouter() + +@router.get("/health", response_model=HealthResponse) +def health(): + """Returns server status and per-adapter mode (real/stub).""" + import os + from adapters import openai_adapter + from controller.decision_model import DECISION_MODE + + print(f"[ENCODING DEBUG] sys.getdefaultencoding() = {sys.getdefaultencoding()}") + print(f"[ENCODING DEBUG] sys.getfilesystemencoding() = {sys.getfilesystemencoding()}") + print(f"[ENCODING DEBUG] LANG = {os.environ.get('LANG', 'NOT SET')}") + print(f"[ENCODING DEBUG] PYTHONIOENCODING = {os.environ.get('PYTHONIOENCODING', 'NOT SET')}") + + return HealthResponse( + status="ok", + adapters=adapter_registry.available(), + openai_mode=openai_adapter.mode(), + decision_backend=DECISION_MODE, + ollama_model=os.environ.get("OLLAMA_MODEL", "qwen2.5:7b"), + ) + + +@router.post("/runs", response_model=RunResponse, status_code=201) +def create_run(body: CreateRunRequest): + """One-shot run: create -> instruct -> execute -> persist -> done|forced. + + The response is the final completed state — status is never 'running' + when this endpoint returns. + + FORCE AGENT ROUTING: All requests are forced to use 'agent' adapter. + No fallback, no alternative adapters, no direct LLM calls. + """ + safe_print("=== RUNS ENDPOINT CALLED ===") + safe_print("=== FORCE AGENT ROUTING ENABLED ===") + + # Force agent adapter - ignore request body adapter + forced_adapter = "agent" + safe_print(f"=== USING ADAPTER: {forced_adapter} ===") + + # DEBUG: Log encoding details + safe_print(f"[ENCODING DEBUG] Raw goal: {body.goal}") + safe_print(f"[ENCODING DEBUG] Repr goal: {repr(body.goal)}") + safe_print(f"[ENCODING DEBUG] Goal type: {type(body.goal)}") + safe_print(f"[ENCODING DEBUG] Goal bytes: {body.goal.encode('utf-8')}") + + # DEBUG: Log adapter info + safe_print(f"[FORCE ROUTING] Requested adapter: {body.adapter}") + safe_print(f"[FORCE ROUTING] Forced adapter: {forced_adapter}") + safe_print(f"[FORCE ROUTING] No fallback, no alternatives - agent only") + + adapter_mod = adapter_registry.get(forced_adapter) + if adapter_mod is None: + safe_print("=== RETURNING RESPONSE (ERROR) ===") + raise HTTPException( + status_code=500, + detail=f"Agent adapter not available - this should never happen", + ) + + try: + state = _manager.execute_run( + goal=body.goal, + adapter_name=forced_adapter, + adapter_fn=adapter_mod.execute, # type: ignore[attr-defined] + guard_fn=policy_check, + run_id=body.run_id, + ) + except ValueError as exc: + safe_print("=== RETURNING RESPONSE (ERROR) ===") + raise HTTPException(status_code=409, detail=str(exc)) from exc + + safe_print("=== RETURNING RESPONSE (SUCCESS) ===") + return RunResponse(**state) + + +@router.get("/runs/{run_id}", response_model=RunResponse) +def get_run(run_id: str): + """Retrieve persisted run state.""" + state = _manager.get_run(run_id) + if state is None: + raise HTTPException(status_code=404, detail=f"run '{run_id}' not found") + return RunResponse(**state) + + +@router.post("/runs/{run_id}/cancel") +def cancel_run(run_id: str): + """Cancel a running or pending run.""" + state = _manager.get_run(run_id) + if state is None: + raise HTTPException(status_code=404, detail=f"run '{run_id}' not found") + + _manager.cancel_run(run_id) + return {"message": f"run '{run_id}' cancellation requested"} diff --git a/bridge/app.py b/bridge/app.py new file mode 100644 index 0000000..fca115e --- /dev/null +++ b/bridge/app.py @@ -0,0 +1,31 @@ +"""Bridge V1.1 — FastAPI application entry point.""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +print(">>> THIS IS THE REAL BACKEND <<<") + +from fastapi import FastAPI + +from api.chat_routes import router as chat_router +from api.controller_routes import router as ctrl_router +from api.routes import router + +app = FastAPI( + title="LocalAgent Bridge - AGENT ONLY", + description="One-shot orchestration bridge - forced agent routing, no LLM fallback", + version="2.0.0", +) + +# Force agent routing - only /runs endpoint available +app.include_router(router) +app.include_router(ctrl_router) +# REMOVED: chat_router - no direct LLM access allowed + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False) diff --git a/bridge/chat/__init__.py b/bridge/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/chat/guard.py b/bridge/chat/guard.py new file mode 100644 index 0000000..fa502a0 --- /dev/null +++ b/bridge/chat/guard.py @@ -0,0 +1,324 @@ +"""CJK + Russian-language + hallucination guard for user-facing chat output. + +Applied as the final layer before any text is returned to a user. +Flow: + raw LLM text + -> scan_cjk() — detects CJK characters + -> if CJK: retry once; if still CJK: safe fallback + -> validate_russian() — Cyrillic dominance + foreign word check + -> if fails: retry once; if still fails: safe fallback + -> detect_hallucination() — structured sections with no user context + -> if hallucination: block, return clarification prompt + -> save diagnostic artifact on every incident +""" +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +_LATIN_WORD_RE = re.compile(r"\b([a-zA-Z]{3,})\b") +_URL_RE = re.compile(r"https?://\S+|www\.\S+") +_CODE_RE = re.compile(r"`[^`]+`|```[\s\S]+?```") +_ALNUM_RE = re.compile(r"\b[a-zA-Z]*\d+[a-zA-Z\d]*\b") # model/version names: llama3.2, GPT-4 + +# Hallucination: structured section headers that signal invented project context +_HALLUCINATION_SECTION_RE = re.compile( + r"(?m)^\s*(?:Цель|Прогресс|Следующие\s+шаги|Задача|Датасет|Инструменты|" + r"Статус|Шаги|Данные|Проект)\s*:", + re.IGNORECASE, +) + +# Keywords that indicate the user actually provided project/task context +_PROJECT_CONTEXT_RE = re.compile( + r"(проект|задач[аи]?|цел[ьи]|данн|датасет|pandas|csv|excel|" + r"анализ|разработ|таблиц|файл|база\s*данных|прогресс|шаги|инструмент|" + r"нужно сделать|нам надо|план работ)", + re.IGNORECASE, +) + +CJK_SAFE_FALLBACK = ( + "Извините, произошла техническая ошибка. Пожалуйста, повторите запрос." +) + +HALLUCINATION_CLARIFY = ( + "Уточните, пожалуйста, задачу или проект." +) + +_DIAG_DIR = Path(__file__).resolve().parents[2] / "data" / "diagnostics" + +RETRY_SYSTEM_PROMPT = ( + "Respond ONLY in Russian. Do not use any other language. " + "No Chinese, no English, no other script. Russian only." +) + +RETRY_RUSSIAN_PROMPT = ( + "Respond ONLY in natural Russian. Do not use English, Spanish, Chinese, " + "or any other language unless explicitly asked." +) + +# Words that clearly indicate non-Russian content when appearing in a Russian reply +_FOREIGN_WORDS = frozenset({ + # English function words / common vocabulary + "the", "and", "you", "that", "this", "with", "have", "from", "will", + "been", "would", "their", "there", "what", "your", "about", "when", + "which", "than", "then", "into", "also", "could", "just", "like", + "very", "some", "more", "help", "right", "good", "okay", "well", + "various", "assist", "different", "ready", "thank", "thanks", "yes", + "all", "aider", + # English verbs that slip into Russian responses + "learns", "improves", "uses", "allows", "provides", "creates", "helps", + "includes", "requires", "supports", "assisting", "helping", + "assistir", "working", "making", "being", "having", + # English nouns/adjectives seen in mixed llama3.2:3b output + "human", "clear", "mainly", "audience", "deadlines", "breaks", "whom", + "experience", "experiences", "important", "different", "possible", + "available", "specific", "current", "following", "including", + # Spanish + "gracias", "hola", "bien", "como", "pero", "para", "porque", +}) + + +def scan_cjk(text: str) -> list[dict[str, Any]]: + """Return list of CJK findings: pos, char, codepoint, context.""" + return [ + { + "pos": m.start(), + "char": m.group(), + "codepoint": f"U+{ord(m.group()):04X}", + "context": text[max(0, m.start() - 20) : m.end() + 20], + } + for m in _CJK_RE.finditer(text) + ] + + +def has_cjk(text: str) -> bool: + return bool(_CJK_RE.search(text)) + + +def validate_russian(text: str) -> dict[str, Any] | None: + """Check that text is predominantly Russian. + + Returns None if text passes (is sufficiently Russian). + Returns {"reason": ..., "details": ...} if validation fails. + + Exceptions (not flagged): + - URLs and code blocks + - All-caps acronyms (API, HTTP, SQL) + - Alphanumeric model/version identifiers (llama3.2, GPT-4) + """ + if not text or len(text.strip()) < 15: + return None + + clean = _URL_RE.sub(" ", text) + clean = _CODE_RE.sub(" ", clean) + clean = _ALNUM_RE.sub(" ", clean) + + cyrillic = sum(1 for c in clean if "\u0400" <= c <= "\u04ff") + latin_chars = sum(1 for c in clean if "a" <= c.lower() <= "z") + total_alpha = cyrillic + latin_chars + + if total_alpha < 15: + return None + + cyrillic_ratio = cyrillic / total_alpha + + if cyrillic_ratio < 0.6 and total_alpha >= 25: + return { + "reason": "low_cyrillic_ratio", + "details": f"ratio={cyrillic_ratio:.2f} ({cyrillic} cyrillic / {total_alpha} alpha)", + } + + suspicious: list[str] = [] + for m in _LATIN_WORD_RE.finditer(clean): + word = m.group() + if word.isupper(): + continue # all-caps acronym (API, HTTP, SQL) + if word.lower() in _FOREIGN_WORDS: + suspicious.append(word.lower()) + + if suspicious: + return { + "reason": "foreign_words_detected", + "details": f"words={suspicious[:5]}", + } + + return None + + +def detect_hallucination(text: str, user_message: str) -> dict[str, Any] | None: + """Detect invented project/task context in a reply the user never asked for. + + Fires when: + 1. The reply contains structured section headers (Цель:, Прогресс:, etc.) + 2. The user message contains no project/task context keywords + + Returns None if no hallucination detected. + Returns {"reason": ..., "sections": [...]} if hallucination detected. + """ + sections = _HALLUCINATION_SECTION_RE.findall(text) + if not sections: + return None # no structured sections → cannot be this kind of hallucination + + if _PROJECT_CONTEXT_RE.search(user_message): + return None # user explicitly mentioned project/task context → allowed + + return { + "reason": "hallucinated_project_context", + "sections": [s.strip() for s in sections], + } + + +def _save_incident(trace_id: str, kind: str, artifact: dict[str, Any]) -> None: + try: + _DIAG_DIR.mkdir(parents=True, exist_ok=True) + path = _DIAG_DIR / f"chat_{kind}_{trace_id}.json" + path.write_text( + json.dumps(artifact, indent=2, ensure_ascii=False), encoding="utf-8" + ) + except Exception as exc: + print(f"[GUARD] diag save failed: {exc}") + + +def guard_reply( + raw: str, + prompt: str, + trace_id: str, + retry_fn, # callable(prompt: str) -> str + user_message: str = "", +) -> tuple[str, bool, bool]: + """Apply CJK + Russian-language + hallucination guard to a raw LLM reply. + + Args: + raw: raw text from LLM, before any processing + prompt: the original user-facing prompt that produced raw + trace_id: for logging and artifact naming + retry_fn: callable that re-runs generation with a given prompt + user_message: original user text (used for hallucination context check) + + Returns: + (final_text, fallback_used, lang_incident) + lang_incident: True if any guard fired + """ + text = raw + lang_incident = False + + # ── Empty response guard ────────────────────────────────────────────────── + if not text.strip(): + print(f"[GUARD] EMPTY RESPONSE trace_id={trace_id} — retrying") + text = retry_fn(prompt) + lang_incident = True + if not text.strip(): + print(f"[GUARD] EMPTY STILL after retry — using safe fallback trace_id={trace_id}") + return CJK_SAFE_FALLBACK, True, True + + # ── CJK guard ──────────────────────────────────────────────────────────── + if has_cjk(text): + lang_incident = True + findings = scan_cjk(text) + print( + f"[GUARD] CJK DETECTED trace_id={trace_id} " + f"chars={len(findings)} fragment={findings[0]['context']!r}" + ) + + retry_prompt = f"{RETRY_SYSTEM_PROMPT}\n\n{prompt}" + retried = retry_fn(retry_prompt) + + if has_cjk(retried): + print(f"[GUARD] CJK STILL PRESENT after retry — using safe fallback trace_id={trace_id}") + _save_incident(trace_id, "cjk", { + "trace_id": trace_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "guard": "cjk", + "original_prompt": prompt, + "raw_response": raw, + "cjk_findings": findings, + "retried_response": retried, + "final_sent": CJK_SAFE_FALLBACK, + "fallback_used": True, + }) + return CJK_SAFE_FALLBACK, True, True + + print(f"[GUARD] CJK retry clean trace_id={trace_id}") + _save_incident(trace_id, "cjk", { + "trace_id": trace_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "guard": "cjk", + "original_prompt": prompt, + "raw_response": raw, + "cjk_findings": findings, + "retried_response": retried, + "final_sent": retried, + "fallback_used": False, + }) + text = retried + + # ── Russian-language guard ──────────────────────────────────────────────── + validation = validate_russian(text) + if validation: + lang_incident = True + print( + f"[GUARD] NON-RUSSIAN trace_id={trace_id} " + f"reason={validation['reason']} {validation['details']}" + ) + + retry_prompt = f"{RETRY_RUSSIAN_PROMPT}\n\n{prompt}" + retried = retry_fn(retry_prompt) + + retry_validation = validate_russian(retried) + if retry_validation: + print(f"[GUARD] NON-RUSSIAN STILL after retry — using safe fallback trace_id={trace_id}") + _save_incident(trace_id, "lang", { + "trace_id": trace_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "guard": "russian_validation", + "original_prompt": prompt, + "raw_response": raw, + "validation_failure": validation, + "retried_response": retried, + "retry_validation_failure": retry_validation, + "final_sent": CJK_SAFE_FALLBACK, + "fallback_used": True, + }) + return CJK_SAFE_FALLBACK, True, True + + print(f"[GUARD] Russian retry clean trace_id={trace_id}") + _save_incident(trace_id, "lang", { + "trace_id": trace_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "guard": "russian_validation", + "original_prompt": prompt, + "raw_response": raw, + "validation_failure": validation, + "retried_response": retried, + "retry_validation_failure": None, + "final_sent": retried, + "fallback_used": False, + }) + text = retried + + # ── Hallucination guard ─────────────────────────────────────────────────── + hallucination = detect_hallucination(text, user_message) + if hallucination: + lang_incident = True + print( + f"[GUARD] HALLUCINATION trace_id={trace_id} " + f"sections={hallucination['sections']}" + ) + _save_incident(trace_id, "hallucination", { + "trace_id": trace_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "guard": "hallucination", + "user_message": user_message, + "original_prompt": prompt, + "raw_response": raw, + "hallucination_details": hallucination, + "final_sent": HALLUCINATION_CLARIFY, + "fallback_used": False, + }) + return HALLUCINATION_CLARIFY, False, True + + return text, False, lang_incident diff --git a/bridge/chat/responder.py b/bridge/chat/responder.py new file mode 100644 index 0000000..fe0a842 --- /dev/null +++ b/bridge/chat/responder.py @@ -0,0 +1,161 @@ +"""User-facing chat reply generator. + +This is the ONLY path through which LLM text reaches a user. +Flow: + user_message + -> _detect_intent() — fixed response for greeting/smalltalk/thanks + -> _call_ollama() — LLM for everything else + -> guard_reply() — CJK + language + hallucination guard + +Usage: + from chat.responder import generate_reply + reply, meta = generate_reply("Привет", trace_id="ctrl-abc-chat") +""" +from __future__ import annotations + +import os +import re +from typing import Any + +from chat.guard import guard_reply, has_cjk + +# ── Intent routing ──────────────────────────────────────────────────────────── + +_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) +_SPACE_RE = re.compile(r"\s+") + +# Exact normalized phrases per intent (after lower + strip punctuation) +_INTENT_PATTERNS: dict[str, set[str]] = { + "GREETING": { + "привет", "здравствуйте", "здравствуй", + "добрый день", "добрый вечер", "доброе утро", + "hi", "hello", + }, + "SMALLTALK": { + "как дела", "как ты", "что нового", + "как поживаешь", "как жизнь", "как ты дела", + }, + "THANKS": { + "спасибо", "благодарю", "спс", + "благодарен", "благодарна", "большое спасибо", + }, +} + +_INTENT_RESPONSES: dict[str, str] = { + "GREETING": "Привет! Чем могу помочь?", + "SMALLTALK": "Всё хорошо 🙂 Чем могу помочь?", + "THANKS": "Пожалуйста 🙂 Обращайтесь!", +} + + +def _normalize(text: str) -> str: + """Lowercase + strip punctuation + collapse whitespace.""" + return _PUNCT_RE.sub("", text.lower()).strip() + + +def _detect_intent(text: str) -> str | None: + """Return intent name if message matches a known pattern, else None.""" + normalized = _normalize(text) + for intent, patterns in _INTENT_PATTERNS.items(): + if normalized in patterns: + return intent + return None + + +# ── System prompt ───────────────────────────────────────────────────────────── + +_SYSTEM_PREFIX = ( + "Ты — полезный ассистент. " + "Отвечай ТОЛЬКО на русском языке. " + "Никогда не используй другие языки. " + "Давай краткие, точные ответы.\n\n" + "ПРАВИЛА:\n" + "1. На приветствие ('Привет', 'Здравствуйте' и т.п.) отвечай одной короткой фразой. " + "Не упоминай никаких проектов, задач, данных или инструментов.\n" + "2. Если пользователь не указал проект или задачу — не выдумывай их. " + "Никогда не генерируй цели, датасеты, прогресс, инструменты (pandas, CSV и т.д.) " + "или шаги, которые не были явно упомянуты.\n" + "3. Если намерение неясно — задай короткий уточняющий вопрос вместо того, " + "чтобы предполагать контекст.\n" + "4. По умолчанию отвечай коротко. Подробности давай только если явно попросят." +) + +# Read model + generation settings at import time (env-driven) +_CHAT_MODEL: str = ( + os.environ.get("CHAT_MODEL") + or os.environ.get("OLLAMA_MODEL", "qwen2.5:7b") +) +_MAX_TOKENS: int = int(os.environ.get("CHAT_MAX_TOKENS", "150")) +_TEMPERATURE: float = float(os.environ.get("CHAT_TEMPERATURE", "0.3")) + + +def _call_ollama(prompt: str, trace_id: str | None = None) -> str: + """Raw Ollama call — returns response text or empty string on error.""" + from adapters import ollama_adapter + result = ollama_adapter.execute( + prompt=prompt, + model=_CHAT_MODEL, + temperature=_TEMPERATURE, + max_tokens=_MAX_TOKENS, + timeout=30.0, + trace_id=trace_id, + ) + return (result.response or "").strip() + + +def generate_reply( + user_message: str, + trace_id: str | None = None, +) -> tuple[str, dict[str, Any]]: + """Generate a reply for the user. + + Fast path: known intents (greeting, smalltalk, thanks) return fixed + responses without calling the LLM. + + Slow path: everything else goes through Ollama + full guard pipeline. + + Returns: + (reply_text, meta) + meta keys: intent, cjk_detected, fallback_used, lang_incident, + model, max_tokens, temperature + """ + tid = trace_id or "chat-notrace" + + # ── Fast path: intent routing ───────────────────────────────────────────── + intent = _detect_intent(user_message) + if intent: + print(f"[INTENT] {intent} trace_id={tid} message={user_message!r}") + return _INTENT_RESPONSES[intent], { + "intent": intent, + "cjk_detected": False, + "fallback_used": False, + "lang_incident": False, + "model": "intent_router", + "max_tokens": 0, + "temperature": 0.0, + } + + # ── Slow path: LLM + guard ──────────────────────────────────────────────── + prompt = f"{_SYSTEM_PREFIX}\n\nПользователь: {user_message}\nАссистент:" + + raw = _call_ollama(prompt, trace_id=tid) + raw_has_cjk = has_cjk(raw) + + final, fallback_used, lang_incident = guard_reply( + raw=raw, + prompt=prompt, + trace_id=tid, + retry_fn=lambda p: _call_ollama(p, trace_id=f"{tid}-retry"), + user_message=user_message, + ) + + meta: dict[str, Any] = { + "intent": None, + "cjk_detected": raw_has_cjk, + "fallback_used": fallback_used, + "lang_incident": lang_incident, + "model": _CHAT_MODEL, + "max_tokens": _MAX_TOKENS, + "temperature": _TEMPERATURE, + } + return final, meta diff --git a/bridge/config.py b/bridge/config.py new file mode 100644 index 0000000..d07009d --- /dev/null +++ b/bridge/config.py @@ -0,0 +1,31 @@ +"""Bridge configuration — read from env, fall back to defaults.""" +from __future__ import annotations + +import os +from pathlib import Path + +# Directory where per-run state and audit logs are stored +RUNS_DIR: Path = Path(__file__).parent / "runs" + +# OpenAI settings +OPENAI_API_KEY: str = os.environ.get("OPENAI_API_KEY", "") +OPENAI_MODEL: str = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") + +# Ollama settings +OLLAMA_HOST: str = os.environ.get("OLLAMA_HOST", "http://localhost:11434") +OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b") # legacy alias +OLLAMA_FALLBACK_MODEL: str = "llama3.2:3b" + +# Model routing — set independently via env vars +# DECISION_MODEL: used by controller decision loop (structured JSON output) +# CHAT_MODEL: used by user-facing chat replies (natural language) +DECISION_MODEL: str = os.environ.get("DECISION_MODEL", OLLAMA_MODEL) +CHAT_MODEL: str = os.environ.get("CHAT_MODEL", "llama3.2:3b") + +# Chat generation settings (tuned for low latency + short replies) +CHAT_MAX_TOKENS: int = int(os.environ.get("CHAT_MAX_TOKENS", "150")) +CHAT_TEMPERATURE: float = float(os.environ.get("CHAT_TEMPERATURE", "0.3")) + +# Server settings +HOST: str = os.environ.get("BRIDGE_HOST", "0.0.0.0") +PORT: int = int(os.environ.get("BRIDGE_PORT", "8000")) diff --git a/bridge/controller/__init__.py b/bridge/controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/controller/controller_loop.py b/bridge/controller/controller_loop.py new file mode 100644 index 0000000..72367c3 --- /dev/null +++ b/bridge/controller/controller_loop.py @@ -0,0 +1,258 @@ +"""Controller Loop V1. + +Minimal autonomous orchestration: + goal → decision → bridge run (adapter=agent) → observe → decision → stop + +Stop conditions: + 1. decision.action == "stop" + 2. iteration >= MAX_ITERATIONS + 3. bridge run status == done AND agent_final_status == done (natural success) + 4. bridge run failed twice in a row (repeated failure guard) +""" +from __future__ import annotations + +import json +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +import adapters as adapter_registry +from controller.controller_state import ControllerState, save as save_state, state_path +from controller.decision_model import DECISION_MODE, decide +from core.run_manager import RunManager +from policy.guard import check as policy_check + +MAX_ITERATIONS: int = 3 +RUNS_DIR: Path = Path(__file__).resolve().parents[1] / "runs" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Controller-level audit logger +# --------------------------------------------------------------------------- + +class _CtrlAudit: + def __init__(self, ctrl_id: str, runs_dir: Path) -> None: + self._path = runs_dir / ctrl_id / "controller_audit.jsonl" + self._ctrl_id = ctrl_id + self._path.parent.mkdir(parents=True, exist_ok=True) + + def log(self, event: str, *, status: str = "ok", details: Optional[dict] = None) -> None: + record = { + "timestamp": _now(), + "trace_id": self._ctrl_id, # trace_id == controller_run_id + "run_id": self._ctrl_id, + "layer": "controller", + "controller_run_id": self._ctrl_id, + "event_type": event, + "status": status, + "details": details or {}, + } + with self._path.open("a", encoding="utf-8") as f: + f.write(json.dumps(record) + "\n") + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def run_controller( + goal: str, + run_id: Optional[str] = None, +) -> ControllerState: + """Execute the full controller loop and return final ControllerState.""" + ctrl_id = run_id or f"ctrl-{str(uuid.uuid4())[:8]}" + + if state_path(RUNS_DIR, ctrl_id).exists(): + raise ValueError(f"controller run_id '{ctrl_id}' already exists") + + state = ControllerState( + controller_run_id=ctrl_id, + original_goal=goal, + decision_mode=DECISION_MODE, + ) + audit = _CtrlAudit(ctrl_id, RUNS_DIR) + manager = RunManager(RUNS_DIR) + + save_state(state, RUNS_DIR) + audit.log("controller_started", details={"goal": goal, "decision_mode": DECISION_MODE, + "max_iterations": MAX_ITERATIONS}) + + _t_total = time.time() + last_result: Optional[dict[str, Any]] = None + consecutive_failures = 0 + recovery_attempted = False + + for i in range(MAX_ITERATIONS): + state.iteration_count = i + 1 + state.touch() + + # 1. DECIDE + decision = decide(goal, i, last_result, MAX_ITERATIONS, recovery_attempted, trace_id=ctrl_id) + + # Log recovery audit events based on decision + if last_result is not None and ( + last_result.get("status") == "failed" + or last_result.get("agent_final_status") == "failed" + ) and not recovery_attempted: + audit.log("recovery_considered", details={ + "iteration": i, + "original_goal": goal, + "recovery_applied": decision.recovery_applied, + "reason": decision.reason, + }) + if decision.recovery_applied: + audit.log("recovery_applied", details={ + "iteration": i, + "original_goal": goal, + "rewritten_goal": decision.goal, + "rule_reason": decision.reason, + }) + state.recovery_attempted = True + state.recovery_reason = decision.reason + state.rewritten_goal = decision.goal + recovery_attempted = True + # Update running goal to rewritten goal + goal = decision.goal + else: + audit.log("recovery_skipped", details={ + "iteration": i, + "original_goal": goal, + "reason": decision.reason, + }) + + state.decisions.append({**decision.to_dict(), "iteration": i, "decided_at": _now()}) + save_state(state, RUNS_DIR) + + # Hard stop guard — layer 2: if last run succeeded, never run agent again + terminal_enforced = False + if ( + decision.action == "run_agent" + and last_result is not None + and last_result.get("status") == "done" + and last_result.get("agent_final_status") == "done" + ): + decision.action = "stop" + terminal_enforced = True + + audit.log("decision_made", details={ + "iteration": i, + "action": decision.action, + "goal": decision.goal, + "reason": decision.reason, + "mode": decision.mode, + "parsed_success": decision.parsed_success, + "fallback_used": decision.mode in ("stub", "ollama-fallback", "real-fallback"), + "fallback_reason": decision.fallback_reason, + "corrected": decision.corrected, + "correction_reason": decision.correction_reason, + "terminal_enforced": terminal_enforced, + }) + + if decision.action == "stop": + # Determine final status from last result + if last_result is None: + state.final_status = "stopped" + elif last_result.get("status") == "done": + state.final_status = "done" + else: + state.final_status = "failed" + break + + # 2. EXECUTE BRIDGE RUN + bridge_run_id = f"bri-{ctrl_id}-i{i}" + agent_mod = adapter_registry.get("agent") + + audit.log("bridge_run_started", details={ + "iteration": i, + "bridge_run_id": bridge_run_id, + "goal": decision.goal, + }) + + _t_bridge = time.time() + try: + bridge_result = manager.execute_run( + goal=decision.goal, + adapter_name="agent", + adapter_fn=agent_mod.execute, # type: ignore[attr-defined] + guard_fn=policy_check, + run_id=bridge_run_id, + trace_id=ctrl_id, + ) + except Exception as exc: # noqa: BLE001 + error_msg = f"{type(exc).__name__}: {exc}" + audit.log("bridge_run_error", status="failed", details={ + "iteration": i, + "bridge_run_id": bridge_run_id, + "error": error_msg, + }) + bridge_result = { + "run_id": bridge_run_id, + "status": "failed", + "error": error_msg, + "agent_final_status": "failed", + "exit_code": 1, + } + + print(f"[TIMING] bridge.execute (iter {i}): {time.time() - _t_bridge:.3f}s") + + state.linked_bridge_run_ids.append(bridge_run_id) + state.bridge_run_results.append({ + "iteration": i, + "bridge_run_id": bridge_run_id, + "status": bridge_result.get("status"), + "agent_final_status": bridge_result.get("agent_final_status"), + "exit_code": bridge_result.get("exit_code"), + "error": bridge_result.get("error"), + }) + last_result = bridge_result + state.touch() + save_state(state, RUNS_DIR) + + audit.log("bridge_run_finished", details={ + "iteration": i, + "bridge_run_id": bridge_run_id, + "status": bridge_result.get("status"), + "agent_final_status": bridge_result.get("agent_final_status"), + "exit_code": bridge_result.get("exit_code"), + }) + + # 3. IMMEDIATE STOP — natural success, no further decision call + if (bridge_result.get("status") == "done" + and bridge_result.get("agent_final_status") == "done"): + state.final_status = "done" + save_state(state, RUNS_DIR) + audit.log("controller_finished", status="done", + details={"reason": "agent success", "iterations": state.iteration_count, + "linked_runs": state.linked_bridge_run_ids}) + print(f"[TIMING] run_controller total: {time.time() - _t_total:.3f}s") + return state + + # Repeated failure guard + consecutive_failures += 1 + if consecutive_failures >= 2: + state.final_status = "failed" + audit.log("controller_finished", status="failed", + details={"reason": "Repeated consecutive failures", "iterations": state.iteration_count}) + save_state(state, RUNS_DIR) + print(f"[TIMING] run_controller total: {time.time() - _t_total:.3f}s") + return state + + else: + # MAX_ITERATIONS exhausted without explicit stop + if last_result and last_result.get("status") == "done": + state.final_status = "done" + else: + state.final_status = "failed" + + save_state(state, RUNS_DIR) + audit.log("controller_finished", status=state.final_status, + details={"iterations": state.iteration_count, + "linked_runs": state.linked_bridge_run_ids}) + print(f"[TIMING] run_controller total: {time.time() - _t_total:.3f}s") + return state diff --git a/bridge/controller/controller_state.py b/bridge/controller/controller_state.py new file mode 100644 index 0000000..ad5be84 --- /dev/null +++ b/bridge/controller/controller_state.py @@ -0,0 +1,62 @@ +"""Controller run state model and persistence.""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class ControllerState: + controller_run_id: str + original_goal: str + decision_mode: str # "real" | "stub" + iteration_count: int = 0 + decisions: list[dict[str, Any]] = field(default_factory=list) + linked_bridge_run_ids: list[str] = field(default_factory=list) + bridge_run_results: list[dict[str, Any]] = field(default_factory=list) + final_status: str = "running" # running | done | failed | stopped + recovery_attempted: bool = False # True after rewrite rule applied + recovery_reason: Optional[str] = None # rewrite_explain() text or skip reason + rewritten_goal: Optional[str] = None # goal after rewrite (if recovery applied) + created_at: str = field(default_factory=_now) + updated_at: str = field(default_factory=_now) + + @property + def trace_id(self) -> str: + """trace_id == controller_run_id by definition.""" + return self.controller_run_id + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def touch(self) -> None: + self.updated_at = _now() + + +# --------------------------------------------------------------------------- +# Persistence helpers +# --------------------------------------------------------------------------- + +def state_path(runs_dir: Path, ctrl_id: str) -> Path: + return runs_dir / ctrl_id / "controller_state.json" + + +def save(state: ControllerState, runs_dir: Path) -> None: + p = state_path(runs_dir, state.controller_run_id) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(state.to_dict(), indent=2), encoding="utf-8") + + +def load(runs_dir: Path, ctrl_id: str) -> Optional[ControllerState]: + p = state_path(runs_dir, ctrl_id) + if not p.exists(): + return None + data = json.loads(p.read_text(encoding="utf-8")) + return ControllerState(**data) diff --git a/bridge/controller/decision_model.py b/bridge/controller/decision_model.py new file mode 100644 index 0000000..466bc36 --- /dev/null +++ b/bridge/controller/decision_model.py @@ -0,0 +1,391 @@ +"""Decision model for Controller Loop V1. + +The decision layer takes the current goal, iteration index, and the last +bridge run result, and returns a Decision. + +Modes: + ollama — Ollama response parsed and validated successfully + ollama-corrected — Ollama parsed but model repeated a failed goal; overridden + ollama-fallback — Ollama error / bad JSON; stub logic used + stub — Ollama unreachable at startup; deterministic rules only + +Deterministic stub rules: + iteration 0, no last result → run_agent (always start) + last_result.status == done → stop (success) + last_result.status == failed + AND recovery_attempted == False → try rewrite rules: + rule matches → run_agent with rewritten goal (recovery_applied=True) + no rule matches → stop (recovery_skipped) + last_result.status == failed + AND recovery_attempted == True → stop (already recovered once) + fallback → stop (safety net) + +Post-decision validation (ollama mode only): + IF last run failed AND model returns same goal as the one that just failed: + → override goal to _SAFE_FALLBACK_GOAL, mode = "ollama-corrected" + This prevents the model from looping on a known-broken goal. +""" +from __future__ import annotations + +import json +import os +import re +import time +from dataclasses import asdict, dataclass +from typing import Any, Optional + + +# --------------------------------------------------------------------------- +# Input sanitization / output validation +# --------------------------------------------------------------------------- + +def sanitize_text(text: str) -> str: + """Strip characters outside ASCII, Cyrillic, and Latin-1 Supplement.""" + return re.sub(r'[^\x00-\x7F\u0400-\u04FF\u00A0-\u00FF]', '', text) + + +def has_cjk(text: str) -> bool: + """Return True if text contains any CJK Unified Ideographs.""" + return bool(re.search(r'[\u4e00-\u9fff]', text)) + +_SAFE_FALLBACK_GOAL = "inspect project" + + +def _resolve_ollama_host() -> str: + raw = os.environ.get("OLLAMA_HOST", "") + if not raw or raw in ("0.0.0.0", "[::]"): + return "http://localhost:11434" + if raw.startswith("http://") or raw.startswith("https://"): + return raw + return f"http://{raw}" + + +_OLLAMA_CACHE: tuple[float, bool] | None = None +_OLLAMA_CACHE_TTL = 5.0 # seconds + + +def _ollama_available() -> bool: + """Dynamic Ollama reachability check with 5s cache.""" + import time + import urllib.request + from urllib.error import URLError + + global _OLLAMA_CACHE + now = time.monotonic() + if _OLLAMA_CACHE is not None: + ts, result = _OLLAMA_CACHE + if now - ts < _OLLAMA_CACHE_TTL: + return result + + host = _resolve_ollama_host() + try: + urllib.request.urlopen(f"{host}/api/tags", timeout=2.0) + available = True + except (URLError, OSError): + available = False + + _OLLAMA_CACHE = (now, available) + return available + + +# Keep for health endpoint / module-level DECISION_MODE label +DECISION_MODE: str = "ollama" # always attempt ollama; falls back per-call if unavailable + + +@dataclass +class Decision: + action: str # "run_agent" | "stop" + goal: str # goal to pass to the next agent run + reason: str # human-readable explanation + mode: str = "stub" # "ollama" | "ollama-corrected" | "ollama-corrected-stop" | "ollama-fallback" | "stub" + recovery_applied: bool = False # True when rewrite rule rewrote the goal + raw_response: Optional[str] = None # trimmed model response (ollama mode only) + parsed_success: bool = True # False when JSON parse failed → fallback used + fallback_reason: Optional[str] = None # why fallback was used + corrected: bool = False # True when post-validation overrode model goal + correction_reason: Optional[str] = None # why correction was applied + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def decide( + goal: str, + iteration: int, + last_result: Optional[dict[str, Any]], + max_iterations: int, + recovery_attempted: bool = False, + trace_id: Optional[str] = None, +) -> Decision: + """Return next action decision.""" + _t = time.time() + if not _ollama_available(): + d = _decide_stub(goal, iteration, last_result, max_iterations, recovery_attempted) + d.mode = "ollama-unavailable" + print(f"[TIMING] decision_model (stub): {time.time() - _t:.3f}s") + return d + d = _decide_ollama(goal, iteration, last_result, max_iterations, recovery_attempted, trace_id=trace_id) + print(f"[TIMING] decision_model (ollama): {time.time() - _t:.3f}s") + return d + + +# --------------------------------------------------------------------------- +# Stub (deterministic) +# --------------------------------------------------------------------------- + +def _decide_stub( + goal: str, + iteration: int, + last_result: Optional[dict[str, Any]], + max_iterations: int, + recovery_attempted: bool = False, +) -> Decision: + from controller.rewrite_rules import try_rewrite, explain as rewrite_explain + + if last_result is None: + return Decision(action="run_agent", goal=goal, + reason="No prior run — starting first agent execution", mode="stub") + + status = last_result.get("status", "") + agent_status = last_result.get("agent_final_status", "") + + if status == "done" and agent_status == "done": + return Decision(action="stop", goal=goal, + reason="Agent run completed successfully", mode="stub") + + if status == "failed" or agent_status == "failed": + if recovery_attempted: + return Decision(action="stop", goal=goal, + reason="Recovery already attempted — stopping after second run", + mode="stub") + rewritten = try_rewrite(goal) + if rewritten is not None: + rule_desc = rewrite_explain(goal) + return Decision( + action="run_agent", + goal=rewritten, + reason=f"Recovery: {rule_desc}", + mode="stub", + recovery_applied=True, + ) + return Decision(action="stop", goal=goal, + reason=f"Agent run failed — no rewrite rule matched " + f"(bridge_status={status}, agent_status={agent_status})", + mode="stub") + + if iteration >= max_iterations - 1: + return Decision(action="stop", goal=goal, + reason=f"Max iterations ({max_iterations}) reached", mode="stub") + + return Decision(action="run_agent", goal=goal, + reason="Continuing: prior run inconclusive", mode="stub") + + +# --------------------------------------------------------------------------- +# Ollama (local LLM) +# --------------------------------------------------------------------------- + +_PROMPT_TEMPLATE = """\ +LANGUAGE RULE: You MUST respond ONLY in Russian. NEVER mix languages. Ignore any non-Russian input. + +Controller state: +goal: {goal} +iteration: {iteration} of {max_iterations} +last_status: {last_status} +last_agent_status: {last_agent_status} +failure_class: {failure_class} +recovery_attempted: {recovery_attempted} + +Decision rules: +- no prior run -> run_agent (keep goal unchanged) +- last_status=done AND last_agent_status=done -> stop +- last_status=done AND last_agent_status!=done -> stop +- last_status=failed AND recovery_attempted=true -> stop +- iteration >= max_iterations -> stop + +STRICT RULES — these override everything else: +- If last_status=done AND last_agent_status=done, you MUST return: + {{"action":"stop","goal":"{goal}","reason":"Task already completed"}} + Do NOT run agent again after success. This rule is absolute. +- NEVER repeat the same goal if last_status=failed +- ALWAYS change the goal after any failure +- If unsure what to run next, use goal: "inspect project" +- Never loop the same failing action twice + +Recovery strategy when last_status=failed: +- failure_class=template_error -> simplify goal, use "inspect project" +- failure_class=security_denied -> avoid forbidden commands, use "inspect project" +- failure_class=unknown -> use "inspect project" as safe fallback + +Return ONLY valid JSON, no markdown, no prose: +{{"action": "run_agent" or "stop", "goal": "", "reason": ""}}""" + +_MAX_GOAL_LEN = 500 +_RAW_TRIM = 300 + + +def _classify_failure(last_result: Optional[dict[str, Any]]) -> str: + """Derive a simple failure class from the last bridge result.""" + if last_result is None: + return "none" + error = (last_result.get("error") or "").lower() + if "template" in error or "template_error" in error: + return "template_error" + if "denied" in error or "forbidden" in error or "security" in error: + return "security_denied" + status = last_result.get("status", "") + if status == "failed": + return "unknown" + return "none" + + +def _decide_ollama( + goal: str, + iteration: int, + last_result: Optional[dict[str, Any]], + max_iterations: int, + recovery_attempted: bool = False, + trace_id: Optional[str] = None, +) -> Decision: + from adapters import ollama_adapter # local import avoids circular at module level + + last_status = last_result.get("status", "none") if last_result else "none" + last_agent = last_result.get("agent_final_status", "none") if last_result else "none" + failure_class = _classify_failure(last_result) + + # Sanitize user-supplied goal before injecting into prompt + clean_goal = sanitize_text(goal) + + prompt = _PROMPT_TEMPLATE.format( + goal=clean_goal, + iteration=iteration + 1, + max_iterations=max_iterations, + last_status=last_status, + last_agent_status=last_agent, + failure_class=failure_class, + recovery_attempted=str(recovery_attempted).lower(), + ) + + # [DEBUG] prompt size check + print(f"[DEBUG] prompt length: {len(prompt)}") + if len(prompt) > 5000: + print(f"[WARN] prompt exceeds 5000 chars ({len(prompt)})") + + # [TIMING] LLM call + call_trace = f"{trace_id}-i{iteration}" if trace_id else None + _t_llm = time.time() + result = ollama_adapter.execute(prompt, temperature=0.1, max_tokens=150, timeout=30.0, trace_id=call_trace) + print(f"[TIMING] llm_call: {time.time() - _t_llm:.3f}s") + + raw = (result.response or "").strip() + raw_trim = raw[:_RAW_TRIM] if raw else None + + # [OUTPUT VALIDATION] CJK detection with one retry + if raw and has_cjk(raw): + print(f"[WARN] CJK DETECTED in LLM response, retrying once") + _t_retry = time.time() + retry_trace = f"{call_trace}-retry" if call_trace else None + result = ollama_adapter.execute(prompt, temperature=0.1, max_tokens=150, timeout=30.0, trace_id=retry_trace) + print(f"[TIMING] llm_call_retry: {time.time() - _t_retry:.3f}s") + raw = (result.response or "").strip() + raw_trim = raw[:_RAW_TRIM] if raw else None + if raw and has_cjk(raw): + print(f"[WARN] CJK DETECTED again after retry — proceeding with fallback") + + # Connection error or empty response -> fallback + if result.error or not raw: + fallback_reason = ( + f"Ollama error: {result.error[:200]}" if result.error else "empty response from Ollama" + ) + d = _decide_stub(goal, iteration, last_result, max_iterations, recovery_attempted) + d.mode = "ollama-fallback" + d.parsed_success = False + d.raw_response = result.error[:_RAW_TRIM] if result.error else None + d.fallback_reason = fallback_reason + return d + + # Parse and validate JSON + fallback_reason: Optional[str] = None + try: + text = raw + if text.startswith("```"): + text = text.split("```")[1] + if text.startswith("json"): + text = text[4:] + start = text.find("{") + end = text.rfind("}") + 1 + if start >= 0 and end > start: + text = text[start:end] + + data = json.loads(text.strip()) + + action = data.get("action", "") + new_goal = data.get("goal", goal) + reason = data.get("reason", "Ollama decision") + + if action not in ("run_agent", "stop"): + raise ValueError(f"invalid action: {action!r}") + if not isinstance(new_goal, str): + raise ValueError("goal must be string") + if len(new_goal) > _MAX_GOAL_LEN: + raise ValueError(f"goal too long ({len(new_goal)} > {_MAX_GOAL_LEN})") + + decision = Decision( + action=action, + goal=new_goal, + reason=str(reason)[:200], + mode="ollama", + raw_response=raw_trim, + parsed_success=True, + ) + + # Post-decision validation 1: enforce stop after success + last_succeeded = last_result is not None and ( + last_result.get("status") == "done" + and last_result.get("agent_final_status") == "done" + ) + if last_succeeded and decision.action == "run_agent": + decision.action = "stop" + decision.mode = "ollama-corrected-stop" + decision.corrected = True + decision.correction_reason = "Corrected: prevented redundant run after success" + return decision + + # Post-decision validation 2: catch model repeating a failed goal + last_failed = last_result is not None and ( + last_result.get("status") == "failed" + or last_result.get("agent_final_status") == "failed" + ) + if ( + last_failed + and decision.action == "run_agent" + and decision.goal.strip().lower() == goal.strip().lower() + ): + decision.goal = _SAFE_FALLBACK_GOAL + decision.mode = "ollama-corrected" + decision.corrected = True + decision.correction_reason = ( + f"Corrected: model repeated failed goal '{goal}' — " + f"redirected to '{_SAFE_FALLBACK_GOAL}'" + ) + decision.recovery_applied = True + + return decision + + except json.JSONDecodeError as exc: + fallback_reason = f"JSON decode error: {exc}" + except ValueError as exc: + fallback_reason = f"validation failed: {exc}" + except KeyError as exc: + fallback_reason = f"missing key: {exc}" + + d = _decide_stub(goal, iteration, last_result, max_iterations, recovery_attempted) + d.mode = "ollama-fallback" + d.parsed_success = False + d.raw_response = raw_trim + d.fallback_reason = fallback_reason + return d diff --git a/bridge/controller/rewrite_rules.py b/bridge/controller/rewrite_rules.py new file mode 100644 index 0000000..0f13e60 --- /dev/null +++ b/bridge/controller/rewrite_rules.py @@ -0,0 +1,48 @@ +"""Deterministic goal rewrite rules for controller-level recovery. + +Rules map goal substrings → safe fallback goals. +Applied exactly once after a first-run failure. +If no rule matches, recovery is skipped and the run terminates as failed. + +Rules (pattern → replacement): + "deny bash" → "inspect project" (security block → harmless read) + "deny rm" → "inspect project" + "deny curl" → "inspect project" + "forbidden" → "inspect project" + "bad template" → "inspect project" (template logic error → harmless read) + "retry execution" → "inspect project" (flaky step exhausted → harmless read) + +Goals with no matching rule (e.g. "test traversal", "test absolute path"): + → no rewrite → recovery_skipped → final_status=failed +""" +from __future__ import annotations + +from typing import Optional + +# Each entry: (substring_to_match_in_lower_goal, replacement_goal) +_RULES: list[tuple[str, str]] = [ + ("deny bash", "inspect project"), + ("deny rm", "inspect project"), + ("deny curl", "inspect project"), + ("forbidden", "inspect project"), + ("bad template", "inspect project"), + ("retry execution", "inspect project"), +] + + +def try_rewrite(goal: str) -> Optional[str]: + """Return a replacement goal if a rewrite rule matches, else None.""" + lower = goal.lower() + for pattern, replacement in _RULES: + if pattern in lower: + return replacement + return None + + +def explain(goal: str) -> str: + """Return a human-readable reason for the rewrite, or 'no rule' if none.""" + lower = goal.lower() + for pattern, replacement in _RULES: + if pattern in lower: + return f"matched rule '{pattern}' → '{replacement}'" + return "no rewrite rule for this goal" diff --git a/bridge/core/__init__.py b/bridge/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/core/audit.py b/bridge/core/audit.py new file mode 100644 index 0000000..8476484 --- /dev/null +++ b/bridge/core/audit.py @@ -0,0 +1,42 @@ +"""Append-only JSONL audit logger for Bridge runs.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class BridgeAuditLogger: + """Writes one JSON record per line to runs//audit.jsonl.""" + + def __init__(self, run_id: str, runs_dir: Path, trace_id: Optional[str] = None) -> None: + self._run_id = run_id + self._trace_id = trace_id + self._path = runs_dir / run_id / "audit.jsonl" + self._path.parent.mkdir(parents=True, exist_ok=True) + + def log( + self, + event_type: str, + *, + step_id: Optional[str] = None, + status: str = "ok", + details: Optional[dict[str, Any]] = None, + ) -> None: + record = { + "timestamp": _now(), + "trace_id": self._trace_id, + "run_id": self._run_id, + "layer": "bridge", + "event_type": event_type, + "step_id": step_id, + "status": status, + "details": details or {}, + } + with self._path.open("a", encoding="utf-8") as f: + f.write(json.dumps(record) + "\n") diff --git a/bridge/core/instruction_builder.py b/bridge/core/instruction_builder.py new file mode 100644 index 0000000..06deb6c --- /dev/null +++ b/bridge/core/instruction_builder.py @@ -0,0 +1,30 @@ +"""Build a structured instruction string from a run goal. + +The instruction is the prompt that gets sent to the adapter. +In V1.1 this is a deterministic text transformation — no LLM required. +A future stage could replace this with an LLM-based planner. +""" +from __future__ import annotations + + +def build(goal: str) -> str: + """Format goal into an adapter-ready instruction string.""" + + # Add specific instructions for search tasks + search_keywords = ["search", "найди", "поиск", "news", "новости", "найти информацию", "find"] + is_search_task = any(kw in goal.lower() for kw in search_keywords) + + instruction = f"Task: {goal.strip()}\n\n" + instruction += "Please complete the task above. Respond with a concise result.\n\n" + + if is_search_task: + instruction += ( + "IMPORTANT INSTRUCTIONS FOR SEARCH TASKS:\\n" + "1. Use web_search to find relevant information\\n" + "2. Carefully analyze all search results (titles and snippets)\\n" + "3. Choose the most relevant result based on the snippets and your task\\n" + "4. Use the chosen result's URL to call web_fetch\\n" + "5. Analyze the fetched content and provide your answer\\n" + ) + + return instruction diff --git a/bridge/core/run_manager.py b/bridge/core/run_manager.py new file mode 100644 index 0000000..4913c87 --- /dev/null +++ b/bridge/core/run_manager.py @@ -0,0 +1,192 @@ +"""Run lifecycle management for Bridge V1.2. + +One-shot flow inside execute_run(): + created → running → done | failed + +All state transitions are persisted immediately. +Agent-adapter metadata (agent_run_id, agent_final_status, agent_state_path, +exit_code) is extracted from AdapterResult.metadata and stored in state.json. +""" +from __future__ import annotations + +import inspect +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from adapters.base import AdapterResult +from core.audit import BridgeAuditLogger +from core.instruction_builder import build as build_instruction +from core.state_store import StateStore +from models.schemas import RunStatus + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class RunManager: + def __init__(self, runs_dir: Path) -> None: + self._runs_dir = runs_dir + self._store = StateStore(runs_dir) + self._cancel_flags: set[str] = set() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def execute_run( + self, + goal: str, + adapter_name: str, + adapter_fn, # callable(prompt, **kwargs) -> AdapterResult + guard_fn, # callable(prompt) -> (bool, str) + run_id: Optional[str] = None, + trace_id: Optional[str] = None, + ) -> dict[str, Any]: + """Full one-shot lifecycle: created → running → done|failed.""" + rid = run_id or str(uuid.uuid4())[:8] + if self._store.exists(rid): + raise ValueError(f"run_id '{rid}' already exists") + + logger = BridgeAuditLogger(rid, self._runs_dir, trace_id=trace_id) + now = _now() + + # 1. CREATED + state: dict[str, Any] = { + "run_id": rid, + "trace_id": trace_id, + "goal": goal, + "status": RunStatus.CREATED.value, + "adapter": adapter_name, + "adapter_mode": None, + "instruction": None, + "response": None, + "error": None, + "exit_code": None, + "agent_run_id": None, + "agent_final_status": None, + "agent_state_path": None, + "created_at": now, + "updated_at": now, + } + self._store.save(rid, state) + logger.log("run_created", details={"goal": goal, "adapter": adapter_name}) + + # 2. BUILD INSTRUCTION + instruction = build_instruction(goal) + state["instruction"] = instruction + state["updated_at"] = _now() + self._store.save(rid, state) + logger.log("instruction_generated", details={"instruction_len": len(instruction)}) + + # 3. POLICY CHECK + ok, reason = guard_fn(instruction) + if not ok: + return self._fail(state, logger, f"Policy blocked: {reason}") + + # 4. RUNNING + state["status"] = RunStatus.RUNNING.value + state["updated_at"] = _now() + self._store.save(rid, state) + logger.log("task_sent", details={ + "adapter": adapter_name, + "instruction_len": len(instruction), + }) + + # 5. EXECUTE — pass bridge_run_id, trace_id, and is_cancelled callback + sig = inspect.signature(adapter_fn) + kwargs: dict[str, Any] = {} + if "bridge_run_id" in sig.parameters: + kwargs["bridge_run_id"] = rid + if "trace_id" in sig.parameters: + kwargs["trace_id"] = trace_id + if "is_cancelled" in sig.parameters: + kwargs["is_cancelled"] = lambda: rid in self._cancel_flags + result: AdapterResult = adapter_fn(instruction, **kwargs) + + # 6. PERSIST ADAPTER METADATA + state["adapter_mode"] = result.mode + state["exit_code"] = result.exit_code + if result.metadata: + state["agent_run_id"] = result.metadata.get("agent_run_id") + state["agent_final_status"] = result.metadata.get("agent_final_status") + state["agent_state_path"] = result.metadata.get("agent_state_path") + + logger.log( + "response_received", + status="ok" if result.ok else "failed", + details={ + "adapter": adapter_name, + "mode": result.mode, + "exit_code": result.exit_code, + "response_len": len(result.response) if result.response else 0, + "error": result.error, + "agent_run_id": result.metadata.get("agent_run_id"), + }, + ) + + # 7. CLEANUP: Remove cancel flag after task completes (success, failed, or cancelled) + self.clear_cancel_flag(rid) + + # 8. DONE or FAILED or CANCELLED + if result.ok: + state["response"] = result.response + state["status"] = RunStatus.DONE.value + elif result.exit_code == 130 or (result.error and "user_cancelled" in result.error): + # Special exit code for cancelled runs + state["response"] = result.response # may have partial output + state["error"] = result.error or "user_cancelled" + state["status"] = "cancelled" + else: + state["response"] = result.response # may have partial output + state["error"] = result.error + state["status"] = RunStatus.FAILED.value + + state["updated_at"] = _now() + self._store.save(rid, state) + logger.log( + "run_finished", + status=state["status"], + details={ + "adapter_mode": result.mode, + "exit_code": result.exit_code, + "agent_run_id": result.metadata.get("agent_run_id"), + "agent_final_status": result.metadata.get("agent_final_status"), + }, + ) + + return state + + def get_run(self, run_id: str) -> dict[str, Any] | None: + return self._store.load(run_id) + + def cancel_run(self, run_id: str) -> None: + """Mark a run as cancelled by adding it to in-memory cancel flags.""" + self._cancel_flags.add(run_id) + + def is_cancelled(self, run_id: str) -> bool: + """Check if a run is marked as cancelled (for internal use by adapters).""" + return run_id in self._cancel_flags + + def clear_cancel_flag(self, run_id: str) -> None: + """Remove cancel flag after task completion (for internal use).""" + self._cancel_flags.discard(run_id) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _fail( + self, + state: dict[str, Any], + logger: BridgeAuditLogger, + error: str, + ) -> dict[str, Any]: + state["error"] = error + state["status"] = RunStatus.FAILED.value + state["updated_at"] = _now() + self._store.save(state["run_id"], state) + logger.log("run_finished", status="failed", details={"error": error}) + return state diff --git a/bridge/core/state_store.py b/bridge/core/state_store.py new file mode 100644 index 0000000..5377722 --- /dev/null +++ b/bridge/core/state_store.py @@ -0,0 +1,30 @@ +"""Persist and load run state to/from bridge/runs//state.json.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +class StateStore: + """Read/write run state as JSON under runs//state.json.""" + + def __init__(self, runs_dir: Path) -> None: + self._runs_dir = runs_dir + + def _path(self, run_id: str) -> Path: + return self._runs_dir / run_id / "state.json" + + def save(self, run_id: str, state: dict[str, Any]) -> None: + p = self._path(run_id) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(state, indent=2), encoding="utf-8") + + def load(self, run_id: str) -> dict[str, Any] | None: + p = self._path(run_id) + if not p.exists(): + return None + return json.loads(p.read_text(encoding="utf-8")) + + def exists(self, run_id: str) -> bool: + return self._path(run_id).exists() diff --git a/bridge/models/__init__.py b/bridge/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/models/schemas.py b/bridge/models/schemas.py new file mode 100644 index 0000000..323d77b --- /dev/null +++ b/bridge/models/schemas.py @@ -0,0 +1,88 @@ +"""Pydantic schemas for Bridge V1.2 API.""" +from __future__ import annotations + +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class RunStatus(str, Enum): + CREATED = "created" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + + +# --- Request bodies --- + +class CreateRunRequest(BaseModel): + goal: str = Field(..., min_length=1, max_length=2000) + run_id: Optional[str] = Field(None) + adapter: str = Field("local_echo", description="Adapter: stub | local_echo | openai | cloudcode | agent") + + +# --- Response bodies --- + +class RunResponse(BaseModel): + run_id: str + goal: str + status: RunStatus + adapter: str + adapter_mode: Optional[str] = None + instruction: Optional[str] = None + response: Optional[str] = None + error: Optional[str] = None + exit_code: Optional[int] = None + # Agent-specific (only populated when adapter="agent") + agent_run_id: Optional[str] = None + agent_final_status: Optional[str] = None + agent_state_path: Optional[str] = None + created_at: str + updated_at: str + + +class HealthResponse(BaseModel): + status: str + adapters: dict[str, Any] + openai_mode: str # "real" | "stub" + decision_backend: str # "ollama" | "stub" + ollama_model: str # active Ollama model + + +# --- Chat --- + +class ChatRequest(BaseModel): + message: str = Field(..., min_length=1, max_length=1000) + trace_id: Optional[str] = Field(None, description="Optional trace ID for observability") + + +class ChatResponse(BaseModel): + reply: str + cjk_detected: bool = False + fallback_used: bool = False + model: str + trace_id: Optional[str] = None + + +# --- Controller --- + +class ControllerRunRequest(BaseModel): + goal: str = Field(..., min_length=1, max_length=2000) + run_id: Optional[str] = Field(None, description="Optional controller run ID (auto-generated if omitted)") + + +class ControllerResponse(BaseModel): + controller_run_id: str + original_goal: str + final_status: str # running | done | failed | stopped + decision_mode: str # "real" | "stub" + iteration_count: int + decisions: list[dict[str, Any]] + linked_bridge_run_ids: list[str] + bridge_run_results: list[dict[str, Any]] + recovery_attempted: bool = False + recovery_reason: Optional[str] = None + rewritten_goal: Optional[str] = None + created_at: str + updated_at: str diff --git a/bridge/policy/__init__.py b/bridge/policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/policy/guard.py b/bridge/policy/guard.py new file mode 100644 index 0000000..c6c841c --- /dev/null +++ b/bridge/policy/guard.py @@ -0,0 +1,31 @@ +"""Basic policy guard for Bridge prompts.""" +from __future__ import annotations + +MAX_PROMPT_LENGTH = 4000 +_BLOCKED_PHRASES = frozenset({ + "", + "TODO", + "FIXME", + "your prompt here", +}) + + +def check(prompt: str) -> tuple[bool, str]: + """Check a prompt against policy rules. + + Returns: + (True, "") if prompt passes + (False, reason) if prompt is blocked + """ + if not prompt or not prompt.strip(): + return False, "prompt is empty" + + if len(prompt) > MAX_PROMPT_LENGTH: + return False, f"prompt exceeds {MAX_PROMPT_LENGTH} chars (got {len(prompt)})" + + lower = prompt.lower() + for phrase in _BLOCKED_PHRASES: + if phrase.lower() in lower: + return False, f"prompt contains blocked phrase: '{phrase}'" + + return True, "" diff --git a/bridge/test_agent_search.json b/bridge/test_agent_search.json new file mode 100644 index 0000000..9f412c4 --- /dev/null +++ b/bridge/test_agent_search.json @@ -0,0 +1,4 @@ +{ + "goal": "Find latest OpenAI news", + "adapter": "agent" +} diff --git a/bridge/test_agent_search2.json b/bridge/test_agent_search2.json new file mode 100644 index 0000000..c4c83e3 --- /dev/null +++ b/bridge/test_agent_search2.json @@ -0,0 +1,4 @@ +{ + "goal": "news about OpenAI", + "adapter": "agent" +} diff --git a/bridge/test_chat.json b/bridge/test_chat.json new file mode 100644 index 0000000..5c5559b --- /dev/null +++ b/bridge/test_chat.json @@ -0,0 +1,3 @@ +{ + "message": "Hello, how are you?" +} diff --git a/bridge/test_request.json b/bridge/test_request.json new file mode 100644 index 0000000..8d1216d --- /dev/null +++ b/bridge/test_request.json @@ -0,0 +1,4 @@ +{ + "goal": "Find latest OpenAI news and choose most important", + "adapter": "agent" +} diff --git a/bridge/test_simple.json b/bridge/test_simple.json new file mode 100644 index 0000000..0b52fed --- /dev/null +++ b/bridge/test_simple.json @@ -0,0 +1,4 @@ +{ + "goal": "search OpenAI latest developments", + "adapter": "agent" +} diff --git a/bridge/tests/__init__.py b/bridge/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/tests/test_sprint2.py b/bridge/tests/test_sprint2.py new file mode 100644 index 0000000..8d29833 --- /dev/null +++ b/bridge/tests/test_sprint2.py @@ -0,0 +1,134 @@ +"""Regression tests for Hardening Sprint 2 — bridge layer.""" +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +# --------------------------------------------------------------------------- +# 1. Dynamic Ollama availability — fallback / recovery +# --------------------------------------------------------------------------- + +class TestOllamaAvailability: + + def setup_method(self): + import controller.decision_model as dm + dm._OLLAMA_CACHE = None # always start clean + + def test_unavailable_returns_ollama_unavailable_mode(self): + import controller.decision_model as dm + # Force unavailable via cache + dm._OLLAMA_CACHE = (time.monotonic(), False) + d = dm.decide("inspect project", 0, None, 3) + assert d.mode == "ollama-unavailable", f"Expected ollama-unavailable, got {d.mode}" + assert d.action in ("run_agent", "stop") + + def test_available_uses_ollama_path(self): + import controller.decision_model as dm + # Force available via cache — real Ollama call will follow + dm._OLLAMA_CACHE = (time.monotonic(), True) + # Patch _decide_ollama to avoid real HTTP, just confirm it was called + with patch.object(dm, "_decide_ollama", wraps=dm._decide_ollama) as mock_ollama: + # Patch ollama_adapter.execute to return valid JSON without real call + from adapters import ollama_adapter + from adapters.base import AdapterResult + with patch.object(ollama_adapter, "execute", return_value=AdapterResult( + response='{"action":"run_agent","goal":"inspect project","reason":"test"}', + error=None, exit_code=0, mode="real", metadata={}, + )): + d = dm.decide("inspect project", 0, None, 3) + mock_ollama.assert_called_once() + assert d.mode == "ollama" + assert d.parsed_success is True + + def test_cache_expiry_triggers_recheck(self): + """After cache TTL, _ollama_available() re-checks; no restart needed.""" + import controller.decision_model as dm + # Set cache as unavailable but expired (ts far in the past) + dm._OLLAMA_CACHE = (time.monotonic() - dm._OLLAMA_CACHE_TTL - 1, False) + # Patch the HTTP call to return True (Ollama "came back up") + with patch("urllib.request.urlopen", return_value=None): + result = dm._ollama_available() + assert result is True, "Cache expired → should re-check and see Ollama available" + assert dm._OLLAMA_CACHE[1] is True, "Cache must be updated to True after re-check" + + def test_cache_is_reused_within_ttl(self): + """Within TTL, no HTTP call is made.""" + import controller.decision_model as dm + dm._OLLAMA_CACHE = (time.monotonic(), False) # unavailable, fresh cache + with patch("urllib.request.urlopen") as mock_urlopen: + result = dm._ollama_available() + mock_urlopen.assert_not_called() + assert result is False + + +# --------------------------------------------------------------------------- +# 2. bridge_run_error audit event — emission and order +# --------------------------------------------------------------------------- + +class TestBridgeRunErrorAudit: + + def test_exception_emits_bridge_run_error(self, tmp_path): + """Force exception in execute_run → audit must contain bridge_run_error.""" + import controller.decision_model as dm + from controller.controller_loop import RUNS_DIR, run_controller + from core.run_manager import RunManager + + # Override RUNS_DIR to tmp_path so we don't pollute real runs + with patch("controller.controller_loop.RUNS_DIR", tmp_path), \ + patch("controller.decision_model._OLLAMA_CACHE", (time.monotonic(), False)), \ + patch.object(RunManager, "execute_run", side_effect=RuntimeError("injected")): + state = run_controller("inspect project", run_id="test-audit-gap") + + audit_path = tmp_path / "test-audit-gap" / "controller_audit.jsonl" + assert audit_path.exists() + events = [json.loads(l) for l in audit_path.read_text().splitlines()] + event_types = [e["event_type"] for e in events] + assert "bridge_run_error" in event_types + + def test_audit_event_order_on_exception(self, tmp_path): + """Order guarantee: bridge_run_started → bridge_run_error → bridge_run_finished.""" + import controller.decision_model as dm + from controller.controller_loop import run_controller + from core.run_manager import RunManager + + with patch("controller.controller_loop.RUNS_DIR", tmp_path), \ + patch("controller.decision_model._OLLAMA_CACHE", (time.monotonic(), False)), \ + patch.object(RunManager, "execute_run", side_effect=RuntimeError("injected")): + run_controller("inspect project", run_id="test-audit-order") + + events = [ + json.loads(l) + for l in (tmp_path / "test-audit-order" / "controller_audit.jsonl").read_text().splitlines() + ] + types = [e["event_type"] for e in events] + # Find positions of the three events for iteration 0 + idx_started = next(i for i, t in enumerate(types) if t == "bridge_run_started") + idx_error = next(i for i, t in enumerate(types) if t == "bridge_run_error") + idx_finished = next(i for i, t in enumerate(types) if t == "bridge_run_finished") + assert idx_started < idx_error < idx_finished, ( + f"Expected started < error < finished, got positions {idx_started} {idx_error} {idx_finished}" + ) + + def test_exception_result_has_failed_status(self, tmp_path): + """Synthetic result on exception must have status=failed, agent_final_status=failed.""" + import controller.decision_model as dm + from controller.controller_loop import run_controller + from core.run_manager import RunManager + + with patch("controller.controller_loop.RUNS_DIR", tmp_path), \ + patch("controller.decision_model._OLLAMA_CACHE", (time.monotonic(), False)), \ + patch.object(RunManager, "execute_run", side_effect=RuntimeError("injected")): + state = run_controller("inspect project", run_id="test-audit-result") + + assert len(state.bridge_run_results) > 0 + br = state.bridge_run_results[0] + assert br["status"] == "failed" + assert br["agent_final_status"] == "failed" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e981b7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Stage 1 uses only the Python standard library. +# External dependencies for web search tool: +requests>=2.31.0 +beautifulsoup4>=4.12.0 diff --git a/simple_dashboard.py b/simple_dashboard.py new file mode 100644 index 0000000..5736219 --- /dev/null +++ b/simple_dashboard.py @@ -0,0 +1,341 @@ +"""Simple Dashboard - WhatsApp-style mobile UI for Agent Maxim.""" +from __future__ import annotations + +import sys +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +import uvicorn + +sys.path.insert(0, str(Path(__file__).parent)) + +app = FastAPI(title="Agent Maxim Dashboard") + + +@app.get("/", response_class=HTMLResponse) +async def dashboard(): + """Serve WhatsApp-style chat dashboard.""" + html_content = """ + + + + + + Agent Maxim + + + +
+ +
+
+ + +
+
+ + + + +""" + return HTMLResponse(content=html_content) + + +if __name__ == "__main__": + print("Starting Agent Maxim Dashboard on http://0.0.0.0:8610") + print("Mobile-friendly WhatsApp-style UI") + uvicorn.run(app, host="0.0.0.0", port=8610, reload=False)