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/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."
|