Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions very-simplified-stack/cognito-backend/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# AGENTS.md — cognito-backend

- No modifiques `app/services/backend_registry.py` ni `app/services/semantic_orchestrator.py` sin aprobación explícita del usuario.
- No modifiques `/v1/chat/completions` (comportamiento estable, usado en producción).
- Los tests de regresión de `/api/agent` y `/v1/chat/completions` deben seguir pasando.
21 changes: 21 additions & 0 deletions very-simplified-stack/cognito-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,31 @@ Settings are loaded in the following order of priority:

- `app/api/routes/openai_compat.py`: Core streaming and uncertainty calculation logic.
- `app/services/backend_client.py`: Unified async client for Ollama and OpenAI backends.
- `app/core/agent_loop.py`: Turning text generation into a tool-executing agent loop.
- `test-voice-api.ps1`: The main PowerShell profile script containing `cog` and `cogt`.
- `Install-CognitoProfile.ps1`: Installer for the PowerShell environment.
- `config.example.json`: Template for the user configuration file.

## 🤖 Cognito Agent (Fase 1)

El backend ahora incluye soporte para agentes capaces de ejecutar herramientas (tools).

### Endpoints
- `POST /api/agent/loop`: Endpoint SSE que ejecuta un bucle de razonamiento y ejecución de herramientas.
- **Body**: `{ "messages": [...], "cwd": "path/to/repo", "model_params": {} }`
- **Eventos**: `text_delta`, `tool_call`, `tool_result`, `done`, `error`.

### Herramientas Disponibles
1. `read`: Lee archivos del sistema (restringido al `cwd`).
2. `write`: Crea o sobrescribe archivos (requiere `trust`).
3. `edit`: Edición basada en búsqueda y reemplazo único (requiere `trust`).
4. `bash`: Ejecución de comandos en el workspace (requiere `trust`, sin `sudo`).

### Seguridad y Trust
- **Protected Files**: Ciertos archivos críticos (`auth.js`, etc.) nunca pueden ser modificados.
- **Project Trust**: Las herramientas de escritura y ejecución requieren que el directorio haya sido marcado como confiable.
- **AGENTS.md**: Si existe en el raíz del `cwd`, se inyecta automáticamente como contexto del sistema.

## 🧪 Testing

To test the uncertainty features:
Expand Down
69 changes: 69 additions & 0 deletions very-simplified-stack/cognito-backend/app/api/routes/ai_agents.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import json
from typing import List, Optional, Any, Dict
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.models.ai import AIRequest, AIResponse
from app.services.reasoning_engine import reasoning_engine
from app.services.backend_router import backend_router
from app.core.agent_loop import agent_loop
from app.core.tools.base import ToolContext
from app.core.tools.read_tool import ReadTool
from app.core.tools.write_tool import WriteTool
from app.core.tools.edit_tool import EditTool
from app.core.tools.bash_tool import BashTool
from app.core.project_trust import ProjectTrustStore
from app.core.resource_loader import ResourceLoader
import logging

# Configure logging
Expand All @@ -9,6 +22,11 @@

router = APIRouter()

class AgentLoopRequest(BaseModel):
messages: List[Dict[str, Any]]
cwd: str
model_params: Optional[Dict[str, Any]] = None

@router.post("/agent", response_model=AIResponse)
async def run_ai_agent(request: AIRequest):
"""
Expand All @@ -27,3 +45,54 @@ async def run_ai_agent(request: AIRequest):
status_code=500,
detail="An unexpected error occurred while processing the AI request."
)

@router.post("/agent/loop")
async def run_agent_loop(request: AgentLoopRequest):
"""
Agent Loop endpoint (SSE).
"""
logger.info(f"Starting agent loop in {request.cwd}")

loader = ResourceLoader(request.cwd)
trust_store = ProjectTrustStore()

context = ToolContext(
cwd=request.cwd,
trusted=trust_store.is_trusted(request.cwd),
protected_files=loader.get_effective_protected_files()
)

tools = [ReadTool(), WriteTool(), EditTool(), BashTool()]

# Inyectar AGENTS.md en el system prompt si existe
agents_md = loader.discover_agents_md()
messages = list(request.messages)
if agents_md:
system_msg = next((m for m in messages if m["role"] == "system"), None)
if system_msg:
system_msg["content"] += f"\n\nContext from AGENTS.md:\n{agents_md}"
else:
messages.insert(0, {"role": "system", "content": f"Context from AGENTS.md:\n{agents_md}"})

async def event_generator():
try:
async for event in agent_loop(
messages=messages,
tools=tools,
context=context,
backend_router=backend_router,
model_params=request.model_params
):
yield f"data: {event.model_dump_json()}\n\n"
except Exception as e:
logger.error(f"Error in agent loop generator: {e}", exc_info=True)
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"

return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from app.services.backend_registry import BACKENDS_BY_PRIORITY, BackendType
from app.services.backend_client import BackendClient
from app.models.ai import AIRequest
from app.core.uncertainty import compute_uncertainty

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -259,45 +260,6 @@ async def chat_completions(req: ChatCompletionRequest):
)


# ══════════════════════════════════════════════════════════════════════════════
# Uncertainty Calculation
# ══════════════════════════════════════════════════════════════════════════════

def compute_uncertainty(logprob_data: Any) -> Optional[float]:
"""
Shannon entropy of the top-k distribution, normalized to [0, 1].
Expects Ollama-style logprobs data.
"""
if not logprob_data:
return None

top_logprobs = {}
if isinstance(logprob_data, list) and logprob_data:
# Ollama returns a list of token info, usually just one for the current token
entry = logprob_data[0]
for candidate in entry.get("top_logprobs", []):
top_logprobs[candidate["token"]] = candidate["logprob"]
elif isinstance(logprob_data, dict):
# Already parsed or different format
top_logprobs = logprob_data

if not top_logprobs:
return None

try:
probs = [math.exp(lp) for lp in top_logprobs.values()]
total = sum(probs)
if total == 0:
return 0.0
probs = [p / total for p in probs]
entropy = -sum(p * math.log2(p) for p in probs if p > 0)
max_entropy = math.log2(len(probs)) if len(probs) > 1 else 1.0
return entropy / max_entropy if max_entropy > 0 else 0.0
except Exception as e:
logger.error("[Uncertainty] Error computing entropy: %s", e)
return None


# ══════════════════════════════════════════════════════════════════════════════
# Streaming logic
# ══════════════════════════════════════════════════════════════════════════════
Expand Down
Empty file.
142 changes: 142 additions & 0 deletions very-simplified-stack/cognito-backend/app/core/agent_loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import json
import logging
import uuid
from typing import AsyncIterator, Dict, List, Optional, Any

from app.core.events import (
AgentEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent, DoneEvent, ErrorEvent
)
from app.core.tools.base import AgentTool, ToolContext, ToolResult
from app.core.uncertainty import compute_uncertainty

logger = logging.getLogger(__name__)

async def agent_loop(
messages: List[Dict[str, Any]],
tools: List[AgentTool],
context: ToolContext,
backend_router,
model_params: Optional[Dict[str, Any]] = None,
max_turns: int = 10,
) -> AsyncIterator[AgentEvent]:
"""
Main Agent Loop:
1. Call backend with tools.
2. Stream text deltas.
3. Handle tool calls: execute tool, emit ToolResultEvent, add to messages.
4. Repeat until end_turn or max_turns.
"""

current_messages = list(messages)
# Convert AgentTool list to JSON Schema format for the backend
tools_schema = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters_schema,
}
}
for t in tools
]

turn = 0
while turn < max_turns:
turn += 1
logger.info(f"Starting agent turn {turn}/{max_turns}")

assistant_content = ""
tool_calls_to_exec = []

try:
async for chunk in backend_router.generate_with_tools(current_messages, tools_schema, model_params):
# Text delta
if chunk.get("token"):
token = chunk["token"]
assistant_content += token
uncertainty = compute_uncertainty(chunk.get("logprobs"))
yield TextDeltaEvent(content=token, uncertainty=uncertainty)

# Tool calls (Ollama style: array in one chunk; OpenAI style: might be fragmented)
# For Phase 1 we assume they arrive complete enough to parse
if chunk.get("tool_calls"):
for tc in chunk["tool_calls"]:
# Normalize format between Ollama and OpenAI
# Ollama: {'function': {'name': ..., 'arguments': {...}}}
# OpenAI: {'id': ..., 'type': 'function', 'function': {...}}

tc_id = tc.get("id") or f"call_{uuid.uuid4().hex[:8]}"
fn = tc.get("function", {})
name = fn.get("name")
args = fn.get("arguments")

if isinstance(args, str):
try:
args = json.loads(args)
except:
pass

tool_calls_to_exec.append({
"id": tc_id,
"name": name,
"arguments": args
})

yield ToolCallEvent(
tool_call_id=tc_id,
tool_name=name,
arguments=args if isinstance(args, dict) else {"raw": args}
)

# Add assistant message to history
# If there were tool calls, we need to add the assistant message with tool_calls
assistant_message = {"role": "assistant", "content": assistant_content}
if tool_calls_to_exec:
# OpenAI format expects tool_calls here
assistant_message["tool_calls"] = [
{
"id": tc["id"],
"type": "function",
"function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])}
}
for tc in tool_calls_to_exec
]
current_messages.append(assistant_message)

if not tool_calls_to_exec:
# No more tools requested, we are done
yield DoneEvent(stop_reason="end_turn")
return

# Execute tools
for tc in tool_calls_to_exec:
tool = next((t for t in tools if t.name == tc["name"]), None)
if not tool:
result = ToolResult(is_error=True, output=f"Tool '{tc['name']}' not found.")
else:
logger.info(f"Executing tool {tool.name} with args {tc['arguments']}")
result = await tool.execute(tc["arguments"], context)

yield ToolResultEvent(
tool_call_id=tc["id"],
tool_name=tc["name"],
output=result.output,
is_error=result.is_error
)

# Add tool result to history
current_messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"name": tc["name"],
"content": result.output
})

except Exception as e:
logger.error(f"Error in agent loop turn {turn}: {e}", exc_info=True)
yield ErrorEvent(message=str(e))
yield DoneEvent(stop_reason="error", error_message=str(e))
return

yield DoneEvent(stop_reason="max_turns")
31 changes: 31 additions & 0 deletions very-simplified-stack/cognito-backend/app/core/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Literal, Optional, Any, Union
from pydantic import BaseModel

class TextDeltaEvent(BaseModel):
type: Literal["text_delta"] = "text_delta"
content: str
uncertainty: Optional[float] = None

class ToolCallEvent(BaseModel):
type: Literal["tool_call"] = "tool_call"
tool_call_id: str
tool_name: str
arguments: dict[str, Any]

class ToolResultEvent(BaseModel):
type: Literal["tool_result"] = "tool_result"
tool_call_id: str
tool_name: str
output: str
is_error: bool = False

class DoneEvent(BaseModel):
type: Literal["done"] = "done"
stop_reason: Literal["end_turn", "tool_use", "max_turns", "error", "aborted"]
error_message: Optional[str] = None

class ErrorEvent(BaseModel):
type: Literal["error"] = "error"
message: str

AgentEvent = Union[TextDeltaEvent, ToolCallEvent, ToolResultEvent, DoneEvent, ErrorEvent]
36 changes: 36 additions & 0 deletions very-simplified-stack/cognito-backend/app/core/project_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import json
import os
from pathlib import Path
from typing import Dict

class ProjectTrustStore:
def __init__(self, store_path: Path | None = None):
self.store_path = store_path or (Path.home() / ".cognito" / "trust.json")
self._ensure_dir()

def _ensure_dir(self):
self.store_path.parent.mkdir(parents=True, exist_ok=True)

def _load(self) -> Dict[str, bool]:
if not self.store_path.exists():
return {}
try:
with open(self.store_path, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}

def _save(self, data: Dict[str, bool]):
with open(self.store_path, "w") as f:
json.dump(data, f, indent=2)

def is_trusted(self, repo_path: str) -> bool:
path = os.path.realpath(repo_path)
data = self._load()
return data.get(path, False)

def set_trusted(self, repo_path: str, trusted: bool) -> None:
path = os.path.realpath(repo_path)
data = self._load()
data[path] = trusted
self._save(data)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Mirror of the "auth is sacred" convention used in hypenosys.github.io
PROTECTED_FILES: set[str] = {
"assets/javascript/auth.js",
"assets/javascript/github-api.js",
"assets/javascript/dashboard-data.js",
"dashboard.html",
}
Loading