mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-05 16:33:08 +00:00
Merge branch 'master' into master
This commit is contained in:
@@ -25,25 +25,13 @@ from .assistant_database import (
|
||||
create_conversation,
|
||||
get_messages,
|
||||
)
|
||||
from .chat_constants import API_ENV_VARS, ROOT_DIR
|
||||
|
||||
# Load environment variables from .env file if present
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Root directory of the project
|
||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
# Environment variables to pass through to Claude CLI for API configuration
|
||||
API_ENV_VARS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"API_TIMEOUT_MS",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
]
|
||||
|
||||
# Read-only feature MCP tools
|
||||
READONLY_FEATURE_MCP_TOOLS = [
|
||||
"mcp__features__feature_get_stats",
|
||||
@@ -76,7 +64,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
|
||||
"""Generate the system prompt for the assistant with project context."""
|
||||
# Try to load app_spec.txt for context
|
||||
app_spec_content = ""
|
||||
app_spec_path = project_dir / "prompts" / "app_spec.txt"
|
||||
from autocoder_paths import get_prompts_dir
|
||||
app_spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
||||
if app_spec_path.exists():
|
||||
try:
|
||||
app_spec_content = app_spec_path.read_text(encoding="utf-8")
|
||||
@@ -90,6 +79,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
|
||||
|
||||
Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code.
|
||||
|
||||
You have MCP tools available for feature management. Use them directly by calling the tool -- do not suggest CLI commands, bash commands, or curl commands to the user. You can create features yourself using the feature_create and feature_create_bulk tools.
|
||||
|
||||
## What You CAN Do
|
||||
|
||||
**Codebase Analysis (Read-Only):**
|
||||
@@ -134,17 +125,21 @@ If the user asks you to modify code, explain that you're a project assistant and
|
||||
|
||||
## Creating Features
|
||||
|
||||
When a user asks to add a feature, gather the following information:
|
||||
1. **Category**: A grouping like "Authentication", "API", "UI", "Database"
|
||||
2. **Name**: A concise, descriptive name
|
||||
3. **Description**: What the feature should do
|
||||
4. **Steps**: How to verify/implement the feature (as a list)
|
||||
When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
|
||||
|
||||
For a **single feature**, call `feature_create` with:
|
||||
- category: A grouping like "Authentication", "API", "UI", "Database"
|
||||
- name: A concise, descriptive name
|
||||
- description: What the feature should do
|
||||
- steps: List of verification/implementation steps
|
||||
|
||||
For **multiple features**, call `feature_create_bulk` with an array of feature objects.
|
||||
|
||||
You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests.
|
||||
|
||||
**Example interaction:**
|
||||
User: "Add a feature for S3 sync"
|
||||
You: I'll create that feature. Let me add it to the backlog...
|
||||
You: I'll create that feature now.
|
||||
[calls feature_create with appropriate parameters]
|
||||
You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board.
|
||||
|
||||
@@ -208,7 +203,7 @@ class AssistantChatSession:
|
||||
# Create a new conversation if we don't have one
|
||||
if is_new_conversation:
|
||||
conv = create_conversation(self.project_dir, self.project_name)
|
||||
self.conversation_id = conv.id
|
||||
self.conversation_id = int(conv.id) # type coercion: Column[int] -> int
|
||||
yield {"type": "conversation_created", "conversation_id": self.conversation_id}
|
||||
|
||||
# Build permissions list for assistant access (read + feature management)
|
||||
@@ -229,7 +224,9 @@ class AssistantChatSession:
|
||||
"allow": permissions_list,
|
||||
},
|
||||
}
|
||||
settings_file = self.project_dir / ".claude_assistant_settings.json"
|
||||
from autocoder_paths import get_claude_assistant_settings_path
|
||||
settings_file = get_claude_assistant_settings_path(self.project_dir)
|
||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(settings_file, "w") as f:
|
||||
json.dump(security_settings, f, indent=2)
|
||||
|
||||
@@ -261,7 +258,11 @@ class AssistantChatSession:
|
||||
system_cli = shutil.which("claude")
|
||||
|
||||
# Build environment overrides for API configuration
|
||||
sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)}
|
||||
sdk_env: dict[str, str] = {}
|
||||
for var in API_ENV_VARS:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
sdk_env[var] = value
|
||||
|
||||
# Determine model from environment or use default
|
||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
||||
@@ -277,7 +278,7 @@ class AssistantChatSession:
|
||||
# This avoids Windows command line length limit (~8191 chars)
|
||||
setting_sources=["project"],
|
||||
allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS],
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
|
||||
permission_mode="bypassPermissions",
|
||||
max_turns=100,
|
||||
cwd=str(self.project_dir.resolve()),
|
||||
@@ -303,6 +304,8 @@ class AssistantChatSession:
|
||||
greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"
|
||||
|
||||
# Store the greeting in the database
|
||||
# conversation_id is guaranteed non-None here (set on line 206 above)
|
||||
assert self.conversation_id is not None
|
||||
add_message(self.project_dir, self.conversation_id, "assistant", greeting)
|
||||
|
||||
yield {"type": "text", "content": greeting}
|
||||
|
||||
@@ -7,20 +7,28 @@ Each project has its own assistant.db file in the project directory.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine, func
|
||||
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Base = declarative_base()
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy 2.0 style declarative base."""
|
||||
pass
|
||||
|
||||
# Engine cache to avoid creating new engines for each request
|
||||
# Key: project directory path (as posix string), Value: SQLAlchemy engine
|
||||
_engine_cache: dict[str, object] = {}
|
||||
_engine_cache: dict[str, Engine] = {}
|
||||
|
||||
# Lock for thread-safe access to the engine cache
|
||||
# Prevents race conditions when multiple threads create engines simultaneously
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
@@ -56,7 +64,8 @@ class ConversationMessage(Base):
|
||||
|
||||
def get_db_path(project_dir: Path) -> Path:
|
||||
"""Get the path to the assistant database for a project."""
|
||||
return project_dir / "assistant.db"
|
||||
from autocoder_paths import get_assistant_db_path
|
||||
return get_assistant_db_path(project_dir)
|
||||
|
||||
|
||||
def get_engine(project_dir: Path):
|
||||
@@ -64,17 +73,33 @@ def get_engine(project_dir: Path):
|
||||
|
||||
Uses a cache to avoid creating new engines for each request, which improves
|
||||
performance by reusing database connections.
|
||||
|
||||
Thread-safe: Uses a lock to prevent race conditions when multiple threads
|
||||
try to create engines simultaneously for the same project.
|
||||
"""
|
||||
cache_key = project_dir.as_posix()
|
||||
|
||||
if cache_key not in _engine_cache:
|
||||
db_path = get_db_path(project_dir)
|
||||
# Use as_posix() for cross-platform compatibility with SQLite connection strings
|
||||
db_url = f"sqlite:///{db_path.as_posix()}"
|
||||
engine = create_engine(db_url, echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
_engine_cache[cache_key] = engine
|
||||
logger.debug(f"Created new database engine for {cache_key}")
|
||||
# Double-checked locking for thread safety and performance
|
||||
if cache_key in _engine_cache:
|
||||
return _engine_cache[cache_key]
|
||||
|
||||
with _cache_lock:
|
||||
# Check again inside the lock in case another thread created it
|
||||
if cache_key not in _engine_cache:
|
||||
db_path = get_db_path(project_dir)
|
||||
# Use as_posix() for cross-platform compatibility with SQLite connection strings
|
||||
db_url = f"sqlite:///{db_path.as_posix()}"
|
||||
engine = create_engine(
|
||||
db_url,
|
||||
echo=False,
|
||||
connect_args={
|
||||
"check_same_thread": False,
|
||||
"timeout": 30, # Wait up to 30s for locks
|
||||
}
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
_engine_cache[cache_key] = engine
|
||||
logger.debug(f"Created new database engine for {cache_key}")
|
||||
|
||||
return _engine_cache[cache_key]
|
||||
|
||||
|
||||
57
server/services/chat_constants.py
Normal file
57
server/services/chat_constants.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Chat Session Constants
|
||||
======================
|
||||
|
||||
Shared constants for all chat session types (assistant, spec, expand).
|
||||
|
||||
The canonical ``API_ENV_VARS`` list lives in ``env_constants.py`` at the
|
||||
project root and is re-exported here for convenience so that existing
|
||||
imports (``from .chat_constants import API_ENV_VARS``) continue to work.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Root directory of the autocoder project (repository root).
|
||||
# Used throughout the server package whenever the repo root is needed.
|
||||
# -------------------------------------------------------------------
|
||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
# Ensure the project root is on sys.path so we can import env_constants
|
||||
# from the root-level module without requiring a package install.
|
||||
_root_str = str(ROOT_DIR)
|
||||
if _root_str not in sys.path:
|
||||
sys.path.insert(0, _root_str)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Environment variables forwarded to Claude CLI subprocesses.
|
||||
# Single source of truth lives in env_constants.py at the project root.
|
||||
# Re-exported here so existing ``from .chat_constants import API_ENV_VARS``
|
||||
# imports continue to work unchanged.
|
||||
# -------------------------------------------------------------------
|
||||
from env_constants import API_ENV_VARS # noqa: E402, F401
|
||||
|
||||
|
||||
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
||||
"""Yield a single multimodal user message in Claude Agent SDK format.
|
||||
|
||||
The Claude Agent SDK's ``query()`` method accepts either a plain string
|
||||
or an ``AsyncIterable[dict]`` for custom message formats. This helper
|
||||
wraps a list of content blocks (text and/or images) in the expected
|
||||
envelope.
|
||||
|
||||
Args:
|
||||
content_blocks: List of content-block dicts, e.g.
|
||||
``[{"type": "text", "text": "..."}, {"type": "image", ...}]``.
|
||||
|
||||
Yields:
|
||||
A single dict representing the user message.
|
||||
"""
|
||||
yield {
|
||||
"type": "user",
|
||||
"message": {"role": "user", "content": content_blocks},
|
||||
"parent_tool_use_id": None,
|
||||
"session_id": "default",
|
||||
}
|
||||
@@ -16,28 +16,19 @@ import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from ..schemas import ImageAttachment
|
||||
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
|
||||
|
||||
# Load environment variables from .env file if present
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variables to pass through to Claude CLI for API configuration
|
||||
API_ENV_VARS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"API_TIMEOUT_MS",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
]
|
||||
|
||||
# Feature MCP tools needed for expand session
|
||||
EXPAND_FEATURE_TOOLS = [
|
||||
"mcp__features__feature_create",
|
||||
@@ -46,22 +37,6 @@ EXPAND_FEATURE_TOOLS = [
|
||||
]
|
||||
|
||||
|
||||
async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
||||
"""
|
||||
Create an async generator that yields a properly formatted multimodal message.
|
||||
"""
|
||||
yield {
|
||||
"type": "user",
|
||||
"message": {"role": "user", "content": content_blocks},
|
||||
"parent_tool_use_id": None,
|
||||
"session_id": "default",
|
||||
}
|
||||
|
||||
|
||||
# Root directory of the project
|
||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
class ExpandChatSession:
|
||||
"""
|
||||
Manages a project expansion conversation.
|
||||
@@ -128,7 +103,8 @@ class ExpandChatSession:
|
||||
return
|
||||
|
||||
# Verify project has existing spec
|
||||
spec_path = self.project_dir / "prompts" / "app_spec.txt"
|
||||
from autocoder_paths import get_prompts_dir
|
||||
spec_path = get_prompts_dir(self.project_dir) / "app_spec.txt"
|
||||
if not spec_path.exists():
|
||||
yield {
|
||||
"type": "error",
|
||||
@@ -162,10 +138,13 @@ class ExpandChatSession:
|
||||
"allow": [
|
||||
"Read(./**)",
|
||||
"Glob(./**)",
|
||||
*EXPAND_FEATURE_TOOLS,
|
||||
],
|
||||
},
|
||||
}
|
||||
settings_file = self.project_dir / f".claude_settings.expand.{uuid.uuid4().hex}.json"
|
||||
from autocoder_paths import get_expand_settings_path
|
||||
settings_file = get_expand_settings_path(self.project_dir, uuid.uuid4().hex)
|
||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._settings_file = settings_file
|
||||
with open(settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(security_settings, f, indent=2)
|
||||
@@ -175,7 +154,12 @@ class ExpandChatSession:
|
||||
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
||||
|
||||
# Build environment overrides for API configuration
|
||||
sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)}
|
||||
# Filter to only include vars that are actually set (non-None)
|
||||
sdk_env: dict[str, str] = {}
|
||||
for var in API_ENV_VARS:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
sdk_env[var] = value
|
||||
|
||||
# Determine model from environment or use default
|
||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
||||
@@ -203,9 +187,12 @@ class ExpandChatSession:
|
||||
allowed_tools=[
|
||||
"Read",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
*EXPAND_FEATURE_TOOLS,
|
||||
],
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
|
||||
permission_mode="bypassPermissions",
|
||||
max_turns=100,
|
||||
cwd=str(self.project_dir.resolve()),
|
||||
@@ -299,7 +286,7 @@ class ExpandChatSession:
|
||||
|
||||
# Build the message content
|
||||
if attachments and len(attachments) > 0:
|
||||
content_blocks = []
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
if message:
|
||||
content_blocks.append({"type": "text", "text": message})
|
||||
for att in attachments:
|
||||
@@ -311,7 +298,7 @@ class ExpandChatSession:
|
||||
"data": att.base64Data,
|
||||
}
|
||||
})
|
||||
await self.client.query(_make_multimodal_message(content_blocks))
|
||||
await self.client.query(make_multimodal_message(content_blocks))
|
||||
logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
|
||||
else:
|
||||
await self.client.query(message)
|
||||
|
||||
@@ -15,7 +15,7 @@ import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable, Literal, Set
|
||||
from typing import Any, Awaitable, Callable, Literal, Set
|
||||
|
||||
import psutil
|
||||
|
||||
@@ -92,7 +92,8 @@ class AgentProcessManager:
|
||||
self._callbacks_lock = threading.Lock()
|
||||
|
||||
# Lock file to prevent multiple instances (stored in project directory)
|
||||
self.lock_file = self.project_dir / ".agent.lock"
|
||||
from autocoder_paths import get_agent_lock_path
|
||||
self.lock_file = get_agent_lock_path(self.project_dir)
|
||||
|
||||
@property
|
||||
def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
|
||||
@@ -296,6 +297,8 @@ class AgentProcessManager:
|
||||
parallel_mode: bool = False,
|
||||
max_concurrency: int | None = None,
|
||||
testing_agent_ratio: int = 1,
|
||||
playwright_headless: bool = True,
|
||||
batch_size: int = 3,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Start the agent as a subprocess.
|
||||
@@ -306,6 +309,7 @@ class AgentProcessManager:
|
||||
parallel_mode: DEPRECATED - ignored, always uses unified orchestrator
|
||||
max_concurrency: Max concurrent coding agents (1-5, default 1)
|
||||
testing_agent_ratio: Number of regression testing agents (0-3, default 1)
|
||||
playwright_headless: If True, run browser in headless mode
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
@@ -346,18 +350,21 @@ class AgentProcessManager:
|
||||
# Add testing agent configuration
|
||||
cmd.extend(["--testing-ratio", str(testing_agent_ratio)])
|
||||
|
||||
# Add --batch-size flag for multi-feature batching
|
||||
cmd.extend(["--batch-size", str(batch_size)])
|
||||
|
||||
try:
|
||||
# Start subprocess with piped stdout/stderr
|
||||
# Use project_dir as cwd so Claude SDK sandbox allows access to project files
|
||||
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
|
||||
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
|
||||
# PYTHONUNBUFFERED ensures output isn't delayed
|
||||
popen_kwargs = {
|
||||
popen_kwargs: dict[str, Any] = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"cwd": str(self.project_dir),
|
||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
|
||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false"},
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
@@ -579,8 +586,18 @@ def cleanup_orphaned_locks() -> int:
|
||||
if not project_path.exists():
|
||||
continue
|
||||
|
||||
lock_file = project_path / ".agent.lock"
|
||||
if not lock_file.exists():
|
||||
# Check both legacy and new locations for lock files
|
||||
from autocoder_paths import get_autocoder_dir
|
||||
lock_locations = [
|
||||
project_path / ".agent.lock",
|
||||
get_autocoder_dir(project_path) / ".agent.lock",
|
||||
]
|
||||
lock_file = None
|
||||
for candidate in lock_locations:
|
||||
if candidate.exists():
|
||||
lock_file = candidate
|
||||
break
|
||||
if lock_file is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
@@ -92,8 +92,9 @@ class SchedulerService:
|
||||
async def _load_project_schedules(self, project_name: str, project_dir: Path) -> int:
|
||||
"""Load schedules for a single project. Returns count of schedules loaded."""
|
||||
from api.database import Schedule, create_database
|
||||
from autocoder_paths import get_features_db_path
|
||||
|
||||
db_path = project_dir / "features.db"
|
||||
db_path = get_features_db_path(project_dir)
|
||||
if not db_path.exists():
|
||||
return 0
|
||||
|
||||
@@ -567,8 +568,9 @@ class SchedulerService:
|
||||
):
|
||||
"""Check if a project should be started on server startup."""
|
||||
from api.database import Schedule, ScheduleOverride, create_database
|
||||
from autocoder_paths import get_features_db_path
|
||||
|
||||
db_path = project_dir / "features.db"
|
||||
db_path = get_features_db_path(project_dir)
|
||||
if not db_path.exists():
|
||||
return
|
||||
|
||||
|
||||
@@ -13,49 +13,19 @@ import shutil
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from ..schemas import ImageAttachment
|
||||
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
|
||||
|
||||
# Load environment variables from .env file if present
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variables to pass through to Claude CLI for API configuration
|
||||
API_ENV_VARS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"API_TIMEOUT_MS",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
]
|
||||
|
||||
|
||||
async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
||||
"""
|
||||
Create an async generator that yields a properly formatted multimodal message.
|
||||
|
||||
The Claude Agent SDK's query() method accepts either:
|
||||
- A string (simple text)
|
||||
- An AsyncIterable[dict] (for custom message formats)
|
||||
|
||||
This function wraps content blocks in the expected message format.
|
||||
"""
|
||||
yield {
|
||||
"type": "user",
|
||||
"message": {"role": "user", "content": content_blocks},
|
||||
"parent_tool_use_id": None,
|
||||
"session_id": "default",
|
||||
}
|
||||
|
||||
# Root directory of the project
|
||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
class SpecChatSession:
|
||||
"""
|
||||
@@ -125,7 +95,8 @@ class SpecChatSession:
|
||||
# Delete app_spec.txt so Claude can create it fresh
|
||||
# The SDK requires reading existing files before writing, but app_spec.txt is created new
|
||||
# Note: We keep initializer_prompt.md so Claude can read and update the template
|
||||
prompts_dir = self.project_dir / "prompts"
|
||||
from autocoder_paths import get_prompts_dir
|
||||
prompts_dir = get_prompts_dir(self.project_dir)
|
||||
app_spec_path = prompts_dir / "app_spec.txt"
|
||||
if app_spec_path.exists():
|
||||
app_spec_path.unlink()
|
||||
@@ -145,7 +116,9 @@ class SpecChatSession:
|
||||
],
|
||||
},
|
||||
}
|
||||
settings_file = self.project_dir / ".claude_settings.json"
|
||||
from autocoder_paths import get_claude_settings_path
|
||||
settings_file = get_claude_settings_path(self.project_dir)
|
||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(settings_file, "w") as f:
|
||||
json.dump(security_settings, f, indent=2)
|
||||
|
||||
@@ -167,7 +140,12 @@ class SpecChatSession:
|
||||
system_cli = shutil.which("claude")
|
||||
|
||||
# Build environment overrides for API configuration
|
||||
sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)}
|
||||
# Filter to only include vars that are actually set (non-None)
|
||||
sdk_env: dict[str, str] = {}
|
||||
for var in API_ENV_VARS:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
sdk_env[var] = value
|
||||
|
||||
# Determine model from environment or use default
|
||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
||||
@@ -289,7 +267,7 @@ class SpecChatSession:
|
||||
# Build the message content
|
||||
if attachments and len(attachments) > 0:
|
||||
# Multimodal message: build content blocks array
|
||||
content_blocks = []
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
|
||||
# Add text block if there's text
|
||||
if message:
|
||||
@@ -308,7 +286,7 @@ class SpecChatSession:
|
||||
|
||||
# Send multimodal content to Claude using async generator format
|
||||
# The SDK's query() accepts AsyncIterable[dict] for custom message formats
|
||||
await self.client.query(_make_multimodal_message(content_blocks))
|
||||
await self.client.query(make_multimodal_message(content_blocks))
|
||||
logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
|
||||
else:
|
||||
# Text-only message: use string format
|
||||
@@ -317,7 +295,7 @@ class SpecChatSession:
|
||||
current_text = ""
|
||||
|
||||
# Track pending writes for BOTH required files
|
||||
pending_writes = {
|
||||
pending_writes: dict[str, dict[str, Any] | None] = {
|
||||
"app_spec": None, # {"tool_id": ..., "path": ...}
|
||||
"initializer": None, # {"tool_id": ..., "path": ...}
|
||||
}
|
||||
@@ -392,7 +370,8 @@ class SpecChatSession:
|
||||
logger.warning(f"Tool error: {content}")
|
||||
# Clear any pending writes that failed
|
||||
for key in pending_writes:
|
||||
if pending_writes[key] and tool_use_id == pending_writes[key].get("tool_id"):
|
||||
pending_write = pending_writes[key]
|
||||
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
|
||||
logger.error(f"{key} write failed: {content}")
|
||||
pending_writes[key] = None
|
||||
else:
|
||||
|
||||
@@ -371,7 +371,7 @@ class TerminalSession:
|
||||
# Reap zombie if not already reaped
|
||||
if self._child_pid is not None:
|
||||
try:
|
||||
os.waitpid(self._child_pid, os.WNOHANG)
|
||||
os.waitpid(self._child_pid, os.WNOHANG) # type: ignore[attr-defined] # Unix-only method, guarded by runtime platform selection
|
||||
except ChildProcessError:
|
||||
pass
|
||||
except Exception:
|
||||
@@ -736,7 +736,7 @@ async def cleanup_all_terminals() -> None:
|
||||
Called on server shutdown to ensure all PTY processes are terminated.
|
||||
"""
|
||||
with _sessions_lock:
|
||||
all_sessions = []
|
||||
all_sessions: list[TerminalSession] = []
|
||||
for project_sessions in _sessions.values():
|
||||
all_sessions.extend(project_sessions.values())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user