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
16 changes: 16 additions & 0 deletions .agents/skills/recheck-after-submit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
name: recheck-after-submit
description: "Recheck the project after submitting or completing code changes. Use after implementation to run pylint, ESLint, Vite build, and pytest, then fix failures."
---

# Recheck After Submit

Run this workflow after implementing code changes and before reporting the work as complete.

## Procedure

1. Inspect the repository scripts and tracked files to identify the project's existing ESLint and Vite build commands.
2. Run `pylint $(git ls-files '*.py')` for tracked Python files.
3. Run the project's ESLint command and Vite production build.
4. Run the relevant `pytest` suite, expanding to all tests when practical.
5. Fix failures caused by the changes, then rerun the checks that failed. Do not report success for checks that were skipped or failed; state any unavailable checks explicitly.
3 changes: 0 additions & 3 deletions AGENTS.md

This file was deleted.

4 changes: 3 additions & 1 deletion app/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ class ErrorCode(StrEnum):
AI_PROVIDER_FAILED = "AI_PROVIDER_FAILED"
AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE = "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE"
AI_CHAT_CONVERSATION_NOT_FOUND = "AI_CHAT_CONVERSATION_NOT_FOUND"
AI_CHAT_MESSAGE_NOT_FOUND = "AI_CHAT_MESSAGE_NOT_FOUND"
AI_CHAT_MODEL_UNAVAILABLE = "AI_CHAT_MODEL_UNAVAILABLE"
AI_CHAT_SKILL_UNAVAILABLE = "AI_CHAT_SKILL_UNAVAILABLE"
AI_CHAT_LIMIT_EXCEEDED = "AI_CHAT_LIMIT_EXCEEDED"
AI_CHAT_TOOL_LIMIT_REACHED = "AI_CHAT_TOOL_LIMIT_REACHED"
AI_CHAT_EMPTY_RESPONSE = "AI_CHAT_EMPTY_RESPONSE"
AI_COMMAND_APPROVAL_NOT_FOUND = "AI_COMMAND_APPROVAL_NOT_FOUND"
AI_APPROVAL_NOT_FOUND = "AI_APPROVAL_NOT_FOUND"
AI_CHANGE_PROPOSAL_NOT_FOUND = "AI_CHANGE_PROPOSAL_NOT_FOUND"
AI_CHANGE_PROPOSAL_NOT_PENDING = "AI_CHANGE_PROPOSAL_NOT_PENDING"
AI_CHANGE_PROPOSAL_STALE = "AI_CHANGE_PROPOSAL_STALE"
Expand Down
12 changes: 10 additions & 2 deletions app/core/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@
"Configure AI credential encryption to use providers"
),
"AI_CHAT_CONVERSATION_NOT_FOUND": "Chat conversation not found",
"AI_CHAT_MESSAGE_NOT_FOUND": (
"That message is no longer available to edit and resend"
),
"AI_CHAT_MODEL_UNAVAILABLE": "The selected model is not available for this provider",
"AI_CHAT_SKILL_UNAVAILABLE": "The selected skill is no longer available",
"AI_CHAT_LIMIT_EXCEEDED": (
"The assistant reached a safety limit for this reply. Send another message to continue"
),
Expand All @@ -65,7 +69,7 @@
"AI_CHAT_EMPTY_RESPONSE": (
"The model returned an empty response. Send another message to retry"
),
"AI_COMMAND_APPROVAL_NOT_FOUND": "Command approval expired or is no longer available",
"AI_APPROVAL_NOT_FOUND": "This approval expired or is no longer available",
"AI_CHANGE_PROPOSAL_NOT_FOUND": "Change proposal not found",
"AI_CHANGE_PROPOSAL_NOT_PENDING": "Change proposal is no longer pending",
"AI_CHANGE_PROPOSAL_STALE": "The file changed after the proposal was created",
Expand Down Expand Up @@ -131,7 +135,11 @@
"Hãy cấu hình khóa mã hóa trước khi dùng AI provider"
),
"AI_CHAT_CONVERSATION_NOT_FOUND": "Không tìm thấy cuộc trò chuyện",
"AI_CHAT_MESSAGE_NOT_FOUND": (
"Tin nhắn này không còn để sửa và gửi lại"
),
"AI_CHAT_MODEL_UNAVAILABLE": "Model đã chọn không khả dụng với provider này",
"AI_CHAT_SKILL_UNAVAILABLE": "Skill đã chọn không còn khả dụng",
"AI_CHAT_LIMIT_EXCEEDED": (
"Trợ lý đã đạt giới hạn an toàn cho phản hồi này. Hãy gửi thêm tin nhắn để tiếp tục"
),
Expand All @@ -140,7 +148,7 @@
"Hãy gửi thêm tin nhắn để tiếp tục"
),
"AI_CHAT_EMPTY_RESPONSE": ("Model trả về nội dung rỗng. Hãy gửi lại tin nhắn để thử lại"),
"AI_COMMAND_APPROVAL_NOT_FOUND": "Yêu cầu duyệt lệnh đã hết hạn hoặc không còn khả dụng",
"AI_APPROVAL_NOT_FOUND": "Yêu cầu duyệt đã hết hạn hoặc không còn khả dụng",
"AI_CHANGE_PROPOSAL_NOT_FOUND": "Không tìm thấy đề xuất thay đổi",
"AI_CHANGE_PROPOSAL_NOT_PENDING": "Đề xuất thay đổi không còn ở trạng thái chờ duyệt",
"AI_CHANGE_PROPOSAL_STALE": "Tệp đã thay đổi sau khi đề xuất được tạo",
Expand Down
1 change: 1 addition & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def _apply_lightweight_migrations() -> None:
tables = set(inspect(engine).get_table_names())
if "ai_chat_messages" in tables:
_add_column_if_missing("ai_chat_messages", "thinking", "TEXT NOT NULL DEFAULT ''")
_add_column_if_missing("ai_chat_messages", "error", "TEXT NOT NULL DEFAULT ''")
if "ai_change_proposals" in tables:
_add_column_if_missing(
"ai_change_proposals", "change_type", "VARCHAR(16) NOT NULL DEFAULT 'modify'"
Expand Down
19 changes: 18 additions & 1 deletion app/modules/ai_changes/service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import difflib
import hashlib
from datetime import datetime

from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session

from app.core.errors import ErrorCode, api_error
Expand Down Expand Up @@ -253,6 +254,22 @@ def _persist(
return proposal_read(proposal)


def delete_proposals_since(db: Session, conversation_id: str, since: datetime) -> int:
"""Delete proposals created at or after ``since`` in a conversation.

Used when earlier chat turns are replaced (edit and resend) so proposals
produced by discarded turns do not linger in the conversation.
"""
result = db.execute(
delete(ChangeProposal).where(
ChangeProposal.conversation_id == conversation_id,
ChangeProposal.created_at >= since,
)
)
db.commit()
return result.rowcount or 0


def list_proposals(
db: Session, user: User, project_id: int, conversation_id: str
) -> list[ChangeProposalRead]:
Expand Down
10 changes: 6 additions & 4 deletions app/modules/ai_chat/approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,24 @@ class PendingApproval:
approval_id: str
user_id: int
project_id: int
command: str
kind: str
detail: str
decision: asyncio.Future[bool]


_pending: dict[str, PendingApproval] = {}


def create_approval(user_id: int, project_id: int, command: str) -> PendingApproval:
def create_approval(user_id: int, project_id: int, kind: str, detail: str) -> PendingApproval:
if len(_pending) >= MAX_PENDING_APPROVALS:
raise ApprovalCapacityError("too many pending command approvals")
raise ApprovalCapacityError("too many pending approvals")
approval_id = uuid4().hex
pending = PendingApproval(
approval_id=approval_id,
user_id=user_id,
project_id=project_id,
command=command,
kind=kind,
detail=detail,
decision=asyncio.get_running_loop().create_future(),
)
_pending[approval_id] = pending
Expand Down
58 changes: 58 additions & 0 deletions app/modules/ai_chat/instructions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""User and project agent instructions (``AGENTS.md``) for chat sessions.

The project tools refuse paths outside the project, so the agent cannot read the
user-level file itself. The server therefore loads both files and injects them
into the system prompt when a chat turn starts.
"""

from __future__ import annotations

import logging
from pathlib import Path

MAX_INSTRUCTIONS_CHARS = 24_000
MAX_INSTRUCTIONS_FILE_CHARS = 16_000
PROJECT_INSTRUCTIONS_NAME = "AGENTS.md"
USER_INSTRUCTIONS_PARTS = (".config", "agents", PROJECT_INSTRUCTIONS_NAME)
# Only these display labels reach the provider: absolute server paths would leak
# the operator's home directory and worktree layout to a third-party AI vendor.
USER_INSTRUCTIONS_LABEL = "~/.config/agents/" + PROJECT_INSTRUCTIONS_NAME

logger = logging.getLogger(__name__)


def user_instructions_path() -> Path:
"""Return the user-level instructions path (``~/.config/agents/AGENTS.md``)."""
return Path.home().joinpath(*USER_INSTRUCTIONS_PARTS)


def load_agent_instructions(project_path: Path | None) -> str:
"""Return the user and project instructions formatted for the system prompt.

Missing or unreadable files are skipped. Each file is read up to
``MAX_INSTRUCTIONS_FILE_CHARS`` and the combined result is capped at
``MAX_INSTRUCTIONS_CHARS`` so large files cannot crowd out the conversation.
"""
sources = [("User", user_instructions_path(), USER_INSTRUCTIONS_LABEL)]
if project_path is not None:
sources.append(
("Project", project_path / PROJECT_INSTRUCTIONS_NAME, PROJECT_INSTRUCTIONS_NAME)
)

sections: list[str] = []
for label, path, display in sources:
content = _read_optional(path, MAX_INSTRUCTIONS_FILE_CHARS)
if content:
sections.append(f"# {label} instructions ({display})\n\n{content}")
return "\n\n".join(sections)[:MAX_INSTRUCTIONS_CHARS]


def _read_optional(path: Path, limit: int) -> str:
try:
if not path.is_file():
return ""
with path.open("r", encoding="utf-8", errors="replace") as handle:
return handle.read(limit).strip()
except OSError:
logger.warning("Could not read agent instructions at %s", path)
return ""
1 change: 1 addition & 0 deletions app/modules/ai_chat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class ChatMessage(Base):
)
content: Mapped[str] = mapped_column(Text)
thinking: Mapped[str] = mapped_column(Text, default="")
error: Mapped[str] = mapped_column(Text, default="")
status: Mapped[MessageStatus] = mapped_column(
Enum(
MessageStatus,
Expand Down
37 changes: 31 additions & 6 deletions app/modules/ai_chat/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
from app.core.deps import DbDep, SettingsDep
from app.core.errors import ErrorCode, api_error, error_responses
from app.modules.ai_changes.router import router as ai_changes_router
from app.modules.ai_chat import approvals, service
from app.modules.ai_chat import approvals, service, skills
from app.modules.ai_chat.context import ProjectContext
from app.modules.ai_chat.schemas import (
ChatTurnRequest,
CommandApprovalDecision,
CommandPermissionRead,
ConversationDetail,
ConversationListResponse,
SkillListResponse,
SkillRead,
)
from app.modules.ai_chat.streaming import stream_chat_turn
from app.modules.ai_providers import service as provider_service
Expand Down Expand Up @@ -49,11 +51,11 @@ def update_command_permission(


@router.post(
"/commands/{approval_id}",
"/approvals/{approval_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses=error_responses(401, 403, 404),
)
async def decide_command_approval(
async def decide_approval(
project_id: int,
approval_id: str,
payload: CommandApprovalDecision,
Expand All @@ -68,7 +70,22 @@ async def decide_command_approval(
project_id,
payload.approved,
):
raise api_error(ErrorCode.AI_COMMAND_APPROVAL_NOT_FOUND, status_code=404)
raise api_error(ErrorCode.AI_APPROVAL_NOT_FOUND, status_code=404)


@router.get("/skills", response_model=SkillListResponse, responses=error_responses(401, 403, 404))
def list_skills(
project_id: int,
current_user: CurrentUser,
db: DbDep,
) -> SkillListResponse:
project = project_service.get_project(db, current_user, project_id)
return SkillListResponse(
skills=[
SkillRead(slug=skill.slug, name=skill.name, description=skill.description)
for skill in skills.list_skills(Path(project.path))
]
)


@router.get("/conversations", response_model=ConversationListResponse, responses=_CHAT_ERRORS)
Expand Down Expand Up @@ -115,6 +132,12 @@ async def stream_message(
models = await provider_service.list_models(db, current_user.id, payload.provider_id, settings)
if not any(model.id == payload.model_id for model in models):
raise api_error(ErrorCode.AI_CHAT_MODEL_UNAVAILABLE, status_code=422)
skill_body: str | None = None
if payload.skill is not None:
skill_body = skills.load_skill_body(payload.skill, Path(project.path))
if skill_body is None:
raise api_error(ErrorCode.AI_CHAT_SKILL_UNAVAILABLE, status_code=422)
user_content = payload.message or f"/{payload.skill}"
_, api_key, adapter = provider_service.chat_credentials(
db, current_user.id, payload.provider_id, settings
)
Expand All @@ -132,7 +155,8 @@ async def stream_message(
payload.conversation_id,
payload.provider_id,
payload.model_id,
payload.message,
user_content,
payload.edit_user_message_id,
)
return StreamingResponse(
stream_chat_turn(
Expand All @@ -147,9 +171,10 @@ async def stream_message(
conversation_id=conversation.id,
conversation_title=conversation.title,
user_message_id=user_message.id,
user_content=payload.message,
user_content=user_content,
assistant_message=assistant_message,
history=history,
skill_body=skill_body,
),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
Expand Down
27 changes: 25 additions & 2 deletions app/modules/ai_chat/schemas.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import datetime
from enum import StrEnum

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator

from app.modules.ai_chat.models import MessageRole, MessageStatus

Expand Down Expand Up @@ -30,7 +30,29 @@ class ChatTurnRequest(BaseModel):
conversation_id: str | None = Field(default=None, min_length=1, max_length=36)
provider_id: int = Field(gt=0)
model_id: str = Field(min_length=1, max_length=255)
message: str = Field(min_length=1, max_length=16000)
message: str = Field(default="", max_length=16000)
skill: str | None = Field(default=None, min_length=1, max_length=64)
edit_user_message_id: int | None = Field(default=None, gt=0)

@model_validator(mode="after")
def _require_conversation_for_edit(self) -> "ChatTurnRequest":
if self.edit_user_message_id is not None and self.conversation_id is None:
raise ValueError("edit_user_message_id requires conversation_id")
if not self.message and not self.skill:
raise ValueError("message or skill is required")
return self


class SkillRead(BaseModel):
model_config = ConfigDict(extra="forbid")

slug: str
name: str
description: str


class SkillListResponse(BaseModel):
skills: list[SkillRead]


class ConversationRead(BaseModel):
Expand All @@ -54,6 +76,7 @@ class ChatMessageRead(BaseModel):
role: MessageRole
content: str
thinking: str
error: str
status: MessageStatus
created_at: datetime

Expand Down
Loading
Loading