Skip to content

Commit 5ff0775

Browse files
authored
Merge pull request #13 from BrunoV21/hf-space-demo
Hf space demo
2 parents 1b6c2cf + c2c8aed commit 5ff0775

12 files changed

Lines changed: 519 additions & 140 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ This will start a web server for the AgentTide UI. Follow the on-screen instruct
8383

8484
**Usage Tips:**
8585
If you know the exact code context, specify identifiers directly in your request (e.g., `module.submodule.file_withoutextension.object`).
86-
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!
86+
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!
8787

8888
---
8989

codetide/__init__.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from codetide.parsers import BaseParser
1111
from codetide import parsers
1212

13-
from pydantic import BaseModel, Field, field_validator
13+
from pydantic import BaseModel, ConfigDict, Field, field_validator
1414
from typing import Optional, List, Tuple, Union, Dict
1515
from datetime import datetime, timezone
1616
from pathlib import Path
@@ -27,6 +27,11 @@ class CodeTide(BaseModel):
2727
codebase :CodeBase = Field(default_factory=CodeBase)
2828
files :Dict[Path, datetime]= Field(default_factory=dict)
2929
_instantiated_parsers :Dict[str, BaseParser] = {}
30+
_repo :pygit2.Repository = None
31+
32+
model_config = ConfigDict(
33+
arbitrary_types_allowed=True
34+
)
3035

3136
@field_validator("rootpath", mode="after")
3237
@classmethod
@@ -94,6 +99,34 @@ def relative_filepaths(self)->List[str]:
9499
def cached_ids(self)->List[str]:
95100
return self.codebase.unique_ids+self.relative_filepaths
96101

102+
@property
103+
def repo(self)->Optional[pygit2.Repository]:
104+
"""
105+
Lazily initializes and returns the Git repository for the given root path.
106+
107+
If the repository has not yet been loaded (`self._repo is None`), this
108+
property attempts to open a `pygit2.Repository` at `self.rootpath`.
109+
If the root path does not exist or is not a directory, an error is logged
110+
and `None` is returned. If the repository's working directory differs
111+
from `self.rootpath`, the root path is updated to match the repository's
112+
actual working directory.
113+
114+
Returns:
115+
Optional[pygit2.Repository]: The initialized Git repository if
116+
successful, otherwise `None`.
117+
"""
118+
if self._repo is None:
119+
120+
if not self.rootpath.exists() or not self.rootpath.is_dir():
121+
logger.error(f"Root path does not exist or is not a directory: {self.rootpath}")
122+
return None
123+
124+
self._repo = pygit2.Repository(self.rootpath)
125+
if not Path(self._repo.workdir) == self.rootpath:
126+
self.rootpath = Path(self._repo.workdir)
127+
128+
return self._repo
129+
97130
async def _reset(self):
98131
self = await self.from_path(self.rootpath)
99132

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

297330
try:
298331
# Try to open the repository
299-
repo = pygit2.Repository(self.rootpath)
300-
if not Path(repo.workdir) == self.rootpath:
301-
self.rootpath = Path(repo.workdir)
332+
repo = self.repo
302333

303334
# Get the repository's index (staging area)
304335
index = repo.index

codetide/agents/tide/agent.py

Lines changed: 120 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from ...autocomplete import AutoComplete
66
from .models import Steps
77
from .prompts import (
8-
AGENT_TIDE_SYSTEM_PROMPT, GET_CODE_IDENTIFIERS_SYSTEM_PROMPT, STEPS_SYSTEM_PROMPT, WRITE_PATCH_SYSTEM_PROMPT
8+
AGENT_TIDE_SYSTEM_PROMPT, GET_CODE_IDENTIFIERS_SYSTEM_PROMPT, STAGED_DIFFS_TEMPLATE, STEPS_SYSTEM_PROMPT, WRITE_PATCH_SYSTEM_PROMPT
99
)
10-
from .utils import parse_patch_blocks, parse_steps_markdown, trim_to_patch_section
10+
from .utils import parse_commit_blocks, parse_patch_blocks, parse_steps_markdown, trim_to_patch_section
1111
from .consts import AGENT_TIDE_ASCII_ART
1212

1313
try:
@@ -23,12 +23,13 @@
2323
from prompt_toolkit import PromptSession
2424
from pydantic import BaseModel, Field, model_validator
2525
from typing_extensions import Self
26-
from typing import List, Optional
26+
from typing import List, Optional, Set
2727
from datetime import date
2828
from pathlib import Path
2929
from ulid import ulid
3030
import aiofiles
3131
import asyncio
32+
import pygit2
3233
import os
3334

3435
async def custom_logger_fn(message :str, session_id :str, filepath :str):
@@ -44,6 +45,10 @@ class AgentTide(BaseModel):
4445
history :Optional[list]=None
4546
steps :Optional[Steps]=None
4647
session_id :str=Field(default_factory=ulid)
48+
changed_paths :List[str]=Field(default_factory=list)
49+
_skip_context_retrieval :bool=False
50+
_last_code_identifers :Optional[Set[str]]=set()
51+
_last_code_context :Optional[str] = None
4752

4853
@model_validator(mode="after")
4954
def pass_custom_logger_fn(self)->Self:
@@ -74,7 +79,7 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None):
7479
include_types=True
7580
)
7681

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

8691
codeContext = None
8792
if codeIdentifiers:
88-
autocomplete = AutoComplete(self.tide.cached_ids)
93+
autocomplete = AutoComplete(self.tide.cached_ids)
8994
# Validate each code identifier
9095
validatedCodeIdentifiers = []
9196
for codeId in codeIdentifiers:
@@ -96,7 +101,9 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None):
96101
elif result.get("matching_identifiers"):
97102
validatedCodeIdentifiers.append(result.get("matching_identifiers")[0])
98103

104+
self._last_code_identifers = set(validatedCodeIdentifiers)
99105
codeContext = self.tide.get(validatedCodeIdentifiers, as_string=True)
106+
self._last_code_context = codeContext
100107

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

111118
await trim_to_patch_section(self.patch_path)
112119
if os.path.exists(self.patch_path):
113-
process_patch(self.patch_path, open_file, write_file, remove_file, file_exists)
120+
changed_paths = process_patch(self.patch_path, open_file, write_file, remove_file, file_exists)
121+
self.changed_paths.extend(changed_paths)
122+
123+
commitMessage = parse_commit_blocks(response, multiple=False)
124+
if commitMessage:
125+
self.commit(commitMessage)
114126

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

125137
self.history.append(response)
126138

139+
@staticmethod
140+
async def get_git_diff_staged_simple(directory: str) -> str:
141+
"""
142+
Simple async function to get git diff --staged output
143+
"""
144+
# Validate directory exists
145+
if not Path(directory).is_dir():
146+
raise FileNotFoundError(f"Directory not found: {directory}")
147+
148+
process = await asyncio.create_subprocess_exec(
149+
'git', 'diff', '--staged',
150+
stdout=asyncio.subprocess.PIPE,
151+
stderr=asyncio.subprocess.PIPE,
152+
cwd=directory
153+
)
154+
155+
stdout, stderr = await process.communicate()
156+
157+
if process.returncode != 0:
158+
raise Exception(f"Git command failed: {stderr.decode().strip()}")
159+
160+
return stdout.decode()
161+
162+
async def _stage(self)->str:
163+
index = self.tide.repo.index
164+
for path in self.changed_paths:
165+
index.add(path)
166+
167+
staged_diff = await self.get_git_diff_staged_simple(self.tide.rootpath)
168+
staged_diff = staged_diff.strip()
169+
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"
170+
171+
async def prepare_commit(self)->str:
172+
staged_diff = await self._stage()
173+
self.changed_paths = []
174+
self._skip_context_retrieval = True
175+
return STAGED_DIFFS_TEMPLATE.format(diffs=staged_diff)
176+
177+
def commit(self, message :str):
178+
"""
179+
Commit all staged files in a git repository with the given message.
180+
181+
Args:
182+
repo_path (str): Path to the git repository
183+
message (str): Commit message
184+
author_name (str, optional): Author name. If None, uses repo config
185+
author_email (str, optional): Author email. If None, uses repo config
186+
187+
Returns:
188+
pygit2.Commit: The created commit object, or None if no changes to commit
189+
190+
Raises:
191+
ValueError: If no files are staged for commit
192+
Exception: For other git-related errors
193+
"""
194+
try:
195+
# Open the repository
196+
repo = self.repo
197+
198+
# Get author and committer information
199+
config = repo.config
200+
author_name = config.get('user.name', 'Unknown Author')
201+
author_email = config.get('user.email', 'unknown@example.com')
202+
203+
author = pygit2.Signature(author_name, author_email)
204+
committer = author # Typically same as author
205+
206+
# Get the current tree from the index
207+
tree = repo.index.write_tree()
208+
209+
# Get the parent commit (current HEAD)
210+
parents = [repo.head.target] if repo.head else []
211+
212+
# Create the commit
213+
commit_oid = repo.create_commit(
214+
'HEAD', # Reference to update
215+
author,
216+
committer,
217+
message,
218+
tree,
219+
parents
220+
)
221+
222+
# Clear the staging area after successful commit
223+
repo.index.write()
224+
225+
return repo[commit_oid]
226+
227+
except pygit2.GitError as e:
228+
raise Exception(f"Git error: {e}")
229+
except KeyError as e:
230+
raise Exception(f"Configuration error: {e}")
231+
232+
finally:
233+
self._skip_context_retrieval = False
234+
127235
async def run(self, max_tokens: int = 48000):
128236
if self.history is None:
129237
self.history = []
@@ -171,7 +279,11 @@ def _(event):
171279
finally:
172280
_logger.logger.info("Exited by user. Goodbye!")
173281

174-
async def _handle_commands(self, command :str):
282+
async def _handle_commands(self, command :str) -> str:
175283
# TODO add logic here to handlle git command, i.e stage files, write commit messages and checkout
176284
# expand to support new branches
177-
pass
285+
context = ""
286+
if command == "commit":
287+
context = await self.prepare_commit()
288+
289+
return context

codetide/agents/tide/prompts.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@
258258
259259
FINAL CHECKLIST BEFORE PATCHING:
260260
261+
0. Ensure you have all the required context to generate the patch, if you feel like something is missing ask the user for clarification:
262+
- it is preferable to ask for clarification instead of halucinating a patch without enough context.
261263
1. Validate that every line you edit exists exactly as-is in the original context
262264
2. Ensure one patch block per file, using multiple @@ hunks as needed
263265
3. Include no formatting, layout, or interpretation changes
@@ -274,9 +276,7 @@
274276
275277
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.
276278
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.
277-
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:
278-
279-
<START_CODING>
279+
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.
280280
281281
Important Note:
282282
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.
@@ -320,16 +320,16 @@
320320
321321
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.
322322
323-
When the user confirms everything is correct and no further planning is needed, respond only with:
324-
325-
<START_CODING>
326-
327323
---
328324
329325
`repo_tree`
330326
{REPO_TREE}
331327
"""
332328

329+
CMD_TRIGGER_PLANNING_STEPS = """
330+
331+
"""
332+
333333
CMD_WRITE_TESTS_PROMPT = """
334334
Analyze the provided code and write comprehensive tests.
335335
Ensure high coverage by including unit, integration, and end-to-end tests that address edge cases and follow best practices.
@@ -343,4 +343,23 @@
343343
CMD_COMMIT_PROMPT = """
344344
Generate a conventional commit message that summarizes the work done since the previous commit.
345345
The message should have a clear subject line and a body explaining the problem solved and the implementation approach.
346+
347+
Important Instructions:
348+
349+
Place the commit message inside exactly this format:
350+
*** Begin Commit
351+
[commit message]
352+
*** End Commit
353+
354+
You may include additional comments about the changes made outside of this block
355+
356+
If no diffs for staged files are provided in the context, reply that there's nothing to commit
357+
358+
The commit message should follow conventional commit format with a clear type/scope prefix
359+
"""
360+
361+
STAGED_DIFFS_TEMPLATE = """
362+
** The following diffs are currently staged and will be commited once you generate an appropriate description:**
363+
364+
{diffs}
346365
"""

codetide/agents/tide/ui/agent_tide_ui.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"Install it with: pip install codetide[agents-ui]"
1010
) from e
1111

12-
from codetide.agents.tide.prompts import CMD_CODE_REVIEW_PROMPT, CMD_COMMIT_PROMPT, CMD_WRITE_TESTS_PROMPT
12+
from codetide.agents.tide.prompts import CMD_CODE_REVIEW_PROMPT, CMD_COMMIT_PROMPT, CMD_TRIGGER_PLANNING_STEPS, CMD_WRITE_TESTS_PROMPT
1313
from codetide.agents.tide.defaults import DEFAULT_AGENT_TIDE_LLM_CONFIG_PATH
1414
from codetide.agents.tide.ui.defaults import PLACEHOLDER_LLM_CONFIG
1515
from codetide.agents.tide.agent import AgentTide
@@ -39,6 +39,7 @@ def __init__(self, project_path: Path = Path("./"), history :Optional[list]=None
3939
self.history = [] if history is None else history
4040
self.current_step :Optional[int] = None
4141
self.commands_prompts = {
42+
"plan": CMD_TRIGGER_PLANNING_STEPS,
4243
"review": CMD_CODE_REVIEW_PROMPT,
4344
"test": CMD_WRITE_TESTS_PROMPT,
4445
"commit": CMD_COMMIT_PROMPT
@@ -48,7 +49,8 @@ def __init__(self, project_path: Path = Path("./"), history :Optional[list]=None
4849
commands = [
4950
{"id": "review", "icon": "search-check", "description": "Review file(s) or object(s)"},
5051
{"id": "test", "icon": "flask-conical", "description": "Test file(s) or object(s)"},
51-
{"id": "commit", "icon": "git-commit", "description": "Generate commit message"},
52+
{"id": "commit", "icon": "git-commit", "description": "Commit changed files"},
53+
{"id": "plan", "icon": "notepad-text-dashed", "description": "Create a step-by-step task plan"}
5254
]
5355

5456
async def load(self):
@@ -127,6 +129,5 @@ def settings(self):
127129
]
128130

129131
async def get_command_prompt(self, command :str)->Optional[str]:
130-
await self.agent_tide._handle_commands(command)
131-
132-
return self.commands_prompts.get(command)
132+
context = await self.agent_tide._handle_commands(command)
133+
return f"{self.commands_prompts.get(command)} {context}"

0 commit comments

Comments
 (0)