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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ This will start a web server for the AgentTide UI. Follow the on-screen instruct

**Usage Tips:**
If you know the exact code context, specify identifiers directly in your request (e.g., `module.submodule.file_withoutextension.object`).
You can request a plan, edit steps, and proceed step-by-step—see the [chainlit.md](codetide/agents/tide/ui/chainlit.md) for full details and advanced workflows!
You can use the `plan` command to generate a step-by-step implementation plan for your request, review and edit the plan, and then proceed step-by-step. The `commit` command allows you to review and finalize changes before they are applied. See the [chainlit.md](codetide/agents/tide/ui/chainlit.md) for full details and advanced workflows, including the latest specifications for these commands!

---

Expand Down
39 changes: 35 additions & 4 deletions codetide/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from codetide.parsers import BaseParser
from codetide import parsers

from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, List, Tuple, Union, Dict
from datetime import datetime, timezone
from pathlib import Path
Expand All @@ -27,6 +27,11 @@ class CodeTide(BaseModel):
codebase :CodeBase = Field(default_factory=CodeBase)
files :Dict[Path, datetime]= Field(default_factory=dict)
_instantiated_parsers :Dict[str, BaseParser] = {}
_repo :pygit2.Repository = None

model_config = ConfigDict(
arbitrary_types_allowed=True
)

@field_validator("rootpath", mode="after")
@classmethod
Expand Down Expand Up @@ -94,6 +99,34 @@ def relative_filepaths(self)->List[str]:
def cached_ids(self)->List[str]:
return self.codebase.unique_ids+self.relative_filepaths

@property
def repo(self)->Optional[pygit2.Repository]:
"""
Lazily initializes and returns the Git repository for the given root path.

If the repository has not yet been loaded (`self._repo is None`), this
property attempts to open a `pygit2.Repository` at `self.rootpath`.
If the root path does not exist or is not a directory, an error is logged
and `None` is returned. If the repository's working directory differs
from `self.rootpath`, the root path is updated to match the repository's
actual working directory.

Returns:
Optional[pygit2.Repository]: The initialized Git repository if
successful, otherwise `None`.
"""
if self._repo is None:

if not self.rootpath.exists() or not self.rootpath.is_dir():
logger.error(f"Root path does not exist or is not a directory: {self.rootpath}")
return None

self._repo = pygit2.Repository(self.rootpath)
if not Path(self._repo.workdir) == self.rootpath:
self.rootpath = Path(self._repo.workdir)

return self._repo

async def _reset(self):
self = await self.from_path(self.rootpath)

Expand Down Expand Up @@ -296,9 +329,7 @@ def _find_code_files(self, languages: Optional[List[str]] = None) -> List[Path]:

try:
# Try to open the repository
repo = pygit2.Repository(self.rootpath)
if not Path(repo.workdir) == self.rootpath:
self.rootpath = Path(repo.workdir)
repo = self.repo

# Get the repository's index (staging area)
index = repo.index
Expand Down
128 changes: 120 additions & 8 deletions codetide/agents/tide/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from ...autocomplete import AutoComplete
from .models import Steps
from .prompts import (
AGENT_TIDE_SYSTEM_PROMPT, GET_CODE_IDENTIFIERS_SYSTEM_PROMPT, STEPS_SYSTEM_PROMPT, WRITE_PATCH_SYSTEM_PROMPT
AGENT_TIDE_SYSTEM_PROMPT, GET_CODE_IDENTIFIERS_SYSTEM_PROMPT, STAGED_DIFFS_TEMPLATE, STEPS_SYSTEM_PROMPT, WRITE_PATCH_SYSTEM_PROMPT
)
from .utils import parse_patch_blocks, parse_steps_markdown, trim_to_patch_section
from .utils import parse_commit_blocks, parse_patch_blocks, parse_steps_markdown, trim_to_patch_section
from .consts import AGENT_TIDE_ASCII_ART

try:
Expand All @@ -23,12 +23,13 @@
from prompt_toolkit import PromptSession
from pydantic import BaseModel, Field, model_validator
from typing_extensions import Self
from typing import List, Optional
from typing import List, Optional, Set
from datetime import date
from pathlib import Path
from ulid import ulid
import aiofiles
import asyncio
import pygit2
import os

async def custom_logger_fn(message :str, session_id :str, filepath :str):
Expand All @@ -44,6 +45,10 @@ class AgentTide(BaseModel):
history :Optional[list]=None
steps :Optional[Steps]=None
session_id :str=Field(default_factory=ulid)
changed_paths :List[str]=Field(default_factory=list)
_skip_context_retrieval :bool=False
_last_code_identifers :Optional[Set[str]]=set()
_last_code_context :Optional[str] = None

@model_validator(mode="after")
def pass_custom_logger_fn(self)->Self:
Expand Down Expand Up @@ -74,7 +79,7 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None):
include_types=True
)

if codeIdentifiers is None:
if codeIdentifiers is None and not self._skip_context_retrieval:
codeIdentifiers = await self.llm.acomplete(
self.history,
system_prompt=[GET_CODE_IDENTIFIERS_SYSTEM_PROMPT.format(DATE=TODAY)],
Expand All @@ -85,7 +90,7 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None):

codeContext = None
if codeIdentifiers:
autocomplete = AutoComplete(self.tide.cached_ids)
autocomplete = AutoComplete(self.tide.cached_ids)
# Validate each code identifier
validatedCodeIdentifiers = []
for codeId in codeIdentifiers:
Expand All @@ -96,7 +101,9 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None):
elif result.get("matching_identifiers"):
validatedCodeIdentifiers.append(result.get("matching_identifiers")[0])

self._last_code_identifers = set(validatedCodeIdentifiers)
codeContext = self.tide.get(validatedCodeIdentifiers, as_string=True)
self._last_code_context = codeContext

response = await self.llm.acomplete(
self.history,
Expand All @@ -110,7 +117,12 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None):

await trim_to_patch_section(self.patch_path)
if os.path.exists(self.patch_path):
process_patch(self.patch_path, open_file, write_file, remove_file, file_exists)
changed_paths = process_patch(self.patch_path, open_file, write_file, remove_file, file_exists)
self.changed_paths.extend(changed_paths)

commitMessage = parse_commit_blocks(response, multiple=False)
if commitMessage:
self.commit(commitMessage)

steps = parse_steps_markdown(response)
if steps:
Expand All @@ -124,6 +136,102 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None):

self.history.append(response)

@staticmethod
async def get_git_diff_staged_simple(directory: str) -> str:
"""
Simple async function to get git diff --staged output
"""
# Validate directory exists
if not Path(directory).is_dir():
raise FileNotFoundError(f"Directory not found: {directory}")

process = await asyncio.create_subprocess_exec(
'git', 'diff', '--staged',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=directory
)

stdout, stderr = await process.communicate()

if process.returncode != 0:
raise Exception(f"Git command failed: {stderr.decode().strip()}")

return stdout.decode()

async def _stage(self)->str:
index = self.tide.repo.index
for path in self.changed_paths:
index.add(path)

staged_diff = await self.get_git_diff_staged_simple(self.tide.rootpath)
staged_diff = staged_diff.strip()
return staged_diff if staged_diff else "No files were staged. Nothing to commit. Tell the user to request some changes so there is something to commit"

async def prepare_commit(self)->str:
staged_diff = await self._stage()
self.changed_paths = []
self._skip_context_retrieval = True
return STAGED_DIFFS_TEMPLATE.format(diffs=staged_diff)

def commit(self, message :str):
"""
Commit all staged files in a git repository with the given message.

Args:
repo_path (str): Path to the git repository
message (str): Commit message
author_name (str, optional): Author name. If None, uses repo config
author_email (str, optional): Author email. If None, uses repo config

Returns:
pygit2.Commit: The created commit object, or None if no changes to commit

Raises:
ValueError: If no files are staged for commit
Exception: For other git-related errors
"""
try:
# Open the repository
repo = self.repo

# Get author and committer information
config = repo.config
author_name = config.get('user.name', 'Unknown Author')
author_email = config.get('user.email', 'unknown@example.com')

author = pygit2.Signature(author_name, author_email)
committer = author # Typically same as author

# Get the current tree from the index
tree = repo.index.write_tree()

# Get the parent commit (current HEAD)
parents = [repo.head.target] if repo.head else []

# Create the commit
commit_oid = repo.create_commit(
'HEAD', # Reference to update
author,
committer,
message,
tree,
parents
)

# Clear the staging area after successful commit
repo.index.write()

return repo[commit_oid]

except pygit2.GitError as e:
raise Exception(f"Git error: {e}")
except KeyError as e:
raise Exception(f"Configuration error: {e}")

finally:
self._skip_context_retrieval = False

async def run(self, max_tokens: int = 48000):
if self.history is None:
self.history = []
Expand Down Expand Up @@ -171,7 +279,11 @@ def _(event):
finally:
_logger.logger.info("Exited by user. Goodbye!")

async def _handle_commands(self, command :str):
async def _handle_commands(self, command :str) -> str:
# TODO add logic here to handlle git command, i.e stage files, write commit messages and checkout
# expand to support new branches
pass
context = ""
if command == "commit":
context = await self.prepare_commit()

return context
33 changes: 26 additions & 7 deletions codetide/agents/tide/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@

FINAL CHECKLIST BEFORE PATCHING:

0. Ensure you have all the required context to generate the patch, if you feel like something is missing ask the user for clarification:
- it is preferable to ask for clarification instead of halucinating a patch without enough context.
1. Validate that every line you edit exists exactly as-is in the original context
2. Ensure one patch block per file, using multiple @@ hunks as needed
3. Include no formatting, layout, or interpretation changes
Expand All @@ -274,9 +276,7 @@

Your job is to take a user request, analyze any provided code context (including repository structure / repo_tree identifiers), and decompose the work into the minimal set of concrete implementation steps needed to fully satisfy the request.
If the requirement is simple, output a single step; if it’s complex, decompose it into multiple ordered steps. You must build upon, refine, or correct any existing code context rather than ignoring it.
If the user provides feedback on prior steps, update the current steps to reflect that feedback. If the user responds “all is good” or equivalent, do not repeat the steps—reply exactly with:

<START_CODING>
If the user provides feedback on prior steps, update the current steps to reflect that feedback. If the user responds “all is good” or equivalent, do not repeat the steps - ask the user if he wants you to start implementing them one by one in sequence.

Important Note:
If the user's request already contains a complete step, is direct enough to be solved without additional decomposition, or does not require implementation planning at all (e.g., general questions, documentation requests, commit messages), you may skip the multi-step planning and execution mode entirely.
Expand Down Expand Up @@ -320,16 +320,16 @@

10. **Succinctness of Format:** Strictly adhere to the step formatting with separators (`---`) and the beginning/end markers. Do not add extraneous numbering or narrative outside the prescribed structure.

When the user confirms everything is correct and no further planning is needed, respond only with:

<START_CODING>

---

`repo_tree`
{REPO_TREE}
"""

CMD_TRIGGER_PLANNING_STEPS = """

"""

CMD_WRITE_TESTS_PROMPT = """
Analyze the provided code and write comprehensive tests.
Ensure high coverage by including unit, integration, and end-to-end tests that address edge cases and follow best practices.
Expand All @@ -343,4 +343,23 @@
CMD_COMMIT_PROMPT = """
Generate a conventional commit message that summarizes the work done since the previous commit.
The message should have a clear subject line and a body explaining the problem solved and the implementation approach.

Important Instructions:

Place the commit message inside exactly this format:
*** Begin Commit
[commit message]
*** End Commit

You may include additional comments about the changes made outside of this block

If no diffs for staged files are provided in the context, reply that there's nothing to commit

The commit message should follow conventional commit format with a clear type/scope prefix
"""

STAGED_DIFFS_TEMPLATE = """
** The following diffs are currently staged and will be commited once you generate an appropriate description:**

{diffs}
"""
11 changes: 6 additions & 5 deletions codetide/agents/tide/ui/agent_tide_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"Install it with: pip install codetide[agents-ui]"
) from e

from codetide.agents.tide.prompts import CMD_CODE_REVIEW_PROMPT, CMD_COMMIT_PROMPT, CMD_WRITE_TESTS_PROMPT
from codetide.agents.tide.prompts import CMD_CODE_REVIEW_PROMPT, CMD_COMMIT_PROMPT, CMD_TRIGGER_PLANNING_STEPS, CMD_WRITE_TESTS_PROMPT
from codetide.agents.tide.defaults import DEFAULT_AGENT_TIDE_LLM_CONFIG_PATH
from codetide.agents.tide.ui.defaults import PLACEHOLDER_LLM_CONFIG
from codetide.agents.tide.agent import AgentTide
Expand Down Expand Up @@ -39,6 +39,7 @@ def __init__(self, project_path: Path = Path("./"), history :Optional[list]=None
self.history = [] if history is None else history
self.current_step :Optional[int] = None
self.commands_prompts = {
"plan": CMD_TRIGGER_PLANNING_STEPS,
"review": CMD_CODE_REVIEW_PROMPT,
"test": CMD_WRITE_TESTS_PROMPT,
"commit": CMD_COMMIT_PROMPT
Expand All @@ -48,7 +49,8 @@ def __init__(self, project_path: Path = Path("./"), history :Optional[list]=None
commands = [
{"id": "review", "icon": "search-check", "description": "Review file(s) or object(s)"},
{"id": "test", "icon": "flask-conical", "description": "Test file(s) or object(s)"},
{"id": "commit", "icon": "git-commit", "description": "Generate commit message"},
{"id": "commit", "icon": "git-commit", "description": "Commit changed files"},
{"id": "plan", "icon": "notepad-text-dashed", "description": "Create a step-by-step task plan"}
]

async def load(self):
Expand Down Expand Up @@ -127,6 +129,5 @@ def settings(self):
]

async def get_command_prompt(self, command :str)->Optional[str]:
await self.agent_tide._handle_commands(command)

return self.commands_prompts.get(command)
context = await self.agent_tide._handle_commands(command)
return f"{self.commands_prompts.get(command)} {context}"
Loading