Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c539d5f60 | |||
| 64fcd2967c | |||
| 4d050ff527 | |||
| 1944e2a9cf |
22
HISTORY.md
22
HISTORY.md
@@ -5,10 +5,32 @@ Changelog
|
|||||||
(unreleased)
|
(unreleased)
|
||||||
------------
|
------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- More file change fixes, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.9.7 (2026-04-11)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- More file generation improvements, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.9.6 (2026-04-11)
|
||||||
|
------------------
|
||||||
|
|
||||||
Fix
|
Fix
|
||||||
~~~
|
~~~
|
||||||
- Repo onboarding fix, refs NOISSUE. [Simon Diesenreiter]
|
- Repo onboarding fix, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
0.9.5 (2026-04-11)
|
0.9.5 (2026-04-11)
|
||||||
------------------
|
------------------
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.9.6
|
0.9.8
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import json
|
|||||||
import py_compile
|
import py_compile
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from pathlib import PurePosixPath
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -31,6 +32,10 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
REMOTE_READY_REPOSITORY_MODES = {'project', 'onboarded'}
|
REMOTE_READY_REPOSITORY_MODES = {'project', 'onboarded'}
|
||||||
REMOTE_READY_REPOSITORY_STATUSES = {'created', 'exists', 'ready', 'onboarded'}
|
REMOTE_READY_REPOSITORY_STATUSES = {'created', 'exists', 'ready', 'onboarded'}
|
||||||
|
GENERATED_TEXT_FILE_SUFFIXES = {'.py', '.md', '.txt', '.toml', '.yaml', '.yml', '.json', '.ini', '.cfg', '.sh', '.html', '.css', '.js', '.ts'}
|
||||||
|
GENERATED_TEXT_FILE_NAMES = {'README', 'README.md', '.gitignore', 'requirements.txt', 'pyproject.toml', 'Dockerfile', 'Containerfile', 'Makefile'}
|
||||||
|
MAX_WORKSPACE_CONTEXT_FILES = 20
|
||||||
|
MAX_WORKSPACE_CONTEXT_CHARS = 24000
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -240,6 +245,59 @@ class AgentOrchestrator:
|
|||||||
fallback_used=False,
|
fallback_used=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _is_safe_relative_path(self, path: str) -> bool:
|
||||||
|
"""Return whether a generated file path is safe to write under the project root."""
|
||||||
|
normalized = str(PurePosixPath((path or '').strip()))
|
||||||
|
if not normalized or normalized in {'.', '..'}:
|
||||||
|
return False
|
||||||
|
if normalized.startswith('/') or normalized.startswith('../') or '/../' in normalized:
|
||||||
|
return False
|
||||||
|
if normalized.startswith('.git/'):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _is_supported_generated_text_file(self, path: str) -> bool:
|
||||||
|
"""Return whether the generated path is a supported text artifact."""
|
||||||
|
normalized = PurePosixPath(path)
|
||||||
|
if normalized.name in self.GENERATED_TEXT_FILE_NAMES:
|
||||||
|
return True
|
||||||
|
return normalized.suffix.lower() in self.GENERATED_TEXT_FILE_SUFFIXES
|
||||||
|
|
||||||
|
def _collect_workspace_context(self) -> dict:
|
||||||
|
"""Collect a compact, text-only snapshot of the current project workspace."""
|
||||||
|
if not self.project_root.exists():
|
||||||
|
return {'has_existing_files': False, 'files': []}
|
||||||
|
|
||||||
|
files: list[dict] = []
|
||||||
|
total_chars = 0
|
||||||
|
for path in sorted(self.project_root.rglob('*')):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
relative_path = path.relative_to(self.project_root).as_posix()
|
||||||
|
if relative_path == '.gitignore':
|
||||||
|
continue
|
||||||
|
if not self._is_safe_relative_path(relative_path) or not self._is_supported_generated_text_file(relative_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding='utf-8')
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
remaining_chars = self.MAX_WORKSPACE_CONTEXT_CHARS - total_chars
|
||||||
|
if remaining_chars <= 0:
|
||||||
|
break
|
||||||
|
snippet = content[:remaining_chars]
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
'path': relative_path,
|
||||||
|
'content': snippet,
|
||||||
|
'truncated': len(snippet) < len(content),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_chars += len(snippet)
|
||||||
|
if len(files) >= self.MAX_WORKSPACE_CONTEXT_FILES:
|
||||||
|
break
|
||||||
|
return {'has_existing_files': bool(files), 'files': files}
|
||||||
|
|
||||||
def _parse_generated_files(self, content: str | None) -> dict[str, str]:
|
def _parse_generated_files(self, content: str | None) -> dict[str, str]:
|
||||||
"""Parse an LLM file bundle response into relative-path/content pairs."""
|
"""Parse an LLM file bundle response into relative-path/content pairs."""
|
||||||
if not content:
|
if not content:
|
||||||
@@ -248,7 +306,6 @@ class AgentOrchestrator:
|
|||||||
parsed = json.loads(content)
|
parsed = json.loads(content)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
allowed_paths = set(self._fallback_generated_files().keys())
|
|
||||||
generated: dict[str, str] = {}
|
generated: dict[str, str] = {}
|
||||||
if isinstance(parsed, dict) and isinstance(parsed.get('files'), list):
|
if isinstance(parsed, dict) and isinstance(parsed.get('files'), list):
|
||||||
for item in parsed['files']:
|
for item in parsed['files']:
|
||||||
@@ -256,17 +313,50 @@ class AgentOrchestrator:
|
|||||||
continue
|
continue
|
||||||
path = str(item.get('path') or '').strip()
|
path = str(item.get('path') or '').strip()
|
||||||
file_content = item.get('content')
|
file_content = item.get('content')
|
||||||
if path in allowed_paths and isinstance(file_content, str) and file_content.strip():
|
if (
|
||||||
|
self._is_safe_relative_path(path)
|
||||||
|
and self._is_supported_generated_text_file(path)
|
||||||
|
and isinstance(file_content, str)
|
||||||
|
and file_content.strip()
|
||||||
|
):
|
||||||
generated[path] = file_content.rstrip() + "\n"
|
generated[path] = file_content.rstrip() + "\n"
|
||||||
elif isinstance(parsed, dict):
|
elif isinstance(parsed, dict):
|
||||||
for path, file_content in parsed.items():
|
for path, file_content in parsed.items():
|
||||||
if path in allowed_paths and isinstance(file_content, str) and file_content.strip():
|
normalized_path = str(path).strip()
|
||||||
generated[str(path)] = file_content.rstrip() + "\n"
|
if (
|
||||||
|
self._is_safe_relative_path(normalized_path)
|
||||||
|
and self._is_supported_generated_text_file(normalized_path)
|
||||||
|
and isinstance(file_content, str)
|
||||||
|
and file_content.strip()
|
||||||
|
):
|
||||||
|
generated[normalized_path] = file_content.rstrip() + "\n"
|
||||||
return generated
|
return generated
|
||||||
|
|
||||||
async def _generate_prompt_driven_files(self) -> tuple[dict[str, str], dict | None]:
|
async def _generate_prompt_driven_files(self) -> tuple[dict[str, str], dict | None, bool]:
|
||||||
"""Use the configured LLM to generate prompt-specific project files."""
|
"""Use the configured LLM to generate prompt-specific project files."""
|
||||||
fallback_files = self._fallback_generated_files()
|
fallback_files = self._fallback_generated_files()
|
||||||
|
workspace_context = self._collect_workspace_context()
|
||||||
|
has_existing_files = bool(workspace_context.get('has_existing_files'))
|
||||||
|
if has_existing_files:
|
||||||
|
system_prompt = (
|
||||||
|
'You modify an existing software repository. '
|
||||||
|
'Return only JSON. Update the smallest necessary set of files to satisfy the new prompt. '
|
||||||
|
'Prefer editing existing files over inventing a new starter app. '
|
||||||
|
'Only return files that should be written. Omit unchanged files. '
|
||||||
|
'Use repository-relative paths and do not wrap the JSON in markdown fences.'
|
||||||
|
)
|
||||||
|
user_prompt = (
|
||||||
|
f"Project name: {self.project_name}\n"
|
||||||
|
f"Description: {self.description}\n"
|
||||||
|
f"Original prompt: {self.prompt_text or self.description}\n"
|
||||||
|
f"Requested features: {json.dumps(self.features)}\n"
|
||||||
|
f"Preferred tech stack: {json.dumps(self.tech_stack)}\n"
|
||||||
|
f"Related issue: {json.dumps(self.related_issue) if self.related_issue else 'null'}\n\n"
|
||||||
|
f"Current workspace snapshot:\n{json.dumps(workspace_context['files'], indent=2)}\n\n"
|
||||||
|
'Return JSON shaped as {"files": [{"path": "relative/path.py", "content": "..."}, ...]}. '
|
||||||
|
'Each file path must be relative to the repository root.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
'You generate small but concrete starter projects. '
|
'You generate small but concrete starter projects. '
|
||||||
'Return only JSON. Provide production-like but compact code that directly reflects the user request. '
|
'Return only JSON. Provide production-like but compact code that directly reflects the user request. '
|
||||||
@@ -282,7 +372,8 @@ class AgentOrchestrator:
|
|||||||
f"Requested features: {json.dumps(self.features)}\n"
|
f"Requested features: {json.dumps(self.features)}\n"
|
||||||
f"Preferred tech stack: {json.dumps(self.tech_stack)}\n"
|
f"Preferred tech stack: {json.dumps(self.tech_stack)}\n"
|
||||||
f"Related issue: {json.dumps(self.related_issue) if self.related_issue else 'null'}\n\n"
|
f"Related issue: {json.dumps(self.related_issue) if self.related_issue else 'null'}\n\n"
|
||||||
"Return JSON shaped as {\"files\": [{\"path\": \"README.md\", \"content\": \"...\"}, ...]}."
|
'Return JSON shaped as {"files": [{"path": "README.md", "content": "..."}, ...]}. '
|
||||||
|
'At minimum include README.md, requirements.txt, main.py, and tests/test_app.py.'
|
||||||
)
|
)
|
||||||
content, trace = await LLMServiceClient().chat_with_trace(
|
content, trace = await LLMServiceClient().chat_with_trace(
|
||||||
stage='generation_plan',
|
stage='generation_plan',
|
||||||
@@ -293,12 +384,15 @@ class AgentOrchestrator:
|
|||||||
'project_name': self.project_name,
|
'project_name': self.project_name,
|
||||||
'repository': self.ui_manager.ui_data.get('repository'),
|
'repository': self.ui_manager.ui_data.get('repository'),
|
||||||
'related_issue': self.related_issue,
|
'related_issue': self.related_issue,
|
||||||
|
'workspace_files': workspace_context.get('files', []),
|
||||||
},
|
},
|
||||||
expect_json=True,
|
expect_json=True,
|
||||||
)
|
)
|
||||||
generated_files = self._parse_generated_files(content)
|
generated_files = self._parse_generated_files(content)
|
||||||
|
if has_existing_files:
|
||||||
|
return generated_files, trace, True
|
||||||
merged_files = {**fallback_files, **generated_files}
|
merged_files = {**fallback_files, **generated_files}
|
||||||
return merged_files, trace
|
return merged_files, trace, False
|
||||||
|
|
||||||
async def _sync_issue_context(self) -> None:
|
async def _sync_issue_context(self) -> None:
|
||||||
"""Sync repository issues and resolve a linked issue from the prompt when present."""
|
"""Sync repository issues and resolve a linked issue from the prompt when present."""
|
||||||
@@ -571,6 +665,8 @@ class AgentOrchestrator:
|
|||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
change_type = "UPDATE" if target.exists() else "CREATE"
|
change_type = "UPDATE" if target.exists() else "CREATE"
|
||||||
previous_content = target.read_text(encoding="utf-8") if target.exists() else ""
|
previous_content = target.read_text(encoding="utf-8") if target.exists() else ""
|
||||||
|
if previous_content == content:
|
||||||
|
return
|
||||||
diff_text = self._build_diff_text(relative_path, previous_content, content)
|
diff_text = self._build_diff_text(relative_path, previous_content, content)
|
||||||
target.write_text(content, encoding="utf-8")
|
target.write_text(content, encoding="utf-8")
|
||||||
self.changed_files.append(relative_path)
|
self.changed_files.append(relative_path)
|
||||||
@@ -679,9 +775,12 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
async def _generate_code(self) -> None:
|
async def _generate_code(self) -> None:
|
||||||
"""Generate code using Ollama."""
|
"""Generate code using Ollama."""
|
||||||
generated_files, trace = await self._generate_prompt_driven_files()
|
change_count_before = len(self.pending_code_changes)
|
||||||
|
generated_files, trace, editing_existing_workspace = await self._generate_prompt_driven_files()
|
||||||
for relative_path, content in generated_files.items():
|
for relative_path, content in generated_files.items():
|
||||||
self._write_file(relative_path, content)
|
self._write_file(relative_path, content)
|
||||||
|
if editing_existing_workspace and len(self.pending_code_changes) == change_count_before:
|
||||||
|
raise RuntimeError('The LLM response did not produce any file changes for the existing project.')
|
||||||
fallback_used = bool(trace and trace.get('fallback_used')) or trace is None
|
fallback_used = bool(trace and trace.get('fallback_used')) or trace is None
|
||||||
if self.db_manager and self.history and self.prompt_audit and trace:
|
if self.db_manager and self.history and self.prompt_audit and trace:
|
||||||
self.db_manager.log_llm_trace(
|
self.db_manager.log_llm_trace(
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ class RequestInterpreter:
|
|||||||
parsed = json.loads(content)
|
parsed = json.loads(content)
|
||||||
interpreted = self._normalize_interpreted_request(parsed, normalized)
|
interpreted = self._normalize_interpreted_request(parsed, normalized)
|
||||||
routing = self._normalize_routing(parsed.get('routing'), interpreted, compact_context)
|
routing = self._normalize_routing(parsed.get('routing'), interpreted, compact_context)
|
||||||
|
if routing.get('intent') == 'continue_project' and routing.get('project_name'):
|
||||||
|
interpreted['name'] = routing['project_name']
|
||||||
naming_trace = None
|
naming_trace = None
|
||||||
if routing.get('intent') == 'new_project':
|
if routing.get('intent') == 'new_project':
|
||||||
interpreted, routing, naming_trace = await self._refine_new_project_identity(
|
interpreted, routing, naming_trace = await self._refine_new_project_identity(
|
||||||
@@ -265,6 +267,14 @@ class RequestInterpreter:
|
|||||||
matched_project = project
|
matched_project = project
|
||||||
break
|
break
|
||||||
intent = str(routing.get('intent') or '').strip() or ('continue_project' if matched_project else 'new_project')
|
intent = str(routing.get('intent') or '').strip() or ('continue_project' if matched_project else 'new_project')
|
||||||
|
if matched_project is None and intent == 'continue_project':
|
||||||
|
recent_chat_history = context.get('recent_chat_history', [])
|
||||||
|
recent_project_id = recent_chat_history[0].get('project_id') if recent_chat_history else None
|
||||||
|
if recent_project_id:
|
||||||
|
matched_project = next(
|
||||||
|
(project for project in context.get('projects', []) if project.get('project_id') == recent_project_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
normalized = {
|
normalized = {
|
||||||
'intent': intent,
|
'intent': intent,
|
||||||
'project_id': matched_project.get('project_id') if matched_project else project_id,
|
'project_id': matched_project.get('project_id') if matched_project else project_id,
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ async def _run_generation(
|
|||||||
resolved_prompt_text = prompt_text or _compose_prompt_text(request)
|
resolved_prompt_text = prompt_text or _compose_prompt_text(request)
|
||||||
if preferred_project_id and reusable_history is not None:
|
if preferred_project_id and reusable_history is not None:
|
||||||
project_id = reusable_history.project_id
|
project_id = reusable_history.project_id
|
||||||
elif reusable_history and not is_explicit_new_project and manager.get_open_pull_request(project_id=reusable_history.project_id):
|
elif reusable_history and not is_explicit_new_project:
|
||||||
project_id = reusable_history.project_id
|
project_id = reusable_history.project_id
|
||||||
else:
|
else:
|
||||||
if is_explicit_new_project or prompt_text:
|
if is_explicit_new_project or prompt_text:
|
||||||
|
|||||||
Reference in New Issue
Block a user