Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c9b5b55
fix(session): add BaseAgent session-model compatibility methods
sskmlm May 28, 2026
04316b6
fix(ws): avoid cwd history corruption and harden config schema endpoint
sskmlm May 28, 2026
c32a8cf
docs(migration): track ws/model-switch regressions and fixes
sskmlm May 28, 2026
4e3fc17
feat(api): migrate core api, db, routers, and session context
sskmlm May 28, 2026
45c1b50
feat(ws): migrate modular websocket handlers and runtime management
sskmlm May 28, 2026
796062f
test(api): migrate api/ws test suites and desk templates
sskmlm May 28, 2026
1e5bf34
feat(core): migrate backend logging and model error normalization mod…
sskmlm May 28, 2026
52cd3b1
chore(migration): add legacy ws snapshot and migration docs
sskmlm Jun 1, 2026
a47fe93
feat(api): add puppy-desk backend runtime support
sskmlm Jun 1, 2026
2e03e1d
feat(ws): adapt complete responses to streaming frames
sskmlm Jun 1, 2026
ffdc3e0
refactor(api): remove terminal and sessions server routes
sskmlm Jun 1, 2026
be3c13c
Clean up puppy-desk websocket migration
sskmlm Jun 2, 2026
fd02dac
phase3: extract session_persistence.py and send_utils.py from chat_ha…
sskmlm Jun 2, 2026
4a3889b
fix: use result.get() for plugin key access in lifespan
sskmlm Jun 2, 2026
0dd008d
docs: phase 3 post-extraction fixes plan
sskmlm Jun 2, 2026
aca870f
fix: wire emitter ContextVar + sync sender session_id
sskmlm Jun 3, 2026
865d7ca
style: sync import ordering with external repo (isort)
Jun 4, 2026
a3281eb
wip: preserve feature/puppy-desk-migration state for manual ws sync
sskmlm Jun 10, 2026
534225d
fix(ws): add one-shot resume recovery with sqlite reload
sskmlm Jun 11, 2026
80a37ae
fix(codex): dedupe response input items by id before API request
Jun 13, 2026
af4aaf0
test(codex): cover duplicate input item id dedupe behavior
Jun 13, 2026
55c2061
feat(desk): refresh chat UI and session recovery
sskmlm Jun 13, 2026
732d651
chore(api): remove DBOS startup from desk API
sskmlm Jun 13, 2026
1b0e62f
chore(pr): keep legacy CLI session resume unchanged
sskmlm Jun 13, 2026
85a9705
Support browser ask_user_question responses
sskmlm Jun 14, 2026
1c2b641
Remove stale puppy desk migration issue notes
sskmlm Jun 14, 2026
70850c0
style: format ws and ask-user-question modules
sskmlm Jun 15, 2026
75fa456
fix(agents): skip null models during fallback resolution
sskmlm Jul 9, 2026
faad758
fix: restore test compatibility across context, mcp, approvals, sessi…
sskmlm Jul 10, 2026
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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,7 @@ You can toggle DBOS via either of these options:

- CLI config (persists): `/set enable_dbos false` to disable (enabled by default)


Config takes precedence if set; otherwise the environment variable is used.
DBOS durable execution is implemented by the `dbos_durable_exec` plugin and uses its own DBOS system database. It is separate from the Puppy Desk chat history database at `~/.puppy_desk/chat_messages.db`.

### Configuration

Expand Down
12 changes: 8 additions & 4 deletions code_puppy/agents/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,12 +370,16 @@ def load_model_with_fallback(
continue
try:
model = ModelFactory.get_model(candidate, models_config)
emit_info(
f"Using fallback model: {candidate}", message_group=message_group
)
return model, candidate
except ValueError:
continue
if model is None:
# Missing credentials/provider reachability can make a model
# "configured" but unavailable at runtime. Keep searching for
# a *real* fallback instead of returning a None model that only
# explodes later in pydantic-ai run().
continue
emit_info(f"Using fallback model: {candidate}", message_group=message_group)
return model, candidate

friendly = (
"No valid model could be loaded. Update the model configuration or "
Expand Down
24 changes: 24 additions & 0 deletions code_puppy/agents/base_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def __init__(self) -> None:
self._code_generation_agent: Any = None
self._last_model_name: Optional[str] = None
self._runtime_model_name_override: Optional[str] = None
self._session_model_name: Optional[str] = None
self._puppy_rules: Optional[str] = None
self._mcp_servers: List[Any] = []
self.cur_model: Optional[pydantic_ai.models.Model] = None
Expand Down Expand Up @@ -143,6 +144,8 @@ def get_model_name(self) -> Optional[str]:
override = self.get_runtime_model_name_override()
if override:
return override
if self._session_model_name:
return self._session_model_name
pinned = get_agent_pinned_model(self.name)
return pinned if pinned else get_global_model_name()

Expand Down Expand Up @@ -191,6 +194,27 @@ def clear_message_history(self) -> None:
def append_to_message_history(self, message: Any) -> None:
self._message_history.append(message)

# ---- Session model + compaction compatibility helpers ----------------
def set_session_model(self, model_name: Optional[str]) -> None:
"""Set a per-session model override for this agent instance."""
self._session_model_name = model_name or None

def get_session_model(self) -> Optional[str]:
"""Return the per-session model override, if any."""
return self._session_model_name

def reset_session_model(self) -> None:
"""Clear the per-session model override for this agent instance."""
self._session_model_name = None

def get_compacted_message_hashes(self) -> Set[int]:
"""Expose compacted-message hashes for session state transfers."""
return set(self._compacted_message_hashes)

def add_compacted_message_hash(self, message_hash: int) -> None:
"""Track a compacted-message hash for this agent instance."""
self._compacted_message_hashes.add(message_hash)

# ---- Token / context helpers ------------------------------------------
def estimate_tokens_for_message(self, message: Any) -> int:
return estimate_tokens_for_message(message, self.get_model_name())
Expand Down
13 changes: 13 additions & 0 deletions code_puppy/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Code Puppy REST API module.

This module provides a FastAPI-based REST API for Code Puppy configuration,
sessions, commands, and real-time WebSocket communication.

Exports:
create_app: Factory function to create the FastAPI application
main: Entry point to run the server
"""

from code_puppy.api.app import create_app

__all__ = ["create_app"]
250 changes: 250 additions & 0 deletions code_puppy/api/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
"""FastAPI application factory for Code Puppy API."""

import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware


logger = logging.getLogger(__name__)

# Default request timeout (seconds) - fail fast!
REQUEST_TIMEOUT = 30.0


class TimeoutMiddleware(BaseHTTPMiddleware):
"""Middleware to enforce request timeouts and prevent hanging requests."""

def __init__(self, app, timeout: float = REQUEST_TIMEOUT):
super().__init__(app)
self.timeout = timeout

async def dispatch(self, request: Request, call_next):
# Skip timeout for WebSocket upgrades and streaming endpoints
if request.headers.get(
"upgrade", ""
).lower() == "websocket" or request.url.path.startswith("/ws/"):
return await call_next(request)

try:
return await asyncio.wait_for(
call_next(request),
timeout=self.timeout,
)
except asyncio.TimeoutError:
return JSONResponse(
status_code=504,
content={
"detail": f"Request timed out after {self.timeout}s",
"error": "timeout",
},
)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Lifespan context manager for startup and shutdown events.

Handles graceful cleanup of resources when the server shuts down.
"""
# Startup
logger.info("🐶 Code Puppy API starting up...")

# Load plugin callbacks (including frontend_emitter for real-time streaming)
from code_puppy import plugins

result = plugins.load_plugin_callbacks()
logger.info(
f"✓ Loaded plugins: builtin={result.get('builtin', [])}, user={result.get('user', [])}, external={result.get('external', [])}"
)

# Initialise shared SQLite database
try:
from code_puppy.api.db.connection import init_db

await init_db()
logger.info("✓ aiosqlite DB initialised")
except Exception as _db_exc:
logger.error("SQLite DB init failed (continuing without DB): %s", _db_exc)

yield
# Shutdown: clean up all the things!
logger.info("🐶 Code Puppy API shutting down, cleaning up...")

# 1. Shutdown session cache thread pool executor
try:
from code_puppy.api.session_cache import shutdown_executor

await shutdown_executor()
logger.info("✓ Session cache executor shut down")
except Exception as e:
logger.error("Error shutting down session cache executor: %s", e)

# 2. Shutdown ws_sessions thread pool executor
try:
from code_puppy.api.routers import ws_sessions

ws_sessions._executor.shutdown(wait=False)
logger.info("✓ WS sessions executor shut down")
except Exception as e:
logger.error("Error shutting down ws_sessions executor: %s", e)

# 4. Remove PID file so /api status knows we're gone
try:
from code_puppy.config import STATE_DIR

pid_file = Path(STATE_DIR) / "api_server.pid"
if pid_file.exists():
pid_file.unlink()
logger.info("✓ PID file removed")
except Exception as e:
logger.error("Error removing PID file: %s", e)

# 6. Close SQLite database
try:
from code_puppy.api.db.connection import close_db

await close_db()
logger.info("✓ aiosqlite DB closed")
except Exception as e:
logger.error("Error closing SQLite DB: %s", e)


def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(
lifespan=lifespan,
title="Code Puppy API",
description="REST API and Interactive Terminal for Code Puppy",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)

# Timeout middleware - added first so it wraps everything
app.add_middleware(TimeoutMiddleware, timeout=REQUEST_TIMEOUT)

# CORS middleware for frontend access
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Local/trusted
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Include routers
from code_puppy.api.routers import (
agents,
commands,
config,
sessions,
ws_sessions,
)

app.include_router(config.router, prefix="/api/config", tags=["config"])
app.include_router(commands.router, prefix="/api/commands", tags=["commands"])
app.include_router(sessions.router, prefix="/api/sessions", tags=["sessions"])
app.include_router(
ws_sessions.router, prefix="/api/ws-sessions", tags=["ws-sessions"]
)
app.include_router(agents.router, prefix="/api/agents", tags=["agents"])

from code_puppy.api.routers import models

app.include_router(models.router, prefix="/api/models", tags=["models"])

from code_puppy.api.routers import protocol

app.include_router(protocol.router, prefix="/api/protocol", tags=["protocol"])

# WebSocket endpoints
from code_puppy.api.websocket import setup_websocket

setup_websocket(app)

# Templates directory
templates_dir = Path(__file__).parent / "templates"

@app.get("/")
async def root():
"""Landing page with links to terminal and docs."""
return HTMLResponse(
content="""
<!DOCTYPE html>
<html>
<head>
<title>Code Puppy 🐶</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-white min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-6xl mb-4">🐶</h1>
<h2 class="text-3xl font-bold mb-8">Code Puppy</h2>
<div class="space-x-4">
<a href="/chat" class="px-6 py-3 bg-green-600 hover:bg-green-700 rounded-lg text-lg font-semibold">
Open Chat
</a>
<a href="/docs" class="px-6 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-lg">
API Docs
</a>
</div>
<p class="mt-8 text-gray-400">
WebSocket: ws://localhost:8765/ws/chat
</p>
</div>
</body>
</html>
"""
)

@app.get("/chat")
async def chat_page():
"""Serve the chat interface page."""
html_file = templates_dir / "chat.html"
if html_file.exists():
return FileResponse(html_file, media_type="text/html")
return HTMLResponse(
content="<h1>Chat template not found</h1>",
status_code=404,
)

@app.get("/health")
async def health():
"""Simple health check endpoint."""
return {"status": "healthy"}

@app.get("/api/version-check")
async def version_check():
"""
Check current version and latest available version.

Returns:
dict: Contains current_version, latest_version, and update_available
"""
from code_puppy import __version__
from code_puppy.plugins.walmart_specific.auto_update import fetch_latest_version
from code_puppy.version_checker import versions_are_equal

current_version = __version__
latest_version = fetch_latest_version()

# Determine if update is available
update_available = False
if latest_version:
update_available = not versions_are_equal(current_version, latest_version)

return {
"current_version": current_version,
"latest_version": latest_version or current_version,
"update_available": update_available,
"status": "success" if latest_version else "error_fetching_latest",
}

return app
Loading
Loading