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
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)
|