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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/stream_events.py ADDED
@@ -0,0 +1,437 @@
1
+ """Translate one agent run's Pydantic AI events into pcode's runtime events.
2
+
3
+ `agent.run_stream_events` yields typed upstream events: model parts streaming
4
+ in, tool calls and results, and the harness's shell, delegation and file
5
+ events. `EventTranslator.translate` turns each into the plain dataclasses in
6
+ `pcode.runtime` that the terminal renders. It holds the per-run bookkeeping
7
+ that pairs a tool's start with its end.
8
+ """
9
+
10
+ import re
11
+ from collections.abc import AsyncIterator, Iterator
12
+ from dataclasses import replace
13
+ from datetime import datetime, timezone
14
+ from time import monotonic
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from pydantic_ai import (
18
+ AgentRunResultEvent,
19
+ FunctionToolCallEvent,
20
+ FunctionToolResultEvent,
21
+ PartDeltaEvent,
22
+ PartEndEvent,
23
+ PartStartEvent,
24
+ TextPart,
25
+ TextPartDelta,
26
+ ThinkingPart,
27
+ ThinkingPartDelta,
28
+ )
29
+ from pydantic_ai.messages import (
30
+ ModelResponse,
31
+ NativeToolCallPart,
32
+ NativeToolReturnPart,
33
+ RetryPromptPart,
34
+ ToolCallPart,
35
+ )
36
+ from pydantic_ai_harness.shell import (
37
+ CommandFinishedEvent,
38
+ CommandOutputEvent,
39
+ CommandStartedEvent,
40
+ )
41
+ from pydantic_ai_harness.subagents import DelegationEndEvent, DelegationStartEvent
42
+
43
+ from pcode.cache_warnings import CacheBustEvent
44
+ from pcode.delegation import ChildActivity, ChildOutput
45
+ from pcode.edit_preview import StreamingEditPreview
46
+ from pcode.filesystem import FileChangeEvent
47
+ from pcode.inspection import capture
48
+ from pcode.plan_preview import StreamingPlanPreview
49
+ from pcode.runtime import (
50
+ CacheBust,
51
+ ChildPlan,
52
+ ChildText,
53
+ Event,
54
+ Message,
55
+ PlanUpdated,
56
+ RunStatus,
57
+ TextDelta,
58
+ Thinking,
59
+ ThinkingDelta,
60
+ ToolStarted,
61
+ ToolSummary,
62
+ )
63
+ from pcode.shell import ShellPreview, result_projection
64
+ from pcode.tool_display import (
65
+ COMMAND_TOOLS,
66
+ assignment,
67
+ command_error,
68
+ delegation_detail,
69
+ execution_mode,
70
+ invocation,
71
+ job_status,
72
+ label,
73
+ native_result_detail,
74
+ native_result_projection,
75
+ result_detail,
76
+ spilled_call_id,
77
+ stated_purpose,
78
+ subject,
79
+ target,
80
+ )
81
+ from pcode.turn import TurnContext
82
+
83
+ if TYPE_CHECKING:
84
+ from pcode.live import AgentRuntime
85
+
86
+
87
+ class EventTranslator:
88
+ """Per-run state and one handler per upstream event kind.
89
+
90
+ Besides translating, `on_run_result` records the finished run: it settles
91
+ the turn's history and counts the turn on the runtime.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ runtime: "AgentRuntime",
97
+ context: TurnContext,
98
+ plan_items: list[dict],
99
+ *,
100
+ plan_preview: StreamingPlanPreview | None,
101
+ edit_preview: StreamingEditPreview | None,
102
+ ) -> None:
103
+ self.runtime = runtime
104
+ self.context = context
105
+ self.run_id = context.run_id
106
+ # The plan as last published; a settled tool republishes it on change.
107
+ self.plan_items = plan_items
108
+ self.plan_preview = plan_preview
109
+ self.edit_preview = edit_preview
110
+ self.emitted_text = False
111
+ # Running tools by call id: (name, args, monotonic start).
112
+ self.tools: dict[str, tuple[str, dict, float]] = {}
113
+ # Every tool this run called, by call id, settled or not: a later
114
+ # `read_tool_result` names the call whose spilled output it reads.
115
+ self.called: dict[str, str] = {}
116
+ self.delegates: dict[str, ToolStarted] = {}
117
+ self.child_tools: dict[str, ToolStarted] = {}
118
+ self.delegation_ends: dict[str, DelegationEndEvent] = {}
119
+ self.shell_preview = ShellPreview()
120
+ self.shell_ends: dict[str, CommandFinishedEvent] = {}
121
+
122
+ async def translate(self, event: Any) -> AsyncIterator[Event]:
123
+ """Yield the pcode events for one upstream event, in display order."""
124
+ if self.edit_preview is not None:
125
+ for edit_update in self.edit_preview.update(event):
126
+ yield edit_update
127
+ if isinstance(event, FunctionToolResultEvent):
128
+ # The only handler that awaits: it rereads the plan store.
129
+ async for out in self.on_tool_result(event):
130
+ yield out
131
+ else:
132
+ for out in self._dispatch(event):
133
+ yield out
134
+ if self.plan_preview is not None:
135
+ if (update := self.plan_preview.update(event, self.plan_items)) is not None:
136
+ yield update
137
+
138
+ def _dispatch(self, event: Any) -> Iterator[Event]:
139
+ match event:
140
+ case CacheBustEvent():
141
+ return self.on_cache_bust(event)
142
+ case FileChangeEvent():
143
+ return self.on_file_change(event)
144
+ case DelegationStartEvent():
145
+ return self.on_delegation_start(event)
146
+ case DelegationEndEvent():
147
+ return self.on_delegation_end(event)
148
+ case ChildOutput():
149
+ return self.on_child_output(event)
150
+ case ChildActivity():
151
+ return self.on_child_activity(event)
152
+ case CommandStartedEvent() | CommandOutputEvent() | CommandFinishedEvent():
153
+ return self.on_shell(event)
154
+ case PartStartEvent():
155
+ return self.on_part_start(event)
156
+ case PartDeltaEvent():
157
+ return self.on_part_delta(event)
158
+ case PartEndEvent():
159
+ return self.on_part_end(event)
160
+ case FunctionToolCallEvent():
161
+ return self.on_tool_call(event)
162
+ case AgentRunResultEvent():
163
+ return self.on_run_result(event)
164
+ case _:
165
+ return iter(())
166
+
167
+ def activity(self) -> RunStatus:
168
+ tools = self.tools
169
+ if not tools:
170
+ return RunStatus("Waiting for model…")
171
+ if len(tools) == 1:
172
+ name, args, _ = next(iter(tools.values()))
173
+ where = target(name, args, self.spill_source(name, args))
174
+ title = label(name) if name == "delegate_task" else name
175
+ return RunStatus(f"Running {title}" + (f" · {where}" if where else "") + "…")
176
+ names = ", ".join(label(item[0]) for item in list(tools.values())[:3])
177
+ return RunStatus(f"Running {len(tools)} tools · {names}…")
178
+
179
+ def spill_source(self, name: str, args: dict) -> str:
180
+ """The tool whose stored output a `read_tool_result` call reads, or "".
181
+
182
+ This run's calls are not in the turn's history until it settles;
183
+ earlier turns', including a resumed session's, are. Either answer is
184
+ remembered, so a call's start, status, and result scan history once.
185
+ """
186
+ call_id = spilled_call_id(args) if name == "read_tool_result" else ""
187
+ if not call_id:
188
+ return ""
189
+ if call_id not in self.called:
190
+ self.called[call_id] = next(
191
+ (
192
+ part.tool_name
193
+ for message in reversed(self.context.history)
194
+ if isinstance(message, ModelResponse)
195
+ for part in message.parts
196
+ if isinstance(part, ToolCallPart) and part.tool_call_id == call_id
197
+ ),
198
+ "",
199
+ )
200
+ return self.called[call_id]
201
+
202
+ # Harness and pcode capability events
203
+
204
+ def on_cache_bust(self, event: CacheBustEvent) -> Iterator[Event]:
205
+ yield CacheBust(event.text)
206
+
207
+ def on_file_change(self, event: FileChangeEvent) -> Iterator[Event]:
208
+ yield event.change
209
+
210
+ def on_delegation_start(self, event: DelegationStartEvent) -> Iterator[Event]:
211
+ if start := self.delegates.get(event.tool_call_id):
212
+ start = replace(start, activity="Waiting for model")
213
+ self.delegates[event.tool_call_id] = start
214
+ yield start
215
+
216
+ def on_delegation_end(self, event: DelegationEndEvent) -> Iterator[Event]:
217
+ self.delegation_ends[event.tool_call_id] = event
218
+ # Deliberately not added to session totals: a child's *tokens*
219
+ # already reach `result.usage` even under its own budget, so
220
+ # adding `event.usage` here counts them twice. Only its
221
+ # request count stays isolated, which is the point of the
222
+ # budget. Tokens from an interrupted turn are a separate gap.
223
+ # Timeouts/budget stops can leave a child's tool without a
224
+ # result. Settle it before the parent resumes its tool loop.
225
+ for call_id, child in list(self.child_tools.items()):
226
+ if child.parent_call_id == event.tool_call_id:
227
+ yield ToolSummary(
228
+ child.name,
229
+ child.detail + " → Interrupted",
230
+ failed=True,
231
+ call_id=call_id,
232
+ run_id=self.run_id,
233
+ outcome="interrupted",
234
+ parent_call_id=child.parent_call_id,
235
+ )
236
+ del self.child_tools[call_id]
237
+
238
+ def on_child_output(self, event: ChildOutput) -> Iterator[Event]:
239
+ if event.tool_call_id in self.delegates:
240
+ yield ChildText(
241
+ event.tool_call_id,
242
+ event.text,
243
+ thinking=event.thinking,
244
+ start=event.start,
245
+ )
246
+
247
+ def on_child_activity(self, event: ChildActivity) -> Iterator[Event]:
248
+ if start := self.delegates.get(event.tool_call_id):
249
+ if event.activity != start.activity:
250
+ start = replace(start, activity=event.activity)
251
+ self.delegates[event.tool_call_id] = start
252
+ yield start
253
+ if event.plan is not None:
254
+ yield ChildPlan(event.tool_call_id, event.plan)
255
+ if event.child is not None:
256
+ if isinstance(event.child, ToolStarted):
257
+ self.child_tools[event.child.call_id] = event.child
258
+ else:
259
+ self.child_tools.pop(event.child.call_id, None)
260
+ yield event.child
261
+
262
+ def on_shell(
263
+ self, event: CommandStartedEvent | CommandOutputEvent | CommandFinishedEvent
264
+ ) -> Iterator[Event]:
265
+ if isinstance(event, CommandFinishedEvent):
266
+ self.shell_ends[event.tool_call_id] = event
267
+ if (output := self.shell_preview.update(event)) is not None:
268
+ yield output
269
+
270
+ # Model response parts
271
+
272
+ def on_part_start(self, event: PartStartEvent) -> Iterator[Event]:
273
+ if isinstance(event.part, TextPart):
274
+ yield TextDelta(event.part.content)
275
+ elif isinstance(event.part, ThinkingPart):
276
+ if event.part.content:
277
+ yield ThinkingDelta(event.part.content)
278
+ yield RunStatus("Thinking…")
279
+ elif isinstance(event.part, NativeToolReturnPart):
280
+ yield from self.on_native_tool_return(event.part)
281
+
282
+ def on_native_tool_return(self, part: NativeToolReturnPart) -> Iterator[Event]:
283
+ # Provider-executed tools (native web search/fetch) return
284
+ # inside the response stream; there is no function event.
285
+ name, args, started = self.tools.pop(part.tool_call_id, (part.tool_name, {}, monotonic()))
286
+ detail, failed = native_result_detail(name, args, part.content, part.outcome)
287
+ yield ToolSummary(
288
+ name,
289
+ detail,
290
+ failed=failed,
291
+ call_id=part.tool_call_id,
292
+ result=capture(native_result_projection(part.content)),
293
+ run_id=self.run_id,
294
+ outcome=part.outcome if not failed else "error",
295
+ elapsed_seconds=max(0, monotonic() - started),
296
+ )
297
+ yield self.activity()
298
+
299
+ def on_part_delta(self, event: PartDeltaEvent) -> Iterator[Event]:
300
+ if isinstance(event.delta, TextPartDelta):
301
+ yield TextDelta(event.delta.content_delta)
302
+ elif isinstance(event.delta, ThinkingPartDelta):
303
+ if event.delta.content_delta:
304
+ yield ThinkingDelta(event.delta.content_delta)
305
+ yield RunStatus("Thinking…")
306
+
307
+ def on_part_end(self, event: PartEndEvent) -> Iterator[Event]:
308
+ if isinstance(event.part, TextPart) and event.part.content:
309
+ self.emitted_text = True
310
+ yield Message(event.part.content)
311
+ elif isinstance(event.part, ThinkingPart) and event.part.content:
312
+ yield Thinking(event.part.content)
313
+ elif isinstance(event.part, NativeToolCallPart):
314
+ yield from self.on_native_tool_call(event.part)
315
+
316
+ def on_native_tool_call(self, part: NativeToolCallPart) -> Iterator[Event]:
317
+ try:
318
+ args = part.args_as_dict()
319
+ except (ValueError, TypeError):
320
+ args = {}
321
+ self.tools[part.tool_call_id] = (part.tool_name, args, monotonic())
322
+ agent, task = assignment(part.tool_name, args)
323
+ yield ToolStarted(
324
+ part.tool_name,
325
+ target(part.tool_name, args),
326
+ part.tool_call_id,
327
+ arguments=capture(args if args else part.args),
328
+ run_id=self.run_id,
329
+ started_at=datetime.now(timezone.utc).isoformat(),
330
+ agent=agent,
331
+ task=task,
332
+ )
333
+ yield self.activity()
334
+
335
+ # Function tools and the run's end
336
+
337
+ def on_tool_call(self, event: FunctionToolCallEvent) -> Iterator[Event]:
338
+ try:
339
+ args = event.part.args_as_dict()
340
+ except (ValueError, TypeError):
341
+ args = {}
342
+ self.tools[event.part.tool_call_id] = (event.part.tool_name, args, monotonic())
343
+ self.called[event.part.tool_call_id] = event.part.tool_name
344
+ agent, task = assignment(event.part.tool_name, args)
345
+ command, purpose = subject(event.part.tool_name, args, self.runtime.jobs)
346
+ start = ToolStarted(
347
+ event.part.tool_name,
348
+ target(event.part.tool_name, args, self.spill_source(event.part.tool_name, args)),
349
+ event.part.tool_call_id,
350
+ arguments=capture(args if args else event.part.args),
351
+ run_id=self.run_id,
352
+ started_at=datetime.now(timezone.utc).isoformat(),
353
+ process_id=capture(args.get("command_id", "")),
354
+ command=command,
355
+ purpose=purpose,
356
+ execution=execution_mode(event.part.tool_name, args),
357
+ agent=agent,
358
+ task=task,
359
+ )
360
+ if event.part.tool_name == "delegate_task":
361
+ self.delegates[event.part.tool_call_id] = start
362
+ yield start
363
+ yield self.activity()
364
+
365
+ async def on_tool_result(self, event: FunctionToolResultEvent) -> AsyncIterator[Event]:
366
+ name, args, started = self.tools.pop(
367
+ event.tool_call_id,
368
+ (event.part.tool_name or "tool", {}, monotonic()),
369
+ )
370
+ outcome = "retry" if isinstance(event.part, RetryPromptPart) else event.part.outcome
371
+ detail, failed = result_detail(
372
+ name, args, event.part.content, outcome, self.spill_source(name, args)
373
+ )
374
+ shell_end = self.shell_ends.pop(event.tool_call_id, None)
375
+ if name == "shell" and shell_end is not None and outcome == "success":
376
+ # The result's own job marker is authoritative: it is
377
+ # written after the wait ends, while the event is a
378
+ # snapshot the command can finish just after.
379
+ status, failed = job_status(event.part.content)
380
+ detail = target(name, args) + (f" → {status}" if status else "")
381
+ if shell_end.truncated:
382
+ detail += " · preview capped"
383
+ self.delegates.pop(event.tool_call_id, None)
384
+ end = self.delegation_ends.pop(event.tool_call_id, None)
385
+ if end is not None:
386
+ outcome = end.outcome
387
+ detail, failed = delegation_detail(args, outcome)
388
+ elif name == "delegate_task" and outcome == "success":
389
+ # Harness returns max_calls refusals as normal strings,
390
+ # with no lifecycle events because no child was launched.
391
+ outcome = "not_started"
392
+ detail = target(name, args) + " → Not started"
393
+ failed = True
394
+ # Read the store after every settled tool: covers granular,
395
+ # batched, and future plan mutations without parsing results.
396
+ items = [item.model_dump(mode="json") for item in await self.context.plan_store.get_items()]
397
+ if items != self.plan_items:
398
+ self.plan_items = items
399
+ yield PlanUpdated(items)
400
+ display_content = (
401
+ result_projection(event.part.content, shell_end)
402
+ if name == "shell"
403
+ else event.part.content
404
+ )
405
+ yield ToolSummary(
406
+ name,
407
+ detail,
408
+ failed=failed,
409
+ call_id=event.tool_call_id,
410
+ result=capture(display_content),
411
+ run_id=self.run_id,
412
+ outcome=outcome,
413
+ process_id=(
414
+ str(shell_end.pid)
415
+ if shell_end is not None
416
+ else match[1]
417
+ if name == "start_command"
418
+ and isinstance(event.part.content, str)
419
+ and (match := re.search(r"^ID: (\w+)$", event.part.content, re.MULTILINE))
420
+ else capture(args.get("command_id", ""))
421
+ ),
422
+ elapsed_seconds=max(0, monotonic() - started),
423
+ command=invocation(name, args),
424
+ purpose=stated_purpose(args),
425
+ error=command_error(display_content) if failed and name in COMMAND_TOOLS else "",
426
+ )
427
+ yield self.activity()
428
+
429
+ def on_run_result(self, event: AgentRunResultEvent) -> Iterator[Event]:
430
+ result = event.result
431
+ if result.output and not self.emitted_text:
432
+ yield Message(str(result.output))
433
+ # Full successful history. The outer persistence wrapper also
434
+ # recovers settled tool-boundary snapshots after failures.
435
+ self.context.history = result.all_messages()
436
+ self.context.pending_shell = []
437
+ self.runtime.turns += 1
pcode/strict_tools.py ADDED
@@ -0,0 +1,130 @@
1
+ """Grammar-constrained tool arguments for Anthropic models.
2
+
3
+ Claude sometimes emits a nested tool argument as an escaped JSON *string* rather
4
+ than the array the schema asks for, and botches the escaping while doing it. In
5
+ ~2k recorded `edit_file` calls the Anthropic share of `replacements` arrivals
6
+ failed validation about one time in eight, while the OpenAI share never did --
7
+ Pydantic AI turns OpenAI's strict mode on by itself, and strict mode is
8
+ constrained decoding.
9
+
10
+ Anthropic exposes the same thing as `tools[].strict`, but two gaps keep it off:
11
+ `ToolDefinition.strict` defaults to `None`, which Pydantic AI reads as "off" for
12
+ Anthropic (only OpenAI infers it), and the Anthropic profile carries no
13
+ `json_schema_transformer`, so the schema Pydantic generates is rejected with
14
+ `For 'object' type, 'additionalProperties' must be explicitly set to false`.
15
+ This module closes the object nodes and sets the flag.
16
+
17
+ It does not close the hole. With `strict: true` confirmed on the wire, Anthropic
18
+ still delivers `replacements` as a truncated JSON string a few times in ten when
19
+ the model is nudged toward that shape (claude-opus-5, subscription endpoint,
20
+ pcode's schema and the plainest `{"type": "array"}` alike), while a scalar
21
+ property in the same schema is held to its type. Production moved from 11.5% to
22
+ 9.6% of `replacements` arrivals failing. Left on because nothing measured got
23
+ worse, not because it works; the retry budget is what actually recovers the
24
+ edit.
25
+ """
26
+
27
+ from dataclasses import replace
28
+
29
+ from pydantic_ai.capabilities import PrepareTools
30
+ from pydantic_ai.tools import ToolDefinition
31
+
32
+ from pcode.preferences import SETTINGS, load_preferences
33
+
34
+ # Only `edit_file` has a measured failure: its `replacements` array is the one
35
+ # tool argument with a nested object shape. Keeping the set small keeps the
36
+ # blast radius small too -- Anthropic validates the schema of tools marked
37
+ # strict and 400s the whole request, so MCP and extension tools carrying
38
+ # arbitrary schemas must stay out of it.
39
+ STRICT_TOOLS = frozenset({"edit_file"})
40
+
41
+
42
+ def strict_tools_enabled() -> bool:
43
+ preferences = load_preferences()
44
+ return preferences.get("strict_tools", SETTINGS["strict_tools"].default) == "on"
45
+
46
+
47
+ def _closed(schema: object) -> object:
48
+ """Copy `schema` with every object node explicitly closed."""
49
+ if isinstance(schema, dict):
50
+ node = {key: _closed(value) for key, value in schema.items()}
51
+ if node.get("type") == "object":
52
+ node["additionalProperties"] = False
53
+ return node
54
+ if isinstance(schema, list):
55
+ return [_closed(item) for item in schema]
56
+ return schema
57
+
58
+
59
+ # Anthropic's strict mode accepts a subset of JSON Schema and rejects the whole
60
+ # request on anything outside it, so an upstream schema change could break every
61
+ # edit rather than one. An allow-list fails the safe way: an unrecognized keyword
62
+ # declines strict and leaves the tool exactly as it behaves today.
63
+ SUPPORTED_KEYWORDS = frozenset(
64
+ {
65
+ "$defs",
66
+ "$ref",
67
+ "additionalProperties",
68
+ "anyOf",
69
+ "const",
70
+ "default",
71
+ "description",
72
+ "enum",
73
+ "items",
74
+ "properties",
75
+ "required",
76
+ "title",
77
+ "type",
78
+ }
79
+ )
80
+
81
+ # Keyed by name rather than by keyword, so their keys are not schema vocabulary.
82
+ _SCHEMA_MAPS = frozenset({"$defs", "properties"})
83
+
84
+
85
+ def _constrainable(node: object) -> bool:
86
+ """Whether `node` is safe to close and send as strict, and faithful once closed."""
87
+ if isinstance(node, list):
88
+ return all(_constrainable(item) for item in node)
89
+ if not isinstance(node, dict):
90
+ return True
91
+ # A map-valued object: closing it would forbid the keys it exists to carry.
92
+ if node.get("type") == "object" and not node.get("properties"):
93
+ return False
94
+ if node.keys() - SUPPORTED_KEYWORDS:
95
+ return False
96
+ return all(
97
+ all(_constrainable(child) for child in value.values())
98
+ if key in _SCHEMA_MAPS and isinstance(value, dict)
99
+ else _constrainable(value)
100
+ for key, value in node.items()
101
+ )
102
+
103
+
104
+ def prepare_strict_tools(ctx, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
105
+ """Mark the allow-listed tools strict when the model can honor it."""
106
+ model = getattr(ctx, "model", None)
107
+ if getattr(model, "system", None) != "anthropic":
108
+ return tool_defs
109
+ profile = getattr(model, "profile", None)
110
+ # The same gate `AnthropicModel` applies before it will send `strict` at all,
111
+ # so an unsupported model silently keeps the schema it already had.
112
+ if not (profile and profile.get("supports_json_schema_output", False)):
113
+ return tool_defs
114
+ return [
115
+ replace(
116
+ tool_def,
117
+ parameters_json_schema=_closed(tool_def.parameters_json_schema),
118
+ strict=True,
119
+ )
120
+ if tool_def.name in STRICT_TOOLS and _constrainable(tool_def.parameters_json_schema)
121
+ else tool_def
122
+ for tool_def in tool_defs
123
+ ]
124
+
125
+
126
+ def create_strict_tools() -> PrepareTools | None:
127
+ """Return the capability when enabled, or None to leave tool schemas alone."""
128
+ if not strict_tools_enabled():
129
+ return None
130
+ return PrepareTools(prepare_strict_tools)
pcode/syntax.py ADDED
@@ -0,0 +1,43 @@
1
+ """Syntax highlighting rendered on the terminal's own background."""
2
+
3
+ from functools import cache
4
+
5
+ from pygments.token import _TokenType
6
+ from rich.style import Style
7
+ from rich.syntax import Syntax, SyntaxTheme
8
+
9
+
10
+ class TransparentSyntaxTheme(SyntaxTheme):
11
+ """A Pygments style with its backgrounds dropped rather than painted over.
12
+
13
+ Passing ``background_color="default"`` to :class:`~rich.syntax.Syntax`
14
+ paints every token background, not just the block's, and some styles carry
15
+ meaning there: gruvbox, the default for both palettes, colours diff +/−
16
+ lines by background and sets their foreground to the style's own background
17
+ colour. Flattened that way a diff loses every marker colour and reads as
18
+ unhighlighted text. Fold a meaningful background into the foreground
19
+ instead, and drop the backgrounds that only repeat the block's.
20
+ """
21
+
22
+ def __init__(self, code_theme: str) -> None:
23
+ self.theme = Syntax.get_theme(code_theme)
24
+ self.background = self.theme.get_background_style().bgcolor
25
+
26
+ def get_background_style(self) -> Style:
27
+ return Style()
28
+
29
+ def get_style_for_token(self, token_type: _TokenType) -> Style:
30
+ style = self.theme.get_style_for_token(token_type)
31
+ color = style.color if style.bgcolor in (None, self.background) else style.bgcolor
32
+ return Style(
33
+ color=color,
34
+ bold=style.bold,
35
+ italic=style.italic,
36
+ underline=style.underline,
37
+ )
38
+
39
+
40
+ @cache
41
+ def transparent_theme(code_theme: str) -> TransparentSyntaxTheme:
42
+ """The named style, resolved once, with its backgrounds left transparent."""
43
+ return TransparentSyntaxTheme(code_theme)