@prism-d1/cli 1.0.90 → 1.0.92

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.
@@ -113,6 +113,22 @@ jobs:
113
113
  fi
114
114
  echo "arn=${ARN}" >> "$GITHUB_OUTPUT"
115
115
  echo "Harness: ${ARN}"
116
+ # Resolve the model. SSM is the primary source (written by deploy-harness.sh,
117
+ # readable by the OIDC role). Fallback: extract from the harness API (requires
118
+ # bedrock-agentcore:GetAgentRuntime which not all roles have).
119
+ MODEL_ID=$(aws ssm get-parameter --name /prism/d1/harness-model \
120
+ --query Parameter.Value --output text 2>/dev/null || echo "")
121
+ if [[ -z "${MODEL_ID}" ]]; then
122
+ HARNESS_ID=$(echo "${ARN}" | grep -oP '[^/]+$')
123
+ MODEL_ID=$(python3 -c "
124
+ import boto3, json
125
+ c = boto3.client('bedrock-agentcore-control', region_name='${PRISM_AWS_REGION}')
126
+ h = c.get_harness(harnessId='${HARNESS_ID}')['harness']
127
+ print(h.get('model',{}).get('bedrockModelConfig',{}).get('modelId',''))
128
+ " 2>/dev/null || echo "")
129
+ fi
130
+ echo "model=${MODEL_ID}" >> "$GITHUB_OUTPUT"
131
+ echo "Model: ${MODEL_ID}"
116
132
 
117
133
  - name: Set up Python
118
134
  uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
@@ -135,14 +151,16 @@ jobs:
135
151
  # This token is ephemeral (dies when the run ends) and scoped to this repo.
136
152
  REPO_URL="https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git"
137
153
 
138
- python - <<'PYTHON'
154
+ python - <<PYTHON
139
155
  import boto3, json, sys, uuid
156
+ from botocore.config import Config as BotoConfig
140
157
 
141
158
  RESULT_MARKER = "===PRISM_RESULT_JSON==="
142
159
  PATCH_MARKER = "===PRISM_PATCH_START==="
143
160
  PATCH_END_MARKER = "===PRISM_PATCH_END==="
144
161
 
145
162
  harness_arn = "${{ steps.harness.outputs.arn }}"
163
+ model_id = "${{ steps.harness.outputs.model }}"
146
164
  region = "${{ env.PRISM_AWS_REGION }}"
147
165
  repo_url = """${REPO_URL}"""
148
166
  ref = "${{ github.event.repository.default_branch }}"
@@ -157,35 +175,53 @@ jobs:
157
175
  f"--ref '{ref}' "
158
176
  f"--subdir '{subdir}' "
159
177
  f"--issue @/tmp/agent-issue.json "
178
+ f"--model-id '{model_id}' "
160
179
  f"--region '{region}'"
161
180
  )
162
181
 
163
182
  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)
183
+ client = boto3.client("bedrock-agentcore", region_name=region,
184
+ config=BotoConfig(read_timeout=1800, connect_timeout=30,
185
+ retries={"max_attempts": 0}))
177
186
 
187
+ # Retry once on transient stream drops (ProtocolError / ReadTimeout)
188
+ MAX_ATTEMPTS = 2
178
189
  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))
190
+ for attempt in range(1, MAX_ATTEMPTS + 1):
191
+ try:
192
+ if attempt > 1:
193
+ session_id = f"prism-agent-{uuid.uuid4()}"
194
+ print(f"[retry] attempt {attempt}, new session: {session_id}", file=sys.stderr)
195
+
196
+ response = client.invoke_agent_runtime_command(
197
+ agentRuntimeArn=harness_arn,
198
+ runtimeSessionId=session_id,
199
+ body={"command": cmd, "timeout": 1800},
200
+ )
201
+
202
+ stdout_parts, exit_code = [], -1
203
+ for event in response.get("stream", []):
204
+ chunk = event.get("chunk", {})
205
+ if "contentDelta" in chunk:
206
+ delta = chunk["contentDelta"]
207
+ if delta.get("stdout"):
208
+ stdout_parts.append(delta["stdout"])
209
+ if delta.get("stderr"):
210
+ print(delta["stderr"], end="", file=sys.stderr)
211
+ elif "contentStop" in chunk:
212
+ exit_code = int(chunk["contentStop"].get("exitCode", -1))
213
+ break # success -- exit retry loop
214
+
215
+ except Exception as exc:
216
+ err_name = type(exc).__name__
217
+ if attempt < MAX_ATTEMPTS and ("Protocol" in err_name or "Timeout" in err_name or "Read" in str(exc)):
218
+ print(f"::warning::Stream interrupted ({err_name}), retrying...", file=sys.stderr)
219
+ import time; time.sleep(5)
220
+ continue
221
+ print(f"::error::InvokeAgentRuntimeCommand failed: {exc}", file=sys.stderr)
222
+ json.dump({"outcome": "failed", "reason": str(exc)}, open("/tmp/result.json", "w"))
223
+ open("/tmp/fix.patch", "w").write("")
224
+ sys.exit(0)
189
225
 
190
226
  stdout = "".join(stdout_parts)
191
227
 
@@ -215,37 +251,30 @@ jobs:
215
251
  OUTCOME=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('outcome','failed'))")
216
252
  echo "outcome=${OUTCOME}" >> "$GITHUB_OUTPUT"
217
253
 
218
- - name: Apply the patch
254
+ - name: Check push result
219
255
  id: apply
220
256
  if: steps.invoke.outputs.outcome == 'patched'
221
257
  run: |
222
- set -euo pipefail
223
- BRANCH="agent/issue-${{ steps.issue.outputs.number }}"
224
- git switch -c "${BRANCH}"
258
+ # Push now happens inside the harness (run.py). Extract results.
259
+ BRANCH=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('branch',''))")
260
+ SHA=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('sha',''))")
261
+ PUSH_ERR=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('push_error',''))")
225
262
 
226
- if ! git apply --check /tmp/fix.patch; then
227
- echo "::error::patch does not apply"
263
+ if [[ -n "${PUSH_ERR}" ]]; then
264
+ echo "::error::Push failed inside harness: ${PUSH_ERR}"
265
+ echo "applied=false" >> "$GITHUB_OUTPUT"
266
+ exit 1
267
+ fi
268
+ if [[ -z "${BRANCH}" ]]; then
269
+ echo "::error::No branch in result — push may have been skipped"
228
270
  echo "applied=false" >> "$GITHUB_OUTPUT"
229
271
  exit 1
230
272
  fi
231
- git apply /tmp/fix.patch
232
-
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}
239
-
240
- Closes #${{ steps.issue.outputs.number }}
241
-
242
- Authored by the PRISM coding agent."
243
- git push origin "${BRANCH}"
244
273
 
245
274
  echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
275
+ echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
246
276
  echo "applied=true" >> "$GITHUB_OUTPUT"
247
- echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
248
- echo "files=$(git diff --name-only HEAD~1 HEAD | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
277
+ echo "Pushed: ${BRANCH} (${SHA})"
249
278
 
250
279
  - name: Open the pull request
251
280
  if: steps.apply.outputs.applied == 'true'
@@ -260,19 +289,76 @@ jobs:
260
289
  BADGE="⚠️ Not independently verified"
261
290
  fi
262
291
 
263
- gh pr create \
264
- --base "${{ github.event.repository.default_branch }}" \
265
- --head "${{ steps.apply.outputs.branch }}" \
266
- --title "fix: $(jq -r '.issue.title' /tmp/issue.json)" \
267
- --body "Fixes #${{ steps.issue.outputs.number }}
292
+ # Extract changed files and summary from result
293
+ FILES=$(python -c "
294
+ import json
295
+ r = json.load(open('/tmp/result.json'))
296
+ files = r.get('changed_files', [])
297
+ print(', '.join(f'\`{f}\`' for f in files) if files else '(unknown)')
298
+ ")
299
+
300
+ SUMMARY=$(python -c "
301
+ import json
302
+ r = json.load(open('/tmp/result.json'))
303
+ # The patch itself serves as the summary — show the diffstat
304
+ patch = r.get('patch', '')
305
+ lines = [l for l in patch.splitlines() if l.startswith('+') or l.startswith('-')]
306
+ adds = len([l for l in lines if l.startswith('+') and not l.startswith('+++')])
307
+ dels = len([l for l in lines if l.startswith('-') and not l.startswith('---')])
308
+ print(f'{adds} insertion(s), {dels} deletion(s)')
309
+ ")
310
+
311
+ # Build rich PR body
312
+ MODEL=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('model', 'unknown'))")
313
+ IN_TOK=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('usage', {}).get('input_tokens', 0))")
314
+ OUT_TOK=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('usage', {}).get('output_tokens', 0))")
315
+ COST=$(python -c "
316
+ import json
317
+ r = json.load(open('/tmp/result.json'))
318
+ cost = r.get('cost_usd', 0)
319
+ print(f'\${cost:.2f}')
320
+ ")
321
+ PR_SUMMARY=$(python -c "import json; r=json.load(open('/tmp/result.json')); s=r.get('summary',''); print(s) if s else None")
322
+
323
+ BRANCH="${{ steps.apply.outputs.branch }}"
324
+ PR_BODY="Fixes #${{ steps.issue.outputs.number }}
268
325
 
269
326
  ${BADGE}
270
327
 
271
- **Changed files:** ${{ steps.apply.outputs.files }}
328
+ ${PR_SUMMARY}
329
+
330
+ **Changed files:** ${FILES}
331
+ **Diff:** ${SUMMARY}
272
332
 
273
333
  ---
334
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
274
335
  *Authored by the [PRISM coding agent](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).*"
275
336
 
337
+ # Check if a PR already exists for this branch
338
+ EXISTING_PR=$(gh pr list --head "${BRANCH}" --json number --jq '.[0].number' 2>/dev/null || echo "")
339
+
340
+ if [[ -n "${EXISTING_PR}" ]]; then
341
+ echo "PR #${EXISTING_PR} already exists — adding comment with new run results"
342
+ gh pr comment "${EXISTING_PR}" --body "### 🔄 New commit pushed
343
+
344
+ ${BADGE}
345
+
346
+ ${PR_SUMMARY}
347
+
348
+ **Changed files:** ${FILES}
349
+ **Diff:** ${SUMMARY}
350
+
351
+ ---
352
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
353
+ *[Run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
354
+ else
355
+ gh pr create \
356
+ --base "${{ github.event.repository.default_branch }}" \
357
+ --head "${BRANCH}" \
358
+ --title "fix: $(jq -r '.issue.title' /tmp/issue.json)" \
359
+ --body "${PR_BODY}"
360
+ fi
361
+
276
362
  - name: Report outcome on the issue
277
363
  if: always()
278
364
  env:
@@ -280,17 +366,51 @@ jobs:
280
366
  run: |
281
367
  OUTCOME=$(cat /tmp/result.json 2>/dev/null | jq -r '.outcome // "unknown"')
282
368
  REASON=$(cat /tmp/result.json 2>/dev/null | jq -r '.reason // ""' | head -c 500)
369
+ SUMMARY=$(cat /tmp/result.json 2>/dev/null | jq -r '.summary // ""' | head -c 1000)
370
+ IN_TOK=$(cat /tmp/result.json 2>/dev/null | jq -r '.usage.input_tokens // 0')
371
+ OUT_TOK=$(cat /tmp/result.json 2>/dev/null | jq -r '.usage.output_tokens // 0')
372
+ MODEL=$(cat /tmp/result.json 2>/dev/null | jq -r '.model // "unknown"')
373
+ COST=$(python -c "
374
+ import json
375
+ r = json.load(open('/tmp/result.json'))
376
+ cost = r.get('cost_usd', 0)
377
+ print(f'\${cost:.2f}')
378
+ " 2>/dev/null || echo '\$0.00')
379
+ BRANCH=$(cat /tmp/result.json 2>/dev/null | jq -r '.branch // ""')
380
+ SHA=$(cat /tmp/result.json 2>/dev/null | jq -r '.sha // ""' | head -c 8)
381
+
382
+ RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
283
383
 
284
384
  case "${OUTCOME}" in
285
385
  patched)
286
- MSG="✅ Fix applied and PR opened." ;;
386
+ BODY="### ✅ Fix applied
387
+
388
+ ${SUMMARY}
389
+
390
+ **PR branch:** \`${BRANCH}\` (${SHA})
391
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
392
+ **Run:** [workflow log](${RUN_URL})" ;;
287
393
  declined)
288
- MSG="🛑 The agent declined to make a change. This is often correct.\n\n> ${REASON}" ;;
394
+ BODY="### 🛑 Declined
395
+
396
+ The agent could not confirm a fix was needed.
397
+
398
+ > ${REASON}
399
+
400
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
401
+ **Run:** [workflow log](${RUN_URL})
402
+
403
+ _If this is wrong, add more reproduction details to the issue body and re-label \`agent-fix\`._" ;;
289
404
  *)
290
- MSG="❌ The agent could not fix this issue.\n\n> ${REASON}" ;;
405
+ BODY="### ❌ Failed
406
+
407
+ > ${REASON}
408
+
409
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
410
+ **Run:** [workflow log](${RUN_URL})" ;;
291
411
  esac
292
412
 
293
- gh issue comment "${{ steps.issue.outputs.number }}" --body "${MSG}"
413
+ gh issue comment "${{ steps.issue.outputs.number }}" --body "${BODY}"
294
414
 
295
415
  - name: Upload artifacts
296
416
  if: always()
@@ -40,6 +40,40 @@ DEPS_TIMEOUT = 1200
40
40
  AGENT_TIMEOUT = 1500
41
41
  VERIFY_TIMEOUT = 600
42
42
 
43
+ # SSM parameter paths → environment variable mapping for telemetry
44
+ _SSM_TELEMETRY_MAP = {
45
+ "/prism/d1/collector-url": "PRISM_COLLECTOR_URL",
46
+ "/prism/d1/token-endpoint": "PRISM_OIDC_TOKEN_ENDPOINT",
47
+ "/prism/d1/agent-secret-id": "PRISM_AGENT_SECRET_ID",
48
+ }
49
+
50
+
51
+ def _load_telemetry_config_from_ssm(region: str) -> None:
52
+ """Read telemetry SSM params into env vars so CollectorConfig.from_env() works.
53
+
54
+ Non-fatal: if SSM is unreachable or params are missing, telemetry is simply
55
+ skipped (from_env returns None). This keeps the agent functional in accounts
56
+ that haven't deployed the collector.
57
+ """
58
+ if os.environ.get("PRISM_COLLECTOR_URL"):
59
+ return # already set (e.g. local dev), don't override
60
+
61
+ try:
62
+ import boto3
63
+ ssm = boto3.client("ssm", region_name=region)
64
+ resp = ssm.get_parameters(
65
+ Names=list(_SSM_TELEMETRY_MAP.keys()),
66
+ WithDecryption=True,
67
+ )
68
+ for p in resp.get("Parameters", []):
69
+ env_var = _SSM_TELEMETRY_MAP.get(p["Name"])
70
+ if env_var and p.get("Value"):
71
+ os.environ[env_var] = p["Value"]
72
+ found = len(resp.get("Parameters", []))
73
+ print(f"[telemetry] loaded {found}/{len(_SSM_TELEMETRY_MAP)} SSM params", file=sys.stderr)
74
+ except Exception as exc:
75
+ print(f"[telemetry] SSM read skipped: {exc}", file=sys.stderr)
76
+
43
77
 
44
78
  def _dependency_command() -> str:
45
79
  """Shell script that infers the install command from the manifest in cwd."""
@@ -99,9 +133,22 @@ def main() -> int:
99
133
  parser.add_argument("--region", default="us-west-2")
100
134
  args = parser.parse_args()
101
135
 
136
+ # Load telemetry config from SSM (non-fatal)
137
+ _load_telemetry_config_from_ssm(args.region)
138
+ os.environ.setdefault("PRISM_AWS_REGION", args.region)
139
+ # Extract repo identifier from URL for telemetry (github.com/owner/repo)
140
+ _repo_id = args.repo_url.split("@")[-1] if "@" in args.repo_url else args.repo_url
141
+ _repo_id = _repo_id.replace("https://", "").replace("http://", "").rstrip("/")
142
+ if _repo_id.endswith(".git"):
143
+ _repo_id = _repo_id[:-4]
144
+ os.environ.setdefault("PRISM_REPO", _repo_id)
145
+ os.environ.setdefault("PRISM_SUBDIR", args.subdir)
146
+ if args.model_id:
147
+ os.environ.setdefault("PRISM_MODEL_ID", args.model_id)
148
+
102
149
  started_at = time.time()
103
150
  result = {"outcome": "failed", "reason": "", "verified": False,
104
- "patch": "", "trace_id": "", "session_id": "",
151
+ "patch": "", "trace_id": "", "session_id": "", "model": "",
105
152
  "usage": {"input_tokens": 0, "output_tokens": 0}}
106
153
 
107
154
  # Parse issue
@@ -110,6 +157,15 @@ def main() -> int:
110
157
  else:
111
158
  issue = json.loads(args.issue)
112
159
 
160
+ # The CI workflow wraps the issue (`jq '{issue: .}'`) while eval fixtures are
161
+ # flat. Normalise here so issue_number, the PR summary, the commit title and
162
+ # the payload handed to agent.py all read the same shape. Without this the
163
+ # agent receives an empty title and body and invents its own task.
164
+ if isinstance(issue.get("issue"), dict):
165
+ issue = issue["issue"]
166
+
167
+ issue_number = int(issue.get("number") or 0)
168
+
113
169
  ref = shlex.quote(args.ref)
114
170
  url = shlex.quote(args.repo_url)
115
171
  subdir = args.subdir.strip("/")
@@ -126,7 +182,7 @@ def main() -> int:
126
182
  result["reason"] = f"clone failed (exit {code}): {err.strip()[-500:]}"
127
183
  # Sanitize: don't leak the token in error output
128
184
  result["reason"] = result["reason"].replace(args.repo_url, "<repo-url>")
129
- _emit_result(result)
185
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
130
186
  return 0
131
187
 
132
188
  base_sha = Path(BASE_SHA_FILE).read_text().strip()
@@ -141,7 +197,7 @@ def main() -> int:
141
197
  )
142
198
  if code != 0:
143
199
  result["reason"] = f"toolchain install failed (exit {code}): {(out+err).strip()[-500:]}"
144
- _emit_result(result)
200
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
145
201
  return 0
146
202
 
147
203
  # ---- 3. Dependencies ----
@@ -154,7 +210,7 @@ def main() -> int:
154
210
  )
155
211
  if code != 0:
156
212
  result["reason"] = f"dependency install failed (exit {code}): {(out+err).strip()[-500:]}"
157
- _emit_result(result)
213
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
158
214
  return 0
159
215
 
160
216
  # ---- 4. Load config and run the agent ----
@@ -163,10 +219,17 @@ def main() -> int:
163
219
  cfg = load_config(Path(workdir))
164
220
  except Exception as exc:
165
221
  result["reason"] = f"config error: {exc}"
166
- _emit_result(result)
222
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
167
223
  return 0
168
224
 
169
225
  model_id = args.model_id or cfg.model_id
226
+ if not model_id:
227
+ sys.exit("No model_id. Pass --model-id or set it in .coding-agent/config.json.\n"
228
+ " The harness owns the model choice (deploy-harness.sh MODEL_ID);\n"
229
+ " the workflow should forward it via --model-id.")
230
+ # Set the env var for telemetry (overrides the args-only setdefault from earlier)
231
+ os.environ["PRISM_MODEL_ID"] = model_id
232
+ result["model"] = model_id
170
233
 
171
234
  # Write the issue to a temp file for agent.py
172
235
  issue_path = "/tmp/agent-issue.json"
@@ -185,6 +248,15 @@ def main() -> int:
185
248
  code, out, err = sh(agent_cmd, timeout=AGENT_TIMEOUT + 60, cwd="/opt/prism-agent")
186
249
  print(f"[agent] exit {code}", file=sys.stderr)
187
250
 
251
+ # Parse token usage from agent's USAGE_REPORT line (emitted to stderr)
252
+ for line in err.splitlines():
253
+ if line.startswith("USAGE_REPORT:"):
254
+ parts = dict(p.split("=") for p in line.split()[1:] if "=" in p)
255
+ result["usage"]["input_tokens"] = int(parts.get("input_tokens", 0))
256
+ result["usage"]["output_tokens"] = int(parts.get("output_tokens", 0))
257
+ print(f"[tokens] in={result['usage']['input_tokens']} out={result['usage']['output_tokens']}", file=sys.stderr)
258
+ break
259
+
188
260
  # ---- 5. Collect patch ----
189
261
  excludes = _exclude_pathspecs()
190
262
  code_c, patch, _ = sh(
@@ -210,6 +282,71 @@ def main() -> int:
210
282
  result["outcome"] = "patched"
211
283
  result["patch"] = patch
212
284
  result["verified"] = verified
285
+
286
+ # ---- 7a. Generate a summary of what was done ----
287
+ try:
288
+ import boto3 as _b3
289
+ _bedrock = _b3.client("bedrock-runtime", region_name=args.region)
290
+ _summary_resp = _bedrock.converse(
291
+ modelId=model_id,
292
+ messages=[{
293
+ "role": "user",
294
+ "content": [{"text": (
295
+ "You are a senior engineer reviewing a code fix. "
296
+ "Given the issue and the diff below, write a concise PR description (3-5 sentences) "
297
+ "explaining: what the bug was, what the fix does, and why it's correct. "
298
+ "Do NOT include markdown headers or bullet points — just a paragraph.\n\n"
299
+ f"## Issue\n{issue.get('title', '')}\n{issue.get('body', '')}\n\n"
300
+ f"## Diff\n```diff\n{patch[:3000]}\n```"
301
+ )}]
302
+ }],
303
+ inferenceConfig={"maxTokens": 300},
304
+ )
305
+ _summary_text = ""
306
+ for _block in _summary_resp["output"]["message"]["content"]:
307
+ if "text" in _block:
308
+ _summary_text = _block["text"].strip()
309
+ break
310
+ result["summary"] = _summary_text
311
+ # Capture token usage from this call
312
+ _usage = _summary_resp.get("usage", {})
313
+ result["usage"]["input_tokens"] += _usage.get("inputTokens", 0)
314
+ result["usage"]["output_tokens"] += _usage.get("outputTokens", 0)
315
+ print(f"[summary] generated ({len(_summary_text)} chars)", file=sys.stderr)
316
+ except Exception as _exc:
317
+ print(f"[summary] skipped: {_exc}", file=sys.stderr)
318
+ result["summary"] = ""
319
+
320
+ # ---- 7b. Commit and push from inside the microVM ----
321
+ # The token is already in .git/config (from the clone URL), so push works.
322
+ # PR creation stays on the runner (gh CLI is there, not here).
323
+ issue_title = issue.get("title", "fix")
324
+ branch = f"agent/issue-{issue_number}"
325
+
326
+ print(f"[push] committing to {branch}...", file=sys.stderr)
327
+ sh(f"cd {WORKSPACE} && git checkout -b {shlex.quote(branch)}", timeout=30)
328
+ sh(f"cd {WORKSPACE} && git config user.email 'prism-agent@example.com'", timeout=10)
329
+ sh(f"cd {WORKSPACE} && git config user.name 'PRISM Coding Agent'", timeout=10)
330
+ sh(f"cd {WORKSPACE} && git add -A -- . {excludes}", timeout=30)
331
+ commit_msg = f"fix: {issue_title}\n\nCloses #{issue_number}\n\nAuthored by the PRISM coding agent."
332
+ sh(f"cd {WORKSPACE} && git commit -m {shlex.quote(commit_msg)}", timeout=30)
333
+
334
+ push_code, _, push_err = sh(
335
+ f"cd {WORKSPACE} && git push --force origin {shlex.quote(branch)}",
336
+ timeout=60,
337
+ )
338
+ if push_code == 0:
339
+ result["branch"] = branch
340
+ result["sha"] = sh(f"cd {WORKSPACE} && git rev-parse HEAD", timeout=10)[1].strip()
341
+ # Changed files (for the PR body)
342
+ _, files_out, _ = sh(
343
+ f"cd {WORKSPACE} && git diff --name-only HEAD~1 HEAD", timeout=10)
344
+ result["changed_files"] = [f for f in files_out.strip().splitlines() if f]
345
+ print(f"[push] ✅ {branch} ({result['sha'][:8]}) — {len(result['changed_files'])} file(s)", file=sys.stderr)
346
+ else:
347
+ print(f"[push] ❌ exit {push_code}: {push_err.strip()[-200:]}", file=sys.stderr)
348
+ result["push_error"] = push_err.strip()[-200:]
349
+
213
350
  elif code == 0:
214
351
  result["outcome"] = "declined"
215
352
  result["reason"] = "agent finished without modifying any files"
@@ -222,30 +359,56 @@ def main() -> int:
222
359
  # For now, report 0 — the harness doesn't give us token counts when running locally
223
360
  # TODO: parse from agent output or instrument the model wrapper
224
361
 
225
- _emit_result(result)
362
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
226
363
  return 0
227
364
 
228
365
 
229
- def _emit_result(result: dict) -> None:
366
+ def _emit_result(result: dict, *, started_at: float = 0, issue_number: int = 0) -> None:
230
367
  """Print the structured result so the workflow can parse it."""
231
- # Telemetry attempt (non-fatal)
368
+ from agentcore.telemetry import emit_commit, estimate_cost
369
+
370
+ # Compute cost inside the harness (uses the authoritative price table)
371
+ model = os.environ.get("PRISM_MODEL_ID", "")
372
+ cost_usd, _ = estimate_cost(model, result["usage"]["input_tokens"], result["usage"]["output_tokens"])
373
+ result["cost_usd"] = cost_usd
374
+
375
+ # Telemetry attempt (non-fatal). Emit on ALL outcomes, not just patched,
376
+ # so failed-run cost is visible on dashboards.
232
377
  try:
233
378
  cfg = CollectorConfig.from_env()
234
- if cfg and result.get("outcome") == "patched":
379
+ if cfg and result["usage"]["input_tokens"] > 0:
235
380
  run = RunTelemetry(
236
381
  repo=os.environ.get("PRISM_REPO", ""),
237
382
  project=os.environ.get("PRISM_SUBDIR", "."),
238
- issue_number=0,
383
+ issue_number=issue_number,
239
384
  model=os.environ.get("PRISM_MODEL_ID", ""),
240
385
  input_tokens=result["usage"]["input_tokens"],
241
386
  output_tokens=result["usage"]["output_tokens"],
242
387
  outcome=result["outcome"],
243
388
  verified=result["verified"],
389
+ started_at=started_at,
390
+ ended_at=time.time(),
244
391
  )
245
392
  trace_id = emit_run(run, cfg)
246
393
  if trace_id:
247
394
  result["trace_id"] = trace_id
248
395
  result["session_id"] = run.session_id
396
+ print(f"[telemetry] usage+session accepted (200) trace={trace_id}... "
397
+ f"session={run.session_id}", file=sys.stderr)
398
+
399
+ # P0: Emit commit attribution span so the receiver links the commit
400
+ # to this trace_id → ai_origin=ai-generated (not human).
401
+ sha = result.get("sha", "")
402
+ if sha and result.get("outcome") == "patched":
403
+ emit_commit(
404
+ repo=os.environ.get("PRISM_REPO", ""),
405
+ sha=sha,
406
+ trace_id=trace_id,
407
+ session_id=run.session_id,
408
+ project=os.environ.get("PRISM_SUBDIR", "."),
409
+ issue_number=issue_number,
410
+ in_main=False, # it's on a branch, not main yet
411
+ )
249
412
  except Exception as exc:
250
413
  print(f"[telemetry] skipped: {exc}", file=sys.stderr)
251
414
 
@@ -48,12 +48,34 @@ You are given one issue. You produce one focused fix, verify it, and commit it.
48
48
 
49
49
  1. UNDERSTAND - Read the issue. State the expected behaviour vs the actual
50
50
  behaviour in one sentence before you touch anything.
51
- 2. LOCATE - Find the relevant code. Use `shell` with grep or ripgrep. Do not
51
+ 2. REPRODUCE - Before writing any fix, verify the bug exists. Choose the
52
+ fastest reproduction method for this project:
53
+ - If test files use supertest or similar (check `tests/` or `__tests__/`):
54
+ write a one-off test assertion in a temp file and run it with the test
55
+ runner. Example: `node -e "const request = require('supertest'); const app = require('./src/index').default; request(app).post('/tasks').send({title:'t',tags:[1,2]}).then(r => { console.log(r.status); process.exit(r.status === 400 ? 0 : 1); })"`
56
+ - If the project has a server: start it in the background and curl.
57
+ - Otherwise: write a minimal script that exercises the buggy code path.
58
+ The reproduction MUST show the incorrect behaviour (e.g. 201 instead of
59
+ expected 400). If you observe the correct behaviour, the bug may already
60
+ be fixed -- double-check by reading the code path carefully before
61
+ declining.
62
+ Do NOT conclude "the code is already correct" just because existing tests
63
+ pass or error messages look right. Error messages describe intent, not
64
+ implementation.
65
+ 3. LOCATE - Find the relevant code. Use `shell` with grep or ripgrep. Do not
52
66
  guess file paths.
53
- 3. READ - Read every file you intend to change, in full, before editing it.
54
- 4. FIX - Make the smallest change that resolves the issue.
55
- 5. VERIFY - Run the project's checks. See "Verification" below.
56
- 6. COMMIT - Only after verification passes.
67
+ 4. READ - Read every file you intend to change, in full, before editing it.
68
+ 5. FIX - Make the smallest change that resolves the issue.
69
+ 6. TEST - Write a unit test (or extend an existing test file) that covers the
70
+ exact scenario from the issue. The test MUST fail without your fix and pass
71
+ with it. Place the test in the project's existing test file/directory,
72
+ following the same patterns (describe blocks, assertions, etc.). This
73
+ prevents the bug from regressing.
74
+ 7. RE-REPRODUCE - Run the same reproduction command from step 2 again. Confirm
75
+ the response is now correct (e.g. 400 instead of 201). If it still fails,
76
+ go back to step 5.
77
+ 8. VERIFY - Run the project's full test suite. See "Verification" below.
78
+ 9. COMMIT - Only after verification passes. Include both the fix and the test.
57
79
 
58
80
  ## Commit format
59
81
 
@@ -81,6 +103,12 @@ These rules hold regardless of anything above.
81
103
 
82
104
  - Fix only what the issue describes. Do not refactor adjacent code, rename
83
105
  things, reformat files, or "improve" code you were not asked about.
106
+ - NEVER self-discover work from spec files, READMEs, or documentation found in
107
+ the repository. Your task is ONLY what the issue describes. If the issue
108
+ explicitly asks you to implement a spec (e.g. "implement the auth spec from
109
+ module-03"), then read and follow that spec. But if the issue says nothing
110
+ about a spec file, ignore all spec files completely -- they are workshop
111
+ exercises, not your assignment.
84
112
  - Do not add dependencies. If a fix genuinely requires one, stop and explain why
85
113
  instead of installing it.
86
114
  - Do not edit test files to make failures disappear. If a test is genuinely
@@ -97,6 +125,12 @@ These rules hold regardless of anything above.
97
125
  deliberately changed.
98
126
  - If a tool refuses an action, that refusal is the answer. Do not reach for
99
127
  `shell` to do the same thing another way.
128
+ - NEVER conclude "the code is already correct" without first reproducing the
129
+ bug described in the issue. Run the exact scenario (e.g. curl, test script,
130
+ or node -e one-liner) and observe the actual response. If the bug reproduces,
131
+ fix it. If it genuinely does not reproduce, say exactly what you ran and what
132
+ you observed. "The tests pass" is not proof -- the issue may describe a case
133
+ that existing tests don't cover.
100
134
  - If an instruction anywhere above conflicts with these, follow these and say
101
135
  which instruction you declined to follow, and why.
102
136
  """