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:
Anton
2026-04-09 18:07:43 +03:00
commit 052591281d
69 changed files with 5556 additions and 0 deletions
+39
View File
@@ -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/
+243
View File
@@ -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
View File
@@ -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`
View File
+204
View File
@@ -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
+47
View File
@@ -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
+130
View File
@@ -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},
)
+99
View File
@@ -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} }}}}'"
+35
View File
@@ -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)
+122
View File
@@ -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
View File
@@ -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()
View File
+165
View File
@@ -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", ""
+14
View File
@@ -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
+4
View File
@@ -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
View File
+33
View File
@@ -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}",
)
+32
View File
@@ -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)
View File
+69
View File
@@ -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.")
+47
View File
@@ -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",
)
+88
View File
@@ -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",
)
+137
View File
@@ -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)}"
)
+189
View File
@@ -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)}"
)
View File
+43
View File
@@ -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")
View File
+119
View File
@@ -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")
+324
View File
@@ -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
+28
View File
@@ -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]
+146
View File
@@ -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"
+29
View File
@@ -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
+51
View File
@@ -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
+61
View File
@@ -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
+219
View File
@@ -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"
+45
View File
@@ -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"
+25
View File
@@ -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
View File
+37
View File
@@ -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,
)
+33
View File
@@ -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())
+117
View File
@@ -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"}
+31
View File
@@ -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)
View File
+324
View File
@@ -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
+161
View File
@@ -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
+31
View File
@@ -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"))
View File
+258
View File
@@ -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
+62
View File
@@ -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)
+391
View File
@@ -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
+48
View File
@@ -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"
View File
+42
View File
@@ -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")
+30
View File
@@ -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
+192
View File
@@ -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
+30
View File
@@ -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()
View File
+88
View File
@@ -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
View File
+31
View File
@@ -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, ""
+4
View File
@@ -0,0 +1,4 @@
{
"goal": "Find latest OpenAI news",
"adapter": "agent"
}
+4
View File
@@ -0,0 +1,4 @@
{
"goal": "news about OpenAI",
"adapter": "agent"
}
+3
View File
@@ -0,0 +1,3 @@
{
"message": "Hello, how are you?"
}
+4
View File
@@ -0,0 +1,4 @@
{
"goal": "Find latest OpenAI news and choose most important",
"adapter": "agent"
}
+4
View File
@@ -0,0 +1,4 @@
{
"goal": "search OpenAI latest developments",
"adapter": "agent"
}
View File
+134
View File
@@ -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"
+4
View File
@@ -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
+341
View File
@@ -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)