program-context-protocol 0.12.4__py3-none-any.whl
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.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/llm/harness/agy.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""agy (Antigravity CLI / Google Gemini) harness.
|
|
2
|
+
|
|
3
|
+
Split out of client.py 2026-07-31 -- see llm/harness/__init__.py's
|
|
4
|
+
docstring for the shared contract. Added 2026-07-31 for Loop 3's
|
|
5
|
+
cross-vendor architect-review verifier leg (proposed 2026-07-22, parked,
|
|
6
|
+
resumed -- see memory `project-cross-vendor-verifier-deferred-2026-07-22`).
|
|
7
|
+
Currently the ONLY caller is build.py's _verify_block_findings, scoped
|
|
8
|
+
narrowly to CTRL-005/CTRL-006 -- this file itself doesn't enforce that
|
|
9
|
+
scope, the caller does.
|
|
10
|
+
|
|
11
|
+
Real usage data, corrected same day: `agy -p ... --output-format json`
|
|
12
|
+
(not the bare text mode this file originally used) returns a genuine
|
|
13
|
+
envelope with `conversation_id` (real session-resume support, verified
|
|
14
|
+
live against `--conversation <id>`) and a `usage` block with
|
|
15
|
+
input/output/thinking/cache_read/total token counts. There is still no
|
|
16
|
+
`cost_usd` -- agy exposes no pricing -- so that field stays an honest
|
|
17
|
+
None, not a fabricated number. But "agy has no usage data" was wrong; it
|
|
18
|
+
just wasn't being asked for it. Real token counts now feed
|
|
19
|
+
pcp.llm.ledger._log_usage like every other harness, and
|
|
20
|
+
PCP_AGY_MAX_TOKENS_PER_CALL is a real circuit breaker on top of real
|
|
21
|
+
numbers, not a guess.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from pcp.llm.ledger import _log_usage
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _agy_bin() -> str:
|
|
33
|
+
return os.environ.get("PCP_AGY_BIN", "agy")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _agy_timeout() -> int:
|
|
37
|
+
return int(os.environ.get("PCP_AGY_TIMEOUT", "240"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _agy_effort() -> str:
|
|
41
|
+
"""Default "low" -- Loop 3's only caller today is a verifier check, not
|
|
42
|
+
a task that benefits from deep reasoning, and effort is real cost
|
|
43
|
+
control agy exposes (confirmed: --effort low measurably drops
|
|
44
|
+
thinking_tokens). PCP_AGY_EFFORT overrides for a caller that needs more."""
|
|
45
|
+
return os.environ.get("PCP_AGY_EFFORT", "low")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _agy_max_tokens_per_call() -> int:
|
|
49
|
+
"""Per-call ceiling, same role --max-budget-usd plays for claude (a
|
|
50
|
+
live cap passed/checked per call, not a cumulative tracker) -- except
|
|
51
|
+
agy has no self-enforcing budget flag, so this is checked AFTER the
|
|
52
|
+
call returns, against real reported usage. Can't prevent the tokens
|
|
53
|
+
already spent on a call that blew past it, but does turn that call
|
|
54
|
+
into a raised, non-silent failure (fails the same way a timeout or
|
|
55
|
+
missing binary does) rather than a quietly-accepted result -- and
|
|
56
|
+
stops a caller from treating an oversized response as normal."""
|
|
57
|
+
return int(os.environ.get("PCP_AGY_MAX_TOKENS_PER_CALL", "200000"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _call_agy(system: str, user: str, model: str | None = None, pcp_dir: Path | None = None,
|
|
61
|
+
command: str = "llm.call", return_meta: bool = False) -> str | tuple[str, dict]:
|
|
62
|
+
"""Same contract as harness.claude._call_claude(). `model`, if given, is
|
|
63
|
+
passed through to agy's own `--model` flag. `--effort` is always passed
|
|
64
|
+
(see _agy_effort()) -- real cost control, not optional."""
|
|
65
|
+
prompt = f"{system}\n\n---\n\n{user}"
|
|
66
|
+
cwd = Path(pcp_dir).parent if pcp_dir else None
|
|
67
|
+
cmd = [
|
|
68
|
+
_agy_bin(), "-p", prompt, "--output-format", "json",
|
|
69
|
+
"--print-timeout", f"{_agy_timeout()}s", "--effort", _agy_effort(),
|
|
70
|
+
]
|
|
71
|
+
if model:
|
|
72
|
+
cmd += ["--model", model]
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
cmd, capture_output=True, text=True, timeout=_agy_timeout() + 30, cwd=cwd,
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
f"agy CLI not found at '{_agy_bin()}'. Install Antigravity CLI, or unset "
|
|
81
|
+
"PCP_LLM_HARNESS/PCP_VERIFIER_CROSS_VENDOR to skip it."
|
|
82
|
+
)
|
|
83
|
+
except subprocess.TimeoutExpired:
|
|
84
|
+
raise RuntimeError(f"agy CLI timed out after {_agy_timeout()}s")
|
|
85
|
+
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
raise RuntimeError(f"agy CLI exited {result.returncode}: {result.stderr.strip()}")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
envelope = json.loads(result.stdout)
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
# Fallback: unexpected/older CLI output -- treat stdout as raw text,
|
|
93
|
+
# same posture harness.claude._call_claude() takes on the same failure.
|
|
94
|
+
text = result.stdout.strip()
|
|
95
|
+
return (text, {"model": model or "agy/default", "session_id": None, "usage": {}, "cost_usd": None, "duration_ms": None}) if return_meta else text
|
|
96
|
+
|
|
97
|
+
if envelope.get("status") and envelope.get("status") != "SUCCESS":
|
|
98
|
+
raise RuntimeError(f"agy CLI reported status={envelope.get('status')}: {envelope.get('response', '')}")
|
|
99
|
+
|
|
100
|
+
usage = envelope.get("usage", {}) or {}
|
|
101
|
+
total_tokens = usage.get("total_tokens", 0)
|
|
102
|
+
if total_tokens > _agy_max_tokens_per_call():
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"agy call used {total_tokens} tokens, over the {_agy_max_tokens_per_call()} "
|
|
105
|
+
"PCP_AGY_MAX_TOKENS_PER_CALL ceiling -- treated as a failed call, same as a "
|
|
106
|
+
"timeout, rather than silently accepted."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
duration_ms = int(envelope.get("duration_seconds", 0) * 1000) if envelope.get("duration_seconds") else None
|
|
110
|
+
conversation_id = envelope.get("conversation_id")
|
|
111
|
+
|
|
112
|
+
_log_usage(pcp_dir, command, model or "agy/default", conversation_id, usage, None)
|
|
113
|
+
|
|
114
|
+
text = (envelope.get("response") or "").strip()
|
|
115
|
+
if not return_meta:
|
|
116
|
+
return text
|
|
117
|
+
meta = {
|
|
118
|
+
"model": model or "agy/default", "session_id": conversation_id,
|
|
119
|
+
"usage": usage, "cost_usd": None, "duration_ms": duration_ms,
|
|
120
|
+
}
|
|
121
|
+
return text, meta
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Antigravity CLI (agy) coding-agent-loop harness -- implements
|
|
2
|
+
CodingAgentHarness (llm/coding_agent_contract.py).
|
|
3
|
+
|
|
4
|
+
Provenance: the first draft of this file was written BY agy itself
|
|
5
|
+
(2026-08-01), given the contract and agy's own verified real CLI behavior
|
|
6
|
+
as input -- the same reasoning applied to "let Codex verify its own
|
|
7
|
+
contract instead of guessing" throughout the multi-harness conversation
|
|
8
|
+
(tag `pre-multi-harness-extension`) applies to agy here too. Reviewed and
|
|
9
|
+
lightly corrected before being accepted, same as any other AI-authored
|
|
10
|
+
change in this codebase -- not applied blind.
|
|
11
|
+
|
|
12
|
+
One correction from the original draft worth recording: it hardcoded
|
|
13
|
+
`--effort low` for every call, copying that choice from harness/agy.py's
|
|
14
|
+
verifier leg (a yes/no judgment call, where low effort is a deliberate,
|
|
15
|
+
measured cost control) without noticing that actual code generation is a
|
|
16
|
+
different kind of task -- forcing low reasoning effort on real coding work
|
|
17
|
+
isn't a cost optimization there, it's a quality regression. Fixed to leave
|
|
18
|
+
effort unset by default (agy's own default reasoning depth), configurable
|
|
19
|
+
via PCP_AGY_CODING_EFFORT for a caller that wants to override it either
|
|
20
|
+
direction.
|
|
21
|
+
|
|
22
|
+
Honest limitations, stated rather than papered over:
|
|
23
|
+
1. No pre-enforced budget ceiling. agy has no `--max-budget-usd`
|
|
24
|
+
equivalent -- request.max_budget_usd cannot be pre-enforced at the CLI
|
|
25
|
+
level, only checked after the fact against real reported tokens
|
|
26
|
+
(see PCP_AGY_MAX_TOKENS_PER_CALL below, default 500,000/call).
|
|
27
|
+
2. cost_usd is always None. agy reports real token usage but no pricing
|
|
28
|
+
data -- never fabricated here.
|
|
29
|
+
3. request.session_id (the caller-chosen id for a FRESH session) is
|
|
30
|
+
advisory only, not actually usable. Unlike Claude Code's
|
|
31
|
+
`--session-id <id>` (open a NEW session under a caller-picked name),
|
|
32
|
+
agy always self-assigns its own conversation_id on a fresh call --
|
|
33
|
+
there is no flag to open one under a name PCP picked. The REAL id
|
|
34
|
+
PCP must track going forward is whatever comes back in the result,
|
|
35
|
+
not what it requested. This is a genuine harness capability
|
|
36
|
+
difference (see the multi-harness conversation's "accept divergence
|
|
37
|
+
at the implementation layer" conclusion), not a bug to paper over.
|
|
38
|
+
|
|
39
|
+
Not wired into `pcp build` -- Stage 3/4 of the multi-harness plan still
|
|
40
|
+
require a deliberate decision to actually route criteria through this
|
|
41
|
+
harness instead of Claude Code. This file existing does not change
|
|
42
|
+
`pcp build`'s behavior.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import subprocess
|
|
50
|
+
import time
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
from pcp.llm.coding_agent_contract import CodingAgentRequest, CodingAgentResult
|
|
54
|
+
|
|
55
|
+
DEFAULT_MAX_TOKENS_PER_CALL = 500_000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _agy_coding_effort() -> str | None:
|
|
59
|
+
"""None by default -- let agy use its own default reasoning depth for
|
|
60
|
+
real code-writing, unlike the verifier leg's deliberate --effort low.
|
|
61
|
+
PCP_AGY_CODING_EFFORT overrides either direction."""
|
|
62
|
+
return os.environ.get("PCP_AGY_CODING_EFFORT")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _agy_coding_max_tokens_per_call() -> int:
|
|
66
|
+
env_limit = os.environ.get("PCP_AGY_MAX_TOKENS_PER_CALL")
|
|
67
|
+
return int(env_limit) if env_limit else DEFAULT_MAX_TOKENS_PER_CALL
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AgyCodingAgentHarness:
|
|
71
|
+
"""Coding agent harness implementation wrapping the Antigravity `agy`
|
|
72
|
+
CLI. See module docstring for what's honestly not supported."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, max_tokens_limit: int | None = None) -> None:
|
|
75
|
+
self.max_tokens_limit = max_tokens_limit if max_tokens_limit is not None else _agy_coding_max_tokens_per_call()
|
|
76
|
+
|
|
77
|
+
def run(self, request: CodingAgentRequest) -> CodingAgentResult:
|
|
78
|
+
"""Run one coding-task attempt using the `agy` CLI in unattended
|
|
79
|
+
file-editing mode against request.cwd."""
|
|
80
|
+
cmd: list[str] = [
|
|
81
|
+
"agy", "-p", request.prompt,
|
|
82
|
+
"--output-format", "json",
|
|
83
|
+
"--print-timeout", f"{request.timeout_sec}s",
|
|
84
|
+
"--dangerously-skip-permissions",
|
|
85
|
+
"--mode", "accept-edits",
|
|
86
|
+
"--sandbox",
|
|
87
|
+
"--add-dir", str(request.cwd),
|
|
88
|
+
]
|
|
89
|
+
effort = _agy_coding_effort()
|
|
90
|
+
if effort:
|
|
91
|
+
cmd += ["--effort", effort]
|
|
92
|
+
|
|
93
|
+
if request.resume_session_id:
|
|
94
|
+
cmd += ["--conversation", request.resume_session_id]
|
|
95
|
+
# No else branch: agy has no flag to open a fresh session under a
|
|
96
|
+
# caller-chosen id (see module docstring, limitation 3) -- a fresh
|
|
97
|
+
# call just starts a new conversation and self-assigns its own id.
|
|
98
|
+
|
|
99
|
+
if request.model:
|
|
100
|
+
cmd += ["--model", str(request.model)]
|
|
101
|
+
|
|
102
|
+
start_time = time.monotonic()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
res = subprocess.run(
|
|
106
|
+
cmd, cwd=request.cwd, capture_output=True, text=True, timeout=request.timeout_sec,
|
|
107
|
+
)
|
|
108
|
+
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
109
|
+
except subprocess.TimeoutExpired:
|
|
110
|
+
return CodingAgentResult(
|
|
111
|
+
ok=False, error=f"Execution timed out after {request.timeout_sec} seconds",
|
|
112
|
+
session_id=request.resume_session_id or request.session_id, model=request.model,
|
|
113
|
+
duration_ms=int((time.monotonic() - start_time) * 1000),
|
|
114
|
+
)
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
return CodingAgentResult(
|
|
117
|
+
ok=False, error="Executable 'agy' CLI was not found in PATH",
|
|
118
|
+
session_id=request.resume_session_id or request.session_id, model=request.model,
|
|
119
|
+
duration_ms=int((time.monotonic() - start_time) * 1000),
|
|
120
|
+
)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
return CodingAgentResult(
|
|
123
|
+
ok=False, error=f"Subprocess execution failed: {e}",
|
|
124
|
+
session_id=request.resume_session_id or request.session_id, model=request.model,
|
|
125
|
+
duration_ms=int((time.monotonic() - start_time) * 1000),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
stdout = res.stdout.strip()
|
|
129
|
+
stderr = res.stderr.strip()
|
|
130
|
+
|
|
131
|
+
if not stdout:
|
|
132
|
+
err_msg = stderr or f"Process exited with code {res.returncode} and empty stdout"
|
|
133
|
+
return CodingAgentResult(
|
|
134
|
+
ok=False, error=err_msg,
|
|
135
|
+
session_id=request.resume_session_id or request.session_id, model=request.model,
|
|
136
|
+
duration_ms=elapsed_ms,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
payload = json.loads(stdout)
|
|
141
|
+
except json.JSONDecodeError:
|
|
142
|
+
err_msg = f"Failed to parse JSON response envelope from agy. Raw stdout: {stdout[:500]}"
|
|
143
|
+
if stderr:
|
|
144
|
+
err_msg += f" | stderr: {stderr[:500]}"
|
|
145
|
+
return CodingAgentResult(
|
|
146
|
+
ok=False, error=err_msg,
|
|
147
|
+
session_id=request.resume_session_id or request.session_id, model=request.model,
|
|
148
|
+
duration_ms=elapsed_ms,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# The REAL session id going forward -- never what was requested,
|
|
152
|
+
# see module docstring limitation 3.
|
|
153
|
+
session_id = payload.get("conversation_id") or request.resume_session_id or request.session_id
|
|
154
|
+
status = payload.get("status")
|
|
155
|
+
usage = payload.get("usage") or {}
|
|
156
|
+
duration_seconds = payload.get("duration_seconds")
|
|
157
|
+
duration_ms = int(duration_seconds * 1000) if duration_seconds is not None else elapsed_ms
|
|
158
|
+
|
|
159
|
+
if res.returncode != 0 or status != "SUCCESS":
|
|
160
|
+
err_msg = payload.get("error") or payload.get("response") or stderr or f"agy reported status={status} (exit code {res.returncode})"
|
|
161
|
+
return CodingAgentResult(
|
|
162
|
+
ok=False, error=str(err_msg), session_id=session_id, model=request.model,
|
|
163
|
+
usage=usage, cost_usd=None, duration_ms=duration_ms,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
total_tokens = usage.get("total_tokens", 0)
|
|
167
|
+
if total_tokens > self.max_tokens_limit:
|
|
168
|
+
return CodingAgentResult(
|
|
169
|
+
ok=False,
|
|
170
|
+
error=(
|
|
171
|
+
f"Token usage limit exceeded: call used {total_tokens} total tokens "
|
|
172
|
+
f"(limit is {self.max_tokens_limit} tokens)"
|
|
173
|
+
),
|
|
174
|
+
session_id=session_id, model=request.model, usage=usage, cost_usd=None, duration_ms=duration_ms,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return CodingAgentResult(
|
|
178
|
+
ok=True, error=None, session_id=session_id, model=request.model,
|
|
179
|
+
usage=usage, cost_usd=None, duration_ms=duration_ms,
|
|
180
|
+
)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Claude Code harness -- `claude -p` CLI subprocess. No API key required.
|
|
2
|
+
|
|
3
|
+
Everything in this file is genuinely Claude-Code-specific: exact CLI flags,
|
|
4
|
+
the --output-format json / --input-format stream-json envelope shapes,
|
|
5
|
+
session_id/usage/cost field names. Split out of client.py 2026-07-31 so the
|
|
6
|
+
common dispatch layer (client.py) and per-harness implementations (this
|
|
7
|
+
file, agy.py) are physically separated, not just logically -- see
|
|
8
|
+
llm/harness/__init__.py's docstring for the contract every file here must
|
|
9
|
+
implement.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pcp.llm.ledger import _log_usage
|
|
20
|
+
|
|
21
|
+
_MEDIA_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _claude_bin() -> str:
|
|
25
|
+
return os.environ.get("PCP_CLAUDE_BIN", "claude")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _timeout() -> int:
|
|
29
|
+
return int(os.environ.get("PCP_LLM_TIMEOUT", "300"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _call_claude(system: str, user: str, model: str | None = None, pcp_dir: Path | None = None,
|
|
33
|
+
command: str = "llm.call", return_meta: bool = False) -> str | tuple[str, dict]:
|
|
34
|
+
"""Run prompt through claude CLI (one-shot, no session reuse). Returns text output.
|
|
35
|
+
|
|
36
|
+
model: explicit model for this call site (e.g. JUDGE_MODEL). PCP_MODEL env overrides
|
|
37
|
+
everything if set, so a human can force a model for debugging.
|
|
38
|
+
pcp_dir/command: if given, usage+cost is appended to .pcp/token_ledger.yaml.
|
|
39
|
+
return_meta: if True, returns (text, meta) where meta has model/session_id/usage/
|
|
40
|
+
cost_usd/duration_ms — for callers building richer per-event telemetry (see telemetry.py).
|
|
41
|
+
"""
|
|
42
|
+
prompt = f"{system}\n\n---\n\n{user}"
|
|
43
|
+
|
|
44
|
+
resolved_model = os.environ.get("PCP_MODEL") or model
|
|
45
|
+
cmd = [_claude_bin(), "-p", "--output-format", "json"]
|
|
46
|
+
if resolved_model:
|
|
47
|
+
cmd += ["--model", resolved_model]
|
|
48
|
+
|
|
49
|
+
# Real bug, found 2026-07-08: this call never passed cwd, so it always
|
|
50
|
+
# ran in whatever the calling PROCESS's actual OS cwd happened to be --
|
|
51
|
+
# not necessarily the target project. Harmless when the CLI is invoked
|
|
52
|
+
# from the project root (the common case), actively wrong otherwise: a
|
|
53
|
+
# test process (or any caller) with a different cwd would silently run
|
|
54
|
+
# the agent against the wrong directory. pcp_dir is already passed by
|
|
55
|
+
# every call site for token-ledger logging, so it doubles as the correct
|
|
56
|
+
# anchor here — project_root = pcp_dir.parent.
|
|
57
|
+
cwd = Path(pcp_dir).parent if pcp_dir else None
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
cmd,
|
|
62
|
+
input=prompt,
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
timeout=_timeout(),
|
|
66
|
+
cwd=cwd,
|
|
67
|
+
)
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"claude CLI not found at '{_claude_bin()}'. "
|
|
71
|
+
"Install Claude Code: https://claude.ai/download"
|
|
72
|
+
)
|
|
73
|
+
except subprocess.TimeoutExpired:
|
|
74
|
+
raise RuntimeError(f"claude CLI timed out after {_timeout()}s")
|
|
75
|
+
|
|
76
|
+
if result.returncode != 0:
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
f"claude CLI exited {result.returncode}: {result.stderr.strip()}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
envelope = json.loads(result.stdout)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
# Fallback: older CLI or unexpected output — treat stdout as raw text.
|
|
85
|
+
text = result.stdout.strip()
|
|
86
|
+
return (text, {}) if return_meta else text
|
|
87
|
+
|
|
88
|
+
if envelope.get("is_error"):
|
|
89
|
+
raise RuntimeError(f"claude CLI returned an error: {envelope.get('result', '')}")
|
|
90
|
+
|
|
91
|
+
_log_usage(
|
|
92
|
+
pcp_dir, command, resolved_model, envelope.get("session_id"),
|
|
93
|
+
envelope.get("usage", {}), envelope.get("total_cost_usd"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
text = (envelope.get("result") or "").strip()
|
|
97
|
+
if not return_meta:
|
|
98
|
+
return text
|
|
99
|
+
|
|
100
|
+
meta = {
|
|
101
|
+
"model": resolved_model or "default",
|
|
102
|
+
"session_id": envelope.get("session_id"),
|
|
103
|
+
"usage": envelope.get("usage", {}),
|
|
104
|
+
"cost_usd": envelope.get("total_cost_usd"),
|
|
105
|
+
"duration_ms": envelope.get("duration_ms"),
|
|
106
|
+
}
|
|
107
|
+
return text, meta
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def call_with_images(system: str, user: str, image_paths: list[Path], model: str | None = None,
|
|
111
|
+
pcp_dir: Path | None = None, command: str = "llm.call_with_images",
|
|
112
|
+
return_meta: bool = False) -> str | tuple[str, dict]:
|
|
113
|
+
"""Same contract as _call_claude(), but attaches one or more images as
|
|
114
|
+
inline multimodal content blocks. Plain text stdin (what _call_claude()
|
|
115
|
+
uses) has no image channel -- `claude -p` only accepts an image via
|
|
116
|
+
`--input-format stream-json` (a single JSON message with text + image
|
|
117
|
+
content blocks), which in turn requires `--output-format stream-json`
|
|
118
|
+
(confirmed by the CLI's own error: "requires --verbose" once stream-json
|
|
119
|
+
output is set). The final envelope is the last `type: "result"` line on
|
|
120
|
+
stdout -- same fields (result/session_id/usage/total_cost_usd) as
|
|
121
|
+
--output-format json.
|
|
122
|
+
|
|
123
|
+
Claude-Code-only, genuinely -- not part of the harness dispatch seam in
|
|
124
|
+
client.py (no agy equivalent exists). Enables VLM-based checks
|
|
125
|
+
(uat.check_visual_quality) that judge a rendered screenshot -- optionally
|
|
126
|
+
against a second reference image -- without requiring the coding agent
|
|
127
|
+
itself to have filesystem access to the image at judge-call time.
|
|
128
|
+
Verified live 2026-07-18: the model sometimes first attempts a Read tool
|
|
129
|
+
call on a hallucinated file path for an inline image before falling back
|
|
130
|
+
to inspecting it directly -- the explicit instruction below cuts that
|
|
131
|
+
wasted turn most of the time but not always; harmless either way, just
|
|
132
|
+
an extra attempt."""
|
|
133
|
+
content = [{
|
|
134
|
+
"type": "text",
|
|
135
|
+
"text": (
|
|
136
|
+
f"{system}\n\n---\n\n{user}\n\n"
|
|
137
|
+
f"{'The image is' if len(image_paths) == 1 else 'The images are'} attached "
|
|
138
|
+
"inline in this message's content as image blocks -- not files on disk. "
|
|
139
|
+
"Do not attempt to Read a file path for them; analyze the attached "
|
|
140
|
+
"image(s) directly."
|
|
141
|
+
),
|
|
142
|
+
}]
|
|
143
|
+
for image_path in image_paths:
|
|
144
|
+
media_type = _MEDIA_TYPES.get(image_path.suffix.lower(), "image/png")
|
|
145
|
+
data = base64.b64encode(image_path.read_bytes()).decode()
|
|
146
|
+
content.append({"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}})
|
|
147
|
+
message = {"type": "user", "message": {"role": "user", "content": content}}
|
|
148
|
+
|
|
149
|
+
resolved_model = os.environ.get("PCP_MODEL") or model
|
|
150
|
+
cmd = [_claude_bin(), "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"]
|
|
151
|
+
if resolved_model:
|
|
152
|
+
cmd += ["--model", resolved_model]
|
|
153
|
+
cwd = Path(pcp_dir).parent if pcp_dir else None
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
cmd, input=json.dumps(message), capture_output=True, text=True,
|
|
158
|
+
timeout=_timeout(), cwd=cwd,
|
|
159
|
+
)
|
|
160
|
+
except FileNotFoundError:
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
f"claude CLI not found at '{_claude_bin()}'. "
|
|
163
|
+
"Install Claude Code: https://claude.ai/download"
|
|
164
|
+
)
|
|
165
|
+
except subprocess.TimeoutExpired:
|
|
166
|
+
raise RuntimeError(f"claude CLI timed out after {_timeout()}s")
|
|
167
|
+
|
|
168
|
+
if result.returncode != 0:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"claude CLI exited {result.returncode}: {result.stderr.strip()}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
envelope = None
|
|
174
|
+
for line in result.stdout.splitlines():
|
|
175
|
+
line = line.strip()
|
|
176
|
+
if not line:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
obj = json.loads(line)
|
|
180
|
+
except json.JSONDecodeError:
|
|
181
|
+
continue
|
|
182
|
+
if obj.get("type") == "result":
|
|
183
|
+
envelope = obj
|
|
184
|
+
if envelope is None:
|
|
185
|
+
raise RuntimeError("claude CLI stream-json output had no result event")
|
|
186
|
+
|
|
187
|
+
if envelope.get("is_error"):
|
|
188
|
+
raise RuntimeError(f"claude CLI returned an error: {envelope.get('result', '')}")
|
|
189
|
+
|
|
190
|
+
_log_usage(
|
|
191
|
+
pcp_dir, command, resolved_model, envelope.get("session_id"),
|
|
192
|
+
envelope.get("usage", {}), envelope.get("total_cost_usd"),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
text = (envelope.get("result") or "").strip()
|
|
196
|
+
if not return_meta:
|
|
197
|
+
return text
|
|
198
|
+
|
|
199
|
+
meta = {
|
|
200
|
+
"model": resolved_model or "default",
|
|
201
|
+
"session_id": envelope.get("session_id"),
|
|
202
|
+
"usage": envelope.get("usage", {}),
|
|
203
|
+
"cost_usd": envelope.get("total_cost_usd"),
|
|
204
|
+
"duration_ms": envelope.get("duration_ms"),
|
|
205
|
+
}
|
|
206
|
+
return text, meta
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def call_with_image(system: str, user: str, image_path: Path, model: str | None = None,
|
|
210
|
+
pcp_dir: Path | None = None, command: str = "llm.call_with_image",
|
|
211
|
+
return_meta: bool = False) -> str | tuple[str, dict]:
|
|
212
|
+
"""Single-image convenience wrapper over call_with_images()."""
|
|
213
|
+
return call_with_images(
|
|
214
|
+
system, user, [image_path], model=model, pcp_dir=pcp_dir, command=command, return_meta=return_meta,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def call_json_with_images(system: str, user: str, image_paths: list[Path], model: str | None = None,
|
|
219
|
+
pcp_dir: Path | None = None, command: str = "llm.call_json_with_images",
|
|
220
|
+
return_meta: bool = False) -> Any:
|
|
221
|
+
"""call_with_images + JSON parsing, same contract as client.call_json()."""
|
|
222
|
+
out = call_with_images(
|
|
223
|
+
system, user + "\n\nRespond with valid JSON only. No markdown fences.",
|
|
224
|
+
image_paths, model=model, pcp_dir=pcp_dir, command=command, return_meta=return_meta,
|
|
225
|
+
)
|
|
226
|
+
text, meta = out if return_meta else (out, None)
|
|
227
|
+
text = text.strip()
|
|
228
|
+
if text.startswith("```"):
|
|
229
|
+
lines = text.split("\n")
|
|
230
|
+
text = "\n".join(lines[1:-1])
|
|
231
|
+
parsed = json.loads(text)
|
|
232
|
+
return (parsed, meta) if return_meta else parsed
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def call_json_with_image(system: str, user: str, image_path: Path, model: str | None = None,
|
|
236
|
+
pcp_dir: Path | None = None, command: str = "llm.call_json_with_image",
|
|
237
|
+
return_meta: bool = False) -> Any:
|
|
238
|
+
"""Single-image convenience wrapper over call_json_with_images()."""
|
|
239
|
+
return call_json_with_images(
|
|
240
|
+
system, user, [image_path], model=model, pcp_dir=pcp_dir, command=command, return_meta=return_meta,
|
|
241
|
+
)
|
pcp/llm/ledger.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""token_ledger.yaml — usage/cost logging shared by every harness.
|
|
2
|
+
|
|
3
|
+
Split out of client.py 2026-07-31 (harness/common split, see llm/harness/
|
|
4
|
+
package docstring) -- this is PCP's own ledger format, not a harness
|
|
5
|
+
implementation detail, so it lives independent of both client.py's
|
|
6
|
+
dispatch layer and any individual harness/*.py file to avoid a circular
|
|
7
|
+
import between them (client.py -> harness.claude -> needs _log_usage ->
|
|
8
|
+
would need client.py back, without this module in between).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
# Guards token_ledger.yaml's read-modify-write -- gate checks in build.py's
|
|
18
|
+
# per-criterion loop that make an LLM call (architect-review, gate,
|
|
19
|
+
# design-justification) run concurrently with each other (2026-07-18,
|
|
20
|
+
# Project O dogfood finding: gate stages were needlessly sequential).
|
|
21
|
+
# Without this lock, two concurrent calls reading the same ledger snapshot
|
|
22
|
+
# before either writes back would silently drop one call's usage record.
|
|
23
|
+
_LEDGER_LOCK = threading.Lock()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _log_usage(pcp_dir: Path | None, command: str, model: str | None, session_id: str | None,
|
|
27
|
+
usage: dict, cost_usd: float | None) -> None:
|
|
28
|
+
if pcp_dir is None:
|
|
29
|
+
return
|
|
30
|
+
with _LEDGER_LOCK:
|
|
31
|
+
ledger_path = Path(pcp_dir) / "token_ledger.yaml"
|
|
32
|
+
entries = []
|
|
33
|
+
if ledger_path.exists():
|
|
34
|
+
data = yaml.safe_load(ledger_path.read_text()) or {}
|
|
35
|
+
entries = data.get("calls", [])
|
|
36
|
+
entries.append({
|
|
37
|
+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
38
|
+
"command": command,
|
|
39
|
+
"model": model or "default",
|
|
40
|
+
"session_id": session_id,
|
|
41
|
+
"input_tokens": usage.get("input_tokens", 0),
|
|
42
|
+
"output_tokens": usage.get("output_tokens", 0),
|
|
43
|
+
"cache_read_tokens": usage.get("cache_read_input_tokens", 0),
|
|
44
|
+
"cache_creation_tokens": usage.get("cache_creation_input_tokens", 0),
|
|
45
|
+
"cost_usd": cost_usd,
|
|
46
|
+
})
|
|
47
|
+
ledger_path.write_text(yaml.dump({"calls": entries}, default_flow_style=False))
|