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/live.py ADDED
@@ -0,0 +1,1301 @@
1
+ """Translate Pydantic streams to UI-independent application events."""
2
+
3
+ import asyncio
4
+ import re
5
+ from collections.abc import AsyncIterator, Callable
6
+ from contextlib import aclosing, asynccontextmanager, nullcontext
7
+ from copy import deepcopy
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from uuid import uuid4
11
+
12
+ from pydantic_ai import (
13
+ Agent,
14
+ AgentRunResultEvent,
15
+ FunctionToolCallEvent,
16
+ FunctionToolResultEvent,
17
+ PartDeltaEvent,
18
+ PartEndEvent,
19
+ PartStartEvent,
20
+ TextPart,
21
+ TextPartDelta,
22
+ ThinkingPart,
23
+ ThinkingPartDelta,
24
+ ToolReturn,
25
+ )
26
+ from pydantic_ai.capabilities import LocalWorkspace
27
+ from pydantic_ai.messages import (
28
+ ModelMessage,
29
+ ModelMessagesTypeAdapter,
30
+ ModelRequest,
31
+ ModelResponse,
32
+ UserPromptPart,
33
+ )
34
+ from pydantic_ai.usage import RunUsage, UsageLimits
35
+ from pydantic_ai.workspaces import LocalWorkspaceBackend
36
+ from pydantic_ai_harness.filesystem import FileSystem
37
+ from pydantic_ai_harness.planning import InMemoryPlanStore, Planning
38
+ from pydantic_ai_harness.shell import Shell
39
+ from pydantic_ai_harness.step_persistence import ContinuableSnapshot, StepPersistence
40
+ from pydantic_ai_harness.subagents import SubAgents
41
+ from pydantic_ai_harness.tool_output_limits import ToolOutputLimits
42
+
43
+ from pcode import retries
44
+ from pcode.agent import SideModel, worker_toolsets
45
+ from pcode.aside import SideReply
46
+ from pcode.compaction import AutoCompaction, ContextTracking, effective_window, summarize
47
+ from pcode.conversation_ids import model_conversation
48
+ from pcode.conversation_tree import ConversationTree
49
+ from pcode.diagnostics import (
50
+ error_details,
51
+ provider_context,
52
+ quota_message,
53
+ transient,
54
+ transport_types,
55
+ )
56
+ from pcode.edit_preview import StreamingEditPreview
57
+ from pcode.inspection import ToolArchive
58
+ from pcode.job_notices import JobNotices
59
+ from pcode.jobs import registry as job_registry
60
+ from pcode.mcp import (
61
+ OAUTH_PACKAGES,
62
+ MCPConnectError,
63
+ MCPState,
64
+ config_path,
65
+ deferred_schemas_rejected,
66
+ find_cause,
67
+ )
68
+ from pcode.mcp_notice import enabled_servers
69
+ from pcode.native_results import drop_unreadable_results, unreadable_native_results
70
+ from pcode.plan_preview import StreamingPlanPreview
71
+ from pcode.planning import SizedPlanItem
72
+ from pcode.preferences import SETTINGS, load_preferences, parse_token_count
73
+ from pcode.profiling import activity as profiled_activity
74
+ from pcode.retries import RequestCheckpoint
75
+ from pcode.runtime import (
76
+ ChildPlan,
77
+ ChildText,
78
+ CommandOutput,
79
+ EditCompleted,
80
+ EditPreview,
81
+ Event,
82
+ Message,
83
+ PlanPreview,
84
+ ToolStarted,
85
+ ToolSummary,
86
+ )
87
+ from pcode.sessions import SavedSession, SessionError
88
+ from pcode.shell_mode import ShellRun, reduce_result, shell_exchange
89
+ from pcode.shell_tools import JobShell
90
+ from pcode.steering import Steering
91
+ from pcode.stream_events import EventTranslator
92
+ from pcode.token_accounting import TokenAccounting, TokenTotals
93
+ from pcode.tool_display import label, target
94
+ from pcode.turn import TurnContext
95
+
96
+
97
+ def _local_workspace(agent) -> Path | None:
98
+ """The directory of the agent's own `LocalWorkspace` capability, if it has one."""
99
+ for capability in agent.root_capability.capabilities:
100
+ if isinstance(capability, LocalWorkspace):
101
+ return Path(capability.working_dir)
102
+ return None
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class TurnModel:
107
+ """What one turn runs on in place of the conversation's model, from `$MODEL` or `+EFFORT`.
108
+
109
+ `model` is another model, with its own settings; `settings` alone replace
110
+ the conversation model's for this turn, at another effort.
111
+ `conversation_id` is the one that model's requests carry; see
112
+ `AgentRuntime._model_conversation`.
113
+ """
114
+
115
+ model: SideModel | None
116
+ settings: dict | None
117
+ conversation_id: str
118
+
119
+ @asynccontextmanager
120
+ async def applied(self, agent):
121
+ settings = self.model.settings if self.model is not None else self.settings
122
+ if self.model is None and settings is None:
123
+ yield
124
+ return
125
+ with agent.override(model_settings=settings):
126
+ yield
127
+
128
+
129
+ class AgentRuntime:
130
+ def __init__(
131
+ self,
132
+ agent: Agent,
133
+ session: SavedSession | None = None,
134
+ *,
135
+ session_factory: Callable[[], SavedSession] | None = None,
136
+ ) -> None:
137
+ self.agent = agent
138
+ self.session = session
139
+ if session is not None:
140
+ model, workspace, root = (
141
+ session.info.model,
142
+ Path(session.info.workspace),
143
+ session.directory.parent,
144
+ )
145
+
146
+ def session_factory():
147
+ return SavedSession.create(model, workspace, root)
148
+
149
+ self.session_factory = session_factory
150
+ preferences = load_preferences()
151
+ self.auto_compact = preferences.get("autocompact", "on") == "on"
152
+ # Tokens at which to compact even when the window has more room; None
153
+ # leaves the threshold to the model's window.
154
+ limit = preferences.get("autocompact_tokens")
155
+ self.auto_compact_limit: int | None = parse_token_count(limit) if limit else None
156
+ # Snapshotted like autocompact: a saved default applies to the next launch.
157
+ self.retry_attempts = int(
158
+ preferences.get("retry_attempts", SETTINGS["retry_attempts"].default)
159
+ )
160
+ self.compaction_notice = lambda text: None
161
+ self.retry_notice = lambda text: None
162
+ self.warning_notice = lambda text: None
163
+ # Shell jobs outlive both the run and the conversation, so the registry
164
+ # is not reset by `_clear`, `/new`, or conversation checkout.
165
+ self.jobs = job_registry()
166
+ self.take_steering = lambda: []
167
+ self.has_steering = lambda: False
168
+ # Prompt overhead describes the agent's configuration, not one
169
+ # conversation, so it outlives /new and conversation checkout.
170
+ self.request_parameters = None
171
+ self._clear()
172
+ self.replace_agent(agent)
173
+
174
+ def replace_agent(self, agent: Agent) -> None:
175
+ """Change the agent without resetting conversation-scoped state."""
176
+ self.agent = agent
177
+ # Coder's public root capability is flattened by Pydantic AI. A resolver
178
+ # keeps the store conversation-scoped, including after /new. It resolves
179
+ # per request, so this is also where a second turn would be handed its
180
+ # own store: `ctx` names the run the tools are being called for.
181
+ for capability in self.agent.root_capability.capabilities:
182
+ if isinstance(capability, Planning):
183
+ capability.store_resolver = lambda ctx: self.plan_store
184
+
185
+ def _persist_child_runs(self) -> None:
186
+ """Record delegated runs in the current session's store.
187
+
188
+ Sub-agents receive `shared_capabilities`, not the per-run capabilities the
189
+ parent passes to `run_stream_events`, so child requests are otherwise
190
+ absent from the store: their tokens reach session totals but nothing says
191
+ how they were spent. The store changes with `/new`, so this is resolved
192
+ per turn rather than at construction. `agent_name` is left unset: one
193
+ shared capability serves every sub-agent, and `parent_run_id` already
194
+ marks a run as delegated.
195
+ """
196
+ for capability in self.agent.root_capability.capabilities:
197
+ if not isinstance(capability, SubAgents):
198
+ continue
199
+ shared = [
200
+ shared_capability
201
+ for shared_capability in capability.shared_capabilities
202
+ if not isinstance(shared_capability, (StepPersistence, TokenAccounting))
203
+ ]
204
+ if self.session is not None:
205
+ shared.append(StepPersistence(store=self.session.store))
206
+ # Count a delegated request where it happens. Child tokens also
207
+ # aggregate into the parent's run usage, which nothing reads.
208
+ shared.append(TokenAccounting(record=self.totals.add))
209
+ capability.shared_capabilities = shared
210
+
211
+ async def refresh_context(self) -> None:
212
+ """Refresh optional metadata outside rendering and before model requests."""
213
+ from pydantic_ai.models import infer_model
214
+
215
+ from pcode.model_metadata import refresh_context
216
+
217
+ if isinstance(self.agent.model, str):
218
+ try:
219
+ # Retain the resolved provider so the UI and future requests
220
+ # share its identity-scoped metadata, including deferred login.
221
+ self.agent.model = infer_model(self.agent.model)
222
+ except Exception:
223
+ # Optional discovery must not prevent reaching /login.
224
+ pass
225
+ await refresh_context(self.agent.model)
226
+
227
+ def startup_context(self) -> list[str]:
228
+ """Report only repository context configured on this agent."""
229
+ from pcode.repo_context import AutomaticRepoContext
230
+
231
+ lines = []
232
+ for capability in self.agent.root_capability.capabilities:
233
+ if isinstance(capability, AutomaticRepoContext):
234
+ lines.extend(capability.startup_summary())
235
+ return lines
236
+
237
+ def _mcp_connect_failed(self, name: str, error: BaseException) -> None:
238
+ """One server is down; the turn continues with every other tool."""
239
+ text = self.mcp.unavailable[name]
240
+ saved = self.session
241
+ path = saved.record_error(error, run_id=f"mcp:{name}") if saved else None
242
+ if path is not None:
243
+ text += f" Diagnostics: {path}"
244
+ self.warning_notice(text)
245
+
246
+ def _clear(self) -> None:
247
+ info = self.session.info if self.session else None
248
+ self.tree = self.session.tree if self.session else ConversationTree()
249
+ self.inspections = ToolArchive()
250
+ # The active branch's turn state. Everything a turn reads and writes
251
+ # back lives here rather than on the runtime; see `TurnContext`.
252
+ self.context = TurnContext()
253
+ self.conversation_id = info.id if info else str(uuid4())
254
+ self.turns = info.turns if info else 0
255
+ self.totals = TokenTotals(
256
+ input=info.input_tokens if info else 0,
257
+ output=info.output_tokens if info else 0,
258
+ cache_read=info.cache_read_tokens if info else 0,
259
+ cache_write=info.cache_write_tokens if info else 0,
260
+ )
261
+ self.recovery_blocked = ""
262
+ self.mcp = MCPState()
263
+ self.mcp.on_connect_failure = self._mcp_connect_failed
264
+
265
+ # The active branch's turn state, under the names callers already use.
266
+ @property
267
+ def history(self) -> list[ModelMessage]:
268
+ return self.context.history
269
+
270
+ @history.setter
271
+ def history(self, messages: list[ModelMessage]) -> None:
272
+ self.context.history = messages
273
+
274
+ @property
275
+ def pending_shell(self) -> list[ModelMessage]:
276
+ return self.context.pending_shell
277
+
278
+ @pending_shell.setter
279
+ def pending_shell(self, messages: list[ModelMessage]) -> None:
280
+ self.context.pending_shell = messages
281
+
282
+ @property
283
+ def plan_store(self) -> InMemoryPlanStore:
284
+ return self.context.plan_store
285
+
286
+ @plan_store.setter
287
+ def plan_store(self, store: InMemoryPlanStore) -> None:
288
+ self.context.plan_store = store
289
+
290
+ @property
291
+ def context_history(self) -> list[ModelMessage] | None:
292
+ return self.context.context_history
293
+
294
+ @context_history.setter
295
+ def context_history(self, messages: list[ModelMessage] | None) -> None:
296
+ self.context.context_history = messages
297
+
298
+ @property
299
+ def input_tokens(self) -> int:
300
+ return self.totals.input
301
+
302
+ @property
303
+ def output_tokens(self) -> int:
304
+ return self.totals.output
305
+
306
+ def _save_totals(self, info) -> None:
307
+ info.input_tokens = self.totals.input
308
+ info.output_tokens = self.totals.output
309
+ info.cache_read_tokens = self.totals.cache_read
310
+ info.cache_write_tokens = self.totals.cache_write
311
+
312
+ def reset(self) -> None:
313
+ if self.session:
314
+ self.session.close()
315
+ self.session = None
316
+ self._clear()
317
+
318
+ async def restore(self) -> None:
319
+ if self.session:
320
+ self.history = await self.session.recover()
321
+ await self.plan_store.set_items(
322
+ [SizedPlanItem.model_validate(item) for item in self.session.latest_plan()]
323
+ )
324
+
325
+ async def navigate(self, identity: str | None, *, edit: bool = False) -> str:
326
+ """Restore a safe checkpoint without running a model or replaying tools."""
327
+ if self.recovery_blocked:
328
+ raise SessionError(self.recovery_blocked)
329
+ self.tree.path(identity)
330
+ node = self.tree.nodes[identity] if identity else None
331
+ target = node.parent if edit and node else identity
332
+ draft = node.prompt if edit and node else ""
333
+ history = (
334
+ await self.session.history_at(target)
335
+ if self.session
336
+ else deepcopy(self.tree.nodes[target].history or [])
337
+ if target
338
+ else []
339
+ )
340
+ plan = InMemoryPlanStore()
341
+ await plan.set_items(
342
+ [
343
+ SizedPlanItem.model_validate(item)
344
+ for item in (self.tree.nodes[target].plan if target else [])
345
+ ]
346
+ )
347
+ # Publish the cursor only after all restoration/validation succeeds.
348
+ if self.session:
349
+ self.session.append("tree_selected", node_id=target, sync=True)
350
+ else:
351
+ self.tree.active = target
352
+ self.history = history
353
+ self.plan_store = plan
354
+ return draft
355
+
356
+ def aside_context(self) -> list[ModelMessage]:
357
+ """The newest context a side question can be asked against.
358
+
359
+ `context_history` is the request in flight, so a question asked mid-turn
360
+ sees what the model is working on rather than the state before the turn
361
+ began. Only the settled prefix is usable; see `settled_context`.
362
+
363
+ The copy is not a precaution but the isolation itself: a side question is
364
+ appended to the last request the way steering is, and these message
365
+ objects belong to the running turn's own history.
366
+ """
367
+ from pcode.aside import settled_context
368
+
369
+ history = self.context_history if self.context_history is not None else self.history
370
+ return deepcopy(settled_context(list(history)))
371
+
372
+ async def aside(
373
+ self,
374
+ question: str,
375
+ *,
376
+ report=None,
377
+ model: SideModel | None = None,
378
+ settings: dict | None = None,
379
+ after: SideReply | None = None,
380
+ fresh: bool = False,
381
+ framing: Callable[[str], str] | None = None,
382
+ ) -> SideReply:
383
+ """Answer `question` beside the conversation, recording nothing.
384
+
385
+ Returns the answer with the run's messages, conversation id, model and
386
+ settings: everything `after` needs to ask a follow-up to it.
387
+
388
+ Nothing here touches conversation state: no journal record, no tree
389
+ node, no plan, and `self.history` is only read. The run is billed to the
390
+ session's token totals, because the tokens were really spent. `report`
391
+ receives `(answer_so_far, activity)` as the answer streams.
392
+
393
+ The run is set up the way `_stream` sets up a turn -- same agent, MCP
394
+ toolsets, server list, conversation id, and model settings -- because
395
+ the provider caches a request prefix, and a side question whose
396
+ instructions or tool definitions differ by a byte re-bills the whole
397
+ conversation. Per-run capabilities here add neither instructions nor
398
+ tools; `AsideGuard` refuses the plan and delegation tools at execution
399
+ instead of hiding them.
400
+
401
+ `model` runs the question on another model instead. It keeps the same
402
+ agent, tools and history but has no cache to share, so it takes that
403
+ model's own settings rather than the conversation's, and a conversation
404
+ id of its own: Meridian keys its session on the id, and a request from
405
+ another model under the conversation's id would move that session and
406
+ force the conversation's next turn to replay cold.
407
+
408
+ `settings` replaces the conversation's model settings for this question
409
+ alone, which is how `/btw +EFFORT` asks the conversation's own model at
410
+ another effort. It keeps the conversation id: the cache may not match
411
+ at a different effort, but the user asked for that trade.
412
+
413
+ `after` asks a follow-up to that earlier reply instead, continuing its
414
+ messages rather than the conversation's newest context, on the agent,
415
+ model, settings and conversation id it ran with. Its history is exactly
416
+ what that run sent plus what it answered, so the follow-up reuses its
417
+ cache, even after /model replaced the conversation's agent. A `model`
418
+ or `settings` given with it, or `fresh` for the conversation's own
419
+ model as it is now, switches the follow-up the way they switch a new
420
+ question: on the conversation's agent and with the conversation id that
421
+ model would get, at the price of that cache.
422
+
423
+ `framing` turns `question` into the message sent, in place of the side
424
+ question or follow-up framing; a thread's summary is asked that way.
425
+ """
426
+ from pcode.aside import ASIDE_REQUEST_LIMIT, framed, framed_follow_up
427
+ from pcode.aside_guard import AsideGuard
428
+
429
+ agent = self.agent
430
+ inherit = after is not None and not fresh and model is None and settings is None
431
+ if inherit:
432
+ agent = agent if after.agent is None else after.agent
433
+ model = after.model
434
+ settings = after.settings
435
+ if after is not None:
436
+ messages = list(after.messages)
437
+ else:
438
+ messages = self.aside_context()
439
+ # A turn in flight ends on a user-role request: its new prompt, or the
440
+ # tool results it is working through. A second user message after one of
441
+ # those is what providers reject as non-alternating roles, so the
442
+ # question joins that request the way steering does, and the run
443
+ # continues from history instead of adding a message of its own.
444
+ joined = bool(messages) and isinstance(messages[-1], ModelRequest)
445
+ frame = framing or (framed_follow_up if after is not None else framed)
446
+ pending = [frame(question)]
447
+ capabilities = [AsideGuard(), TokenAccounting(record=self.totals.add)]
448
+ if joined:
449
+ capabilities.append(Steering(lambda: [pending.pop()] if pending else []))
450
+ conversation_id = self._model_conversation(model, "btw")
451
+ other: dict = {}
452
+ if model is not None:
453
+ other = {"model": model.model}
454
+ override = agent.override(model_settings=model.settings)
455
+ elif settings is not None:
456
+ override = agent.override(model_settings=settings)
457
+ else:
458
+ override = nullcontext()
459
+ if inherit:
460
+ # A follow-up belongs to its thread's session, not a fresh one.
461
+ conversation_id = after.conversation_id
462
+ with override:
463
+ async with (
464
+ agent,
465
+ model.model if model is not None else nullcontext(),
466
+ worker_toolsets([self.mcp.live()]),
467
+ enabled_servers(self.mcp.servers, self.mcp.unavailable),
468
+ agent.run_stream_events(
469
+ None if joined else pending.pop(),
470
+ message_history=messages,
471
+ workspace=self._run_workspace(agent),
472
+ toolsets=[self.mcp.live()],
473
+ conversation_id=conversation_id,
474
+ capabilities=capabilities,
475
+ usage_limits=UsageLimits(request_limit=ASIDE_REQUEST_LIMIT),
476
+ **other,
477
+ ) as events,
478
+ ):
479
+ answer, messages = await self._aside_answer(events, report)
480
+ return SideReply(
481
+ answer=answer,
482
+ messages=messages,
483
+ conversation_id=conversation_id,
484
+ agent=agent,
485
+ model=model,
486
+ settings=settings,
487
+ )
488
+
489
+ def _model_conversation(self, model: SideModel | None, kind: str) -> str:
490
+ """The conversation id a run on `model` carries; `None` is the conversation's model.
491
+
492
+ Another model gets an id of its own; see `pcode.conversation_ids`.
493
+ """
494
+ if model is None:
495
+ return self.conversation_id
496
+ return model_conversation(self.conversation_id, kind)
497
+
498
+ async def _aside_answer(self, events, report) -> tuple[str, list[ModelMessage]]:
499
+ """Collect a side question's answer and its run's messages, reporting progress."""
500
+ messages: list[ModelMessage] = []
501
+ blocks: list[str] = []
502
+ partial = ""
503
+ activity = "Waiting for model…"
504
+ tools: dict[str, str] = {}
505
+
506
+ def publish() -> None:
507
+ if report is not None:
508
+ report("\n\n".join([*blocks, partial] if partial else blocks), activity)
509
+
510
+ # The phases are the main status row's words, since the viewer draws
511
+ # them with that row: `Thinking…`, `Responding…`, `Run shell · ls`.
512
+ async for event in events:
513
+ if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
514
+ partial += event.part.content
515
+ activity = "Responding…"
516
+ elif isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
517
+ partial += event.delta.content_delta
518
+ activity = "Responding…"
519
+ elif (isinstance(event, PartStartEvent) and isinstance(event.part, ThinkingPart)) or (
520
+ isinstance(event, PartDeltaEvent) and isinstance(event.delta, ThinkingPartDelta)
521
+ ):
522
+ if activity == "Thinking…":
523
+ continue # Nothing new to show: thoughts are not the answer.
524
+ activity = "Thinking…"
525
+ elif isinstance(event, PartEndEvent) and isinstance(event.part, TextPart):
526
+ if event.part.content:
527
+ blocks.append(event.part.content)
528
+ partial = ""
529
+ elif isinstance(event, FunctionToolCallEvent):
530
+ try:
531
+ args = event.part.args_as_dict()
532
+ except (ValueError, TypeError):
533
+ args = {}
534
+ where = target(event.part.tool_name, args)
535
+ tools[event.part.tool_call_id] = event.part.tool_name
536
+ activity = label(event.part.tool_name) + (f" · {where}" if where else "")
537
+ elif isinstance(event, FunctionToolResultEvent):
538
+ tools.pop(event.tool_call_id, None)
539
+ activity = "Waiting for model…" if not tools else activity
540
+ elif isinstance(event, AgentRunResultEvent):
541
+ messages = event.result.all_messages()
542
+ continue
543
+ else:
544
+ continue
545
+ publish()
546
+ if partial:
547
+ blocks.append(partial)
548
+ partial = ""
549
+ activity = ""
550
+ publish()
551
+ return "\n\n".join(blocks), messages
552
+
553
+ async def merge_aside(self, steps: list[tuple[str, str, list]], parent: str | None) -> bool:
554
+ """Add a side thread to the tree under `parent`; whether the conversation moved onto it.
555
+
556
+ `steps` holds each answered question as `(question, answer, history)`,
557
+ the history being what that answer ended with. When the thread extends
558
+ the conversation exactly -- asked at the active node, with nothing
559
+ since -- the conversation continues from its last answer. Otherwise it
560
+ is a branch to check out from /tree, and the active node stays put:
561
+ moving there would drop what the conversation did after it was asked.
562
+ """
563
+ if self.recovery_blocked:
564
+ raise SessionError(self.recovery_blocked)
565
+ self._open_session()
566
+ previous = self.tree.active
567
+ final = steps[-1][2]
568
+ extends = parent == previous and final[: len(self.history)] == self.history
569
+ await self._add_aside_nodes(steps, parent)
570
+ if extends:
571
+ self.history = deepcopy(final)
572
+ elif self.session:
573
+ self.session.append("tree_selected", node_id=previous, sync=True)
574
+ else:
575
+ self.tree.active = previous
576
+ return extends
577
+
578
+ async def summarize_aside(
579
+ self, reply: SideReply, request: str, instructions: str = "", *, report=None
580
+ ) -> str:
581
+ """Add a summary of a side thread to the active branch; returns the summary.
582
+
583
+ The summary is asked in the thread, after `reply`, where the questions
584
+ and answers are and its cache is warm. The conversation records it as
585
+ one exchange: `request`, which names the questions, and the summary as
586
+ its answer, so the next turn reads it like any earlier reply.
587
+ """
588
+ from pcode.aside import framed_summary
589
+
590
+ if self.recovery_blocked:
591
+ raise SessionError(self.recovery_blocked)
592
+ if self.history and not isinstance(self.history[-1], ModelResponse):
593
+ # Two requests in a row is what providers reject as non-alternating.
594
+ raise SessionError(
595
+ "The conversation stopped mid-turn; send a message or /resend first."
596
+ )
597
+ summary = await self.aside(instructions, report=report, after=reply, framing=framed_summary)
598
+ history = [
599
+ *self.history,
600
+ ModelRequest([UserPromptPart(request)]),
601
+ ModelResponse([TextPart(summary.answer)]),
602
+ ]
603
+ self._open_session()
604
+ await self._add_aside_nodes([(request, summary.answer, history)], self.tree.active)
605
+ self.history = history
606
+ return summary.answer
607
+
608
+ async def _add_aside_nodes(self, steps: list[tuple[str, str, list]], parent: str | None):
609
+ """Record side-thread exchanges as completed nodes, each a child of the last.
610
+
611
+ They are ordinary turn records flagged `aside`, with the history to
612
+ continue from saved the way a turn saves its own, so checkout, resume,
613
+ replay and /tree treat them like any turn. The tree's active node ends
614
+ on the last one.
615
+ """
616
+ for prompt, answer, history in steps:
617
+ identity = str(uuid4())
618
+ started = {
619
+ "prompt": prompt,
620
+ "run_id": identity,
621
+ "parent_id": parent,
622
+ "continuation": False,
623
+ "aside": True,
624
+ }
625
+ if self.session:
626
+ self.session.append("turn_started", sync=True, **started)
627
+ self.session.event(Message(answer), run_id=identity)
628
+ await self.session.store.save_snapshot(
629
+ ContinuableSnapshot(
630
+ run_id=identity,
631
+ step_index=0,
632
+ messages=history,
633
+ conversation_id=self.conversation_id,
634
+ )
635
+ )
636
+ self.session.append("turn_completed", run_id=identity, sync=True)
637
+ else:
638
+ self.tree.consume({"kind": "turn_started", **started})
639
+ self.tree.consume({"kind": "Message", "run_id": identity, "markdown": answer})
640
+ self.tree.consume({"kind": "turn_completed", "run_id": identity})
641
+ self.tree.nodes[identity].history = deepcopy(history)
642
+ parent = identity
643
+
644
+ def _open_session(self) -> SavedSession | None:
645
+ """The session to record in, created on first use as a turn creates it."""
646
+ if self.session is None and self.session_factory is not None:
647
+ self.session = self.session_factory()
648
+ self.conversation_id = self.session.info.id
649
+ self.tree = self.session.tree
650
+ return self.session
651
+
652
+ async def compact(self, focus: str = ""):
653
+ """Persist a new branch-local context checkpoint before publishing it."""
654
+ if self.recovery_blocked:
655
+ raise SessionError(self.recovery_blocked)
656
+ usage = RunUsage()
657
+ # Size the kept history and summary to the autocompact cap, as automatic
658
+ # compaction does, or the result can land just under it and recompact.
659
+ window = None
660
+ if self.auto_compact and (limit := self.auto_compact_limit):
661
+ window = min(effective_window(self.agent.model) or limit, limit)
662
+ try:
663
+ async with self.agent:
664
+ result = await summarize(
665
+ self.history,
666
+ model=self.agent.model,
667
+ focus=focus or None,
668
+ usage=usage,
669
+ window=window,
670
+ )
671
+ finally:
672
+ # A cancelled/failed summary can still have incurred provider usage.
673
+ self.totals.add(usage)
674
+ if self.session:
675
+ self._save_totals(self.session.info)
676
+ self.session.save_info()
677
+ if not result.changed:
678
+ return result
679
+ record = {
680
+ "node_id": str(uuid4()),
681
+ "parent_id": self.tree.active,
682
+ "focus": focus,
683
+ "messages": ModelMessagesTypeAdapter.dump_python(result.messages, mode="json"),
684
+ "plan": [item.model_dump(mode="json") for item in await self.plan_store.get_items()],
685
+ "before": result.before,
686
+ "after": result.after,
687
+ }
688
+ if self.session:
689
+ self.session.append("compaction_checkpoint", sync=True, **record)
690
+ else:
691
+ self.tree.consume({"kind": "compaction_checkpoint", **record})
692
+ self.history = result.messages
693
+ return result
694
+
695
+ def close(self) -> None:
696
+ if self.session:
697
+ self.session.close()
698
+ # Drops the logs of finished jobs only. A still-running job is the
699
+ # whole point of the design and is left alone, still writing its log.
700
+ self.jobs.shutdown()
701
+
702
+ def shell_environment(self) -> tuple[Path, dict[str, str] | None]:
703
+ """Where and with what environment `!command` runs: the agent's own shell settings."""
704
+ for capability in self.agent.root_capability.capabilities:
705
+ if isinstance(capability, JobShell):
706
+ return Path(capability.workdir), capability.env
707
+ if isinstance(capability, Shell):
708
+ return self._workspace_dir(), capability.env
709
+ return Path.cwd(), None
710
+
711
+ def _workspace_dir(self) -> Path:
712
+ """The directory the agent's `LocalWorkspace` works in, else the process's own."""
713
+ return _local_workspace(self.agent) or Path.cwd()
714
+
715
+ @staticmethod
716
+ def _run_workspace(agent) -> LocalWorkspaceBackend | None:
717
+ """The run's workspace, named explicitly rather than taken from history.
718
+
719
+ Pydantic AI records each run's workspace on its responses and, left to
720
+ choose, refuses history recorded in another directory. pcode continues
721
+ sessions elsewhere on purpose (a removed worktree's session moves to the
722
+ mainline, `-C DIR --continue`), and the history stays byte-identical.
723
+ """
724
+ directory = _local_workspace(agent)
725
+ return None if directory is None else LocalWorkspaceBackend(directory)
726
+
727
+ async def record_shell(self, run: ShellRun) -> str | ToolReturn:
728
+ """Queue a finished `!command` as a shell tool exchange for the next request.
729
+
730
+ It is not written to history yet: nothing has been sent, and a turn
731
+ that fails before its first request must not leave a tool call the
732
+ session's snapshots never saw. `_stream` appends it to the request's
733
+ message history, so the turn's own persistence carries it from then on.
734
+ Returns what the model will see, reduced by the agent's output limits.
735
+ """
736
+ call_id = f"shell_mode_{uuid4().hex[:12]}"
737
+ content: str | ToolReturn = run.tool_result()
738
+ limits = next(
739
+ (c for c in self.agent.root_capability.capabilities if isinstance(c, ToolOutputLimits)),
740
+ None,
741
+ )
742
+ if limits is not None:
743
+ content = await reduce_result(
744
+ limits, call_id=call_id, command=run.command, result=content
745
+ )
746
+ first = not self.history and not self.pending_shell
747
+ self.pending_shell.extend(
748
+ shell_exchange(run.command, content, call_id=call_id, first=first)
749
+ )
750
+ return content
751
+
752
+ def resend_prompt(self) -> str:
753
+ """The original prompt is a display label, not another model message."""
754
+ if self.recovery_blocked:
755
+ raise SessionError(self.recovery_blocked)
756
+ active = self.tree.nodes.get(self.tree.active)
757
+ if active and active.kind == "aside":
758
+ # Regenerating it here would answer a side question, or a summary
759
+ # of a thread this history does not hold, as a conversation turn.
760
+ raise SessionError(
761
+ "The last exchange came from a side thread; send a message instead of /resend."
762
+ )
763
+ if active and active.resend_blocked:
764
+ raise SessionError(
765
+ "The interrupted turn may have changed files or run commands. "
766
+ "Inspect its tools and send an explicit next step instead of /resend."
767
+ )
768
+ for identity in reversed(self.tree.path(self.tree.active)):
769
+ node = self.tree.nodes[identity]
770
+ if node.kind == "turn" and node.prompt:
771
+ return node.prompt
772
+ raise SessionError("There is no earlier prompt to resend; send a message instead.")
773
+
774
+ async def stream(
775
+ self,
776
+ prompt: str | None,
777
+ *,
778
+ model: SideModel | None = None,
779
+ settings: dict | None = None,
780
+ ) -> AsyncIterator[Event]:
781
+ """Retry only failed provider requests, with one budget per submitted turn.
782
+
783
+ `model` runs this turn alone on another model, and `settings` replaces
784
+ the conversation's model settings for it; see `TurnModel`. Retries keep
785
+ them, so a retried request goes where the failed one went.
786
+
787
+ A history the current credential cannot replay is the exception: the
788
+ same request would be rejected every time, so it is repaired once and
789
+ resent outside that budget. Provider errors arrive here rather than at a
790
+ capability because the model request is streamed, and its failure
791
+ surfaces while the event stream is consumed.
792
+
793
+ The whole generator is one profiled span, including the consumer's
794
+ rendering of each event, so a capture separates a session's working cost
795
+ from what it burns sitting at an idle prompt.
796
+ """
797
+ if self.recovery_blocked:
798
+ raise SessionError(self.recovery_blocked)
799
+ send = prompt
800
+ if send is None:
801
+ original = self.resend_prompt()
802
+ node = self.tree.nodes[self.tree.active]
803
+ if not self.history:
804
+ send = original
805
+ # A completed text response would short-circuit Agent.run(None).
806
+ # Regenerate just that response, retaining all settled tool results.
807
+ elif isinstance(self.history[-1], ModelResponse):
808
+ if self.history[-1].tool_calls:
809
+ raise SessionError("The checkpoint has unsettled tools; cannot resend safely.")
810
+ if node.status != "completed" and not node.continuation:
811
+ send = original
812
+ else:
813
+ self.history = self.history[:-1]
814
+ attempt = 0
815
+ repaired = False
816
+ undeferred = False
817
+ chosen = None
818
+ if model is not None or settings is not None:
819
+ chosen = TurnModel(model, settings, self._model_conversation(model, "turn"))
820
+ while True:
821
+ try:
822
+ with profiled_activity("turn"):
823
+ async with aclosing(self._turn(send, chosen)) as turn:
824
+ async for event in turn:
825
+ yield event
826
+ except Exception as error:
827
+ # Deferred MCP schemas are this request's shape, not its history:
828
+ # a provider that rejects them rejects the next turn too, and the
829
+ # session is stuck until they are sent in full. Repairing before
830
+ # the checkpoint guard is what keeps a failed *first* request
831
+ # recoverable, since there is no history to continue from yet.
832
+ if (
833
+ not undeferred
834
+ and not self.recovery_blocked
835
+ and deferred_schemas_rejected(error)
836
+ ):
837
+ if servers := self.mcp.undefer():
838
+ undeferred = True
839
+ if self.context.checkpoint.messages is not None:
840
+ send = None
841
+ listed = ", ".join(servers)
842
+ self.retry_notice(
843
+ "This model rejected hidden MCP tool schemas, so "
844
+ f"{listed} now {'sends' if len(servers) == 1 else 'send'} "
845
+ "every tool up front. Retrying…"
846
+ )
847
+ continue
848
+ if self.recovery_blocked or self.context.checkpoint.messages is None:
849
+ raise
850
+ # Results the current login cannot decrypt fail identically on
851
+ # every attempt, so repair the history once rather than spend
852
+ # the retry budget on a request that cannot succeed.
853
+ if not repaired and unreadable_native_results(error):
854
+ dropped = drop_unreadable_results(self.context.history)
855
+ if dropped:
856
+ repaired = True
857
+ send = None
858
+ self.retry_notice(
859
+ f"Dropped {dropped} unreadable web search "
860
+ f"{'result' if dropped == 1 else 'results'} from an earlier "
861
+ "sign-in, and retrying…"
862
+ )
863
+ continue
864
+ if attempt == self.retry_attempts or not transient(error):
865
+ raise
866
+ # _turn saved the exact failed request, including steering and
867
+ # compaction. Never infer progress from the length of history.
868
+ attempt += 1
869
+ send = None
870
+ self.retry_notice(
871
+ f"{error_message(error)} Retrying provider request "
872
+ f"{attempt}/{self.retry_attempts}…"
873
+ )
874
+ await asyncio.sleep(retries.RETRY_DELAY)
875
+ else:
876
+ return
877
+
878
+ async def _turn(
879
+ self, send: str | None, chosen: TurnModel | None = None
880
+ ) -> AsyncIterator[Event]:
881
+ """Run one attempt. A `None` prompt continues from history without adding to it."""
882
+ prompt = send or ""
883
+ # This turn's state, read and written through one object rather than
884
+ # through the runtime. One turn runs at a time, so it is still the
885
+ # active branch's context; a second turn would be given its own.
886
+ context = self.context
887
+ context.checkpoint = RequestCheckpoint()
888
+ saved = self._open_session()
889
+ run_id = str(uuid4())
890
+ if saved:
891
+ saved.append(
892
+ "turn_started",
893
+ prompt=prompt,
894
+ run_id=run_id,
895
+ parent_id=self.tree.active,
896
+ continuation=send is None,
897
+ sync=True,
898
+ )
899
+ saved.info.status = "running"
900
+ saved.save_info()
901
+ else:
902
+ self.tree.consume(
903
+ {
904
+ "kind": "turn_started",
905
+ "prompt": prompt,
906
+ "continuation": send is None,
907
+ "run_id": run_id,
908
+ "parent_id": self.tree.active,
909
+ }
910
+ )
911
+ self.inspections.run_id = run_id
912
+ context.run_id = run_id
913
+ context.compaction_usage = RunUsage()
914
+ tools_started = False
915
+ try:
916
+ async with aclosing(self._stream(send, context, chosen)) as stream:
917
+ async for event in stream:
918
+ if isinstance(event, ToolStarted):
919
+ tools_started = True
920
+ if isinstance(
921
+ event, (PlanPreview, ChildPlan, ChildText, CommandOutput, EditPreview)
922
+ ):
923
+ # Unexecuted arguments and a sub-agent's transient plan
924
+ # and prose must never enter replay/tree history.
925
+ yield event
926
+ continue
927
+ if saved:
928
+ saved.event(event, run_id=run_id)
929
+ if saved is None:
930
+ # Named with its turn, the way the journal records it.
931
+ record = {"kind": type(event).__name__, **asdict(event)}
932
+ record["run_id"] = record.get("run_id") or run_id
933
+ self.tree.consume(record)
934
+ if saved is None and isinstance(
935
+ event, (ToolStarted, ToolSummary, EditCompleted)
936
+ ):
937
+ self.inspections.event(event)
938
+ yield event
939
+ except BaseException as error:
940
+ resend_blocked = tools_started and context.checkpoint.messages is None
941
+ # The model the failed request went to, which a `$MODEL` turn chose.
942
+ ran_on = chosen.model.model if chosen and chosen.model else self.agent.model
943
+ if saved:
944
+ self._save_totals(saved.info)
945
+ self.inspections.settle(
946
+ "interrupted"
947
+ if isinstance(error, (asyncio.CancelledError, KeyboardInterrupt, GeneratorExit))
948
+ else "unknown"
949
+ )
950
+ if saved:
951
+ cancelled = isinstance(
952
+ error, (asyncio.CancelledError, KeyboardInterrupt, GeneratorExit)
953
+ )
954
+ saved.append(
955
+ "turn_cancelled" if cancelled else "turn_failed",
956
+ run_id=run_id,
957
+ error=error_details(error),
958
+ provider_context=provider_context(ran_on),
959
+ resend_blocked=resend_blocked,
960
+ sync=True,
961
+ )
962
+ if not cancelled:
963
+ # A bug reaches the user as a type and a message; the frames
964
+ # that name the responsible line live only in this process.
965
+ # Stopping on purpose is not a defect worth a traceback.
966
+ saved.record_error(
967
+ error,
968
+ run_id=run_id,
969
+ provider_context=provider_context(ran_on),
970
+ )
971
+ saved.info.status = "cancelled" if cancelled else "failed"
972
+ saved.save_info()
973
+ try:
974
+ # Keep completed tool results even if the *following* request
975
+ # failed. Never silently re-run a side effect on retry.
976
+ checkpoint = context.checkpoint
977
+ if checkpoint.messages is not None:
978
+ # The failed request already carried any shell-mode
979
+ # exchange; recovering it below must not queue it twice.
980
+ context.pending_shell = []
981
+ # Override any partial response saved during unwind. This
982
+ # also persists a bare first prompt, which Harness omits.
983
+ await saved.store.save_snapshot(
984
+ ContinuableSnapshot(
985
+ run_id=run_id,
986
+ step_index=checkpoint.step,
987
+ messages=checkpoint.messages,
988
+ conversation_id=self.conversation_id,
989
+ )
990
+ )
991
+ context.history = await saved.recover()
992
+ except SessionError as recovery_error:
993
+ self.recovery_blocked = str(recovery_error)
994
+ else:
995
+ cancelled = isinstance(
996
+ error, (asyncio.CancelledError, KeyboardInterrupt, GeneratorExit)
997
+ )
998
+ if context.checkpoint.messages is not None:
999
+ context.pending_shell = []
1000
+ context.history = context.checkpoint.messages
1001
+ self.tree.consume(
1002
+ {
1003
+ "kind": "turn_cancelled" if cancelled else "turn_failed",
1004
+ "run_id": run_id,
1005
+ "resend_blocked": resend_blocked,
1006
+ }
1007
+ )
1008
+ self.tree.nodes[run_id].history = deepcopy(context.history)
1009
+ raise
1010
+ else:
1011
+ self.inspections.settle("unknown")
1012
+ if saved:
1013
+ saved.append("turn_completed", run_id=run_id, sync=True)
1014
+ saved.info.status = "complete"
1015
+ saved.info.turns = self.turns
1016
+ self._save_totals(saved.info)
1017
+ saved.save_info()
1018
+ else:
1019
+ self.tree.consume({"kind": "turn_completed", "run_id": run_id})
1020
+ self.tree.nodes[run_id].history = deepcopy(context.history)
1021
+
1022
+ finally:
1023
+ # The auto-compaction summarizer is its own agent run: its usage
1024
+ # reaches neither `after_model_request` nor this run's result. It is
1025
+ # reset per attempt, so a retry loop cannot double-count it.
1026
+ self.totals.add(context.compaction_usage)
1027
+ context.context_history = None
1028
+
1029
+ def _consume_steering(self, run_id: str) -> list[str]:
1030
+ messages = self.take_steering()
1031
+ if self.session:
1032
+ for text in messages:
1033
+ self.session.append("steering", run_id=run_id, prompt=text)
1034
+ return messages
1035
+
1036
+ def _run_capabilities(self, context: TurnContext, chosen: TurnModel) -> list:
1037
+ """Per-run capabilities bind to this turn's context, not to the runtime:
1038
+ what they publish and rewrite belongs to this turn."""
1039
+ run_id = context.run_id
1040
+ return (
1041
+ ([StepPersistence(store=self.session.store)] if self.session else [])
1042
+ + [
1043
+ Steering(lambda: self._consume_steering(run_id), lambda: self.has_steering()),
1044
+ # Finished jobs reach the model here rather than by
1045
+ # being polled for; see `pcode.job_notices`. Ahead of the
1046
+ # checkpoint, so a saved request carries the notices it
1047
+ # was really sent with, as steering and compaction do.
1048
+ JobNotices(self.jobs),
1049
+ context.checkpoint,
1050
+ TokenAccounting(record=self.totals.add),
1051
+ ContextTracking(self, context),
1052
+ ]
1053
+ # Compaction rewrites the conversation's history, so it is
1054
+ # judged on the conversation's model, never on a model one
1055
+ # `$MODEL` turn borrowed: its window and summarizer would
1056
+ # decide what the conversation keeps from then on.
1057
+ # Installed whatever `auto_compact` says; it checks that per request.
1058
+ + ([AutoCompaction(self, context)] if chosen.model is None else [])
1059
+ )
1060
+
1061
+ async def _translator(self, context: TurnContext) -> EventTranslator:
1062
+ capabilities = self.agent.root_capability.capabilities
1063
+ plan_items = [item.model_dump(mode="json") for item in await context.plan_store.get_items()]
1064
+ plan_preview = (
1065
+ StreamingPlanPreview() if any(isinstance(c, Planning) for c in capabilities) else None
1066
+ )
1067
+ # Relative edit paths resolve from the workspace, whatever `root_dir` bounds.
1068
+ writable = any(isinstance(c, FileSystem) and not c.read_only for c in capabilities)
1069
+ edit_preview = StreamingEditPreview(self._workspace_dir()) if writable else None
1070
+ return EventTranslator(
1071
+ self, context, plan_items, plan_preview=plan_preview, edit_preview=edit_preview
1072
+ )
1073
+
1074
+ async def _stream(
1075
+ self, prompt: str | None, context: TurnContext, chosen: TurnModel | None = None
1076
+ ) -> AsyncIterator[Event]:
1077
+ await self.refresh_context()
1078
+ self._persist_child_runs()
1079
+ translator = await self._translator(context)
1080
+ # Unlike run_stream(), this completes the tool loop even when the model
1081
+ # sends explanatory text alongside its tool calls.
1082
+ # Enter the agent too: a run alone does not own a statically supplied
1083
+ # model's HTTP client. Exit closes it on success, failure, or cancellation.
1084
+ chosen = chosen or TurnModel(None, None, self.conversation_id)
1085
+ async with (
1086
+ chosen.applied(self.agent),
1087
+ self.agent,
1088
+ chosen.model.model if chosen.model is not None else nullcontext(),
1089
+ worker_toolsets([self.mcp.live()]),
1090
+ enabled_servers(self.mcp.servers, self.mcp.unavailable),
1091
+ self.agent.run_stream_events(
1092
+ prompt,
1093
+ message_history=context.messages(),
1094
+ workspace=self._run_workspace(self.agent),
1095
+ toolsets=[self.mcp.live()],
1096
+ conversation_id=chosen.conversation_id,
1097
+ run_id=context.run_id,
1098
+ **({"model": chosen.model.model} if chosen.model is not None else {}),
1099
+ capabilities=self._run_capabilities(context, chosen),
1100
+ # Explicitly disable the cap; omitting this restores the library default.
1101
+ usage_limits=UsageLimits(request_limit=None),
1102
+ ) as events,
1103
+ ):
1104
+ async for event in events:
1105
+ # Closed here, not by GC, when the consumer stops mid-event.
1106
+ async with aclosing(translator.translate(event)) as outputs:
1107
+ async for out in outputs:
1108
+ yield out
1109
+
1110
+
1111
+ RETRY_CEILING = re.compile(r"^Tool '(?P<tool>[^']+)' exceeded max retries count of (?P<limit>\d+)")
1112
+
1113
+
1114
+ def retry_ceiling(error: Exception) -> str | None:
1115
+ """Explain a turn that ended because a tool call could not be corrected in time.
1116
+
1117
+ This is a local failure, not a provider one: the model kept sending
1118
+ arguments the tool's schema rejected (or the tool kept raising ModelRetry),
1119
+ and Pydantic AI stopped offering corrections. Saying "check your
1120
+ credentials and connectivity" for it sends the reader to the wrong place.
1121
+
1122
+ Name the tool and the fields that failed, never their values: a rejected
1123
+ argument is model-authored content that can quote a file or a secret.
1124
+ """
1125
+ cause = error.__cause__
1126
+ if type(error).__name__ != "UnexpectedModelBehavior" or cause is None:
1127
+ return None
1128
+ match = RETRY_CEILING.match(str(error))
1129
+ if match is None:
1130
+ return None
1131
+ tool, limit = match["tool"], int(match["limit"])
1132
+ fields = ""
1133
+ if callable(getattr(cause, "errors", None)):
1134
+ try:
1135
+ names = {
1136
+ ".".join(str(part) for part in entry.get("loc", ()))
1137
+ for entry in cause.errors()
1138
+ if entry.get("loc")
1139
+ }
1140
+ except Exception:
1141
+ names = set()
1142
+ if names:
1143
+ fields = " Rejected argument: " + ", ".join(sorted(names)) + "."
1144
+ reason = (
1145
+ "arguments that failed validation"
1146
+ if type(cause).__name__ == "ValidationError"
1147
+ else "a call the tool rejected"
1148
+ )
1149
+ return (
1150
+ f"The model sent {reason} to `{tool}` more times than the retry limit "
1151
+ f"({limit}) allowed, so the turn stopped.{fields} "
1152
+ "Nothing is wrong with the model or the connection; rephrasing the request "
1153
+ "usually clears it. Raise the budget with `/config set tool_retries N`. "
1154
+ "See the saved session diagnostics."
1155
+ )
1156
+
1157
+
1158
+ CODEX_LOGIN_HINT = "Run `/login openai-codex` (or `codex login`, then restart pcode)."
1159
+
1160
+ # Packages whose exceptions mean an MCP server failed, not the model or provider.
1161
+ _MCP_AUTH_PACKAGES = OAUTH_PACKAGES
1162
+ _MCP_PACKAGES = ("mcp", "fastmcp", "pydantic_ai.mcp", "pcode.mcp", *_MCP_AUTH_PACKAGES)
1163
+
1164
+
1165
+ def _mcp_failure(error: BaseException) -> str | None:
1166
+ """`"auth"` or `"server"` when an MCP client raised the error or its explicit cause.
1167
+
1168
+ Recognized by the raising package, never by message text: an MCP tool call
1169
+ that fails mid-turn otherwise reaches the generic guess, which blames the
1170
+ model and provider.
1171
+ """
1172
+ found = None
1173
+ for _ in range(8):
1174
+ module = type(error).__module__
1175
+ if any(module == pkg or module.startswith(f"{pkg}.") for pkg in _MCP_AUTH_PACKAGES):
1176
+ return "auth"
1177
+ if any(module == pkg or module.startswith(f"{pkg}.") for pkg in _MCP_PACKAGES):
1178
+ found = "server"
1179
+ if error.__cause__ is None:
1180
+ break
1181
+ error = error.__cause__
1182
+ return found
1183
+
1184
+
1185
+ def error_message(error: Exception, *, unexpected: str | None = None) -> str:
1186
+ """Don't print raw provider bodies/validation inputs; they can contain secrets.
1187
+
1188
+ `unexpected` replaces the closing guess for an unrecognized error, which
1189
+ otherwise blames the model or provider: right for a turn, wrong for a
1190
+ slash command that never reached one.
1191
+ """
1192
+ if isinstance(error, BaseExceptionGroup) and error.exceptions:
1193
+ return error_message(error.exceptions[0], unexpected=unexpected)
1194
+ if getattr(type(error), "sanitized", False):
1195
+ # Already passed through here in the session host that raised it.
1196
+ return str(error)
1197
+ from pcode.auth import LoginError
1198
+ from pcode.compaction import CompactionError
1199
+ from pcode.workspace import WorkspaceGoneError
1200
+ from pcode.worktree import WorktreeError
1201
+
1202
+ if isinstance(error, (CompactionError, WorktreeError, WorkspaceGoneError)):
1203
+ # All three carry only local paths and git's own messages.
1204
+ return str(error)
1205
+ name = type(error).__name__
1206
+ if isinstance(error, (SessionError, LoginError)):
1207
+ # LoginError contains only fixed, sanitized setup/refresh guidance.
1208
+ return str(error)
1209
+ if (mcp := _mcp_failure(error)) is not None:
1210
+ # SDK messages can carry token-endpoint bodies; the diagnostics log has them.
1211
+ if mcp == "auth":
1212
+ from mcp.client.auth.exceptions import OAuthRegistrationError
1213
+
1214
+ if find_cause(error, OAuthRegistrationError) is not None:
1215
+ # Google's Workspace servers refuse dynamic registration; retrying
1216
+ # sign-in cannot help until the entry names a client.
1217
+ return (
1218
+ "MCP server sign-in failed: it did not accept pcode's automatic OAuth "
1219
+ "client registration, so it needs a pre-registered client. Add "
1220
+ f"`client_id` and `client_secret` to its entry in {config_path()} "
1221
+ "(docs: MCP > Pre-registered clients), then `/mcp disable NAME` and "
1222
+ "`/mcp enable NAME`. Not the model or provider."
1223
+ )
1224
+ return (
1225
+ f"MCP server sign-in failed ({name}), not the model or provider. "
1226
+ "Retry with `/mcp enable NAME`, or `/mcp logout NAME` to start over. "
1227
+ "See the saved session diagnostics."
1228
+ )
1229
+ if (failed := find_cause(error, MCPConnectError)) is not None:
1230
+ # Fixed text, the server name, and the config path only.
1231
+ return f"{failed} Not the model or provider. See the saved session diagnostics."
1232
+ return (
1233
+ f"MCP server request failed ({name}), not the model or provider. "
1234
+ "Check it with `/mcp list`, or turn it off with `/mcp disable NAME`. "
1235
+ "See the saved session diagnostics."
1236
+ )
1237
+ from pcode.claude_sdk import failure_hint as claude_hint
1238
+ from pcode.meridian import failure_hint
1239
+
1240
+ if (hint := failure_hint(error) or claude_hint(error)) is not None:
1241
+ return hint
1242
+ if name == "UserError" and "Codex CLI credentials" in str(error):
1243
+ return f"Provider login missing or invalid. {CODEX_LOGIN_HINT}"
1244
+ if name == "UserError" and "ANTHROPIC_API_KEY" in str(error):
1245
+ # pcode defers the model check so /login stays reachable without a
1246
+ # credential; the failure then surfaces here, on the first prompt.
1247
+ from pcode.models import anthropic_credential_hint
1248
+
1249
+ return f"No Anthropic credential is selected. {anthropic_credential_hint()}"
1250
+ if name == "CredentialsRefreshError":
1251
+ # Recognize only fixed public error codes, never echo token-endpoint bodies.
1252
+ for code in (
1253
+ "refresh_token_invalidated",
1254
+ "refresh_token_reused",
1255
+ "refresh_token_expired",
1256
+ "invalid_grant",
1257
+ ):
1258
+ if code in str(error):
1259
+ return f"Provider login is no longer valid ({code}). {CODEX_LOGIN_HINT}"
1260
+ return f"Provider token refresh failed. {CODEX_LOGIN_HINT}"
1261
+ if "Credential" in name or "Authentication" in name:
1262
+ return f"Authentication failed ({name}). Refresh your provider login and restart."
1263
+ if (quota := quota_message(error)) is not None:
1264
+ return quota
1265
+ status = getattr(error, "status_code", None)
1266
+ if status is not None:
1267
+ details = error_details(error)
1268
+ detail = details.get("provider_message", "")
1269
+ suffix = f" {detail}" if detail else " See the saved session diagnostics."
1270
+ if "reasoning summar" in detail.lower():
1271
+ # An unverified OpenAI organisation, refused the summaries that
1272
+ # `/show-thinking status-line` and `scrollback` ask for.
1273
+ suffix += " `/show-thinking off` stops asking for them."
1274
+ return f"Provider request failed (HTTP {status}).{suffix}"
1275
+ if isinstance(error, ImportError):
1276
+ return "Provider dependency missing. Install its pydantic-ai-slim extra and try again."
1277
+ if (exhausted := retry_ceiling(error)) is not None:
1278
+ return exhausted
1279
+ # SDKs wrap transport failures in ModelAPIError. Classify the bounded cause
1280
+ # chain, but never echo transport text: it may contain URLs or credentials.
1281
+ names = transport_types(error)
1282
+ if "RemoteProtocolError" in names:
1283
+ return (
1284
+ "Provider connection closed or returned an incomplete/invalid response. "
1285
+ "Check provider/proxy connectivity and retry when ready. "
1286
+ "See the saved session diagnostics."
1287
+ )
1288
+ if names & {"APITimeoutError", "ConnectTimeout", "ReadTimeout", "WriteTimeout"}:
1289
+ return (
1290
+ "Provider request timed out. Check provider/proxy connectivity and retry when ready. "
1291
+ "See the saved session diagnostics."
1292
+ )
1293
+ if names & {"APIConnectionError", "ConnectError", "ReadError", "WriteError"}:
1294
+ return (
1295
+ "Could not communicate with the provider. "
1296
+ "Check network/proxy settings and provider availability, then retry when ready. "
1297
+ "See the saved session diagnostics."
1298
+ )
1299
+ if unexpected is not None:
1300
+ return f"{unexpected} ({name})."
1301
+ return f"Run failed ({name}). Check the model string, provider credentials, and connectivity."