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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,5 @@ config/
.files/

codetide/agents/tide/ui/assets/
examples/hf_demo_space/.chainlit/*
examples/hf_demo_space/chainlit.md
7 changes: 6 additions & 1 deletion codetide/agents/tide/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,9 @@ def _(event):
# This can happen if the event loop is shut down
pass
finally:
_logger.logger.info("Exited by user. Goodbye!")
_logger.logger.info("Exited by user. Goodbye!")

async def _handle_commands(self, command :str):
# TODO add logic here to handlle git command, i.e stage files, write commit messages and checkout
# expand to support new branches
pass
6 changes: 4 additions & 2 deletions codetide/agents/tide/ui/agent_tide_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,7 @@ def settings(self):
)
]

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

return self.commands_prompts.get(command)
2 changes: 1 addition & 1 deletion codetide/agents/tide/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None
chat_history = cl.user_session.get("chat_history")

if message.command:
command_prompt = agent_tide_ui.get_command_prompt(message.command)
command_prompt = await agent_tide_ui.get_command_prompt(message.command)
if command_prompt:
message.content = "\n\n---\n\n".join([command_prompt, message.content])

Expand Down
1 change: 1 addition & 0 deletions examples/hf_demo_space/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ RUN mkdir -p /app/.files && chmod 777 /app/.files
RUN mkdir -p /app/logs && chmod 777 /app/logs
RUN mkdir -p /app/observability_data && chmod 777 /app/observability_data
RUN mkdir -p /app/storage && chmod 777 /app/storage
RUN mkdir -p /app/sessions && chmod 777 /app/sessions

# Copy the current repository into the container
COPY . /app
Expand Down
85 changes: 45 additions & 40 deletions examples/hf_demo_space/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,54 +10,30 @@
from codetide.agents.tide.ui.utils import run_concurrent_tasks
from codetide.agents.tide.ui.agent_tide_ui import AgentTideUi
from codetide.core.defaults import DEFAULT_ENCODING
from codetide.core.logs import logger
from codetide.agents.tide.models import Step

from aicore.const import STREAM_END_TOKEN, STREAM_START_TOKEN
from aicore.models import AuthenticationError, ModelError
from aicore.config import Config
from aicore.llm import Llm

from typing import Optional
from git_utils import commit_and_push_changes, validate_git_url
from chainlit.cli import run_chainlit
from typing import Optional
from pathlib import Path
from ulid import ulid
import chainlit as cl
import subprocess
import asyncio
import shutil
import yaml
import json
import stat
import yaml
import os
import re

DEFAULT_SESSIONS_WORKSPACE = Path(os.getcwd()) / "sessions"

GIT_URL_PATTERN = re.compile(
r'^(?:http|https|git|ssh)://' # Protocol
r'(?:\S+@)?' # Optional username
r'([^/]+)' # Domain
r'(?:[:/])([^/]+/[^/]+?)(?:\.git)?$' # Repo path
)

def validate_git_url(url) -> None:
"""Validate the Git repository URL using git ls-remote."""
if not GIT_URL_PATTERN.match(url):
raise ValueError(f"Invalid Git repository URL format: {url}")

try:
result = subprocess.run(
["git", "ls-remote", url],
capture_output=True,
text=True,
check=True,
timeout=10 # Add timeout to prevent hanging
)
if not result.stdout.strip():
raise ValueError(f"URL {url} points to an empty repository")
except subprocess.TimeoutExpired:
raise ValueError(f"Timeout while validating URL {url}")
except subprocess.CalledProcessError as e:
raise ValueError(f"Invalid Git repository URL: {url}. Error: {e.stderr}") from e

async def validate_llm_config_hf(agent_tide_ui: AgentTideUi):
exception = True
session_id = cl.user_session.get("session_id")
Expand Down Expand Up @@ -141,22 +117,35 @@ async def clone_repo(session_id):

while exception:
try:
url = await cl.AskUserMessage(
user_message = await cl.AskUserMessage(
content="Provide a valid github url to give AgentTide some context!"
).send()
validate_git_url(url)
url = user_message.get("output")
await validate_git_url(url)
exception = None
except Exception as e:
await cl.Message(f"Invalid url found, please provide only the url, if it is a private repo you can inlucde a PAT in the url: {e}").send()
exception = e

subprocess.run(
["git", "clone", "--no-checkout", url, DEFAULT_SESSIONS_WORKSPACE / session_id],
check=True,
capture_output=True,
text=True,
timeout=300
logger.info(f"executing cmd git clone --no-checkout {url} {DEFAULT_SESSIONS_WORKSPACE / session_id}")

process = await asyncio.create_subprocess_exec(
"git", "clone", url, str(DEFAULT_SESSIONS_WORKSPACE / session_id),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)

try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
except asyncio.TimeoutError:
process.kill()
await process.wait()
raise

if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, ["git", "clone", url], stdout, stderr)

logger.info(f"finished cloning to {DEFAULT_SESSIONS_WORKSPACE / session_id}")


@cl.on_chat_start
Expand All @@ -178,10 +167,15 @@ async def empty_current_session():
if os.path.exists(session_path):
shutil.rmtree(session_path)

def remove_readonly(func, path, _):
"""Clear the readonly bit and reattempt the removal"""
os.chmod(path, stat.S_IWRITE)
func(path)

@cl.on_app_shutdown
async def empty_all_sessions():
if os.path.exists(DEFAULT_SESSIONS_WORKSPACE):
shutil.rmtree(DEFAULT_SESSIONS_WORKSPACE)
shutil.rmtree(DEFAULT_SESSIONS_WORKSPACE, onexc=remove_readonly)

@cl.action_callback("execute_steps")
async def on_execute_steps(action :cl.Action):
Expand Down Expand Up @@ -249,14 +243,19 @@ async def on_stop_steps(action :cl.Action):
agent_tide_ui.current_step = None
await task_list.remove()

@cl.action_callback("checkout_commit_push")
async def on_checkout_commit_push(action :cl.Action):
session_id = cl.user_session.get("session_id")
await commit_and_push_changes(DEFAULT_SESSIONS_WORKSPACE / session_id)

@cl.on_message
async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None):
agent_tide_ui = await loadAgentTideUi()

chat_history = cl.user_session.get("chat_history")

if message.command:
command_prompt = agent_tide_ui.get_command_prompt(message.command)
command_prompt = await agent_tide_ui.get_command_prompt(message.command)
if command_prompt:
message.content = "\n\n---\n\n".join([command_prompt, message.content])

Expand Down Expand Up @@ -314,6 +313,12 @@ async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None
tooltip="Next step",
icon="fast-forward",
payload={"msg_id": msg.id}
),
cl.Action(
name="checkout_commit_push",
tooltip="A new branch will be created and the changes made so far will be commited and pushed to the upstream repository",
icon="circle-fading-arrow-up",
payload={"msg_id": msg.id}
)
]

Expand Down
117 changes: 117 additions & 0 deletions examples/hf_demo_space/git_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import asyncio
from pathlib import Path
import re
import subprocess


GIT_URL_PATTERN = re.compile(
r'^(?:http|https|git|ssh)://' # Protocol
r'(?:\S+@)?' # Optional username
r'([^/]+)' # Domain
r'(?:[:/])([^/]+/[^/]+?)(?:\.git)?$' # Repo path
)

async def validate_git_url(url) -> None:
"""Validate the Git repository URL using git ls-remote."""

if not GIT_URL_PATTERN.match(url):
raise ValueError(f"Invalid Git repository URL format: {url}")

try:
process = await asyncio.create_subprocess_exec(
"git", "ls-remote", url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)

stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10)

if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, ["git", "ls-remote", url], stdout, stderr)

if not stdout.strip():
raise ValueError(f"URL {url} points to an empty repository")

except asyncio.TimeoutError:
process.kill()
await process.wait()
raise ValueError(f"Timeout while validating URL {url}")
except subprocess.CalledProcessError as e:
raise ValueError(f"Invalid Git repository URL: {url}. Error: {e.stderr}") from e

async def commit_and_push_changes(repo_path: Path, branch_name: str = None, commit_message: str = "Auto-commit: Save changes") -> None:
"""Add all changes, commit with default message, and push to remote."""

repo_path_str = str(repo_path)

try:
# Create new branch with Agent Tide + ULID name if not provided
if not branch_name:
import ulid
branch_name = f"agent-tide-{ulid.new()}"

# Create and checkout new branch
process = await asyncio.create_subprocess_exec(
"git", "checkout", "-b", branch_name,
cwd=repo_path_str,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
text=True
)

stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10)

if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, ["git", "checkout", "-b", branch_name], stdout, stderr)

# Add all changes
process = await asyncio.create_subprocess_exec(
"git", "add", ".",
cwd=repo_path_str,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
text=True
)

stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)

if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, ["git", "add", "."], stdout, stderr)

# Commit changes
process = await asyncio.create_subprocess_exec(
"git", "commit", "-m", commit_message,
cwd=repo_path_str,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
text=True
)

stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)

if process.returncode != 0:
# Check if it's because there are no changes to commit
if "nothing to commit" in stderr or "nothing to commit" in stdout:
return # No changes to commit, exit gracefully
raise subprocess.CalledProcessError(process.returncode, ["git", "commit", "-m", commit_message], stdout, stderr)

# Push to remote
process = await asyncio.create_subprocess_exec(
"git", "push", "origin", branch_name,
cwd=repo_path_str,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
text=True
)

stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60)

if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, ["git", "push", "origin", branch_name], stdout, stderr)

except asyncio.TimeoutError:
process.kill()
await process.wait()
raise ValueError(f"Timeout during git operation in {repo_path}")
except subprocess.CalledProcessError as e:
raise ValueError(f"Git operation failed in {repo_path}. Error: {e.stderr}") from e