pcode 0.1.0__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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Name what moved the cacheable prefix when a cache collapse fires.
|
|
2
|
+
|
|
3
|
+
A collapse warning reports only the provider's token verdict, which cannot say
|
|
4
|
+
whether the prompt prefix was rewritten or the cache simply expired. This keeps a
|
|
5
|
+
small rolling fingerprint of each model request so the next collapse answers that
|
|
6
|
+
question partially: unchanged fingerprints leave the cause unknown, and a
|
|
7
|
+
changed prefix names the message index that moved.
|
|
8
|
+
|
|
9
|
+
Fingerprints are content-free by construction: digests, sizes and kinds only,
|
|
10
|
+
never prompt text. Prompts carry file contents and command output, so writing them
|
|
11
|
+
to a debug file would leak exactly what the rest of pcode refuses to log.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from collections import deque
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pydantic_ai.messages import CachePoint
|
|
24
|
+
|
|
25
|
+
from pcode.diagnostics import versions
|
|
26
|
+
|
|
27
|
+
HISTORY = 8
|
|
28
|
+
"""Requests retained per run. A collapse is diagnosed from the preceding request,
|
|
29
|
+
so this only needs enough depth to show the trend that led into it."""
|
|
30
|
+
|
|
31
|
+
CACHE_SETTINGS = (
|
|
32
|
+
"anthropic_cache",
|
|
33
|
+
"anthropic_cache_instructions",
|
|
34
|
+
"anthropic_cache_tool_definitions",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def diagnostics_root() -> Path:
|
|
39
|
+
"""`PCODE_CACHE_DIAGNOSTICS` overrides the directory; `off` disables dumps."""
|
|
40
|
+
override = os.environ.get("PCODE_CACHE_DIAGNOSTICS", "").strip()
|
|
41
|
+
if override and override.lower() != "off":
|
|
42
|
+
return Path(override).expanduser()
|
|
43
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
44
|
+
return state / "pcode" / "cache-diagnostics"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def enabled() -> bool:
|
|
48
|
+
return os.environ.get("PCODE_CACHE_DIAGNOSTICS", "").strip().lower() != "off"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _digest(text: str) -> str:
|
|
52
|
+
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:12]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _content_text(value) -> str:
|
|
56
|
+
"""Render content for hashing, marking cache points so a moved one is visible."""
|
|
57
|
+
if value is None:
|
|
58
|
+
return ""
|
|
59
|
+
if isinstance(value, str):
|
|
60
|
+
return value
|
|
61
|
+
if isinstance(value, CachePoint):
|
|
62
|
+
return f"<cache-point:{value.ttl}>"
|
|
63
|
+
if isinstance(value, (list, tuple)):
|
|
64
|
+
return "".join(_content_text(item) for item in value)
|
|
65
|
+
# Binary and multimodal parts have no stable text; size and type still detect
|
|
66
|
+
# a change without pulling the payload into the digest.
|
|
67
|
+
data = getattr(value, "data", None)
|
|
68
|
+
size = len(data) if isinstance(data, (bytes, bytearray)) else ""
|
|
69
|
+
return f"<{type(value).__name__}:{size}>"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cache_points(value) -> int:
|
|
73
|
+
if isinstance(value, CachePoint):
|
|
74
|
+
return 1
|
|
75
|
+
if isinstance(value, (list, tuple)):
|
|
76
|
+
return sum(_cache_points(item) for item in value)
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _part_text(part) -> str:
|
|
81
|
+
content = getattr(part, "content", None)
|
|
82
|
+
if content is None and hasattr(part, "args"):
|
|
83
|
+
# Tool calls carry their payload in `args`; `args_as_json_str` normalizes
|
|
84
|
+
# the dict/str union so an unchanged call hashes identically either way.
|
|
85
|
+
try:
|
|
86
|
+
content = part.args_as_json_str()
|
|
87
|
+
except Exception:
|
|
88
|
+
content = repr(part.args)
|
|
89
|
+
kind = getattr(part, "part_kind", type(part).__name__)
|
|
90
|
+
name = getattr(part, "tool_name", "") or ""
|
|
91
|
+
return f"{kind}|{name}|{_content_text(content)}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class MessageFingerprint:
|
|
96
|
+
kind: str
|
|
97
|
+
parts: tuple[str, ...]
|
|
98
|
+
chars: int
|
|
99
|
+
digest: str
|
|
100
|
+
cache_points: int
|
|
101
|
+
|
|
102
|
+
def moved(self, other: "MessageFingerprint") -> bool:
|
|
103
|
+
return self.kind != other.kind or self.digest != other.digest
|
|
104
|
+
|
|
105
|
+
def as_dict(self) -> dict:
|
|
106
|
+
return {
|
|
107
|
+
"kind": self.kind,
|
|
108
|
+
"parts": list(self.parts),
|
|
109
|
+
"chars": self.chars,
|
|
110
|
+
"digest": self.digest,
|
|
111
|
+
"cache_points": self.cache_points,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fingerprint_message(message) -> MessageFingerprint:
|
|
116
|
+
parts = tuple(getattr(part, "part_kind", type(part).__name__) for part in message.parts)
|
|
117
|
+
text = "\n".join(_part_text(part) for part in message.parts)
|
|
118
|
+
points = sum(_cache_points(getattr(part, "content", None)) for part in message.parts)
|
|
119
|
+
return MessageFingerprint(
|
|
120
|
+
kind=getattr(message, "kind", type(message).__name__),
|
|
121
|
+
parts=parts,
|
|
122
|
+
chars=len(text),
|
|
123
|
+
digest=_digest(text),
|
|
124
|
+
cache_points=points,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class RequestFingerprint:
|
|
130
|
+
step: int
|
|
131
|
+
model: str
|
|
132
|
+
at: float
|
|
133
|
+
instructions: str
|
|
134
|
+
instruction_chars: int
|
|
135
|
+
tools: str
|
|
136
|
+
tool_names: tuple[str, ...]
|
|
137
|
+
settings: dict
|
|
138
|
+
messages: tuple[MessageFingerprint, ...]
|
|
139
|
+
cache_read: int
|
|
140
|
+
cache_write: int
|
|
141
|
+
input_tokens: int
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def cache_point_indexes(self) -> list[int]:
|
|
145
|
+
return [i for i, message in enumerate(self.messages) if message.cache_points]
|
|
146
|
+
|
|
147
|
+
def as_dict(self) -> dict:
|
|
148
|
+
return {
|
|
149
|
+
"step": self.step,
|
|
150
|
+
"model": self.model,
|
|
151
|
+
"at": self.at,
|
|
152
|
+
"instructions": self.instructions,
|
|
153
|
+
"instruction_chars": self.instruction_chars,
|
|
154
|
+
"tools": self.tools,
|
|
155
|
+
"tool_names": list(self.tool_names),
|
|
156
|
+
"settings": self.settings,
|
|
157
|
+
"cache_read": self.cache_read,
|
|
158
|
+
"cache_write": self.cache_write,
|
|
159
|
+
"input_tokens": self.input_tokens,
|
|
160
|
+
"cache_point_indexes": self.cache_point_indexes,
|
|
161
|
+
"messages": [message.as_dict() for message in self.messages],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _sent_tools(request_context) -> list[tuple[Any, str]]:
|
|
166
|
+
"""Each tool whose definition the request carries, with how the provider sends it.
|
|
167
|
+
|
|
168
|
+
A deferred tool stays in `function_tools` whether or not tool search has revealed
|
|
169
|
+
it, so the list alone cannot see a reveal. Whether a reveal moves the prefix is the
|
|
170
|
+
provider's call: one that withholds hidden schemas (`claude:`) adds the definition
|
|
171
|
+
ahead of every cached message, while native deferral keeps sending it as deferred.
|
|
172
|
+
The model's own `prepare_request` resolves that, exactly as the request did.
|
|
173
|
+
"""
|
|
174
|
+
parameters = request_context.model_request_parameters
|
|
175
|
+
model = getattr(request_context, "model", None)
|
|
176
|
+
if getattr(parameters, "tool_visibility", None) is None and model is not None:
|
|
177
|
+
try:
|
|
178
|
+
_, parameters = model.prepare_request(request_context.model_settings, parameters)
|
|
179
|
+
except Exception:
|
|
180
|
+
parameters = request_context.model_request_parameters
|
|
181
|
+
visibility_of = getattr(parameters, "visibility_of", None)
|
|
182
|
+
sent = []
|
|
183
|
+
for tool in [
|
|
184
|
+
*(getattr(parameters, "function_tools", None) or []),
|
|
185
|
+
*(getattr(parameters, "output_tools", None) or []),
|
|
186
|
+
]:
|
|
187
|
+
visibility = visibility_of(tool.name) if visibility_of is not None else "visible"
|
|
188
|
+
if visibility in ("visible", "deferred"):
|
|
189
|
+
sent.append((tool, visibility))
|
|
190
|
+
return sent
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def fingerprint(request_context, response, step: int) -> RequestFingerprint:
|
|
194
|
+
parameters = request_context.model_request_parameters
|
|
195
|
+
instruction_text = "\n".join(
|
|
196
|
+
_content_text(getattr(part, "content", None))
|
|
197
|
+
for part in (getattr(parameters, "instruction_parts", None) or [])
|
|
198
|
+
)
|
|
199
|
+
sent = _sent_tools(request_context)
|
|
200
|
+
tools = [tool for tool, _ in sent]
|
|
201
|
+
# Definitions reach the provider in list order: a reordered toolset invalidates
|
|
202
|
+
# the cached block just as an edited schema does, so order stays in the digest.
|
|
203
|
+
tool_text = "\n".join(
|
|
204
|
+
f"{tool.name}|{visibility}|{tool.description or ''}|"
|
|
205
|
+
f"{json.dumps(tool.parameters_json_schema, sort_keys=True, default=str)}"
|
|
206
|
+
for tool, visibility in sent
|
|
207
|
+
)
|
|
208
|
+
settings = request_context.model_settings or {}
|
|
209
|
+
usage = response.usage
|
|
210
|
+
return RequestFingerprint(
|
|
211
|
+
step=step,
|
|
212
|
+
model="/".join(part for part in (response.provider_name, response.model_name) if part),
|
|
213
|
+
at=time.time(),
|
|
214
|
+
instructions=_digest(instruction_text),
|
|
215
|
+
instruction_chars=len(instruction_text),
|
|
216
|
+
tools=_digest(tool_text),
|
|
217
|
+
tool_names=tuple(tool.name for tool in tools),
|
|
218
|
+
settings={key: settings[key] for key in CACHE_SETTINGS if key in settings},
|
|
219
|
+
messages=tuple(fingerprint_message(message) for message in request_context.messages),
|
|
220
|
+
cache_read=usage.cache_read_tokens,
|
|
221
|
+
cache_write=usage.cache_write_tokens,
|
|
222
|
+
input_tokens=usage.input_tokens,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def divergence(previous: RequestFingerprint, current: RequestFingerprint) -> str:
|
|
227
|
+
"""Describe the first prefix change, or report the prefix as intact.
|
|
228
|
+
|
|
229
|
+
Order matters: instructions and tool definitions sit ahead of every message, so
|
|
230
|
+
a change there explains a collapse that a message-level diff would misattribute.
|
|
231
|
+
|
|
232
|
+
Read this line and the dumped fingerprints before theorizing about Harness's
|
|
233
|
+
own warning text. Its generic suffix ``(e.g. a gap longer than the cache TTL)``
|
|
234
|
+
means the gap was *under* the TTL: Harness names the measured gap when it
|
|
235
|
+
actually exceeds it. Its ``model request N`` is per-run, not per-session.
|
|
236
|
+
"""
|
|
237
|
+
if previous.instructions != current.instructions:
|
|
238
|
+
return (
|
|
239
|
+
"Instructions changed "
|
|
240
|
+
f"({previous.instruction_chars} -> {current.instruction_chars} chars): "
|
|
241
|
+
"request prefix differs."
|
|
242
|
+
)
|
|
243
|
+
if previous.tools != current.tools:
|
|
244
|
+
added = [name for name in current.tool_names if name not in previous.tool_names]
|
|
245
|
+
removed = [name for name in previous.tool_names if name not in current.tool_names]
|
|
246
|
+
detail = ", ".join(
|
|
247
|
+
part
|
|
248
|
+
for part in (
|
|
249
|
+
f"added {', '.join(added)}" if added else "",
|
|
250
|
+
f"removed {', '.join(removed)}" if removed else "",
|
|
251
|
+
)
|
|
252
|
+
if part
|
|
253
|
+
)
|
|
254
|
+
return "Tool definitions changed" + (
|
|
255
|
+
f" ({detail})." if detail else " (same names, edited schema or order)."
|
|
256
|
+
)
|
|
257
|
+
if previous.settings != current.settings:
|
|
258
|
+
return f"Cache settings changed ({previous.settings} -> {current.settings})."
|
|
259
|
+
shared = min(len(previous.messages), len(current.messages))
|
|
260
|
+
for index in range(shared):
|
|
261
|
+
before, after = previous.messages[index], current.messages[index]
|
|
262
|
+
if before.moved(after):
|
|
263
|
+
return (
|
|
264
|
+
f"Message {index} of {len(previous.messages)} changed "
|
|
265
|
+
f"(kind {before.kind} -> {after.kind}, {before.chars} -> {after.chars} chars, "
|
|
266
|
+
f"cache points {before.cache_points} -> {after.cache_points}). "
|
|
267
|
+
f"{len(previous.messages) - index} prior message(s) from that index."
|
|
268
|
+
)
|
|
269
|
+
if len(current.messages) < len(previous.messages):
|
|
270
|
+
return (
|
|
271
|
+
f"History shrank from {len(previous.messages)} to {len(current.messages)} "
|
|
272
|
+
"messages: the tail was dropped or rewritten by a capability."
|
|
273
|
+
)
|
|
274
|
+
appended = len(current.messages) - len(previous.messages)
|
|
275
|
+
points = (
|
|
276
|
+
f" Cache points moved {previous.cache_point_indexes} -> {current.cache_point_indexes}."
|
|
277
|
+
if previous.cache_point_indexes != current.cache_point_indexes
|
|
278
|
+
else ""
|
|
279
|
+
)
|
|
280
|
+
gap = current.at - previous.at
|
|
281
|
+
return (
|
|
282
|
+
f"Request fingerprints unchanged; {appended} messages appended (~{gap:.0f}s gap), "
|
|
283
|
+
f"cause unknown.{points}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass
|
|
288
|
+
class CacheDiagnostics:
|
|
289
|
+
"""Per-run rolling fingerprints, written out only when a collapse fires."""
|
|
290
|
+
|
|
291
|
+
records: deque = field(default_factory=lambda: deque(maxlen=HISTORY))
|
|
292
|
+
step: int = 0
|
|
293
|
+
|
|
294
|
+
def record(self, request_context, response) -> RequestFingerprint:
|
|
295
|
+
# Count separately from the window: the deque is bounded, so its length
|
|
296
|
+
# stops growing and would repeat step numbers for exactly the long runs
|
|
297
|
+
# a collapse is most likely to show up in.
|
|
298
|
+
self.step += 1
|
|
299
|
+
current = fingerprint(request_context, response, step=self.step)
|
|
300
|
+
self.records.append(current)
|
|
301
|
+
return current
|
|
302
|
+
|
|
303
|
+
def summary(self) -> str:
|
|
304
|
+
if len(self.records) < 2:
|
|
305
|
+
return ""
|
|
306
|
+
return divergence(self.records[-2], self.records[-1])
|
|
307
|
+
|
|
308
|
+
def dump(self) -> Path | None:
|
|
309
|
+
"""Write the rolling window; return the path, or None when unavailable."""
|
|
310
|
+
if not enabled() or not self.records:
|
|
311
|
+
return None
|
|
312
|
+
current = self.records[-1]
|
|
313
|
+
root = diagnostics_root()
|
|
314
|
+
stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime(current.at))
|
|
315
|
+
# Two runs in one process can collapse in the same second at the same step,
|
|
316
|
+
# so the obvious name is not unique. Overwriting would discard the only
|
|
317
|
+
# record of the earlier collapse.
|
|
318
|
+
base = f"{stamp}-{os.getpid()}-step{current.step}"
|
|
319
|
+
path = root / f"{base}.json"
|
|
320
|
+
for attempt in range(1, 100):
|
|
321
|
+
if not path.exists():
|
|
322
|
+
break
|
|
323
|
+
path = root / f"{base}-{attempt}.json"
|
|
324
|
+
payload = {
|
|
325
|
+
"versions": versions(),
|
|
326
|
+
"model": current.model,
|
|
327
|
+
"summary": self.summary(),
|
|
328
|
+
"requests": [record.as_dict() for record in self.records],
|
|
329
|
+
}
|
|
330
|
+
try:
|
|
331
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
333
|
+
except OSError:
|
|
334
|
+
# A diagnostic must never take down the run that produced it.
|
|
335
|
+
return None
|
|
336
|
+
return path
|
pcode/cache_settings.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Prompt-cache settings that follow a run, including delegated ones.
|
|
2
|
+
|
|
3
|
+
`agent.model_settings` covers the main agent only. A sub-agent inherits the
|
|
4
|
+
parent's *model object* but is run with `model_settings=None`
|
|
5
|
+
(`subagents/_toolset.py`), and cache settings live on the agent, not the model,
|
|
6
|
+
so a delegated run would otherwise request no caching at all. Applying them per
|
|
7
|
+
request also tracks a model switch without rebuilding the sub-agent.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic_ai import RunContext
|
|
14
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
15
|
+
from pydantic_ai.models import ModelRequestContext
|
|
16
|
+
|
|
17
|
+
# Pydantic AI 2.45.0 adds no `cache_control` of its own: without these settings an
|
|
18
|
+
# Anthropic conversation re-reads its whole prefix at full price every request
|
|
19
|
+
# (confirmed against captured request bodies and saved-session usage records).
|
|
20
|
+
# `anthropic_cache` is the server-side automatic breakpoint, which moves forward as
|
|
21
|
+
# history grows; the two explicit breakpoints keep instructions and tool definitions
|
|
22
|
+
# cached. Meridian is excluded on purpose: its passthrough proxy strips client
|
|
23
|
+
# `cache_control` and drives caching from its own lineage hash.
|
|
24
|
+
ANTHROPIC_CACHE_SETTINGS = {
|
|
25
|
+
"anthropic_cache": "5m",
|
|
26
|
+
"anthropic_cache_instructions": True,
|
|
27
|
+
"anthropic_cache_tool_definitions": True,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def model_settings(model: str) -> dict | None:
|
|
32
|
+
"""Default settings for a model name, as `Agent(model_settings=...)` wants them."""
|
|
33
|
+
if model.startswith("openai-codex:"):
|
|
34
|
+
# Codex does not emit visible reasoning unless summaries are requested.
|
|
35
|
+
# Always receive them so Ctrl+T can reveal the preview mid-turn; the
|
|
36
|
+
# display preference remains local and never changes reasoning effort.
|
|
37
|
+
return {"openai_reasoning_summary": "detailed"}
|
|
38
|
+
if model.startswith("anthropic:"):
|
|
39
|
+
return dict(ANTHROPIC_CACHE_SETTINGS)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProviderCacheSettings(AbstractCapability):
|
|
44
|
+
"""Request prompt caching on providers that need it asked for explicitly.
|
|
45
|
+
|
|
46
|
+
Settings already on the request win, so `/effort`, `/thinking` and an
|
|
47
|
+
explicit override are never overwritten.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
async def before_model_request(
|
|
51
|
+
self, ctx: RunContext[Any], request_context: ModelRequestContext
|
|
52
|
+
) -> ModelRequestContext:
|
|
53
|
+
if request_context.model.system != "anthropic":
|
|
54
|
+
return request_context
|
|
55
|
+
settings = request_context.model_settings or {}
|
|
56
|
+
if any(key in settings for key in ANTHROPIC_CACHE_SETTINGS):
|
|
57
|
+
return request_context
|
|
58
|
+
return replace(request_context, model_settings={**ANTHROPIC_CACHE_SETTINGS, **settings})
|
pcode/cache_warnings.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Report prompt-cache reuse drops, from Harness's detector.
|
|
2
|
+
|
|
3
|
+
The full notice goes to the session journal for diagnosis; the terminal shows
|
|
4
|
+
only a short footer label (`footer_label`), not a scrollback line.
|
|
5
|
+
|
|
6
|
+
A drop is information, not a fault: /compact, a new tool, or an expired
|
|
7
|
+
provider cache all shorten what the next request can reuse. The notice says
|
|
8
|
+
how much was reused so the cost is visible; it never claims a cause.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import warnings
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from pydantic_ai import CapabilityEvent
|
|
16
|
+
from pydantic_ai.messages import NativeToolCallPart
|
|
17
|
+
from pydantic_ai_harness.warn_on_cache_busts import CacheBustWarning, WarnOnCacheBusts
|
|
18
|
+
|
|
19
|
+
from pcode.cache_diagnostics import CacheDiagnostics
|
|
20
|
+
from pcode.context_usage import compact_tokens
|
|
21
|
+
from pcode.tool_display import command_text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(kw_only=True)
|
|
25
|
+
class CacheBustEvent(CapabilityEvent, namespace="pcode_cache", name="bust"):
|
|
26
|
+
text: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def server_tool_iterations(response) -> int:
|
|
30
|
+
"""Count server-side tool calls (web search, code execution) in a response.
|
|
31
|
+
|
|
32
|
+
Each one is an extra sampling pass inside a single API call, and the provider
|
|
33
|
+
reports one ``usage`` summed over every pass: ``cache_read_tokens`` is then
|
|
34
|
+
roughly ``passes * prefix``, not a prefix anyone can read back.
|
|
35
|
+
"""
|
|
36
|
+
return sum(isinstance(part, NativeToolCallPart) for part in response.parts)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def notice_text(step: int, read: int, established: int, model: str, *, earlier_turn: bool) -> str:
|
|
40
|
+
"""One neutral line: what was reused, against what an earlier request cached."""
|
|
41
|
+
source = "tokens cached in an earlier turn" if earlier_turn else "previously cached tokens"
|
|
42
|
+
suffix = f" ({model})" if model else ""
|
|
43
|
+
return f"Prompt cache: request {step} reused {read:,} of ~{established:,} {source}{suffix}."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_REUSE = re.compile(r"reused ([\d,]+) of ~([\d,]+)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def footer_label(text: str) -> str:
|
|
50
|
+
"""The footer's short form of a notice, e.g. `cache miss 0/166k`.
|
|
51
|
+
|
|
52
|
+
The notice itself is kept whole in the session journal for diagnosis; the
|
|
53
|
+
footer only says a drop happened and how large it was.
|
|
54
|
+
"""
|
|
55
|
+
found = _REUSE.search(text)
|
|
56
|
+
if found is None:
|
|
57
|
+
return "cache drop"
|
|
58
|
+
read, established = (int(value.replace(",", "")) for value in found.groups())
|
|
59
|
+
kind = "miss" if read == 0 else "drop"
|
|
60
|
+
label = f"cache {kind} {compact_tokens(read)}/{compact_tokens(established)}"
|
|
61
|
+
return f"sub-agent {label}" if text.startswith("Sub-agent:") else label
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class CacheBustReporting(WarnOnCacheBusts):
|
|
66
|
+
"""Keep Harness's detector, thresholds, latch, and warning filters.
|
|
67
|
+
|
|
68
|
+
Upstream keys its marks by `RunContext.conversation_id`, which pcode keeps
|
|
69
|
+
stable per session, so the first request of a turn is compared with what
|
|
70
|
+
the previous turn cached (until the conversation idles past the cache TTL).
|
|
71
|
+
A notice after /compact is therefore expected: it reports the reset.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# Write each notice's fingerprint window to disk (the `debug` setting).
|
|
75
|
+
dump_fingerprints: bool = False
|
|
76
|
+
# `for_run` copies the capability with `replace()`, which re-initializes
|
|
77
|
+
# `init=False` fields, so each run fingerprints its own requests -- matching
|
|
78
|
+
# the upstream detector's per-run step numbering.
|
|
79
|
+
diagnostics: CacheDiagnostics = field(
|
|
80
|
+
init=False, default_factory=CacheDiagnostics, compare=False, repr=False
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def after_model_request(self, ctx, *, request_context, response):
|
|
84
|
+
# Harness hooks are filters, not listeners: the return value replaces the
|
|
85
|
+
# response (or request context, for `before_model_request`). A hook that
|
|
86
|
+
# only records something must still return it, or the loss surfaces far
|
|
87
|
+
# away as `AttributeError: 'NoneType' object has no attribute 'usage'`
|
|
88
|
+
# from the upstream `WarnOnCacheBusts` this class extends.
|
|
89
|
+
#
|
|
90
|
+
# The pinned upstream hook does not suspend: only its synchronous warning
|
|
91
|
+
# emission is captured, never model/tool execution or another task's work.
|
|
92
|
+
# Emit outside this scope; ctx.emit can suspend. No global warning handler.
|
|
93
|
+
key = (response.provider_name, response.model_name)
|
|
94
|
+
prior = self._state.conversation.keys.get(key)
|
|
95
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
96
|
+
result = await super().after_model_request(
|
|
97
|
+
ctx, request_context=request_context, response=response
|
|
98
|
+
)
|
|
99
|
+
if server_tool_iterations(response):
|
|
100
|
+
# Upstream raises its high-water mark to `read + write` of every
|
|
101
|
+
# response, but a summed multi-pass usage puts the mark several
|
|
102
|
+
# times above the real prefix, and the next healthy request then
|
|
103
|
+
# "collapses" against it (seen: 148k established from a 29k prefix
|
|
104
|
+
# after three web searches; the next request read exactly 47k).
|
|
105
|
+
# Keep the previous mark; the next single-pass response sets a real
|
|
106
|
+
# one from its own usage.
|
|
107
|
+
self._state.conversation.keys[key].prefix = prior.prefix if prior else 0
|
|
108
|
+
# Fingerprint every request, not just collapsing ones: diagnosing a
|
|
109
|
+
# collapse needs the healthy request before it to compare against.
|
|
110
|
+
# Part shapes vary by provider and capability, so a diagnostic that
|
|
111
|
+
# cannot read one must degrade to silence rather than end the run.
|
|
112
|
+
try:
|
|
113
|
+
self.diagnostics.record(request_context, response)
|
|
114
|
+
except Exception:
|
|
115
|
+
self.diagnostics.records.clear()
|
|
116
|
+
for warning in caught:
|
|
117
|
+
if issubclass(warning.category, CacheBustWarning):
|
|
118
|
+
# Report measured reuse, not upstream's speculative expiry cause
|
|
119
|
+
# or an inferred number of tokens billed uncached.
|
|
120
|
+
model = "/".join(
|
|
121
|
+
part for part in (response.provider_name, response.model_name) if part
|
|
122
|
+
)
|
|
123
|
+
text = notice_text(
|
|
124
|
+
self._state.step,
|
|
125
|
+
response.usage.cache_read_tokens,
|
|
126
|
+
prior.prefix if prior else 0,
|
|
127
|
+
model,
|
|
128
|
+
earlier_turn=prior is not None and prior.run_id != ctx.run_id,
|
|
129
|
+
)
|
|
130
|
+
text = "\n".join(part for part in (text, self.diagnostics.summary()) if part)
|
|
131
|
+
path = self.diagnostics.dump() if self.dump_fingerprints else None
|
|
132
|
+
if path is not None:
|
|
133
|
+
text += f"\nRequest fingerprints: {path}"
|
|
134
|
+
await ctx.emit(CacheBustEvent(text=command_text(text)))
|
|
135
|
+
else:
|
|
136
|
+
warnings.warn_explicit(
|
|
137
|
+
warning.message, warning.category, warning.filename, warning.lineno
|
|
138
|
+
)
|
|
139
|
+
return result
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""`claude:` models: Claude Code's own login through the Claude Agent SDK, no proxy.
|
|
2
|
+
|
|
3
|
+
`ClaudeModel` (in `model`) is an `AnthropicModel` whose requests never touch
|
|
4
|
+
its HTTP client. Each conversation keeps one `claude` CLI process (the one
|
|
5
|
+
bundled with `claude-agent-sdk`) alive across requests, where Meridian starts a
|
|
6
|
+
fresh one per request behind a Node proxy. pcode's tools are served to that
|
|
7
|
+
process as an in-process MCP server whose handlers *park*: the model calls a
|
|
8
|
+
tool, the CLI invokes the handler, the streamed assistant message ends, and
|
|
9
|
+
Pydantic AI runs the tool itself. The next request carries the result, which
|
|
10
|
+
releases the parked handler, and the CLI goes on to its next API call. The CLI
|
|
11
|
+
streams raw Anthropic events, so parsing reuses `AnthropicModel`'s
|
|
12
|
+
streamed-response code. `session` holds one such process (`ClaudeSession`);
|
|
13
|
+
`cli` the binary and environment it runs with.
|
|
14
|
+
|
|
15
|
+
pcode's history stays the source of truth; the CLI transcript is a disposable
|
|
16
|
+
copy. `messages` normalizes and hashes that history so it can be compared. A
|
|
17
|
+
request continues a live process only when that process holds exactly the
|
|
18
|
+
history before the new user message. Otherwise it forks the CLI transcript at
|
|
19
|
+
the last assistant message both share (`resume` + `fork_session` +
|
|
20
|
+
`resume_session_at`: structured history, warm cache), found through a persisted
|
|
21
|
+
index of history hashes (`resume`), and failing that starts over with the
|
|
22
|
+
history replayed as text, as Meridian does. `session_pool` makes that choice
|
|
23
|
+
and keeps idle processes within limits. See dev/anthropic-providers.md.
|
|
24
|
+
|
|
25
|
+
`ClaudeWorkspace` (in `workspace`) points each request's CLI at the agent's
|
|
26
|
+
workspace, and `errors` holds the failures and what to tell the user about
|
|
27
|
+
them. pcode holds no credentials here: the CLI signs in itself (`/login claude`).
|
|
28
|
+
|
|
29
|
+
Tests patch the submodule that defines a name (`pcode.claude_sdk.session_pool`),
|
|
30
|
+
never this package: a re-export is a copy, so patching it changes nothing the
|
|
31
|
+
submodules read. Tuning knobs and pool state are left out of the re-exports for
|
|
32
|
+
that reason.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from pcode.claude_sdk.cli import CLI_ENV, LOGIN_ENV, cli_path
|
|
36
|
+
from pcode.claude_sdk.errors import (
|
|
37
|
+
MISSING_SDK,
|
|
38
|
+
ClaudeConnectionError,
|
|
39
|
+
ClaudeHTTPError,
|
|
40
|
+
ClaudeProcessError,
|
|
41
|
+
ClaudeSDKMissing,
|
|
42
|
+
ClaudeStartError,
|
|
43
|
+
failure_hint,
|
|
44
|
+
)
|
|
45
|
+
from pcode.claude_sdk.messages import REPLAY_INTRO, lineage, normalize, replay
|
|
46
|
+
from pcode.claude_sdk.model import PREFIX, ClaudeModel, ClaudeProvider, claude_model
|
|
47
|
+
from pcode.claude_sdk.resume import ForkPoint, ResumeIndex, index_path
|
|
48
|
+
from pcode.claude_sdk.session import (
|
|
49
|
+
SERVER,
|
|
50
|
+
TOOL_PREFIX,
|
|
51
|
+
TOOL_USE_ID,
|
|
52
|
+
ClaudeSession,
|
|
53
|
+
SessionConfig,
|
|
54
|
+
)
|
|
55
|
+
from pcode.claude_sdk.session_pool import (
|
|
56
|
+
Checkout,
|
|
57
|
+
SessionPool,
|
|
58
|
+
pool,
|
|
59
|
+
shutdown,
|
|
60
|
+
)
|
|
61
|
+
from pcode.claude_sdk.workspace import CWD_SETTING, ClaudeWorkspace
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"CLI_ENV",
|
|
65
|
+
"CWD_SETTING",
|
|
66
|
+
"LOGIN_ENV",
|
|
67
|
+
"MISSING_SDK",
|
|
68
|
+
"PREFIX",
|
|
69
|
+
"REPLAY_INTRO",
|
|
70
|
+
"SERVER",
|
|
71
|
+
"TOOL_PREFIX",
|
|
72
|
+
"TOOL_USE_ID",
|
|
73
|
+
"Checkout",
|
|
74
|
+
"ClaudeConnectionError",
|
|
75
|
+
"ClaudeHTTPError",
|
|
76
|
+
"ClaudeModel",
|
|
77
|
+
"ClaudeProcessError",
|
|
78
|
+
"ClaudeProvider",
|
|
79
|
+
"ClaudeSDKMissing",
|
|
80
|
+
"ClaudeSession",
|
|
81
|
+
"ClaudeStartError",
|
|
82
|
+
"ClaudeWorkspace",
|
|
83
|
+
"ForkPoint",
|
|
84
|
+
"ResumeIndex",
|
|
85
|
+
"SessionConfig",
|
|
86
|
+
"SessionPool",
|
|
87
|
+
"claude_model",
|
|
88
|
+
"cli_path",
|
|
89
|
+
"failure_hint",
|
|
90
|
+
"index_path",
|
|
91
|
+
"lineage",
|
|
92
|
+
"normalize",
|
|
93
|
+
"pool",
|
|
94
|
+
"replay",
|
|
95
|
+
"shutdown",
|
|
96
|
+
]
|
pcode/claude_sdk/cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The `claude` CLI binary pcode runs, and the environment it runs with."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# The child inherits pcode's environment. An empty value is unset to the CLI
|
|
7
|
+
# (verified: requests then bill the subscription), so neither pcode's own
|
|
8
|
+
# Anthropic key, token or endpoint nor a cloud route exported for other Claude
|
|
9
|
+
# Code use can silently redirect or bill a `claude:` request.
|
|
10
|
+
LOGIN_ENV = {
|
|
11
|
+
"ANTHROPIC_API_KEY": "",
|
|
12
|
+
"ANTHROPIC_AUTH_TOKEN": "",
|
|
13
|
+
"ANTHROPIC_BASE_URL": "",
|
|
14
|
+
"CLAUDE_CODE_USE_BEDROCK": "",
|
|
15
|
+
"CLAUDE_CODE_USE_VERTEX": "",
|
|
16
|
+
"CLAUDE_CODE_USE_FOUNDRY": "",
|
|
17
|
+
"CLAUDE_CODE_USE_GATEWAY": "",
|
|
18
|
+
"CLAUDE_CODE_USE_MANTLE": "",
|
|
19
|
+
"CLAUDE_CODE_USE_ANTHROPIC_AWS": "",
|
|
20
|
+
"CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD": "",
|
|
21
|
+
}
|
|
22
|
+
CLI_ENV = {
|
|
23
|
+
**LOGIN_ENV,
|
|
24
|
+
# pcode owns retries (visible in the UI), compaction and tool deferral. A
|
|
25
|
+
# transcript the CLI compacted itself would no longer match pcode's history.
|
|
26
|
+
"CLAUDE_CODE_MAX_RETRIES": "0",
|
|
27
|
+
"DISABLE_AUTO_COMPACT": "1",
|
|
28
|
+
"DISABLE_COMPACT": "1",
|
|
29
|
+
"ENABLE_TOOL_SEARCH": "false",
|
|
30
|
+
# pcode already bounds tool output; never let the CLI truncate it again.
|
|
31
|
+
"MAX_MCP_OUTPUT_TOKENS": "1000000",
|
|
32
|
+
# A subscription login defaults to 1-hour cache writes (2x input, against
|
|
33
|
+
# 1.25x). pcode's tool loops send requests well inside five minutes, so the
|
|
34
|
+
# hour rarely pays off (dev/anthropic-providers.md). This variable wins
|
|
35
|
+
# over ENABLE_PROMPT_CACHING_1H; only FORCE_PROMPT_CACHING_5M outranks it.
|
|
36
|
+
"CLAUDE_CODE_PROMPT_CACHE_TTL": "5m",
|
|
37
|
+
# A parked handler lasts as long as the tool runs, delegations included.
|
|
38
|
+
"MCP_TOOL_TIMEOUT": str(7 * 24 * 3600 * 1000),
|
|
39
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
40
|
+
"DISABLE_AUTOUPDATER": "1",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cli_path() -> str | None:
|
|
45
|
+
"""The CLI the SDK runs: its bundled binary, else `claude` on PATH."""
|
|
46
|
+
import shutil
|
|
47
|
+
|
|
48
|
+
import claude_agent_sdk
|
|
49
|
+
|
|
50
|
+
name = "claude.exe" if sys.platform == "win32" else "claude"
|
|
51
|
+
bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / name
|
|
52
|
+
return str(bundled) if bundled.is_file() else shutil.which("claude")
|