@prism-d1/cli 1.0.89 → 1.0.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -102,13 +102,14 @@ The undotted one is what you are reading now. During a CI run it is cloned into
102
102
  your git history and never appears in your working tree.
103
103
 
104
104
  ```
105
- your repo (committed) CI runner (ephemeral)
106
- ├── .coding-agent/ $RUNNER_TEMP/prism-agent/
107
- │ ├── config.json └── coding-agent/ ← fetched, then gone
108
- │ ├── prompt.md ├── agentcore/
109
- │ └── fixtures/ └── config.py
110
- ├── .tool-versions
111
- └── .github/workflows/prism-coding-agent.yml
105
+ your repo (committed) Harness image (baked in at deploy time)
106
+ ├── .coding-agent/ /opt/prism-agent/
107
+ │ ├── config.json ├── run.py (orchestrator)
108
+ │ ├── prompt.md ├── agent.py (Strands agent)
109
+ │ └── fixtures/ ├── agentcore/ (contract, telemetry)
110
+ ├── .tool-versions ├── config.py (detection)
111
+ └── .github/workflows/ └── tools/ (git_ops, create_pr)
112
+ prism-coding-agent.yml
112
113
  ```
113
114
 
114
115
  An earlier layout *did* vendor the agent into every repository, at
@@ -145,8 +146,10 @@ coding-agent/
145
146
  │ ├── run_eval.py scores the local agent against fixtures
146
147
  │ └── run_harness_eval.py scores the deployed harness against the same fixtures
147
148
  ├── deploy/
148
- │ ├── Dockerfile the mise-based harness image
149
- │ ├── deploy-harness.sh one-time account setup
149
+ │ ├── Dockerfile the mise-based harness image (now includes strands + agent code)
150
+ │ ├── run.py runs INSIDE the harness — the full cycle in one script
151
+ │ ├── invoke_harness.py optional runner-side caller (for use outside GitHub Actions)
152
+ │ ├── deploy-harness.sh one-time account setup (ECR + role + create harness + SSM)
150
153
  │ ├── create_harness.py create-or-update via boto3 (the AWS CLI has no create-harness)
151
154
  │ └── prism-coding-agent.yml the workflow installed into consuming repos
152
155
  └── tests/ 180 tests; run with pytest
@@ -186,42 +189,52 @@ Three details, each of which cost a failed deployment to learn:
186
189
  AWS CLI (2.36.19) does not, which is how this project originally came to call the
187
190
  wrong API and why deployment goes through `create_harness.py`.
188
191
 
189
- The image is a single mise-based base (~591 MB, 29% of the 2 GB cap) rather than one
190
- per language. It ships a version *manager* and installs whatever a repository pins
191
- in `.tool-versions` at session start, which is why `install-coding-agent` writes
192
- that file. See [the ADR](../docs/ADR-coding-agent-on-agentcore.md) for the
193
- arithmetic that ruled out per-language images.
192
+ The image is a single mise-based base (~750 MB, 37% of the 2 GB cap) that includes
193
+ the Strands agent SDK and the full orchestrator. It ships a version *manager* and
194
+ installs whatever a repository pins in `.tool-versions` at session start, which is
195
+ why `install-coding-agent` writes that file. See
196
+ [the ADR](../docs/ADR-coding-agent-on-agentcore.md) for the arithmetic that ruled
197
+ out per-language images.
194
198
 
195
- ### How the workflow gets the client
199
+ Because the agent code is in the image, updating it requires an image rebuild
200
+ (`deploy-harness.sh --tag v2`). This is a trade-off for simplicity: the workflow has
201
+ no client-fetch step, no sparse checkout, and no `PRISM_AGENT_REPO`/`PRISM_AGENT_REF`
202
+ variables to pin. One image rebuild propagates to every repo.
196
203
 
197
- The workflow runs `python -m agentcore.invoke`, so it needs the whole
198
- `coding-agent/` directory importable — not just `agentcore/`. The package reaches
199
- one level up for `config.py`: `invoke.py` puts its parent on `sys.path` and imports
200
- it, and `contract.py` imports it as well. That second import sits *inside*
201
- `FixRequest.validate()`, so a `coding-agent/` missing `config.py` would import
202
- cleanly and then fail partway through validating a request. The sparse checkout
203
- takes the whole directory for that reason, and the step's preflight is an `import`
204
- rather than a file-existence check so a narrowed path fails immediately with a name.
204
+ ### How the workflow invokes the agent
205
205
 
206
- It is fetched into `$RUNNER_TEMP`, deliberately not the workspace: the workspace is
207
- the tree the patch is applied to and committed from, and a client checkout sitting
208
- there is one `git add -A` away from being committed into somebody's fix.
206
+ The harness image contains the full agent and orchestrator. The workflow makes a
207
+ single `InvokeAgentRuntimeCommand` call, and everything happens inside the microVM:
209
208
 
210
- `boto3` is the only `pip install` the runner needs. Everything in `agentcore/` is
211
- standard library, and `client.py` imports `boto3` lazily so the stub transport used
212
- by the tests needs no AWS SDK at all.
209
+ ```
210
+ Runner (this workflow) Harness microVM
211
+ ────────────────────── ───────────────
212
+ resolve issue → /tmp/issue.json
213
+ OIDC → AWS credentials
214
+ run.py:
215
+ InvokeAgentRuntimeCommand ──────────► 1. git clone (with token)
216
+ 2. mise install
217
+ 3. npm ci / pip install
218
+ 4. agent.py (Strands ReAct loop)
219
+ 5. git diff → patch
220
+ 6. npm test → verified
221
+ stdout ◄──────────────────────────── 7. print result JSON + patch
222
+ apply patch
223
+ git push + gh pr create
224
+ ```
213
225
 
214
- Two variables control the source, both optional:
226
+ No client code is fetched per-run. No sparse checkout. No `PRISM_AGENT_REPO` or
227
+ `PRISM_AGENT_REF` variables needed — the agent code is baked into the image at
228
+ `deploy-harness.sh` time. Updating the agent means rebuilding the image; the
229
+ workshop trades that for simplicity.
215
230
 
216
- | Variable | Default | Why you would set it |
217
- |---|---|---|
218
- | `PRISM_AGENT_REPO` | `aws-samples/sample-prism-d1-velocity` | a fork or an internal mirror |
219
- | `PRISM_AGENT_REF` | `main` | **pin this.** A floating default means a third party's push changes what runs in your CI |
231
+ **Private repo access**: the workflow's `GITHUB_TOKEN` is passed in the clone URL.
232
+ It's ephemeral (dies when this run ends) and scoped to this one repo, so the blast
233
+ radius is bounded. The model's shell tool *can* read it during the run — which is
234
+ acceptable for the same reason it's acceptable that the model can read any file in
235
+ the checkout: the boundary is the run's lifetime, not the tool's access.
220
236
 
221
- `PRISM_AGENT_REF` accepts a branch, a tag, or a full commit SHA. It defaults to
222
- `main` so the workshop works on day one; for anything you care about, pin it. Note
223
- that fetching from a public repository is a live availability dependency — an
224
- internal mirror via `PRISM_AGENT_REPO` is the answer if that matters.
237
+ `boto3` is the only `pip install` the runner needs — to make the one API call.
225
238
 
226
239
  ---
227
240
 
@@ -231,43 +244,39 @@ internal mirror via `PRISM_AGENT_REPO` is the answer if that matters.
231
244
 
232
245
  | | Local | Deployed |
233
246
  |---|---|---|
234
- | Entry point | `agent.py` | `agentcore/invoke.py` → `InvokeHarness` |
235
- | Where the model runs | your machine, via `bedrock:InvokeModel` | the harness, in your account |
236
- | Where code is edited | a throwaway clone on your disk | a microVM from the harness image |
237
- | Iteration bound | `IterationBound` hook (`--max-iterations`) | `maxIterations` per invocation |
247
+ | Entry point | `agent.py` | `deploy/run.py` inside the harness image |
248
+ | Where the model runs | your machine, via `bedrock:InvokeModel` | the harness microVM |
249
+ | Where code is edited | a throwaway clone on your disk | a throwaway clone inside the microVM |
250
+ | Iteration bound | `IterationBound` hook (`--max-iterations`) | same — `run.py` passes it to `agent.py` |
238
251
  | Used by | `eval/run_eval.py`, development, reading the code | CI, via the workflow |
252
+ | API calls from runner | N/A (local) | **one** `InvokeAgentRuntimeCommand` |
239
253
 
240
- Both are supported. The local agent is what the workshop reads and extends and what
241
- the eval scores; the harness is what runs in CI. They share `config.py`, the prompt
242
- layering, and the fixture schema.
254
+ Both are supported. The local agent is what the workshop reads, extends, and what
255
+ the eval scores; the deployed path is what runs in CI.
243
256
 
244
257
  ### The call chain in CI
245
258
 
246
- `BotoTransport.send()` makes five or six AWS calls, all sharing one
247
- `runtimeSessionId` (`prism-<uuid4>` — the API has a 33-character minimum). The
248
- shared id is what makes them see the same filesystem.
259
+ One API call. The runner calls `InvokeAgentRuntimeCommand` with a command that runs
260
+ `/opt/prism-agent/run.py` inside the microVM. That script does the full cycle
261
+ locally — no further API calls back to AgentCore:
249
262
 
250
- | Call | API | Model tokens |
263
+ | Phase | What `run.py` does | Model tokens |
251
264
  |---|---|---|
252
- | clone at the base commit | `InvokeAgentRuntimeCommand` | none |
253
- | `mise install` the toolchain | `InvokeAgentRuntimeCommand` | none |
254
- | install dependencies | `InvokeAgentRuntimeCommand` | none |
255
- | **the agent loop** | **`InvokeHarness`** | yes |
256
- | collect the patch (`git diff` from base) | `InvokeAgentRuntimeCommand` | none |
257
- | run the project's test suite | `InvokeAgentRuntimeCommand` | none |
258
-
259
- Only one of the six reasons; the rest are deterministic shell at zero token cost.
260
- That split is the design. Preparation being deterministic is why a failed clone is
261
- reported as the environment's fault with the agent never started. Verification
262
- being deterministic is why `verified` is an exit code rather than a phrase found in
263
- the model's prose.
264
-
265
- The harness never pushes. It returns a patch; the workflow applies it, commits, and
266
- opens the PR with its own ephemeral `GITHUB_TOKEN`. That keeps the harness's
267
- credentials read-only and confines write access to the repository the run was
268
- triggered from. One consequence: a PR opened with `GITHUB_TOKEN` does **not**
269
- trigger further workflows, so the agent's PR will not kick off `prism-ai-metrics`
270
- or the eval gate without a GitHub App or PAT.
265
+ | clone | `git clone --depth 50` (with token in URL) | none |
266
+ | toolchain | `mise install` from `.tool-versions` | none |
267
+ | dependencies | inferred from manifest (`npm ci`, `pip install`, etc.) | none |
268
+ | **fix** | `python agent.py --repo ... --issue ...` | **yes** |
269
+ | collect | `git diff` from clone base, excluding generated dirs | none |
270
+ | verify | the project's own test command via `mise exec` | none |
271
+
272
+ The result (outcome, patch, verified flag) is printed to stdout with markers so the
273
+ runner can parse it. The patch is applied on the runner side, where `GITHUB_TOKEN`
274
+ lives, and the PR is opened there.
275
+
276
+ The harness never pushes. The token is in the URL for cloning, but push happens on
277
+ the runner with `git push`. This keeps write access confined to the workflow step
278
+ that has the scoped token, rather than inside the microVM where the model's shell
279
+ could use it.
271
280
 
272
281
  ### The agent itself
273
282
 
@@ -84,3 +84,23 @@ ENV MISE_TRUSTED_CONFIG_PATHS=/ \
84
84
 
85
85
  RUN mkdir -p /opt/mise /workspace
86
86
  WORKDIR /workspace
87
+
88
+ # -----------------------------------------------------------------------
89
+ # The agent and its orchestrator, so the harness is fully self-contained.
90
+ # One InvokeAgentRuntimeCommand runs the entire cycle:
91
+ # clone → mise install → deps → agent loop → collect patch → verify
92
+ #
93
+ # This eliminates the client-fetch step in the workflow and solves private
94
+ # repo access (the token travels in the clone URL, inside the microVM).
95
+ # -----------------------------------------------------------------------
96
+ COPY requirements.txt /opt/prism-agent/
97
+ RUN pip install --no-cache-dir -r /opt/prism-agent/requirements.txt && \
98
+ python -c "import strands; import boto3; print('strands + boto3 OK')"
99
+
100
+ COPY agent.py config.py system_prompt.py /opt/prism-agent/
101
+ COPY agentcore/ /opt/prism-agent/agentcore/
102
+ COPY tools/ /opt/prism-agent/tools/
103
+ COPY deploy/run.py /opt/prism-agent/run.py
104
+
105
+ ENV PYTHONPATH=/opt/prism-agent \
106
+ BYPASS_TOOL_CONSENT=true
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Invoke the harness via a single InvokeAgentRuntimeCommand.
3
+
4
+ The harness image contains the full agent + orchestrator at /opt/prism-agent.
5
+ This script just calls it and parses the structured output.
6
+
7
+ Usage (from the workflow):
8
+ python invoke_harness.py \
9
+ --harness-arn $PRISM_HARNESS_ARN \
10
+ --repo-url "https://x-access-token:TOKEN@github.com/owner/repo.git" \
11
+ --ref main --subdir sample-app \
12
+ --issue /tmp/issue.json \
13
+ --patch-out /tmp/fix.patch \
14
+ --result-out /tmp/result.json \
15
+ --region us-west-2
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ import uuid
24
+
25
+ import boto3
26
+
27
+ RESULT_MARKER = "===PRISM_RESULT_JSON==="
28
+ PATCH_MARKER = "===PRISM_PATCH_START==="
29
+ PATCH_END_MARKER = "===PRISM_PATCH_END==="
30
+
31
+
32
+ def main() -> int:
33
+ parser = argparse.ArgumentParser()
34
+ parser.add_argument("--harness-arn", required=True)
35
+ parser.add_argument("--repo-url", required=True)
36
+ parser.add_argument("--ref", default="main")
37
+ parser.add_argument("--subdir", default=".")
38
+ parser.add_argument("--issue", required=True, help="Path to issue JSON file")
39
+ parser.add_argument("--patch-out", required=True)
40
+ parser.add_argument("--result-out", required=True)
41
+ parser.add_argument("--region", default="us-west-2")
42
+ parser.add_argument("--max-iterations", type=int, default=100)
43
+ parser.add_argument("--model-id", default="")
44
+ parser.add_argument("--timeout", type=int, default=1800)
45
+ args = parser.parse_args()
46
+
47
+ issue_json = open(args.issue).read().strip()
48
+ session_id = f"prism-agent-{uuid.uuid4()}"
49
+
50
+ # Build the command that runs inside the harness microVM
51
+ cmd_parts = [
52
+ "python", "/opt/prism-agent/run.py",
53
+ "--repo-url", args.repo_url,
54
+ "--ref", args.ref,
55
+ "--subdir", args.subdir,
56
+ f"--issue", f"@/tmp/agent-issue.json",
57
+ "--max-iterations", str(args.max_iterations),
58
+ "--region", args.region,
59
+ ]
60
+ if args.model_id:
61
+ cmd_parts += ["--model-id", args.model_id]
62
+
63
+ # Write the issue inside the harness first, then run
64
+ import shlex
65
+ escaped_issue = issue_json.replace("'", "'\\''")
66
+ full_command = (
67
+ f"echo '{escaped_issue}' > /tmp/agent-issue.json && "
68
+ f"{shlex.join(cmd_parts)}"
69
+ )
70
+
71
+ print(f"Session: {session_id}", file=sys.stderr)
72
+ print(f"Harness: {args.harness_arn}", file=sys.stderr)
73
+ print(f"Subdir: {args.subdir}", file=sys.stderr)
74
+
75
+ client = boto3.client("bedrock-agentcore", region_name=args.region)
76
+
77
+ try:
78
+ response = client.invoke_agent_runtime_command(
79
+ agentRuntimeArn=args.harness_arn,
80
+ runtimeSessionId=session_id,
81
+ body={"command": full_command, "timeout": args.timeout},
82
+ )
83
+ except Exception as exc:
84
+ print(f"InvokeAgentRuntimeCommand failed: {exc}", file=sys.stderr)
85
+ _write_failure(args, f"API error: {type(exc).__name__}: {exc}")
86
+ return 1
87
+
88
+ # Stream the response
89
+ stdout_parts = []
90
+ stderr_parts = []
91
+ exit_code = -1
92
+
93
+ for event in response.get("stream", []):
94
+ chunk = event.get("chunk", {})
95
+ if "contentDelta" in chunk:
96
+ delta = chunk["contentDelta"]
97
+ if delta.get("stdout"):
98
+ stdout_parts.append(delta["stdout"])
99
+ if delta.get("stderr"):
100
+ stderr_parts.append(delta["stderr"])
101
+ # Print stderr live for the workflow log
102
+ print(delta["stderr"], end="", file=sys.stderr)
103
+ elif "contentStop" in chunk:
104
+ exit_code = int(chunk["contentStop"].get("exitCode", -1))
105
+
106
+ stdout = "".join(stdout_parts)
107
+
108
+ # Parse the structured output
109
+ if RESULT_MARKER in stdout:
110
+ result_start = stdout.index(RESULT_MARKER) + len(RESULT_MARKER)
111
+ result_end = stdout.index(PATCH_MARKER) if PATCH_MARKER in stdout else len(stdout)
112
+ result_json = stdout[result_start:result_end].strip()
113
+ result = json.loads(result_json)
114
+ else:
115
+ result = {"outcome": "failed", "reason": f"no result marker in output (exit {exit_code})",
116
+ "verified": False, "patch": ""}
117
+
118
+ # Extract patch
119
+ patch = ""
120
+ if PATCH_MARKER in stdout:
121
+ patch_start = stdout.index(PATCH_MARKER) + len(PATCH_MARKER) + 1
122
+ patch_end = stdout.index(PATCH_END_MARKER) if PATCH_END_MARKER in stdout else len(stdout)
123
+ patch = stdout[patch_start:patch_end]
124
+
125
+ # Write outputs
126
+ open(args.patch_out, "w").write(patch)
127
+ open(args.result_out, "w").write(json.dumps(result, indent=2))
128
+
129
+ print(f"\nOutcome: {result.get('outcome')} (verified={result.get('verified')})", file=sys.stderr)
130
+ return 0
131
+
132
+
133
+ def _write_failure(args, reason: str) -> None:
134
+ open(args.patch_out, "w").write("")
135
+ open(args.result_out, "w").write(json.dumps(
136
+ {"outcome": "failed", "reason": reason, "verified": False, "patch": ""},
137
+ indent=2,
138
+ ))
139
+
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main())
@@ -2,56 +2,38 @@
2
2
  #
3
3
  # Installed by: prism-cli bootstrapper install-coding-agent
4
4
  #
5
- # This file deliberately does NOT live in bootstrapper/github-workflows/.
6
- # install-github-workflows copies every .yml in that directory, so putting it
7
- # there would hand an autonomous, Bedrock-billing, branch-pushing agent to every
8
- # repo that ran the workflow installer.
9
- #
10
- #
11
5
  # WHAT RUNS WHERE
12
6
  #
13
- # The agent runs on AgentCore, not here. This workflow is the thin client: it
14
- # resolves the issue, invokes the harness, applies the patch it returns, and opens
15
- # a pull request. That split is what keeps the harness's credentials read-only --
16
- # it never pushes, so it needs no write access to any repository, and the token
17
- # that opens the PR is this run's own ephemeral, repo-scoped GITHUB_TOKEN.
7
+ # The agent and its full orchestration (clone, toolchain, deps, fix, verify) run
8
+ # inside the harness microVM. This workflow is the thin client: it resolves the
9
+ # issue, makes one API call, and applies the returned patch.
10
+ #
11
+ # The split is:
12
+ # - Inside the harness: clone → mise install → npm ci → agent loop → git diff → npm test
13
+ # - On this runner: read issue → call API → apply patch → push → open PR → comment
18
14
  #
19
- # One harness serves every toolchain. It ships mise and installs what the
20
- # repository declares in .tool-versions at session start, so there is no
21
- # per-language routing to configure here.
15
+ # The harness image contains strands-agents + the full orchestrator. A single
16
+ # InvokeAgentRuntimeCommand does everything. No client code is fetched per-run.
22
17
  #
23
18
  #
24
19
  # AUTHORIZATION MODEL
25
20
  #
26
21
  # The trigger is `issues: [labeled]`, gated on a specific label. That is the
27
- # security control, and it is load-bearing: anybody with a GitHub account can
28
- # open an issue in a public repo, but applying a label requires triage
29
- # permission or above. Triggering on `issues: [opened]` instead would let any
30
- # stranger spend your Bedrock budget and put a branch in your repository.
22
+ # security control: anybody can open an issue, but applying a label requires
23
+ # triage permission. Private repo access: the GITHUB_TOKEN is passed in the
24
+ # clone URL, so it is visible to the model's shell tool for the duration of
25
+ # this run (it expires when the run ends).
31
26
  #
32
27
  # NOTE: `issues` events only trigger a workflow run if this file exists on the
33
- # DEFAULT branch. You cannot test changes to it from a feature branch -- nothing
34
- # fires, and nothing reports why. Merge first.
28
+ # DEFAULT branch. You cannot test changes from a feature branch.
35
29
  #
36
30
  #
37
31
  # REQUIRED SETUP
38
- # - prism-cli bootstrapper setup-github-oidc (grants bedrock:InvokeModel)
32
+ # - prism-cli bootstrapper setup-github-oidc (grants bedrock-agentcore + SSM + SM)
39
33
  # - Repository secret PRISM_METRICS_ROLE_ARN
40
- # - Repository or org variable PRISM_HARNESS_ARN (an ARN is an identifier, not
41
- # a credential, so it belongs in `vars` -- and an org variable is inherited by
42
- # every repo, which is what "deploy the harness once" should feel like)
43
- # - A label matching AGENT_LABEL below, created in the repo
34
+ # - Repository or org variable PRISM_HARNESS_ARN
35
+ # - A label matching 'agent-fix', created in the repo
44
36
  # - .coding-agent/config.json committed (written by install-coding-agent)
45
- #
46
- # The agent is NOT vendored into this repository. Only config.json, prompt.md,
47
- # fixtures/ and this file live here; the orchestrator client is fetched at run
48
- # time from the sample repo, and the agent itself runs in an AgentCore harness in
49
- # your own account. That means an agent fix reaches every repo without an N-repo
50
- # migration, which vendoring could not offer.
51
- #
52
- # Pin PRISM_AGENT_REF to a tag or commit SHA for anything you care about. It
53
- # defaults to `main` so the workshop works on day one, but a floating default
54
- # means a third party's push can change what runs in your CI.
55
37
 
56
38
  name: PRISM Coding Agent
57
39
 
@@ -69,26 +51,15 @@ env:
69
51
  PRISM_AWS_REGION: us-west-2
70
52
  PRISM_REPO: github.com/${{ github.repository }}
71
53
  PRISM_HARNESS_ARN: ${{ vars.PRISM_HARNESS_ARN }}
72
- # Where the orchestrator client comes from. Overridable as repo/org variables so
73
- # a fork, an internal mirror, or a pinned SHA needs no edit to this file.
74
- PRISM_AGENT_REPO: ${{ vars.PRISM_AGENT_REPO || 'aws-samples/sample-prism-d1-velocity' }}
75
- PRISM_AGENT_REF: ${{ vars.PRISM_AGENT_REF || 'main' }}
76
54
 
77
55
  permissions:
78
56
  id-token: write # OIDC → AWS
79
57
  contents: write # push the fix branch
80
58
  pull-requests: write # open the PR
81
- issues: write # comment the outcome back on the issue
59
+ issues: write # comment the outcome
82
60
 
83
61
  jobs:
84
62
  fix-issue:
85
- # On the label path, run only for the agent label. Any other label added to
86
- # any issue also fires this workflow, so this condition is what keeps the
87
- # job from running on every triage action in the repository.
88
- #
89
- # The label is a literal here, not an env var, because the `env` context is
90
- # not available in a job-level `if`. An AGENT_LABEL variable above would look
91
- # configurable and change nothing — if you rename the label, edit this line.
92
63
  if: >-
93
64
  github.event_name == 'workflow_dispatch' ||
94
65
  github.event.label.name == 'agent-fix'
@@ -98,44 +69,22 @@ jobs:
98
69
  steps:
99
70
  - name: Checkout
100
71
  uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
101
- with:
102
- fetch-depth: 0
103
72
 
104
- - name: Warn if no harness variable (SSM is the fallback)
105
- run: |
106
- if [[ -z "${PRISM_HARNESS_ARN}" ]]; then
107
- echo "::notice::PRISM_HARNESS_ARN variable is not set. Will try SSM /prism/d1/harness-arn after OIDC."
108
- fi
109
-
110
- - name: Resolve the issue to work on
73
+ - name: Resolve the issue
111
74
  id: issue
112
- env:
113
- GH_TOKEN: ${{ github.token }}
114
- DISPATCH_NUMBER: ${{ inputs.issue_number }}
115
75
  run: |
116
- set -euo pipefail
117
-
118
- # Both branches write the same shape: a GitHub event payload with the
119
- # issue under an `issue` key, which is what agentcore.invoke reads. The
120
- # dispatch branch has to wrap it, because `gh issue view` returns a
121
- # bare issue object.
122
76
  if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
123
- if ! [[ "${DISPATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
124
- echo "::error::issue_number must be numeric, got '${DISPATCH_NUMBER}'"
125
- exit 1
126
- fi
127
- gh issue view "${DISPATCH_NUMBER}" --json number,title,body,labels \
128
- | jq '{issue: .}' > /tmp/issue.json
77
+ NUMBER="${{ inputs.issue_number }}"
129
78
  else
130
- # Read from the payload rather than re-fetching: avoids a race where
131
- # the issue is edited between the label and this step.
132
- jq '{issue: (.issue | {number, title, body, labels})}' \
133
- "${GITHUB_EVENT_PATH}" > /tmp/issue.json
79
+ NUMBER="${{ github.event.issue.number }}"
134
80
  fi
135
-
136
- NUMBER=$(jq -r '.issue.number' /tmp/issue.json)
137
81
  echo "number=${NUMBER}" >> "$GITHUB_OUTPUT"
138
- echo "Working on issue #${NUMBER}: $(jq -r '.issue.title' /tmp/issue.json)"
82
+ # Wrap under an `issue` key for the agent
83
+ gh issue view "${NUMBER}" --json number,title,body \
84
+ | jq '{issue: .}' > /tmp/issue.json
85
+ echo "Issue #${NUMBER}: $(jq -r .issue.title /tmp/issue.json)"
86
+ env:
87
+ GH_TOKEN: ${{ github.token }}
139
88
 
140
89
  - name: Acknowledge on the issue
141
90
  env:
@@ -144,140 +93,127 @@ jobs:
144
93
  gh issue comment "${{ steps.issue.outputs.number }}" \
145
94
  --body "🤖 PRISM coding agent picked this up — [run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
146
95
 
147
- - name: Set up Python
148
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
149
- with:
150
- python-version: '3.12'
151
-
152
- - name: Install runner dependencies
153
- run: |
154
- set -euo pipefail
155
- sudo apt-get update -qq && sudo apt-get install -y -qq jq
156
- # Only boto3 -- the agent's own dependencies live in the harness image,
157
- # not here. This runner never loads a model.
158
- pip install --quiet boto3
159
-
160
- - name: Fetch the orchestrator client
161
- id: client
162
- env:
163
- AGENT_REPO: ${{ env.PRISM_AGENT_REPO }}
164
- AGENT_REF: ${{ env.PRISM_AGENT_REF }}
165
- run: |
166
- set -euo pipefail
167
- # Cloned into RUNNER_TEMP, NOT the workspace. actions/checkout can only
168
- # write under GITHUB_WORKSPACE, and anything there is inside the tree the
169
- # agent's patch is applied to and committed from -- a client checkout
170
- # sitting in the working tree is one `git add -A` away from being
171
- # committed into somebody's fix.
172
- DEST="${RUNNER_TEMP}/prism-agent"
173
- rm -rf "${DEST}"
174
- mkdir -p "${DEST}"
175
-
176
- # init + fetch rather than `clone --branch`, because --branch takes a
177
- # branch or tag and rejects a commit SHA -- which is exactly what a repo
178
- # that has pinned PRISM_AGENT_REF will be passing.
179
- git -C "${DEST}" init -q
180
- git -C "${DEST}" remote add origin "https://github.com/${AGENT_REPO}.git"
181
- git -C "${DEST}" sparse-checkout init --cone
182
- git -C "${DEST}" sparse-checkout set coding-agent
183
- git -C "${DEST}" fetch -q --depth 1 origin "${AGENT_REF}"
184
- git -C "${DEST}" checkout -q FETCH_HEAD
185
-
186
- CLIENT="${DEST}/coding-agent"
187
- # Preflight by importing rather than by checking that files exist. The
188
- # runtime dependency is the whole `coding-agent/` directory, not just the
189
- # `agentcore/` package: invoke.py puts its parent on sys.path and imports
190
- # `config`, and contract.py imports it too -- from inside
191
- # FixRequest.validate(), so a missing config.py would not surface until a
192
- # request was already being validated. An import check covers both, plus
193
- # any module inside the package that a narrowed sparse path dropped.
194
- #
195
- # `python -c` rather than a heredoc: a heredoc terminator has to sit at
196
- # column 0, which inside a YAML block scalar means breaking out of the
197
- # block and invalidating the workflow file.
198
- if ! ( cd "${CLIENT}" && python -c "import sys; sys.path.insert(0, '.'); import agentcore.invoke; import config" ); then
199
- echo "::error::${AGENT_REPO}@${AGENT_REF} does not provide a usable" \
200
- "coding-agent/ client (agentcore/ and config.py are both required)"
201
- exit 1
202
- fi
203
- echo "dir=${CLIENT}" >> "$GITHUB_OUTPUT"
204
- echo "Client ${AGENT_REPO}@$(git -C "${DEST}" rev-parse --short FETCH_HEAD)"
205
-
206
96
  - name: Configure AWS credentials (OIDC)
207
97
  uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
208
98
  with:
209
99
  role-to-assume: ${{ secrets.PRISM_METRICS_ROLE_ARN }}
210
100
  aws-region: ${{ env.PRISM_AWS_REGION }}
211
101
 
212
- - name: Load telemetry config from SSM
213
- # One source of truth: CDK writes these at deploy time, so they cannot
214
- # drift from what's actually deployed. Falls back gracefully — a missing
215
- # parameter means telemetry is unconfigured, not that the run fails.
102
+ - name: Resolve harness ARN
103
+ id: harness
216
104
  run: |
217
- set -euo pipefail
218
- read_param() {
219
- aws ssm get-parameter --name "$1" --query Parameter.Value --output text 2>/dev/null || echo ""
220
- }
221
- PRISM_COLLECTOR_URL=$(read_param /prism/d1/collector-url)
222
- PRISM_OIDC_TOKEN_ENDPOINT=$(read_param /prism/d1/token-endpoint)
223
- PRISM_AGENT_SECRET_ID=$(read_param /prism/d1/agent-secret-id)
224
- echo "PRISM_COLLECTOR_URL=${PRISM_COLLECTOR_URL}" >> "$GITHUB_ENV"
225
- echo "PRISM_OIDC_TOKEN_ENDPOINT=${PRISM_OIDC_TOKEN_ENDPOINT}" >> "$GITHUB_ENV"
226
- echo "PRISM_AGENT_SECRET_ID=${PRISM_AGENT_SECRET_ID}" >> "$GITHUB_ENV"
227
- # Harness ARN: the GitHub variable is the primary (pre-OIDC early-fail),
228
- # but if it was empty (e.g. fork with no org vars), SSM is the fallback.
229
- if [[ -z "${PRISM_HARNESS_ARN}" ]]; then
230
- PRISM_HARNESS_ARN=$(read_param /prism/d1/harness-arn)
231
- if [[ -n "${PRISM_HARNESS_ARN}" ]]; then
232
- echo "PRISM_HARNESS_ARN=${PRISM_HARNESS_ARN}" >> "$GITHUB_ENV"
233
- echo "Harness ARN loaded from SSM (GitHub variable was empty)"
234
- fi
105
+ ARN="${PRISM_HARNESS_ARN}"
106
+ if [[ -z "${ARN}" ]]; then
107
+ ARN=$(aws ssm get-parameter --name /prism/d1/harness-arn \
108
+ --query Parameter.Value --output text 2>/dev/null || echo "")
235
109
  fi
236
- if [[ -n "${PRISM_COLLECTOR_URL}" ]]; then
237
- echo "Telemetry: ${PRISM_COLLECTOR_URL} (secret: ${PRISM_AGENT_SECRET_ID})"
238
- else
239
- echo "Telemetry: not configured (SSM params empty or missing)"
110
+ if [[ -z "${ARN}" ]]; then
111
+ echo "::error::No harness ARN (checked variable + SSM /prism/d1/harness-arn)"
112
+ exit 1
240
113
  fi
114
+ echo "arn=${ARN}" >> "$GITHUB_OUTPUT"
115
+ echo "Harness: ${ARN}"
116
+
117
+ - name: Set up Python
118
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
119
+ with:
120
+ python-version: '3.12'
241
121
 
242
- - name: Invoke the harness
122
+ - name: Invoke the agent
243
123
  id: invoke
244
- env:
245
- PRISM_HARNESS_ARN: ${{ env.PRISM_HARNESS_ARN }}
246
124
  run: |
247
- if [[ -z "${PRISM_HARNESS_ARN}" ]]; then
248
- echo "::error::No harness ARN available (checked GitHub variable AND SSM /prism/d1/harness-arn)."
249
- echo "::error::Run coding-agent/deploy/deploy-harness.sh and set the org variable."
250
- exit 1
251
- fi
252
- # GitHub runs `run:` blocks under `bash -e`, so a non-zero exit would
253
- # end the step and skip straight past reporting. `set +e` keeps the exit
254
- # code as data instead of as control flow.
255
- set -o pipefail
256
- set +e
257
- # --repo is the checkout under test, which is NOT the working directory:
258
- # this step runs from the client, and config.json lives in the target
259
- # repo. Passing `.` here would resolve to the client and fail config
260
- # loading with a confusing "no .coding-agent/config.json".
261
- python -m agentcore.invoke \
262
- --repo "${GITHUB_WORKSPACE}" \
263
- --github-event /tmp/issue.json \
264
- --patch-out /tmp/fix.patch \
265
- --result-out /tmp/result.json \
266
- --transcript-out /tmp/agent-transcript.log \
267
- --repo-url "${{ github.server_url }}/${{ github.repository }}.git" \
268
- --ref "${{ github.event.repository.default_branch }}" \
269
- --region "${PRISM_AWS_REGION}" \
270
- 2>&1 | tee /tmp/invoke.log
271
- EXIT_CODE=$?
272
- set -e
273
- echo "exit_code=${EXIT_CODE}" >> "$GITHUB_OUTPUT"
274
-
275
- OUTCOME=$(jq -r '.outcome // "failed"' /tmp/result.json 2>/dev/null || echo failed)
125
+ pip install --quiet boto3
126
+
127
+ # Read subdir from config
128
+ SUBDIR=$(python -c "
129
+ import json, pathlib
130
+ cfg = pathlib.Path('.coding-agent/config.json')
131
+ print(json.loads(cfg.read_text()).get('subdir', '.') if cfg.exists() else '.')
132
+ ")
133
+
134
+ # The repo URL includes the token so the harness can clone private repos.
135
+ # This token is ephemeral (dies when the run ends) and scoped to this repo.
136
+ REPO_URL="https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git"
137
+
138
+ python - <<'PYTHON'
139
+ import boto3, json, sys, uuid
140
+
141
+ RESULT_MARKER = "===PRISM_RESULT_JSON==="
142
+ PATCH_MARKER = "===PRISM_PATCH_START==="
143
+ PATCH_END_MARKER = "===PRISM_PATCH_END==="
144
+
145
+ harness_arn = "${{ steps.harness.outputs.arn }}"
146
+ region = "${{ env.PRISM_AWS_REGION }}"
147
+ repo_url = """${REPO_URL}"""
148
+ ref = "${{ github.event.repository.default_branch }}"
149
+ subdir = """${SUBDIR}"""
150
+ issue_json = open("/tmp/issue.json").read().strip().replace("'", "'\\''")
151
+ session_id = f"prism-agent-{uuid.uuid4()}"
152
+
153
+ cmd = (
154
+ f"echo '{issue_json}' > /tmp/agent-issue.json && "
155
+ f"python /opt/prism-agent/run.py "
156
+ f"--repo-url '{repo_url}' "
157
+ f"--ref '{ref}' "
158
+ f"--subdir '{subdir}' "
159
+ f"--issue @/tmp/agent-issue.json "
160
+ f"--region '{region}'"
161
+ )
162
+
163
+ print(f"Session: {session_id}", file=sys.stderr)
164
+ client = boto3.client("bedrock-agentcore", region_name=region)
165
+
166
+ try:
167
+ response = client.invoke_agent_runtime_command(
168
+ agentRuntimeArn=harness_arn,
169
+ runtimeSessionId=session_id,
170
+ body={"command": cmd, "timeout": 1800},
171
+ )
172
+ except Exception as exc:
173
+ print(f"::error::InvokeAgentRuntimeCommand failed: {exc}", file=sys.stderr)
174
+ json.dump({"outcome": "failed", "reason": str(exc)}, open("/tmp/result.json", "w"))
175
+ open("/tmp/fix.patch", "w").write("")
176
+ sys.exit(0)
177
+
178
+ stdout_parts, exit_code = [], -1
179
+ for event in response.get("stream", []):
180
+ chunk = event.get("chunk", {})
181
+ if "contentDelta" in chunk:
182
+ delta = chunk["contentDelta"]
183
+ if delta.get("stdout"):
184
+ stdout_parts.append(delta["stdout"])
185
+ if delta.get("stderr"):
186
+ print(delta["stderr"], end="", file=sys.stderr)
187
+ elif "contentStop" in chunk:
188
+ exit_code = int(chunk["contentStop"].get("exitCode", -1))
189
+
190
+ stdout = "".join(stdout_parts)
191
+
192
+ # Parse structured output
193
+ if RESULT_MARKER in stdout:
194
+ start = stdout.index(RESULT_MARKER) + len(RESULT_MARKER)
195
+ end = stdout.index(PATCH_MARKER) if PATCH_MARKER in stdout else len(stdout)
196
+ result = json.loads(stdout[start:end].strip())
197
+ else:
198
+ result = {"outcome": "failed", "reason": f"no result marker (exit {exit_code})"}
199
+
200
+ patch = ""
201
+ if PATCH_MARKER in stdout:
202
+ ps = stdout.index(PATCH_MARKER) + len(PATCH_MARKER) + 1
203
+ pe = stdout.index(PATCH_END_MARKER) if PATCH_END_MARKER in stdout else len(stdout)
204
+ patch = stdout[ps:pe]
205
+
206
+ open("/tmp/fix.patch", "w").write(patch)
207
+ json.dump(result, open("/tmp/result.json", "w"), indent=2)
208
+
209
+ outcome = result.get("outcome", "failed")
210
+ verified = result.get("verified", False)
211
+ print(f"\nOutcome: {outcome} (verified={verified}, exit {exit_code})", file=sys.stderr)
212
+ PYTHON
213
+
214
+ # Export for subsequent steps
215
+ OUTCOME=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('outcome','failed'))")
276
216
  echo "outcome=${OUTCOME}" >> "$GITHUB_OUTPUT"
277
- VERIFIED=$(jq -r '.verified // false' /tmp/result.json 2>/dev/null || echo false)
278
- echo "verified=${VERIFIED}" >> "$GITHUB_OUTPUT"
279
- echo "Harness outcome: ${OUTCOME} (verified=${VERIFIED}, exit ${EXIT_CODE})"
280
- working-directory: ${{ steps.client.outputs.dir }}
281
217
 
282
218
  - name: Apply the patch
283
219
  id: apply
@@ -287,108 +223,82 @@ jobs:
287
223
  BRANCH="agent/issue-${{ steps.issue.outputs.number }}"
288
224
  git switch -c "${BRANCH}"
289
225
 
290
- # --check first so a stale or malformed patch is reported as such rather
291
- # than leaving a half-applied tree behind.
292
226
  if ! git apply --check /tmp/fix.patch; then
293
- echo "::error::the returned patch does not apply to this tree"
227
+ echo "::error::patch does not apply"
294
228
  echo "applied=false" >> "$GITHUB_OUTPUT"
295
229
  exit 1
296
230
  fi
297
231
  git apply /tmp/fix.patch
298
232
 
299
- NAME=$(jq -r '.agent_name // "PRISM Coding Agent"' .coding-agent/config.json)
300
- EMAIL=$(jq -r '.agent_email // "prism-agent@example.com"' .coding-agent/config.json)
301
- SUMMARY=$(jq -r '.summary // "Autonomous fix"' /tmp/result.json)
233
+ git config user.email "prism-agent@example.com"
234
+ git config user.name "PRISM Coding Agent"
235
+
236
+ TITLE=$(jq -r '.issue.title' /tmp/issue.json)
237
+ git add -A
238
+ git commit -m "fix: ${TITLE}
302
239
 
303
- git -c "user.name=${NAME}" -c "user.email=${EMAIL}" commit -a -m "$(printf 'fix: %s\n\n%s\n\nCloses #%s\n' \
304
- "$(jq -r '.issue.title' /tmp/issue.json)" \
305
- "${SUMMARY}" \
306
- "${{ steps.issue.outputs.number }}")"
240
+ Closes #${{ steps.issue.outputs.number }}
241
+
242
+ Authored by the PRISM coding agent."
243
+ git push origin "${BRANCH}"
307
244
 
308
245
  echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
309
246
  echo "applied=true" >> "$GITHUB_OUTPUT"
310
- echo "files=$(git diff --name-only HEAD~1 HEAD | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
311
247
  echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
312
- git log --oneline -1
313
-
314
- - name: Emit commit attribution
315
- # After the commit, because the SHA does not exist before it -- the harness
316
- # returns a patch and this workflow makes the commit. Reuses the trace id
317
- # from result.json: a fresh one would leave the commit with no correlated
318
- # usage, and the receiver would freeze its origin as human-authored.
319
- #
320
- # continue-on-error, because a reporting gap must not fail a run that has
321
- # already produced a reviewed fix.
322
- if: steps.apply.outputs.applied == 'true'
323
- continue-on-error: true
324
- run: |
325
- set -euo pipefail
326
- python -m agentcore.emit_commit \
327
- --result /tmp/result.json \
328
- --repo "${PRISM_REPO}" \
329
- --sha "${{ steps.apply.outputs.sha }}"
330
- working-directory: ${{ steps.client.outputs.dir }}
248
+ echo "files=$(git diff --name-only HEAD~1 HEAD | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
331
249
 
332
250
  - name: Open the pull request
333
- id: pr
334
251
  if: steps.apply.outputs.applied == 'true'
335
252
  env:
336
253
  GH_TOKEN: ${{ github.token }}
337
254
  run: |
338
- set -euo pipefail
339
- git push -u origin "${{ steps.apply.outputs.branch }}"
340
- VERIFIED=$(jq -r 'if .verified then "The agent ran this repository'"'"'s checks and they passed." else "⚠️ The agent did NOT verify this change. Review with that in mind." end' /tmp/result.json)
341
- URL=$(gh pr create \
255
+ VERIFIED=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('verified', False))")
256
+ BADGE=""
257
+ if [[ "${VERIFIED}" == "True" ]]; then
258
+ BADGE="✅ Tests pass in the harness"
259
+ else
260
+ BADGE="⚠️ Not independently verified"
261
+ fi
262
+
263
+ gh pr create \
264
+ --base "${{ github.event.repository.default_branch }}" \
265
+ --head "${{ steps.apply.outputs.branch }}" \
342
266
  --title "fix: $(jq -r '.issue.title' /tmp/issue.json)" \
343
- --body "$(printf 'Closes #%s\n\n%s\n\n%s\n\nGenerated by the PRISM coding agent on AgentCore. Nothing here was merged automatically.\n\n[Run log](%s)\n' \
344
- "${{ steps.issue.outputs.number }}" \
345
- "$(jq -r '.summary' /tmp/result.json)" \
346
- "${VERIFIED}" \
347
- "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}")" \
348
- --base "${{ github.event.repository.default_branch }}")
349
- echo "url=${URL}" >> "$GITHUB_OUTPUT"
350
-
351
- - name: Report the outcome on the issue
267
+ --body "Fixes #${{ steps.issue.outputs.number }}
268
+
269
+ ${BADGE}
270
+
271
+ **Changed files:** ${{ steps.apply.outputs.files }}
272
+
273
+ ---
274
+ *Authored by the [PRISM coding agent](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).*"
275
+
276
+ - name: Report outcome on the issue
352
277
  if: always()
353
278
  env:
354
279
  GH_TOKEN: ${{ github.token }}
355
- OUTCOME: ${{ steps.invoke.outputs.outcome }}
356
- PR_URL: ${{ steps.pr.outputs.url }}
357
280
  run: |
358
- set -euo pipefail
359
- RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
360
- REASON=$(jq -r '.reason // ""' /tmp/result.json 2>/dev/null || echo "")
281
+ OUTCOME=$(cat /tmp/result.json 2>/dev/null | jq -r '.outcome // "unknown"')
282
+ REASON=$(cat /tmp/result.json 2>/dev/null | jq -r '.reason // ""' | head -c 500)
361
283
 
362
- case "${OUTCOME:-failed}" in
284
+ case "${OUTCOME}" in
363
285
  patched)
364
- if [[ -n "${PR_URL}" ]]; then
365
- BODY=$(printf '✅ **Fix proposed** — %s\n\nNothing has been merged.\n\n[Run log](%s)' "${PR_URL}" "${RUN_URL}")
366
- else
367
- BODY=$(printf '⚠️ **The agent produced a patch but it could not be applied or pushed.** See the run log for the reason.\n\n[Run log](%s)' "${RUN_URL}")
368
- fi
369
- ;;
286
+ MSG="✅ Fix applied and PR opened." ;;
370
287
  declined)
371
- # Declining is frequently the right answer -- for an issue asking the
372
- # agent to weaken a test suite, or one whose premise does not hold.
373
- # Reporting it as a failure would train people to ignore the signal.
374
- BODY=$(printf '🛑 **The agent declined to make a change.**\n\n> %s\n\nThis is often correct: the request may ask for something it should refuse, or rest on a premise that does not hold.\n\n[Run log](%s)' "${REASON}" "${RUN_URL}")
375
- ;;
288
+ MSG="🛑 The agent declined to make a change. This is often correct.\n\n> ${REASON}" ;;
376
289
  *)
377
- BODY=$(printf '❌ **The agent could not complete this.**\n\n> %s\n\n[Run log](%s)' "${REASON:-see the run log}" "${RUN_URL}")
378
- ;;
290
+ MSG="❌ The agent could not fix this issue.\n\n> ${REASON}" ;;
379
291
  esac
380
292
 
381
- gh issue comment "${{ steps.issue.outputs.number }}" --body "${BODY}"
293
+ gh issue comment "${{ steps.issue.outputs.number }}" --body "${MSG}"
382
294
 
383
- - name: Upload the transcript
295
+ - name: Upload artifacts
384
296
  if: always()
385
297
  uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
386
298
  with:
387
299
  name: agent-run-issue-${{ steps.issue.outputs.number }}
388
300
  path: |
389
- /tmp/invoke.log
390
301
  /tmp/result.json
391
302
  /tmp/fix.patch
392
- /tmp/agent-transcript.log
393
303
  retention-days: 14
394
304
  if-no-files-found: ignore
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env python3
2
+ """Run the full coding agent cycle inside the harness microVM.
3
+
4
+ Called by a single InvokeAgentRuntimeCommand from the workflow. Does everything:
5
+ clone → mise install → deps → agent loop → collect patch → verify → emit telemetry
6
+
7
+ Outputs a JSON object on stdout that the workflow reads for outcome, patch, and
8
+ telemetry correlation. The patch itself is printed as a separate clearly-delimited
9
+ section so the workflow can extract it.
10
+
11
+ Exit 0 always (the outcome is in the JSON, not the exit code). A non-zero exit
12
+ would make the InvokeAgentRuntimeCommand report a generic failure rather than the
13
+ structured result the workflow needs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import shlex
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ # The image has these at /opt/prism-agent via PYTHONPATH
28
+ from agentcore.session import (
29
+ WORKSPACE,
30
+ BASE_SHA_FILE,
31
+ _exclude_pathspecs,
32
+ )
33
+ from agentcore.telemetry import RunTelemetry, emit_run, CollectorConfig
34
+ from config import load_config
35
+
36
+
37
+ CLONE_TIMEOUT = 600
38
+ TOOLCHAIN_TIMEOUT = 900
39
+ DEPS_TIMEOUT = 1200
40
+ AGENT_TIMEOUT = 1500
41
+ VERIFY_TIMEOUT = 600
42
+
43
+
44
+ def _dependency_command() -> str:
45
+ """Shell script that infers the install command from the manifest in cwd."""
46
+ return (
47
+ "sh -c '"
48
+ "if [ -f package-lock.json ]; then npm ci; "
49
+ "elif [ -f package.json ]; then npm install; "
50
+ "elif [ -f requirements.txt ]; then pip install -r requirements.txt; "
51
+ "elif [ -f pyproject.toml ]; then pip install -e . || true; "
52
+ "elif [ -f go.mod ]; then go mod download; "
53
+ "elif [ -f Cargo.toml ]; then cargo fetch; "
54
+ "elif [ -f Gemfile ]; then bundle install; "
55
+ "else echo \"no recognised manifest\"; fi'"
56
+ )
57
+
58
+
59
+ def _fallback_toolchain() -> str:
60
+ """Install a toolchain when the repo pins none."""
61
+ checks = [
62
+ ("package.json", "node", "node@lts"),
63
+ ("pyproject.toml", "python", "python@3.12"),
64
+ ("requirements.txt", "python", "python@3.12"),
65
+ ("go.mod", "go", "go@latest"),
66
+ ("Cargo.toml", "cargo", "rust@latest"),
67
+ ("Gemfile", "ruby", "ruby@3.3"),
68
+ ]
69
+ branches = " ".join(
70
+ f'if [ -f {m} ] && ! command -v {b} >/dev/null 2>&1; then '
71
+ f'echo "UNPINNED: installing {s}"; '
72
+ f'mise use --global {s} >/dev/null 2>&1 || mise install {s}; fi;'
73
+ for m, b, s in checks
74
+ )
75
+ return f"sh -c '{branches}'"
76
+
77
+ RESULT_MARKER = "===PRISM_RESULT_JSON==="
78
+ PATCH_MARKER = "===PRISM_PATCH_START==="
79
+ PATCH_END_MARKER = "===PRISM_PATCH_END==="
80
+
81
+
82
+ def sh(cmd: str, timeout: int = 300, cwd: str | None = None) -> tuple[int, str, str]:
83
+ """Run a shell command. Returns (exit_code, stdout, stderr)."""
84
+ proc = subprocess.run(
85
+ cmd, shell=True, capture_output=True, text=True,
86
+ timeout=timeout, cwd=cwd,
87
+ )
88
+ return proc.returncode, proc.stdout, proc.stderr
89
+
90
+
91
+ def main() -> int:
92
+ parser = argparse.ArgumentParser(prog="run.py")
93
+ parser.add_argument("--repo-url", required=True, help="Clone URL (may include token)")
94
+ parser.add_argument("--ref", default="main")
95
+ parser.add_argument("--subdir", default=".")
96
+ parser.add_argument("--issue", required=True, help="Issue JSON (inline or @filepath)")
97
+ parser.add_argument("--max-iterations", type=int, default=100)
98
+ parser.add_argument("--model-id", default="")
99
+ parser.add_argument("--region", default="us-west-2")
100
+ args = parser.parse_args()
101
+
102
+ started_at = time.time()
103
+ result = {"outcome": "failed", "reason": "", "verified": False,
104
+ "patch": "", "trace_id": "", "session_id": "",
105
+ "usage": {"input_tokens": 0, "output_tokens": 0}}
106
+
107
+ # Parse issue
108
+ if args.issue.startswith("@"):
109
+ issue = json.loads(Path(args.issue[1:]).read_text())
110
+ else:
111
+ issue = json.loads(args.issue)
112
+
113
+ ref = shlex.quote(args.ref)
114
+ url = shlex.quote(args.repo_url)
115
+ subdir = args.subdir.strip("/")
116
+ workdir = f"{WORKSPACE}/{subdir}" if subdir and subdir != "." else WORKSPACE
117
+
118
+ # ---- 1. Clone ----
119
+ print(f"[prepare] clone {args.ref}...", file=sys.stderr)
120
+ code, out, err = sh(
121
+ f"rm -rf {WORKSPACE} && git clone --depth 50 --branch {ref} {url} {WORKSPACE} "
122
+ f"&& cd {WORKSPACE} && git rev-parse HEAD > {BASE_SHA_FILE}",
123
+ timeout=CLONE_TIMEOUT,
124
+ )
125
+ if code != 0:
126
+ result["reason"] = f"clone failed (exit {code}): {err.strip()[-500:]}"
127
+ # Sanitize: don't leak the token in error output
128
+ result["reason"] = result["reason"].replace(args.repo_url, "<repo-url>")
129
+ _emit_result(result)
130
+ return 0
131
+
132
+ base_sha = Path(BASE_SHA_FILE).read_text().strip()
133
+ print(f"[prepare] cloned at {base_sha[:8]}", file=sys.stderr)
134
+
135
+ # ---- 2. Toolchain ----
136
+ print("[prepare] mise install...", file=sys.stderr)
137
+ fallback = _fallback_toolchain()
138
+ code, out, err = sh(
139
+ f"cd {shlex.quote(workdir)} && mise install 2>&1; {fallback} mise ls --current 2>&1 | head -20",
140
+ timeout=TOOLCHAIN_TIMEOUT,
141
+ )
142
+ if code != 0:
143
+ result["reason"] = f"toolchain install failed (exit {code}): {(out+err).strip()[-500:]}"
144
+ _emit_result(result)
145
+ return 0
146
+
147
+ # ---- 3. Dependencies ----
148
+ dep_cmd = _dependency_command()
149
+ if dep_cmd:
150
+ print("[prepare] installing dependencies...", file=sys.stderr)
151
+ code, out, err = sh(
152
+ f"cd {shlex.quote(workdir)} && mise exec -- {dep_cmd}",
153
+ timeout=DEPS_TIMEOUT,
154
+ )
155
+ if code != 0:
156
+ result["reason"] = f"dependency install failed (exit {code}): {(out+err).strip()[-500:]}"
157
+ _emit_result(result)
158
+ return 0
159
+
160
+ # ---- 4. Load config and run the agent ----
161
+ print("[agent] starting...", file=sys.stderr)
162
+ try:
163
+ cfg = load_config(Path(workdir))
164
+ except Exception as exc:
165
+ result["reason"] = f"config error: {exc}"
166
+ _emit_result(result)
167
+ return 0
168
+
169
+ model_id = args.model_id or cfg.model_id
170
+
171
+ # Write the issue to a temp file for agent.py
172
+ issue_path = "/tmp/agent-issue.json"
173
+ Path(issue_path).write_text(json.dumps(issue))
174
+
175
+ # Run agent.py as a subprocess with the iteration bound
176
+ agent_cmd = (
177
+ f"cd /opt/prism-agent && python agent.py "
178
+ f"--repo {shlex.quote(workdir)} "
179
+ f"--issue {issue_path} "
180
+ f"--max-iterations {args.max_iterations} "
181
+ f"--deadline-seconds {AGENT_TIMEOUT} "
182
+ f"--model-id {shlex.quote(model_id)} "
183
+ f"--region {shlex.quote(args.region)}"
184
+ )
185
+ code, out, err = sh(agent_cmd, timeout=AGENT_TIMEOUT + 60, cwd="/opt/prism-agent")
186
+ print(f"[agent] exit {code}", file=sys.stderr)
187
+
188
+ # ---- 5. Collect patch ----
189
+ excludes = _exclude_pathspecs()
190
+ code_c, patch, _ = sh(
191
+ f"cd {WORKSPACE} && git add -N . {excludes} >/dev/null 2>&1; "
192
+ f"git diff {base_sha} -- . {excludes}",
193
+ timeout=120,
194
+ )
195
+
196
+ # ---- 6. Verify ----
197
+ verified = False
198
+ if patch.strip() and cfg.test_command:
199
+ print(f"[verify] {cfg.test_command}...", file=sys.stderr)
200
+ code_v, vout, verr = sh(
201
+ f"cd {shlex.quote(workdir)} && mise exec -- sh -c {shlex.quote(cfg.test_command)}",
202
+ timeout=VERIFY_TIMEOUT,
203
+ )
204
+ verified = code_v == 0
205
+ if not verified:
206
+ print(f"[verify] FAILED (exit {code_v})", file=sys.stderr)
207
+
208
+ # ---- 7. Build result ----
209
+ if patch.strip():
210
+ result["outcome"] = "patched"
211
+ result["patch"] = patch
212
+ result["verified"] = verified
213
+ elif code == 0:
214
+ result["outcome"] = "declined"
215
+ result["reason"] = "agent finished without modifying any files"
216
+ else:
217
+ result["outcome"] = "failed"
218
+ result["reason"] = f"agent exited {code}: {err.strip()[-300:]}"
219
+
220
+ # ---- 8. Telemetry ----
221
+ # Parse usage from agent stderr (it prints iteration counts)
222
+ # For now, report 0 — the harness doesn't give us token counts when running locally
223
+ # TODO: parse from agent output or instrument the model wrapper
224
+
225
+ _emit_result(result)
226
+ return 0
227
+
228
+
229
+ def _emit_result(result: dict) -> None:
230
+ """Print the structured result so the workflow can parse it."""
231
+ # Telemetry attempt (non-fatal)
232
+ try:
233
+ cfg = CollectorConfig.from_env()
234
+ if cfg and result.get("outcome") == "patched":
235
+ run = RunTelemetry(
236
+ repo=os.environ.get("PRISM_REPO", ""),
237
+ project=os.environ.get("PRISM_SUBDIR", "."),
238
+ issue_number=0,
239
+ model=os.environ.get("PRISM_MODEL_ID", ""),
240
+ input_tokens=result["usage"]["input_tokens"],
241
+ output_tokens=result["usage"]["output_tokens"],
242
+ outcome=result["outcome"],
243
+ verified=result["verified"],
244
+ )
245
+ trace_id = emit_run(run, cfg)
246
+ if trace_id:
247
+ result["trace_id"] = trace_id
248
+ result["session_id"] = run.session_id
249
+ except Exception as exc:
250
+ print(f"[telemetry] skipped: {exc}", file=sys.stderr)
251
+
252
+ # Print markers + JSON so the workflow can extract them
253
+ print(RESULT_MARKER)
254
+ print(json.dumps(result, indent=2))
255
+ if result.get("patch"):
256
+ print(PATCH_MARKER)
257
+ print(result["patch"])
258
+ print(PATCH_END_MARKER)
259
+
260
+
261
+ if __name__ == "__main__":
262
+ sys.exit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prism-d1/cli",
3
- "version": "1.0.89",
3
+ "version": "1.0.90",
4
4
  "description": "PRISM D1 Velocity CLI — AI development lifecycle assessment tool",
5
5
  "author": "andklee",
6
6
  "engines": {