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
This commit is contained in:
+39
@@ -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/
|
||||
@@ -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 <repository-url>
|
||||
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
|
||||
+252
@@ -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/<run_id>/state.json`)
|
||||
- Append-only JSONL audit log (`workspace/runs/<run_id>/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/<run_id>/
|
||||
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/<run_id>/
|
||||
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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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.<step_id>.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} }}}}'"
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
+113
@@ -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()
|
||||
@@ -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", ""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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/<run_id>/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")
|
||||
@@ -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")
|
||||
@@ -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: <goal>\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 <instruction> --run-id agent-<rid>
|
||||
│ └─ reads agent/workspace/runs/agent-<rid>/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/<run_id>/
|
||||
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/<run_id>/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/<agent_run_id>/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
|
||||
@@ -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]
|
||||
@@ -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 : <uuid> (e.g. "a1b2c3d4")
|
||||
agent run_id : "agent-<bridge_run_id>" (e.g. "agent-a1b2c3d4")
|
||||
|
||||
This makes every bridge run traceable to a unique agent workspace dir:
|
||||
agent/workspace/runs/agent-<bridge_run_id>/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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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_<trace_id>.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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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": "<goal>", "reason": "<one sentence in Russian>"}}"""
|
||||
|
||||
_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
|
||||
@@ -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"
|
||||
@@ -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/<run_id>/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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Persist and load run state to/from bridge/runs/<run_id>/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/<run_id>/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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Basic policy guard for Bridge prompts."""
|
||||
from __future__ import annotations
|
||||
|
||||
MAX_PROMPT_LENGTH = 4000
|
||||
_BLOCKED_PHRASES = frozenset({
|
||||
"<placeholder>",
|
||||
"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, ""
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"goal": "Find latest OpenAI news",
|
||||
"adapter": "agent"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"goal": "news about OpenAI",
|
||||
"adapter": "agent"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"message": "Hello, how are you?"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"goal": "Find latest OpenAI news and choose most important",
|
||||
"adapter": "agent"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"goal": "search OpenAI latest developments",
|
||||
"adapter": "agent"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Agent Maxim</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: #e5ddd5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#chat {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #e5ddd5;
|
||||
}
|
||||
|
||||
#header {
|
||||
background: #128C7E;
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
background: #dcf8c6;
|
||||
margin-left: auto;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.agent {
|
||||
background: white;
|
||||
margin-right: auto;
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.loading {
|
||||
background: white;
|
||||
margin-right: auto;
|
||||
border-bottom-left-radius: 4px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#inputBox {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
background: #f0f0f0;
|
||||
border-top: 1px solid #ddd;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #ccc;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #128C7E;
|
||||
box-shadow: 0 0 0 2px rgba(18, 140, 126, 0.1);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: #128C7E;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0d6e63;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 600;
|
||||
color: #128C7E;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-snippet {
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-link {
|
||||
color: #06c;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.result-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid #eee;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat">
|
||||
<div id="header">Agent Maxim</div>
|
||||
<div id="messages"></div>
|
||||
<div id="inputBox">
|
||||
<input id="input" placeholder="Напиши задачу..." autocomplete="off" />
|
||||
<button id="sendBtn" onclick="send()">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_URL = '/runs';
|
||||
const input = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
|
||||
function addMessage(text, cls) {
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'message ' + cls;
|
||||
msg.innerHTML = text;
|
||||
document.getElementById('messages').appendChild(msg);
|
||||
scrollToBottom();
|
||||
return msg;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const messages = document.getElementById('messages');
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function formatResponse(text) {
|
||||
if (!text) return 'Нет ответа';
|
||||
|
||||
// Check if it contains web search results
|
||||
if (text.includes('Title:') && text.includes('Snippet:')) {
|
||||
return formatSearchResults(text);
|
||||
}
|
||||
|
||||
// Format numbered items
|
||||
return text.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function formatSearchResults(text) {
|
||||
const lines = text.split('\n');
|
||||
let html = '';
|
||||
let currentResult = null;
|
||||
|
||||
for (const line of lines) {
|
||||
line = line.trim();
|
||||
|
||||
if (line.startsWith('Title:')) {
|
||||
if (currentResult) {
|
||||
html += formatSingleResult(currentResult);
|
||||
}
|
||||
currentResult = {
|
||||
title: line.replace('Title:', '').trim(),
|
||||
snippet: '',
|
||||
url: ''
|
||||
};
|
||||
} else if (line.startsWith('Snippet:')) {
|
||||
if (currentResult) {
|
||||
currentResult.snippet = line.replace('Snippet:', '').trim();
|
||||
}
|
||||
} else if (line.startsWith('URL:')) {
|
||||
if (currentResult) {
|
||||
currentResult.url = line.replace('URL:', '').trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentResult) {
|
||||
html += formatSingleResult(currentResult);
|
||||
}
|
||||
|
||||
return html || text.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function formatSingleResult(result) {
|
||||
html = '<div class="result-title">' + result.title + '</div>';
|
||||
html += '<div class="result-snippet">' + result.snippet + '</div>';
|
||||
if (result.url) {
|
||||
html += '<a href="' + result.url + '" target="_blank" class="result-link">' + result.url + '</a>';
|
||||
}
|
||||
html += '<div class="divider"></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
|
||||
addMessage(text, 'user');
|
||||
const loadingMsg = addMessage('⏳ Думаю...', 'loading');
|
||||
|
||||
try {
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
goal: text,
|
||||
adapter: 'agent'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
loadingMsg.remove();
|
||||
|
||||
if (data.response) {
|
||||
addMessage(formatResponse(data.response), 'agent');
|
||||
} else {
|
||||
addMessage('Ошибка: нет ответа от агента', 'agent');
|
||||
}
|
||||
} catch (err) {
|
||||
loadingMsg.remove();
|
||||
addMessage('Ошибка: ' + err.message, 'agent');
|
||||
}
|
||||
|
||||
input.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
input.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus input on load
|
||||
input.focus();
|
||||
|
||||
// Welcome message
|
||||
setTimeout(() => {
|
||||
addMessage('Привет! Я Agent Maxim. Чем могу помочь?', 'agent');
|
||||
}, 500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user