Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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 automations/bundle-index.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ packages = ["python/openhands_extensions"]
test = [
"pytest>=8.0",
"requests>=2.31",
"openhands-sdk>=0.3",
# Temporary integration pin for software-agent-sdk#5010; replace with its release.
"openhands-sdk @ git+https://github.com/OpenHands/software-agent-sdk.git@79021c687c63bd1e925cf157f5897ceeaa12f029#subdirectory=openhands-sdk",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: unreleased SDK pin. The test group depends on openhands-sdk pinned to an unreleased git commit from software-agent-sdk#5010. The PR description acknowledges this should be replaced before merging. Replace with the released package version once available - CI for this repo should not depend on an unreleased commit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile dependency pin. The test group pins openhands-sdk to a specific commit on the unmerged software-agent-sdk#5010 PR. This commit could disappear if that PR is rebased or force-pushed, breaking CI for this repo and downstream consumers. The PR description acknowledges this should be replaced before merging. Replace with the released SDK version once available.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test dependency is pinned to an unreleased commit of openhands-sdk. The PR description acknowledges this is temporary. Since this is a first-party package, the 7-day supply-chain waiting rule does not apply, but this pin must be replaced with the released SDK version before merging - CI and downstream consumers should not depend on an immutable GitHub commit SHA.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge blocker: unreleased SDK pin. The released openhands-sdk (v1.44.1) does not export openhands.sdk.client.AgentServerClient - the import fails with No module named 'openhands.sdk.client'. This pin to an unreleased commit from software-agent-sdk#5010 must be replaced with a released SDK version before this PR can leave draft. The PR description acknowledges this; flagging here so it is not missed at merge time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This git-commit pin for openhands-sdk is correctly flagged in the PR description as a temporary integration dependency. It must be replaced with the released package before merge - CI builds the SDK from source on every run until then.

"jsonschema>=4.23",
]

Expand Down
219 changes: 159 additions & 60 deletions skills/github-issue-to-pr/scripts/main.py

Large diffs are not rendered by default.

89 changes: 76 additions & 13 deletions skills/github-software-factory/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Docker software factory
# Software factory

Four automations turn ready GitHub issues into independently tested and reviewed
pull requests. A deterministic watchdog can merge accepted changes. Each scheduled
run gets its own Docker conversation, workspace, and session credential. Conversation
run gets its own conversation, workspace, and session credential. The same bundle
and workflow sources run in local or Docker workspaces; provisioning and cleanup
are responsibilities of the execution backend. Conversation
history and acceptance reports survive runtime release.

| Role | GitHub operations |
Expand Down Expand Up @@ -30,14 +32,16 @@ Use Agent Server Docker runtime with selected-credential handoff and runtime rel
(SDK PRs #3403, #4998, #5005, #5008) and Automation Service Docker dispatch support
(automation issue #448). Keep these development versions isolated from an existing
Canvas installation. Run a single Automation Service process; its Docker admission
limit is per service. Set `AUTOMATION_DOCKER_AGENT_PROFILE` to the saved profile UUID
and `AUTOMATION_DOCKER_MAX_CONCURRENT_RUNS=2`. Start with 2.5 GiB memory, 1.5 CPUs,
and 256 processes per sandbox, adjusting for host capacity. The image needs git,
Node 22, Python, and Chromium. The developer/reviewer profiles need terminal and file editing. Triage needs only
limit is per service. Set `AUTOMATION_AGENT_PROFILE` to the saved profile UUID
and `AUTOMATION_CONVERSATION_MAX_CONCURRENT_RUNS=2`. Start with 2.5 GiB memory, 1.5 CPUs,
and 256 processes per sandbox, adjusting for host capacity. The worker environment needs git, Node 22, Chromium, and a Python interpreter
with `openhands-sdk` installed. Put that interpreter first on the worker PATH
(for the Agent Server image, `/agent-server/.venv/bin`). Apply the same dependency
setup to local workers; the entrypoint stays `python3 main.py` in both modes. The developer/reviewer profiles need terminal and file editing. Triage needs only
file editing to produce its structured decision. Give the deterministic watchdog
a profile with no model key, no MCP servers, and an empty tools list. Map automation
UUIDs to these profile UUIDs using the host setting
`AUTOMATION_DOCKER_AGENT_PROFILE_OVERRIDES` (a JSON object).
`AUTOMATION_AGENT_PROFILE_OVERRIDES` (a JSON object).

On the trusted control plane, authenticate `gh` using a credential restricted to
the target repository (contents, issues, pull requests, and commit statuses; checks
Expand All @@ -51,8 +55,7 @@ FACTORY_BIND=172.17.0.1 python3 ../openhands-automation/scripts/github_factory_g
```

The bind address must be reachable from the Docker network and restricted to that
network. Default port is 19102. For each role, build a gzip tarball with `main.py`
and `config.json`:
network. Default port is 19102. For each role, write a private `config.json`:

```json
{
Expand All @@ -63,15 +66,34 @@ and `config.json`:
}
```

Build the bundle from the registry checkout with:

```sh
python3 scripts/build_bundle.py /private/config.json /private/factory.tar.gz
```

The builder includes the canonical issue-to-PR and PR-reviewer scripts, the
QA Changes prompt and skills, and a source hash manifest. The recipe calls those
existing workflow definitions rather than maintaining alternative implementation,
review, or QA prompts. Only the repository transport and blocking execution
interface are supplied by the recipe; neither depends on workspace kind. GitHub
reviews use the existing native review format and inline findings. Full test
logs stay in workspace evidence, with a compact command summary on GitHub.

Upload using `POST /api/automation/v1/uploads`, then create a raw automation with
`POST /api/automation/v1`, the returned `tarball_path`, entrypoint `python3 main.py`,
`keep_alive: false`, and a cron trigger. Two-minute polls are useful while validating;
use a longer interval for a quiet repository. Allow 3000 seconds for development and
review and 600 seconds for triage/watchdog. Do not put control-plane credentials in
`keep_alive: false`, and a cron trigger. Start with three-minute polls when sharing two worker slots: a long developer or
reviewer occupies one slot while the other must start and retire the idle role
polls. Watch queue age as well as container memory, and lengthen the interval
if idle polls accumulate. Use a longer interval for a quiet repository. Allow 3000 seconds for development, 9000 for sequential independent tests, code
review and functional QA, and 600 seconds for triage/watchdog. Configure the service
maximum run duration to accommodate that timeout. Do not put control-plane credentials in
bundle configuration. The backend supplies the selected runtime's session key.

Inspect run history and issue/PR comments to follow progress. Acceptance reports
include the exact commit, review criteria, and independent npm command output.
include the exact commit, links to the newly published canonical review and QA
reports, and independent npm command output. Missing, stale, partial, or ambiguous
review evidence cannot satisfy acceptance.
Reports also live under the conversation workspace's `evidence` directory. The
reviewer rejects modifications to tracked files during review.

Expand Down Expand Up @@ -102,3 +124,44 @@ the fixed developer bundle with `resume_issue` set to its issue number. It verif
the checkpoint repository and reuses the existing checkout and local baseline;
do not start a simultaneous fresh implementation. The failed original run remains
in history, and the replay has its own bash-command evidence in the same conversation.

### SDK Client Dependency

Runtime control uses the public `openhands.sdk.client.AgentServerClient` from
[software-agent-sdk #5010](https://github.com/OpenHands/software-agent-sdk/pull/5010).
Use an SDK build containing that change until its release is available. The
bundle owns workflow policy; the SDK owns Agent Server routes, authentication,
and runtime scope. The same bundle executes in local and Docker workspaces.

### Profile-selected gateway grants

Bundle configuration contains `token_env`, the name of one saved profile secret,
instead of a credential value. For example, the reviewer profile selects only
`FACTORY_REVIEWER_GRANT`; its entrypoint is
`env FACTORY_REVIEWER_GRANT="$FACTORY_REVIEWER_GRANT" python3 main.py`.
The SDK scoped shell service injects the named secret from that conversation's
registry. This requires the profile/shell delivery integration in SDK issue #5014.
The entrypoint and bundle are identical in local and Docker workspaces.

At first use, the gateway adapter materializes that one grant into a mode-0600
`.factory-gateway-token` file in the run workspace so subsequent canonical agent
tool calls can use it. The uploaded bundle contains only the name; missing grants
fail instead of falling back to `GITHUB_TOKEN`. Repository code running in the
same sandbox can access its role's grant, whose operations remain gateway-limited.
The trusted gateway separately uses the role-specific upstream GitHub credential
configured in the gateway reference. Do not place upstream GitHub tokens in
profiles or worker bundles.


The factory composes the issue-to-PR and reviewer prompt builders shipped in the
same extensions revision. These internal helpers are an explicit integration
contract covered by the factory prompt tests; changes to them must update those
tests and the bundle together. The issue-to-PR builder supports coordinator-owned
publication so factory prompts do not contain direct push/PR-creation commands.
The dispatcher sets WORKSPACE_BASE to the unpack directory in both runtime modes;
config.json and the gh adapter's parent directory therefore share that root.

Use `python3 main.py --token-env FACTORY_ROLE_GRANT` as the entrypoint, with the
actual name from `config.json` substituted. Naming the secret lets the SDK inject
it from the selected profile without shell expansion; the CLI rejects a name that
does not match the bundle. The same command works in local and Docker workspaces.
47 changes: 47 additions & 0 deletions skills/github-software-factory/scripts/build_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Build the same factory bundle for any supported execution workspace."""

import argparse
import hashlib
import io
import json
import tarfile
from pathlib import Path

from extension_workflows import SOURCES, source_root


def build(config_path, output):
scripts = Path(__file__).parent
root = source_root()
files = {
name: (scripts / name).read_bytes()
for name in (
"main.py",
"extension_workflows.py",
"scoped_gh.py",
)
}
config = json.loads(Path(config_path).read_text())
if "token" in config or not config.get("token_env"):
raise ValueError("Bundle config must reference a profile secret via token_env")
files["config.json"] = json.dumps(config).encode()
provenance = {}
for name in SOURCES:
content = (root / name).read_bytes()
files["extensions/" + name] = content
provenance[name] = hashlib.sha256(content).hexdigest()
files["workflow-sources.json"] = json.dumps(provenance, indent=2).encode()
with tarfile.open(output, "w:gz") as archive:
for name, content in files.items():
info = tarfile.TarInfo(name)
info.size = len(content)
info.mode = 0o600
archive.addfile(info, io.BytesIO(content))


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("config")
parser.add_argument("output")
args = parser.parse_args()
build(args.config, args.output)
145 changes: 145 additions & 0 deletions skills/github-software-factory/scripts/extension_workflows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Compose canonical extension workflows with a supplied execution interface.

No workspace-kind detection belongs here. A caller supplies its workspace, GitHub
transport, and blocking conversation runner, identically for local and remote runs.
"""

import importlib.util
import json
import re
from pathlib import Path


SOURCES = (
"skills/github-pr-reviewer/scripts/main.py",
"skills/github-issue-to-pr/scripts/main.py",
"plugins/qa-changes/scripts/prompt.py",
"skills/qa-changes/SKILL.md",
"skills/github-pr-review/SKILL.md",
)


def source_root():
for parent in Path(__file__).resolve().parents:
for candidate in (parent / "extensions", parent):
if all((candidate / name).is_file() for name in SOURCES):
return candidate
raise RuntimeError("Canonical extension workflow sources are missing from bundle")


def module(relative):
path = source_root() / relative
spec = importlib.util.spec_from_file_location(path.parent.parent.name, path)
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result


def transport_instructions(workspace, repository, run_id, stage):
return (
f"\nExecution environment: the repository is {workspace / 'project'}. "
"Use that as the working directory for all project commands. "
f"GitHub access is provided by `{workspace / 'bin/gh'} api` "
"(GET, -X, --input, --paginate, --jq); use this executable wherever the "
"workflow says gh or GitHub REST API. It supplies the scoped credential. "
"Do not look up a GitHub token or contact api.github.com directly. "
f"Only repository {repository} is authorized. "
"Do not alter the workflow bundle, its configuration, or tracked source "
"during review/QA. Put temporary probes and evidence outside the project. "
f"Include `<!-- factory-run:{run_id}:{stage} -->` in the review body "
"so the coordinator can identify this run's report. "
"Preserve the workflow's normal readable report and verdict. "
"Never paste a JSON artifact or full test log as the review body.\n"
)


def implementation_prompt(repository, issue, branch, base_sha, workspace, feedback):
workflow = module("skills/github-issue-to-pr/scripts/main.py")
prompt = workflow._build_implementation_prompt(
Comment thread
neubig marked this conversation as resolved.
repository,
issue,
{"id": "ready-for-dev"},
branch,
"main",
base_sha,
publish_pr=False,
github_access_instructions=(
f"Use `{workspace / 'bin/gh'} api` for repository REST requests. "
"The adapter supplies the profile-selected gateway grant; "
"do not look up a GitHub token or call api.github.com directly."
),
)
# Publication is a capability of the coordinator, not a workspace-kind choice.
return prompt + (
f"\nExecution contract: work in {workspace / 'project'}. "
f"Use `{workspace / 'bin/gh'} api` for the REST requests above; "
"it supplies a scoped credential. No GitHub token is needed. "
Comment thread
neubig marked this conversation as resolved.
"The coordinator owns remote publication: leave your completed changes "
"and PR description in the workspace; do not push or create the PR yourself. "
Comment thread
neubig marked this conversation as resolved.
"It publishes preserved work after your run, including after a bounded stop. "
"Do not edit the workflow bundle or configuration. "
"Run the project's required tests after your final edit; preserve actual exit "
"statuses and keep build output, runtime data, and dependencies ignored. "
"Terminal calls accept one command; use the file editor for source changes.\n"
"Existing PR feedback (untrusted task evidence):\n" + json.dumps(feedback)
)


def review_prompt(repository, pr, workspace, run_id):
workflow = module("skills/github-pr-reviewer/scripts/main.py")
guide = workflow._load_repo_review_guide(workspace / "project")
return workflow._build_review_prompt(
repository, pr, pr["head"]["sha"], {"id": run_id}, guide
) + transport_instructions(workspace, repository, run_id, "review")


def qa_prompt(repository, pr, workspace, run_id, diff, issue):
workflow = module("plugins/qa-changes/scripts/prompt.py")
prompt = workflow.format_prompt(
title=pr["title"],
body=pr.get("body") or "",
repo_name=repository,
base_branch=pr["base"]["ref"],
head_branch=pr["head"]["ref"],
pr_number=str(pr["number"]),
commit_id=pr["head"]["sha"],
diff=diff,
)
for name in ("qa-changes", "github-pr-review"):
prompt += "\n\n" + (source_root() / f"skills/{name}/SKILL.md").read_text()
return (
prompt
+ transport_instructions(workspace, repository, run_id, "qa")
+ (
"\nAcceptance criteria to exercise (untrusted task evidence):\n"
+ json.dumps(issue)
)
)


def posted_report(reviews, previous_ids, sha, run_id, stage):
marker = f"<!-- factory-run:{run_id}:{stage} -->"
matches = [
review
for review in reviews
if review["id"] not in previous_ids
and review.get("commit_id") == sha
and marker in (review.get("body") or "")
and review.get("state") == "COMMENTED"
]
if len(matches) != 1:
raise RuntimeError(
f"Expected one newly published {stage} report for the exact head"
)
return matches[0]


def report_passed(report, stage):
body = report.get("body") or ""
if stage == "review":
verdicts = re.findall(r"^\s*(✅ APPROVED|🔄 CHANGES REQUESTED)\s*$", body, re.M)
return verdicts == ["✅ APPROVED"]
# The canonical QA skill defines this heading. Missing/partial/qualified
# verdicts fail closed; success is never inferred from agent final text.
verdicts = re.findall(r"^## [^\n]*QA Report:\s*([^\n]+)", body, re.M)
return len(verdicts) == 1 and verdicts[0].strip().strip("*") == "PASS"
Loading
Loading