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/aside.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Side questions asked beside a running turn; no terminal imports.
|
|
2
|
+
|
|
3
|
+
A side question ("btw") reuses the conversation's context but is never part of
|
|
4
|
+
it: it is not written to the session journal, does not appear in the
|
|
5
|
+
conversation tree, and cannot be steered or resent. It runs on the
|
|
6
|
+
conversation's own agent, with the same instructions and tool definitions, so
|
|
7
|
+
its requests reuse the provider cache the conversation has already paid for.
|
|
8
|
+
That is also why its framing travels in the question rather than the system
|
|
9
|
+
prompt: anything added ahead of the history would miss the cache.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from time import monotonic
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from pcode.error_report import error_message
|
|
20
|
+
from pcode.preferences import EFFORTS
|
|
21
|
+
|
|
22
|
+
# One side question is a question, not a second conversation: it gets a small
|
|
23
|
+
# request budget and a hard deadline so a confused run cannot spend a session's
|
|
24
|
+
# worth of tokens in the background where nobody is watching it.
|
|
25
|
+
ASIDE_REQUEST_LIMIT = 12
|
|
26
|
+
ASIDE_TIMEOUT_SECONDS = 300
|
|
27
|
+
# Side questions accumulate over a long session; keep the recent threads
|
|
28
|
+
# readable rather than growing the viewer without bound.
|
|
29
|
+
ASIDE_HISTORY = 20
|
|
30
|
+
# `/btw $a $b QUESTION` fans out one side question per model. Each one sends
|
|
31
|
+
# the whole conversation, uncached on any model but the conversation's own.
|
|
32
|
+
ASIDE_MODEL_LIMIT = 4
|
|
33
|
+
MODEL_MARK = "$"
|
|
34
|
+
# `+high` asks on the conversation's model at that effort; `$model+high` on
|
|
35
|
+
# another model. The levels are the ones /effort accepts.
|
|
36
|
+
EFFORT_MARK = "+"
|
|
37
|
+
# How every provider prefix is spelled (`openai-codex`, `google-gla`).
|
|
38
|
+
PROVIDER_NAME = re.compile(r"[a-z][a-z0-9_-]*")
|
|
39
|
+
ASIDE_MODEL_USAGE = (
|
|
40
|
+
f"Usage: /btw [$PROVIDER:MODEL[+EFFORT] | +EFFORT ...] QUESTION (EFFORT: {'|'.join(EFFORTS)})"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
ASIDE_FRAMING = (
|
|
44
|
+
"[Side question] The user is asking a side question about the conversation so far. "
|
|
45
|
+
"Answer only this question, briefly, preferring what the conversation already shows "
|
|
46
|
+
"over fresh investigation. Your answer appears in a popup beside the conversation "
|
|
47
|
+
"and is not added to it, so do not address the main agent, continue its task, "
|
|
48
|
+
"or promise work. Tools work normally, but the plan and delegation tools are "
|
|
49
|
+
"unavailable here."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# The first question's framing is already in the history a follow-up re-sends,
|
|
54
|
+
# so this only has to say the same rules still hold.
|
|
55
|
+
FOLLOW_UP_FRAMING = (
|
|
56
|
+
"[Side question follow-up] The user is following up on their side question above. "
|
|
57
|
+
"The same rules apply: answer only this, briefly; it is not added to the conversation."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def framed(question: str) -> str:
|
|
62
|
+
"""The user message a side question is sent as."""
|
|
63
|
+
return f"{ASIDE_FRAMING}\n\nQuestion: {question}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def framed_follow_up(question: str) -> str:
|
|
67
|
+
"""The user message a follow-up to a side answer is sent as."""
|
|
68
|
+
return f"{FOLLOW_UP_FRAMING}\n\nQuestion: {question}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Asked in the thread, where its questions and answers are, so the summary
|
|
72
|
+
# reuses the thread's cache; `summary_request` is what the conversation records.
|
|
73
|
+
SUMMARY_FRAMING = (
|
|
74
|
+
"[Side thread summary] Summarize this side discussion for the main conversation, "
|
|
75
|
+
"which never saw it: what was asked, what was found, and anything that should change "
|
|
76
|
+
"the ongoing work. Be brief and concrete, with no preamble, and do not use tools."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def framed_summary(instructions: str) -> str:
|
|
81
|
+
"""The user message asking a thread to summarize itself, with the user's focus if any."""
|
|
82
|
+
return SUMMARY_FRAMING + (f"\n\nFocus: {instructions}" if instructions else "")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def summary_request(questions: list[str], instructions: str = "") -> str:
|
|
86
|
+
"""The user message a thread's summary is recorded under in the conversation.
|
|
87
|
+
|
|
88
|
+
The conversation never saw the thread, so this names its questions rather
|
|
89
|
+
than pointing at a discussion "above" that is not there.
|
|
90
|
+
"""
|
|
91
|
+
lines = [
|
|
92
|
+
"[Side thread summary] Beside this conversation I asked side questions it did not see:"
|
|
93
|
+
]
|
|
94
|
+
lines += [f"- {' '.join(question.split())}" for question in questions]
|
|
95
|
+
lines.append("Summarize what came of them for the rest of this conversation.")
|
|
96
|
+
if instructions:
|
|
97
|
+
lines.append(f"Focus: {instructions}")
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Bridge:
|
|
103
|
+
"""A request to bring a side thread into the conversation.
|
|
104
|
+
|
|
105
|
+
`merge` adds the thread to the conversation tree as it is; `summary` adds a
|
|
106
|
+
summary of it to the active branch, focused by `instructions` when given.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
thread: str
|
|
110
|
+
action: str
|
|
111
|
+
instructions: str = ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class SideReply:
|
|
116
|
+
"""A side answer, and what a follow-up needs to continue from it.
|
|
117
|
+
|
|
118
|
+
`messages` is the run's whole history, conversation context included, so a
|
|
119
|
+
follow-up re-sends exactly the prefix this run just cached. `agent`,
|
|
120
|
+
`model` and `settings` are what it ran with, opaque here: a follow-up asks
|
|
121
|
+
the same model the same way even after /model or /effort changed the
|
|
122
|
+
conversation's.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
answer: str
|
|
126
|
+
messages: list = field(default_factory=list, repr=False)
|
|
127
|
+
conversation_id: str = ""
|
|
128
|
+
agent: object = field(default=None, repr=False)
|
|
129
|
+
model: object = None
|
|
130
|
+
settings: object = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class SideTarget:
|
|
135
|
+
"""Where one side question runs: a model and the effort to ask it at.
|
|
136
|
+
|
|
137
|
+
An empty `model` is the conversation's own; an empty `effort` keeps the
|
|
138
|
+
effort that model already gets.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
model: str = ""
|
|
142
|
+
effort: str = ""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def split_effort(word: str) -> tuple[str, str]:
|
|
146
|
+
"""Split `model+high` into the model and its effort.
|
|
147
|
+
|
|
148
|
+
Model ids can hold `:` and `/`, so only the part after the last `+` is
|
|
149
|
+
considered, and only a real level counts: anything else stays in the name.
|
|
150
|
+
"""
|
|
151
|
+
name, mark, effort = word.rpartition(EFFORT_MARK)
|
|
152
|
+
if mark and effort in EFFORTS:
|
|
153
|
+
return name, effort
|
|
154
|
+
return word, ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _target(word: str) -> SideTarget:
|
|
158
|
+
if word.startswith(EFFORT_MARK):
|
|
159
|
+
effort = word.removeprefix(EFFORT_MARK)
|
|
160
|
+
if effort not in EFFORTS:
|
|
161
|
+
raise ValueError(f"Unknown effort `{word}`. {ASIDE_MODEL_USAGE}")
|
|
162
|
+
return SideTarget(effort=effort)
|
|
163
|
+
model, effort = split_effort(word.removeprefix(MODEL_MARK))
|
|
164
|
+
if not model:
|
|
165
|
+
raise ValueError(f"A model name must follow `{MODEL_MARK}`. {ASIDE_MODEL_USAGE}")
|
|
166
|
+
return SideTarget(model, effort)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_models(argument: str) -> tuple[list[SideTarget], str]:
|
|
170
|
+
"""Split `$model[+effort] [+effort ...] question` into targets and the question.
|
|
171
|
+
|
|
172
|
+
Only leading `$` and `+` words name targets, so either one inside the
|
|
173
|
+
question is text. Repeated targets collapse to one, in the order first
|
|
174
|
+
given; the same model at two efforts is two targets.
|
|
175
|
+
"""
|
|
176
|
+
models: list[SideTarget] = []
|
|
177
|
+
rest = argument.strip()
|
|
178
|
+
while rest.startswith((MODEL_MARK, EFFORT_MARK)):
|
|
179
|
+
word, *tail = rest.split(maxsplit=1)
|
|
180
|
+
rest = tail[0] if tail else ""
|
|
181
|
+
target = _target(word)
|
|
182
|
+
if target not in models:
|
|
183
|
+
models.append(target)
|
|
184
|
+
if models and not rest:
|
|
185
|
+
raise ValueError(f"No question after the model. {ASIDE_MODEL_USAGE}")
|
|
186
|
+
if len(models) > ASIDE_MODEL_LIMIT:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"At most {ASIDE_MODEL_LIMIT} models per side question; got {len(models)}."
|
|
189
|
+
)
|
|
190
|
+
return models, rest
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def prompt_target(text: str) -> tuple[SideTarget | None, str]:
|
|
194
|
+
"""Split a leading `$PROVIDER:MODEL[+EFFORT]` or `+EFFORT` word off a prompt.
|
|
195
|
+
|
|
196
|
+
A prompt is free text, so unlike `/btw` nothing here is refused: a word
|
|
197
|
+
only names a target when it reads as one unmistakably, a model with its
|
|
198
|
+
provider (`$openai:gpt-5`, never `$HOME` or `$PATH:/usr/bin`, whose
|
|
199
|
+
uppercase "provider" no provider is named like) or a real effort level
|
|
200
|
+
(`+high`, never `+1`), and anything else leaves the prompt as typed. One
|
|
201
|
+
word only, since one prompt runs one turn.
|
|
202
|
+
"""
|
|
203
|
+
word, *tail = text.lstrip().split(maxsplit=1) or [""]
|
|
204
|
+
rest = tail[0] if tail else ""
|
|
205
|
+
if word.startswith(EFFORT_MARK):
|
|
206
|
+
effort = word.removeprefix(EFFORT_MARK)
|
|
207
|
+
return (SideTarget(effort=effort), rest) if effort in EFFORTS else (None, text)
|
|
208
|
+
if word.startswith(MODEL_MARK):
|
|
209
|
+
model, effort = split_effort(word.removeprefix(MODEL_MARK))
|
|
210
|
+
provider, colon, name = model.partition(":")
|
|
211
|
+
if colon and PROVIDER_NAME.fullmatch(provider) and name:
|
|
212
|
+
return SideTarget(model, effort), rest
|
|
213
|
+
return None, text
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _leading_word(argument: str) -> str | None:
|
|
217
|
+
"""The word being typed, while every word so far is a `$` or `+` target."""
|
|
218
|
+
if not argument or argument[-1].isspace():
|
|
219
|
+
return None
|
|
220
|
+
words = argument.split()
|
|
221
|
+
if not all(word.startswith((MODEL_MARK, EFFORT_MARK)) for word in words):
|
|
222
|
+
return None
|
|
223
|
+
return words[-1]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def model_fragment(argument: str) -> str | None:
|
|
227
|
+
"""The partial model name being typed in `/btw` arguments, if any.
|
|
228
|
+
|
|
229
|
+
Only a word in the leading run of `$` and `+` words completes as a model;
|
|
230
|
+
once the question has started, a `$` is ordinary text. A word already
|
|
231
|
+
carrying `+` is choosing an effort instead; see `effort_fragment`.
|
|
232
|
+
"""
|
|
233
|
+
word = _leading_word(argument)
|
|
234
|
+
if word is None or not word.startswith(MODEL_MARK) or EFFORT_MARK in word:
|
|
235
|
+
return None
|
|
236
|
+
return word.removeprefix(MODEL_MARK)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def effort_fragment(argument: str) -> str | None:
|
|
240
|
+
"""The partial effort being typed after `+` in a leading `/btw` word, if any."""
|
|
241
|
+
word = _leading_word(argument)
|
|
242
|
+
if word is None or EFFORT_MARK not in word:
|
|
243
|
+
return None
|
|
244
|
+
return word.rpartition(EFFORT_MARK)[2]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def model_labels(models: list[SideTarget]) -> dict[SideTarget, str]:
|
|
248
|
+
"""Short labels: the model without its provider, unless two would collide.
|
|
249
|
+
|
|
250
|
+
An effort joins the label, so answers at different efforts stay apart; the
|
|
251
|
+
conversation's own model shows the effort alone.
|
|
252
|
+
"""
|
|
253
|
+
names = list(dict.fromkeys(target.model for target in models if target.model))
|
|
254
|
+
short = {name: name.partition(":")[2] or name for name in names}
|
|
255
|
+
counts: dict[str, int] = {}
|
|
256
|
+
for label in short.values():
|
|
257
|
+
counts[label] = counts.get(label, 0) + 1
|
|
258
|
+
labels = {}
|
|
259
|
+
for target in models:
|
|
260
|
+
label = short.get(target.model, "")
|
|
261
|
+
if label and counts[label] > 1:
|
|
262
|
+
label = target.model
|
|
263
|
+
labels[target] = " \u00b7 ".join(part for part in (label, target.effort) if part)
|
|
264
|
+
return labels
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def exchanges(thread: list["Aside"], messages: list) -> list[tuple["Aside", int]]:
|
|
268
|
+
"""The thread's questions that `messages` answered, each with where its answer ends.
|
|
269
|
+
|
|
270
|
+
`messages` is the history the thread's newest answer ran with, so it holds
|
|
271
|
+
every question the chain answered; a failed or stopped follow-up never
|
|
272
|
+
joined it and is skipped. A question starts at the request carrying its
|
|
273
|
+
framed text, and its answer runs to the next one's start, or to the end.
|
|
274
|
+
"""
|
|
275
|
+
from pydantic_ai.messages import ModelRequest, UserPromptPart
|
|
276
|
+
|
|
277
|
+
def asks(message, text: str) -> bool:
|
|
278
|
+
return isinstance(message, ModelRequest) and any(
|
|
279
|
+
isinstance(part, UserPromptPart) and part.content == text for part in message.parts
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
starts: list[tuple[Aside, int]] = []
|
|
283
|
+
index = 0
|
|
284
|
+
for number, aside in enumerate(thread):
|
|
285
|
+
text = framed_follow_up(aside.question) if number else framed(aside.question)
|
|
286
|
+
found = next((i for i in range(index, len(messages)) if asks(messages[i], text)), None)
|
|
287
|
+
if found is not None:
|
|
288
|
+
starts.append((aside, found))
|
|
289
|
+
index = found + 1
|
|
290
|
+
ends = [start for _, start in starts[1:]] + [len(messages)]
|
|
291
|
+
return [(aside, end) for (aside, _), end in zip(starts, ends)]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def settled_context(messages: list) -> list:
|
|
295
|
+
"""The longest prefix of `messages` whose tool calls all have results.
|
|
296
|
+
|
|
297
|
+
The newest context available while a turn runs is the request in flight,
|
|
298
|
+
which usually ends in tool calls that have not returned yet. Providers
|
|
299
|
+
reject a history with an unanswered call, so a side question is asked
|
|
300
|
+
against the last point where the conversation was balanced.
|
|
301
|
+
|
|
302
|
+
The provider stack is imported here, not at module import: the terminal
|
|
303
|
+
reaches its first frame without loading it. See `dev/dependencies.md`.
|
|
304
|
+
"""
|
|
305
|
+
from pydantic_ai.messages import (
|
|
306
|
+
NativeToolCallPart,
|
|
307
|
+
NativeToolReturnPart,
|
|
308
|
+
RetryPromptPart,
|
|
309
|
+
ToolCallPart,
|
|
310
|
+
ToolReturnPart,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
calls = (ToolCallPart, NativeToolCallPart)
|
|
314
|
+
results = (ToolReturnPart, NativeToolReturnPart, RetryPromptPart)
|
|
315
|
+
outstanding: set[str] = set()
|
|
316
|
+
balanced = 0
|
|
317
|
+
for index, message in enumerate(messages, start=1):
|
|
318
|
+
for part in message.parts:
|
|
319
|
+
if isinstance(part, calls):
|
|
320
|
+
outstanding.add(part.tool_call_id)
|
|
321
|
+
elif isinstance(part, results):
|
|
322
|
+
outstanding.discard(part.tool_call_id)
|
|
323
|
+
if not outstanding:
|
|
324
|
+
balanced = index
|
|
325
|
+
return list(messages[:balanced])
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass
|
|
329
|
+
class Aside:
|
|
330
|
+
"""One side question and whatever of its answer has arrived."""
|
|
331
|
+
|
|
332
|
+
question: str
|
|
333
|
+
id: str = field(default_factory=lambda: uuid4().hex[:8])
|
|
334
|
+
# The model named with `/btw $MODEL`, and its short display form. Empty when
|
|
335
|
+
# the question was asked without one, on the conversation's own model.
|
|
336
|
+
model: str = ""
|
|
337
|
+
label: str = ""
|
|
338
|
+
# The effort named with `+EFFORT`; empty when the model's own applies.
|
|
339
|
+
effort: str = ""
|
|
340
|
+
# running → answered / failed / cancelled / timed out.
|
|
341
|
+
status: str = "running"
|
|
342
|
+
answer: str = ""
|
|
343
|
+
activity: str = "Waiting for model…"
|
|
344
|
+
error: str = ""
|
|
345
|
+
started: float = field(default_factory=monotonic)
|
|
346
|
+
finished: float | None = None
|
|
347
|
+
# Whether the answer has been opened in the viewer since it settled.
|
|
348
|
+
read: bool = False
|
|
349
|
+
# The id of the thread's first question; a follow-up shares it.
|
|
350
|
+
thread: str = ""
|
|
351
|
+
# Where a thread's first question was asked: the conversation, and its tree
|
|
352
|
+
# node active then. Merging forks the thread from that node.
|
|
353
|
+
conversation: str = ""
|
|
354
|
+
base: str | None = None
|
|
355
|
+
# How the thread was brought into the conversation, e.g. "merged".
|
|
356
|
+
bridged: str = ""
|
|
357
|
+
# What a follow-up continues from, once answered. Only a thread's newest
|
|
358
|
+
# answer keeps one: each holds a copy of the conversation.
|
|
359
|
+
reply: SideReply | None = field(default=None, repr=False)
|
|
360
|
+
|
|
361
|
+
def __post_init__(self) -> None:
|
|
362
|
+
self.thread = self.thread or self.id
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def running(self) -> bool:
|
|
366
|
+
return self.status == "running"
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def elapsed(self) -> float:
|
|
370
|
+
return (self.finished if self.finished is not None else monotonic()) - self.started
|
|
371
|
+
|
|
372
|
+
def settle(self, status: str, *, error: str = "") -> None:
|
|
373
|
+
self.status = status
|
|
374
|
+
self.error = error
|
|
375
|
+
self.activity = ""
|
|
376
|
+
self.finished = monotonic()
|
|
377
|
+
|
|
378
|
+
def state(self) -> str:
|
|
379
|
+
"""Short status for the viewer list and footer, e.g. `answered 12s`."""
|
|
380
|
+
return f"{self.status} {self.elapsed:.0f}s"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class Asides:
|
|
384
|
+
"""Every side question this session asked, oldest first.
|
|
385
|
+
|
|
386
|
+
A follow-up asked in the viewer is a side question of its own, sharing its
|
|
387
|
+
thread with the question it follows; the viewer shows each thread as one
|
|
388
|
+
conversation.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
def __init__(self) -> None:
|
|
392
|
+
self.items: list[Aside] = []
|
|
393
|
+
self._tasks: dict[str, asyncio.Task] = {}
|
|
394
|
+
# Set by the terminal to repaint while an answer streams and to announce
|
|
395
|
+
# one that settled. Off-terminal callers (tests, `--print`) need neither.
|
|
396
|
+
self.on_update: Callable[[Aside], None] = lambda aside: None
|
|
397
|
+
self.on_settle: Callable[[Aside], None] = lambda aside: None
|
|
398
|
+
# Given the exception of a question that failed or timed out, so the
|
|
399
|
+
# frames can be kept where the session keeps turn failures.
|
|
400
|
+
self.on_failure: Callable[[Aside, BaseException], None] = lambda aside, error: None
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def running(self) -> int:
|
|
404
|
+
return sum(aside.running for aside in self.items)
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def unread(self) -> int:
|
|
408
|
+
return sum(not aside.running and not aside.read for aside in self.items)
|
|
409
|
+
|
|
410
|
+
def latest(self) -> Aside | None:
|
|
411
|
+
"""The answer a bare `/btw` should open: newest unread, else newest."""
|
|
412
|
+
unread = [aside for aside in self.items if not aside.running and not aside.read]
|
|
413
|
+
return (unread or self.items or [None])[-1]
|
|
414
|
+
|
|
415
|
+
def threads(self) -> list[list[Aside]]:
|
|
416
|
+
"""Each thread's questions in order, threads in the order they began."""
|
|
417
|
+
threads: dict[str, list[Aside]] = {}
|
|
418
|
+
for aside in self.items:
|
|
419
|
+
threads.setdefault(aside.thread, []).append(aside)
|
|
420
|
+
return list(threads.values())
|
|
421
|
+
|
|
422
|
+
def thread(self, thread: str) -> list[Aside]:
|
|
423
|
+
return [aside for aside in self.items if aside.thread == thread]
|
|
424
|
+
|
|
425
|
+
def follows(self, thread: str) -> Aside:
|
|
426
|
+
"""The answer a follow-up in `thread` continues from.
|
|
427
|
+
|
|
428
|
+
Raises `ValueError` saying why when there is none to continue: the
|
|
429
|
+
newest question is still running, or no answer in it arrived.
|
|
430
|
+
"""
|
|
431
|
+
asked = self.thread(thread)
|
|
432
|
+
if not asked:
|
|
433
|
+
raise ValueError("That side question is gone; ask again with /btw")
|
|
434
|
+
if asked[-1].running:
|
|
435
|
+
raise ValueError("Wait for this answer first")
|
|
436
|
+
for aside in reversed(asked):
|
|
437
|
+
if aside.reply is not None:
|
|
438
|
+
return aside
|
|
439
|
+
raise ValueError("No answer to follow up on; ask again with /btw")
|
|
440
|
+
|
|
441
|
+
def start(
|
|
442
|
+
self,
|
|
443
|
+
question: str,
|
|
444
|
+
work: Callable[[Aside], Awaitable[SideReply | None]],
|
|
445
|
+
*,
|
|
446
|
+
model: str = "",
|
|
447
|
+
label: str = "",
|
|
448
|
+
effort: str = "",
|
|
449
|
+
thread: str = "",
|
|
450
|
+
conversation: str = "",
|
|
451
|
+
base: str | None = None,
|
|
452
|
+
) -> Aside:
|
|
453
|
+
"""Register a side question and run `work` for it in the background.
|
|
454
|
+
|
|
455
|
+
`work` returns the reply a follow-up would continue from. `thread`
|
|
456
|
+
makes it a follow-up in that thread rather than a new one;
|
|
457
|
+
`conversation` and `base` say where a new one was asked.
|
|
458
|
+
"""
|
|
459
|
+
aside = Aside(
|
|
460
|
+
question=question,
|
|
461
|
+
model=model,
|
|
462
|
+
label=label,
|
|
463
|
+
effort=effort,
|
|
464
|
+
thread=thread,
|
|
465
|
+
conversation=conversation,
|
|
466
|
+
base=base,
|
|
467
|
+
)
|
|
468
|
+
self.items.append(aside)
|
|
469
|
+
self._trim()
|
|
470
|
+
self._tasks[aside.id] = asyncio.create_task(self._run(aside, work))
|
|
471
|
+
# Shown as asked, before the first word of an answer arrives.
|
|
472
|
+
self.on_update(aside)
|
|
473
|
+
return aside
|
|
474
|
+
|
|
475
|
+
def _trim(self) -> None:
|
|
476
|
+
"""Drop the oldest settled threads beyond `ASIDE_HISTORY`, whole.
|
|
477
|
+
|
|
478
|
+
A running question owns a live task, so its thread stays; a thread
|
|
479
|
+
loses no single question, or its conversation would read wrong.
|
|
480
|
+
"""
|
|
481
|
+
threads = self.threads()
|
|
482
|
+
excess = len(threads) - ASIDE_HISTORY
|
|
483
|
+
for asked in threads:
|
|
484
|
+
if excess <= 0:
|
|
485
|
+
break
|
|
486
|
+
if not any(aside.running for aside in asked):
|
|
487
|
+
# In place: the live panel shares this list.
|
|
488
|
+
self.items[:] = [aside for aside in self.items if aside.thread != asked[0].thread]
|
|
489
|
+
excess -= 1
|
|
490
|
+
|
|
491
|
+
def cancel(self) -> int:
|
|
492
|
+
"""Stop every running side question; returns how many were stopped."""
|
|
493
|
+
tasks = [task for task in self._tasks.values() if not task.done()]
|
|
494
|
+
for task in tasks:
|
|
495
|
+
if not task.cancelling():
|
|
496
|
+
task.cancel()
|
|
497
|
+
return len(tasks)
|
|
498
|
+
|
|
499
|
+
async def close(self) -> None:
|
|
500
|
+
self.cancel()
|
|
501
|
+
tasks = list(self._tasks.values())
|
|
502
|
+
if tasks:
|
|
503
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
504
|
+
|
|
505
|
+
def update(self, aside: Aside, *, answer: str, activity: str) -> None:
|
|
506
|
+
aside.answer = answer
|
|
507
|
+
aside.activity = activity
|
|
508
|
+
self.on_update(aside)
|
|
509
|
+
|
|
510
|
+
async def _run(self, aside: Aside, work: Callable[[Aside], Awaitable[None]]) -> None:
|
|
511
|
+
try:
|
|
512
|
+
async with asyncio.timeout(ASIDE_TIMEOUT_SECONDS):
|
|
513
|
+
reply = await work(aside)
|
|
514
|
+
except TimeoutError as error:
|
|
515
|
+
aside.settle("timed out", error=f"No answer within {ASIDE_TIMEOUT_SECONDS}s.")
|
|
516
|
+
self._failed(aside, error)
|
|
517
|
+
except asyncio.CancelledError:
|
|
518
|
+
aside.settle("cancelled")
|
|
519
|
+
raise
|
|
520
|
+
except Exception as error:
|
|
521
|
+
aside.settle("failed", error=error_message(error))
|
|
522
|
+
self._failed(aside, error)
|
|
523
|
+
else:
|
|
524
|
+
aside.settle("answered")
|
|
525
|
+
if reply is not None:
|
|
526
|
+
aside.reply = reply
|
|
527
|
+
# Follow-ups continue from the newest answer; an older one
|
|
528
|
+
# would only hold another copy of the conversation.
|
|
529
|
+
for older in self.thread(aside.thread):
|
|
530
|
+
if older is not aside:
|
|
531
|
+
older.reply = None
|
|
532
|
+
finally:
|
|
533
|
+
self._tasks.pop(aside.id, None)
|
|
534
|
+
self.on_settle(aside)
|
|
535
|
+
|
|
536
|
+
def _failed(self, aside: Aside, error: BaseException) -> None:
|
|
537
|
+
# Diagnostics must never replace the failure being diagnosed.
|
|
538
|
+
try:
|
|
539
|
+
self.on_failure(aside, error)
|
|
540
|
+
except Exception:
|
|
541
|
+
pass
|
pcode/aside_guard.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Refuse the tools a side question would use to change the conversation's state.
|
|
2
|
+
|
|
3
|
+
A side question runs on the conversation's own agent so its requests share the
|
|
4
|
+
provider cache with the turns around it, which means it also shares their
|
|
5
|
+
capability instances. Most tools are safe to share: a shell command or a file
|
|
6
|
+
edit changes the workspace, which the user asked about anyway. The plan and
|
|
7
|
+
delegated workers are different: they belong to the conversation, so a write
|
|
8
|
+
from a popup nobody is reading would silently replace the plan the main turn is
|
|
9
|
+
following, or start a worker billed to a turn that never asked for it.
|
|
10
|
+
|
|
11
|
+
The tools stay declared -- dropping them would change the request prefix and
|
|
12
|
+
break the cache -- and a call is answered with a failed result instead, so the
|
|
13
|
+
model reads why and carries on answering.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
17
|
+
from pydantic_ai.exceptions import ToolFailed
|
|
18
|
+
|
|
19
|
+
from pcode.tool_display import PLAN_TOOLS
|
|
20
|
+
|
|
21
|
+
# Reading the plan is how a side question learns what the turn is doing.
|
|
22
|
+
PLAN_READS = frozenset({"read_plan", "get_available_tasks"})
|
|
23
|
+
# `list_task_worktrees` only reads, so it stays available with the plan reads.
|
|
24
|
+
DELEGATION_TOOLS = frozenset({"delegate_task", "integrate_task", "discard_task"})
|
|
25
|
+
REFUSED_TOOLS = (PLAN_TOOLS - PLAN_READS) | DELEGATION_TOOLS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsideGuard(AbstractCapability):
|
|
29
|
+
"""Per-run capability for side questions; see the module docstring."""
|
|
30
|
+
|
|
31
|
+
async def wrap_tool_execute(self, ctx, *, call, tool_def, args, handler):
|
|
32
|
+
if call.tool_name in REFUSED_TOOLS:
|
|
33
|
+
# A failed result rather than `ModelRetry`: nothing about the call
|
|
34
|
+
# can be corrected, and a retry would spend the tool's retry budget.
|
|
35
|
+
raise ToolFailed(
|
|
36
|
+
f"{call.tool_name} is unavailable in a side question: the plan and "
|
|
37
|
+
"delegated tasks belong to the main conversation. Answer without it."
|
|
38
|
+
)
|
|
39
|
+
return await handler(args)
|