diff --git a/apps/agent/agent/prompt.py b/apps/agent/agent/prompt.py index adf03a5b..535b93a4 100644 --- a/apps/agent/agent/prompt.py +++ b/apps/agent/agent/prompt.py @@ -4,11 +4,80 @@ You are operating in a **remote Linux sandbox** at `{working_dir}`. All code execution and file operations happen in this sandbox environment. + + **Important:** - Use `{working_dir}` as your working directory for all operations + + +### Code Style + +- NEVER add inline comments to code +- Any docstrings on functions you add or modify must be VERY concise (1 line preferred) + +### Installing Dependencies + +If the repository needs dependency installation, run: +``` +uv run poe install-deps +``` + + + +### Committing Changes and Opening Pull Requests + +When you have completed your implementation, follow these steps in order: + +1. **Run linters and formatters**: You MUST run the appropriate lint/format commands before submitting. Determine which languages are in the repo and run the corresponding commands: + + **Python** (if repo contains `.py` files): + - `make format` then `make lint` + + **Frontend / TypeScript / JavaScript** (if repo contains `package.json`): + - `yarn format` then `yarn lint` + + **Go** (if repo contains `.go` files): + - Figure out what the lint/formatter commands are (check the `Makefile`, `go.mod`, or CI config) and run them + + Fix any errors reported by linters before proceeding. + +2. **Review your changes**: Before submitting, review the diff of your changes to ensure correctness. Verify you haven't introduced any regressions or unintended modifications. + +3. **Submit via `commit_and_open_pr` tool**: Call this tool as the final step. It will commit all changes, push to a branch, and create a pull request. + + **PR Title** (keep under 70 characters): + ``` + : [closes {linear_project_id}-{linear_issue_number}] + ``` + Where type is one of: `fix` (bug fix), `feat` (new feature), `chore` (maintenance), `ci` (CI/CD) + + **PR Body**: + ``` + ## Description + + + ## Test Plan + - [ ] + ``` + + **Commit message**: Should be concise and focus on the "why" rather than the "what". If not provided, the PR title is used. + + + + +Always call `commit_and_open_pr` as the final step once your implementation is complete and code quality checks pass. + """ -def construct_system_prompt(working_dir: str) -> str: - return SYSTEM_PROMPT.format(working_dir=working_dir) +def construct_system_prompt( + working_dir: str, + linear_project_id: str = "", + linear_issue_number: str = "", +) -> str: + return SYSTEM_PROMPT.format( + working_dir=working_dir, + linear_project_id=linear_project_id or "", + linear_issue_number=linear_issue_number or "", + ) diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index 611704d2..1e9f4c88 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -30,9 +30,10 @@ from deepagents.backends.sandbox import SandboxBackendProtocol # Local import for encryption from langchain_anthropic import ChatAnthropic + from .encryption import decrypt_token from .prompt import construct_system_prompt -from .tools import fetch_url, http_request +from .tools import commit_and_open_pr, fetch_url, http_request def _get_langsmith_api_key() -> str | None: @@ -456,6 +457,28 @@ async def post_to_linear_after_model( # noqa: PLR0911, PLR0912 return None +def _extract_pr_params_from_messages(messages: list) -> dict[str, str] | None: + """Extract PR title/body/commit_message from the last commit_and_open_pr tool result.""" + for msg in reversed(messages): + if isinstance(msg, dict): + content = msg.get("content", "") + name = msg.get("name", "") + else: + content = getattr(msg, "content", "") + name = getattr(msg, "name", "") + + if name == "commit_and_open_pr" and content: + import json as _json + + try: + parsed = _json.loads(content) if isinstance(content, str) else content + if isinstance(parsed, dict) and "title" in parsed: + return parsed + except (ValueError, TypeError): + pass + return None + + @after_agent async def open_pr_if_needed( # noqa: PLR0912, PLR0915 state: AgentState, @@ -465,7 +488,6 @@ async def open_pr_if_needed( # noqa: PLR0912, PLR0915 logger.info("After-agent middleware started") pr_url = None pr_number = None - pr_title = "feat: Open SWE PR" try: config = get_config() @@ -485,6 +507,21 @@ async def open_pr_if_needed( # noqa: PLR0912, PLR0915 linear_issue = configurable.get("linear_issue", {}) linear_issue_id = linear_issue.get("id") + pr_params = _extract_pr_params_from_messages(messages) + + if not pr_params: + logger.info("No commit_and_open_pr tool call found, skipping PR creation") + if linear_issue_id and last_message_content: + comment = f""" **Agent Response** + +{last_message_content}""" + await comment_on_linear_issue(linear_issue_id, comment) + return None + + pr_title = pr_params.get("title", "feat: Open SWE PR") + pr_body = pr_params.get("body", "Automated PR created by Open SWE agent.") + commit_message = pr_params.get("commit_message", pr_title) + if not thread_id: if linear_issue_id and last_message_content: comment = f"""🤖 **Agent Response** @@ -564,8 +601,9 @@ async def open_pr_if_needed( # noqa: PLR0912, PLR0915 await asyncio.to_thread(sandbox_backend.execute, f"cd {repo_dir} && git add -A") + safe_commit_msg = commit_message.replace("'", "'\\''") await asyncio.to_thread( - sandbox_backend.execute, f'cd {repo_dir} && git commit -m "feat: Open SWE PR"' + sandbox_backend.execute, f"cd {repo_dir} && git commit -m '{safe_commit_msg}'" ) encrypted_token = configurable.get("github_token_encrypted") @@ -579,7 +617,6 @@ async def open_pr_if_needed( # noqa: PLR0912, PLR0915 if remote_result.exit_code == 0: remote_url = remote_result.output.strip() if "github.com" in remote_url and "@" not in remote_url: - # Convert https://github.com/owner/repo.git to https://git:token@github.com/owner/repo.git auth_url = remote_url.replace("https://", f"https://git:{github_token}@") await asyncio.to_thread( sandbox_backend.execute, @@ -590,13 +627,9 @@ async def open_pr_if_needed( # noqa: PLR0912, PLR0915 sandbox_backend.execute, f"cd {repo_dir} && git push origin {target_branch}" ) - # Get default branch from GitHub API (most reliable method) base_branch = await get_github_default_branch(repo_owner, repo_name, github_token) logger.info("Using base branch: %s", base_branch) - pr_title = "feat: Open SWE PR" - pr_body = "Automated PR created by Open SWE agent." - pr_url, pr_number = await create_github_pr( repo_owner=repo_owner, repo_name=repo_name, @@ -911,13 +944,19 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 _SANDBOX_BACKENDS[thread_id] = sandbox_backend + linear_issue = config["configurable"].get("linear_issue", {}) + linear_project_id = linear_issue.get("linear_project_id", "") + linear_issue_number = linear_issue.get("linear_issue_number", "") + logger.info("Returning agent with sandbox for thread %s", thread_id) return create_deep_agent( - model=ChatAnthropic( - model="claude-opus-4-6", max_tokens=20_000 - ), # TODO: Actually pass a model here - system_prompt=construct_system_prompt(repo_dir), - tools=[http_request, fetch_url], + model=ChatAnthropic(model="claude-opus-4-6", max_tokens=20_000), + system_prompt=construct_system_prompt( + repo_dir, + linear_project_id=linear_project_id, + linear_issue_number=linear_issue_number, + ), + tools=[http_request, fetch_url, commit_and_open_pr], backend=sandbox_backend, middleware=[ check_message_queue_before_model, diff --git a/apps/agent/agent/tools/__init__.py b/apps/agent/agent/tools/__init__.py index 4e988eca..dc2b1f2a 100644 --- a/apps/agent/agent/tools/__init__.py +++ b/apps/agent/agent/tools/__init__.py @@ -1,4 +1,5 @@ +from .commit_and_open_pr import commit_and_open_pr from .fetch_url import fetch_url from .http_request import http_request -__all__ = ["fetch_url", "http_request"] +__all__ = ["commit_and_open_pr", "fetch_url", "http_request"] diff --git a/apps/agent/agent/tools/commit_and_open_pr.py b/apps/agent/agent/tools/commit_and_open_pr.py new file mode 100644 index 00000000..c29078c1 --- /dev/null +++ b/apps/agent/agent/tools/commit_and_open_pr.py @@ -0,0 +1,94 @@ +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def commit_and_open_pr( + title: str, + body: str, + commit_message: str | None = None, +) -> dict[str, Any]: + """Commit all current changes and open a GitHub Pull Request. + + You MUST call this tool when you have completed your work and want to + submit your changes for review. This is the final step in your workflow. + + Before calling this tool, ensure you have: + 1. Reviewed your changes for correctness + 2. Run `make format` and `make lint` if a Makefile exists in the repo root + + ## Title Format (REQUIRED — keep under 70 characters) + + The PR title MUST follow this exact format: + + : [closes -] + + The description MUST be entirely lowercase (no capital letters). + + Where is one of: + - fix: for bug fixes + - feat: for new features + - chore: for maintenance tasks (deps, configs, cleanup) + - ci: for CI/CD changes + + The [closes ...] suffix links and auto-closes the Linear ticket. + Use the linear_project_id and linear_issue_number from your context. + + Examples: + - "fix: resolve null pointer in user auth [closes AA-123]" + - "feat: add dark mode toggle to settings [closes ENG-456]" + - "chore: upgrade dependencies to latest versions [closes OPS-789]" + + ## Body Format (REQUIRED) + + The PR body MUST follow this exact template: + + ## Description + + + ## Test Plan + - [ ] + - [ ] + + Example body: + + ## Description + Fixes the null pointer exception that occurs when a user without + a profile attempts to authenticate. The root cause was a missing + null check in the `getProfile` method. + + Changes: + - Added null check in `auth/getProfile.ts` + - Added fallback default profile object + - Updated related unit tests + + Resolves AA-123 + + ## Test Plan + - [ ] Verify login works for users without profiles + - [ ] Verify existing users are unaffected + - [ ] Run `yarn test` and confirm all tests pass + + ## Commit Message + + The commit message should be concise (1-2 sentences) and focus on the "why" + rather than the "what". Summarize the nature of the changes: new feature, + bug fix, refactoring, etc. If not provided, the PR title is used. + + Args: + title: PR title following the format above (e.g. "fix: resolve auth bug [closes AA-123]") + body: PR description following the template above with ## Description and ## Test Plan + commit_message: Optional git commit message. If not provided, the PR title is used. + + Returns: + Dictionary with the result of the operation including PR URL if successful. + """ + return { + "title": title, + "body": body, + "commit_message": commit_message or title, + } diff --git a/apps/agent/agent/webapp.py b/apps/agent/agent/webapp.py index f97d2c4f..d4c0d6ac 100644 --- a/apps/agent/agent/webapp.py +++ b/apps/agent/agent/webapp.py @@ -552,13 +552,23 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 "When you're done, commit and push your changes." ) + identifier = full_issue.get("identifier", "") or issue_data.get("identifier", "") + linear_project_id = "" + linear_issue_number = "" + if identifier and "-" in identifier: + parts = identifier.split("-", 1) + linear_project_id = parts[0] + linear_issue_number = parts[1] + configurable: dict[str, Any] = { "repo": repo_config, "linear_issue": { "id": issue_id, "title": title, "url": full_issue.get("url", "") or issue_data.get("url", ""), - "identifier": full_issue.get("identifier", "") or issue_data.get("identifier", ""), + "identifier": identifier, + "linear_project_id": linear_project_id, + "linear_issue_number": linear_issue_number, }, } if github_token: