pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,27 @@
1
+ """Conversation ids for runs on a model other than the conversation's.
2
+
3
+ Meridian keys its session on the conversation id, so a request from another
4
+ model under the conversation's id would move that session and force the
5
+ conversation's next turn to replay cold. A run on another model (`/btw $MODEL`,
6
+ a `$MODEL` prompt) carries an id of its own, derived from the conversation's so
7
+ anything that needs the session it belongs to can still find it.
8
+ """
9
+
10
+ import re
11
+ from uuid import uuid4
12
+
13
+ KINDS = ("btw", "turn")
14
+ _SUFFIX = re.compile(rf"\.(?:{'|'.join(KINDS)})-[0-9a-f]{{8}}$")
15
+
16
+
17
+ def model_conversation(conversation_id: str, kind: str) -> str:
18
+ """A fresh id for one run of `kind` on another model, within `conversation_id`."""
19
+ assert kind in KINDS
20
+ return f"{conversation_id}.{kind}-{uuid4().hex[:8]}"
21
+
22
+
23
+ def base_conversation(conversation_id: str | None) -> str | None:
24
+ """The conversation (and saved session) a possibly per-model id belongs to."""
25
+ if conversation_id is None:
26
+ return None
27
+ return _SUFFIX.sub("", conversation_id)
@@ -0,0 +1,160 @@
1
+ """Conversation paths, independent of model-message and tool-effect storage."""
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass, field
5
+
6
+ from pcode.links import Link, extract_links, remember_link
7
+
8
+
9
+ @dataclass
10
+ class TurnNode:
11
+ id: str
12
+ parent: str | None
13
+ prompt: str
14
+ status: str = "interrupted"
15
+ continuation: bool = False
16
+ resend_blocked: bool = False
17
+ response: str = ""
18
+ plan: list[dict] = field(default_factory=list)
19
+ # "turn", "compaction", or "aside": a side question merged from /btw, or a
20
+ # side thread's summary, recorded like a turn that ran no tools.
21
+ kind: str = "turn"
22
+ # Unsaved turns and durable compaction checkpoints carry message history.
23
+ history: list | None = None
24
+ links: dict[str, Link] = field(default_factory=dict)
25
+ message_links: dict[str, Link] = field(default_factory=dict)
26
+
27
+ def __post_init__(self) -> None:
28
+ self.add_links(self.prompt, "user")
29
+ self.add_links(self.response, "assistant")
30
+
31
+ def add_links(self, text: str, source: str, *, tool: bool = False) -> None:
32
+ # Keep URL recency in event order, including assistant text before tools.
33
+ for link in extract_links(text, source):
34
+ remember_link(self.links, link)
35
+ if not tool:
36
+ remember_link(self.message_links, link)
37
+
38
+
39
+ class ConversationTree:
40
+ def __init__(self) -> None:
41
+ self.nodes: dict[str, TurnNode] = {}
42
+ self.active: str | None = None
43
+ # Which turn a record belongs to when it does not say. Older journals
44
+ # rely on this entirely; they could never have two turns open at once.
45
+ self.recording: str | None = None
46
+
47
+ def path(self, identity: str | None) -> list[str]:
48
+ path = []
49
+ while identity is not None:
50
+ if identity in path or identity not in self.nodes:
51
+ raise ValueError("Invalid conversation tree path.")
52
+ path.append(identity)
53
+ identity = self.nodes[identity].parent
54
+ return path[::-1]
55
+
56
+ def consume(self, record: dict) -> None:
57
+ kind = record.get("kind")
58
+ if kind == "turn_started" and record.get("run_id"):
59
+ identity = record["run_id"]
60
+ parent = record.get("parent_id", self.active) # Old sessions form a linear tree.
61
+ self.path(parent)
62
+ self.nodes[identity] = TurnNode(
63
+ identity,
64
+ parent,
65
+ record["prompt"],
66
+ continuation=record.get("continuation", False),
67
+ plan=deepcopy(self.nodes[parent].plan) if parent else [],
68
+ kind="aside" if record.get("aside") else "turn",
69
+ )
70
+ self.active = self.recording = identity
71
+ elif kind == "compaction_checkpoint":
72
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
73
+
74
+ identity, parent = record["node_id"], record["parent_id"]
75
+ self.path(parent)
76
+ history = ModelMessagesTypeAdapter.validate_python(record["messages"])
77
+ self.nodes[identity] = TurnNode(
78
+ identity,
79
+ parent,
80
+ record.get("focus", ""),
81
+ status="completed",
82
+ response=f"Context compacted: ~{record['before']} → ~{record['after']} tokens",
83
+ plan=deepcopy(record["plan"]),
84
+ history=history,
85
+ kind="compaction",
86
+ )
87
+ self.active = identity
88
+ self.recording = None
89
+ elif kind == "tree_selected":
90
+ self.path(record["node_id"])
91
+ self.active = record["node_id"]
92
+ # Attribute by the record's own run_id, not its position, so a record from
93
+ # another branch's turn is never absorbed into whichever turn started last.
94
+ # Journals from before run_id fall back to `recording` (see
95
+ # `SessionJournal.active_records`).
96
+ elif (node := self.nodes.get(record.get("run_id") or self.recording)) is not None:
97
+ if kind == "Message":
98
+ node.response = record["markdown"]
99
+ node.add_links(node.response, "assistant")
100
+ elif kind in {"ToolStarted", "ToolSummary"}:
101
+ # Keep only URLs, not another copy of potentially large payloads.
102
+ # The same journal events rebuild these on session resume.
103
+ source = record.get("name") or "tool"
104
+ for key in ("arguments", "result", "command", "detail", "error"):
105
+ text = record.get(key)
106
+ if isinstance(text, str):
107
+ node.add_links(text, source, tool=True)
108
+ elif kind == "PlanUpdated":
109
+ node.plan = deepcopy(record["items"])
110
+ elif kind in {"turn_completed", "turn_failed", "turn_cancelled"}:
111
+ node.status = kind.removeprefix("turn_")
112
+ node.resend_blocked = record.get("resend_blocked", False)
113
+
114
+ def rows(self) -> list[tuple[tuple[str | None, bool], str]]:
115
+ """Depth-first user/edit and assistant/continue rows, with stable IDs."""
116
+ from pcode.diagnostics import redact
117
+ from pcode.ui import plain
118
+
119
+ def excerpt(text):
120
+ return plain(redact(" ".join(text.split())), 100)
121
+
122
+ rows = [
123
+ ((None, False), "Conversation start" + (" ← active" if self.active is None else ""))
124
+ ]
125
+ children: dict[str | None, list[TurnNode]] = {}
126
+ for node in self.nodes.values():
127
+ children.setdefault(node.parent, []).append(node)
128
+ # Iterative traversal also handles very long sessions. Only a fork adds
129
+ # a level: user/assistant pairs and single-child continuations stay aligned.
130
+ roots = children.get(None, [])
131
+ pending = [
132
+ (node, "", len(roots) > 1, i == len(roots) - 1)
133
+ for i, node in reversed(list(enumerate(roots)))
134
+ ]
135
+ while pending:
136
+ node, prefix, fork, last = pending.pop()
137
+ # Keep labels visible even with many nested forks.
138
+ if len(prefix) > 24:
139
+ prefix = "… " + prefix[-22:]
140
+ connector = ("└─ " if last else "├─ ") if fork else ""
141
+ if node.kind != "compaction":
142
+ who = "btw: " if node.kind == "aside" else "user: "
143
+ rows.append(((node.id, True), prefix + connector + who + excerpt(node.prompt)))
144
+ continuation = prefix + ((" " if last else "│ ") if fork else "")
145
+ rows.append(
146
+ (
147
+ (node.id, False),
148
+ continuation
149
+ + ("compaction: " if node.kind == "compaction" else "assistant: ")
150
+ + excerpt(node.response or f"[{node.status}; last safe checkpoint]")
151
+ + (f" [{node.status}]" if node.response and node.status != "completed" else "")
152
+ + (" ← active" if node.id == self.active else ""),
153
+ )
154
+ )
155
+ descendants = children.get(node.id, [])
156
+ pending.extend(
157
+ (child, continuation, len(descendants) > 1, i == len(descendants) - 1)
158
+ for i, child in reversed(list(enumerate(descendants)))
159
+ )
160
+ return rows
pcode/copy_ui.py ADDED
@@ -0,0 +1,172 @@
1
+ """Copy a response, or one quote or code block out of it, to the clipboard.
2
+
3
+ The transcript draws a Markdown quote with a `▌` rail and hard-wraps it to the
4
+ terminal, so selecting it with the mouse picks up both. The source still has
5
+ the text as written: this pulls each top-level quote and fenced code block out
6
+ of it for a small picker, shared by `/copy` and `/tree`.
7
+ """
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ from markdown_it import MarkdownIt
13
+ from prompt_toolkit.application import Application
14
+ from prompt_toolkit.document import Document
15
+ from prompt_toolkit.filters import Always
16
+ from prompt_toolkit.key_binding import KeyBindings
17
+ from prompt_toolkit.layout import HSplit, Layout
18
+ from prompt_toolkit.layout.dimension import Dimension
19
+ from prompt_toolkit.widgets import Label, TextArea
20
+
21
+ from pcode.frame import Dialog
22
+ from pcode.popup_ui import bind_list_paging, popup_container, popup_mouse, popup_style
23
+ from pcode.prefix_keys import PrefixKeys
24
+
25
+ _QUOTE_MARKER = re.compile(r"^[ \t]*> ?")
26
+ _ROW_WIDTH = 80
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Snippet:
31
+ kind: str # "response", "quote", or "code"
32
+ text: str
33
+ language: str = ""
34
+
35
+ @property
36
+ def label(self) -> str:
37
+ if self.kind == "response":
38
+ return "Whole response"
39
+ if self.language:
40
+ return f"{self.kind.title()} ({self.language})"
41
+ return self.kind.title()
42
+
43
+
44
+ def _unquote(lines: list[str]) -> str:
45
+ # A lazy continuation line has no marker; it only loses its indentation.
46
+ return "\n".join(
47
+ _QUOTE_MARKER.sub("", line, count=1) if _QUOTE_MARKER.match(line) else line.lstrip()
48
+ for line in lines
49
+ ).strip("\n")
50
+
51
+
52
+ def snippets(markdown: str) -> list[Snippet]:
53
+ """The whole response, then each top-level quote and code block in order."""
54
+ if not markdown.strip():
55
+ return []
56
+ found = [Snippet("response", markdown)]
57
+ lines = markdown.splitlines()
58
+ depth = 0
59
+ for token in MarkdownIt("commonmark").parse(markdown):
60
+ if token.type == "blockquote_open":
61
+ if depth == 0 and token.map:
62
+ start, end = token.map
63
+ if text := _unquote(lines[start:end]):
64
+ found.append(Snippet("quote", text))
65
+ depth += 1
66
+ elif token.type == "blockquote_close":
67
+ depth -= 1
68
+ elif token.type == "fence" and depth == 0 and token.content.strip():
69
+ language = token.info.split()[0] if token.info.strip() else ""
70
+ # No trailing newline: pasted into a shell, it would run the command.
71
+ text = token.content.rstrip("\n")
72
+ found.append(Snippet("code", text, language))
73
+ return found
74
+
75
+
76
+ def last_response(tree) -> str:
77
+ """The newest assistant text on the active branch, skipping compactions."""
78
+ for identity in reversed(tree.path(tree.active)):
79
+ node = tree.nodes[identity]
80
+ if node.kind != "compaction" and node.response.strip():
81
+ return node.response
82
+ return ""
83
+
84
+
85
+ def snippet_row(snippet: Snippet) -> str:
86
+ text = " ".join(snippet.text.split())
87
+ row = f"{snippet.label}: {text}"
88
+ return row if len(row) <= _ROW_WIDTH else row[: _ROW_WIDTH - 1] + "…"
89
+
90
+
91
+ class SnippetPicker:
92
+ """A list of snippets; Enter picks one, Esc backs out.
93
+
94
+ ``container`` and ``key_bindings`` let a popup host it as an overlay, which
95
+ `/tree` does; ``snippet_dialog`` wraps it in an application of its own.
96
+ """
97
+
98
+ def __init__(self, choices: list[Snippet], on_pick, on_cancel, *, shortcuts=None) -> None:
99
+ self.choices = choices
100
+ self.on_pick = on_pick
101
+ self.list = TextArea(
102
+ read_only=True,
103
+ wrap_lines=False,
104
+ scrollbar=True,
105
+ width=Dimension(min=40, preferred=_ROW_WIDTH + 1),
106
+ height=Dimension(min=1, preferred=len(choices)),
107
+ )
108
+ self.list.window.cursorline = Always()
109
+ rows = [snippet_row(snippet) for snippet in choices]
110
+ # Start on the first quote or block: it is why the picker opened.
111
+ start = 1 if len(choices) > 1 else 0
112
+ position = sum(len(row) + 1 for row in rows[:start])
113
+ self.list.buffer.set_document(Document("\n".join(rows), position), bypass_readonly=True)
114
+ keys = self.key_bindings = KeyBindings()
115
+ bind_list_paging(keys, self.list, Always())
116
+
117
+ @keys.add("enter", eager=True)
118
+ def pick(event):
119
+ on_pick(self.selected())
120
+
121
+ @keys.add("escape", eager=True)
122
+ @keys.add("c-c")
123
+ def cancel(event):
124
+ on_cancel()
125
+
126
+ self.container = HSplit(
127
+ [self.list],
128
+ # Gated by the host's `PrefixKeys`, so a waiting leader owns Enter and Esc.
129
+ key_bindings=shortcuts.gate(keys) if shortcuts else keys,
130
+ modal=True,
131
+ )
132
+
133
+ def help(self) -> list[tuple[str, str]]:
134
+ return [
135
+ ("↑/↓", "Select snippet"),
136
+ ("PgUp/PgDn", "Page"),
137
+ ("Ctrl+U/D", "Half page"),
138
+ ("Enter", "Copy selected snippet"),
139
+ ("Esc / Ctrl+C", "Cancel"),
140
+ ]
141
+
142
+ def selected(self) -> Snippet:
143
+ row = self.list.document.cursor_position_row
144
+ return self.choices[min(row, len(self.choices) - 1)]
145
+
146
+
147
+ def snippet_dialog(choices: list[Snippet], *, input=None, output=None, style=None):
148
+ """A popup application whose result is the picked snippet, or None."""
149
+ app: Application
150
+ shortcuts = PrefixKeys()
151
+
152
+ picker = SnippetPicker(
153
+ choices,
154
+ on_pick=lambda snippet: app.exit(result=snippet),
155
+ on_cancel=lambda: app.exit(result=None),
156
+ shortcuts=shortcuts,
157
+ )
158
+ shortcuts.set_help(picker.help)
159
+ dialog = Dialog(
160
+ title="Copy",
161
+ body=HSplit([picker.container, Label(shortcuts.summary)], padding=1),
162
+ )
163
+ app = Application(
164
+ layout=Layout(popup_container(dialog, shortcuts), focused_element=picker.list),
165
+ key_bindings=shortcuts.key_bindings(KeyBindings()),
166
+ full_screen=True,
167
+ mouse_support=popup_mouse(shortcuts),
168
+ input=input,
169
+ output=output,
170
+ style=popup_style(style),
171
+ )
172
+ return app
pcode/delegation.py ADDED
@@ -0,0 +1,236 @@
1
+ """Forward child activity; child prose goes to the worker viewer, never the transcript.
2
+
3
+ Harness 0.31 supplies delegation lifecycle events and a child stream handler, but
4
+ not the parent's call identity in that handler. A context-local execution wrapper
5
+ pairs concurrent children with their own parent tool call. No global event queue
6
+ or mutable per-agent handler is needed, and cancellation resets the binding.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ from contextvars import ContextVar
12
+ from dataclasses import dataclass, replace
13
+ from datetime import datetime, timezone
14
+ from time import monotonic
15
+
16
+ from pydantic_ai import CapabilityEvent, RunContext
17
+ from pydantic_ai.agent import WrapperAgent
18
+ from pydantic_ai.capabilities import AbstractCapability
19
+ from pydantic_ai.messages import (
20
+ FunctionToolCallEvent,
21
+ FunctionToolResultEvent,
22
+ PartDeltaEvent,
23
+ PartStartEvent,
24
+ RetryPromptPart,
25
+ TextPart,
26
+ TextPartDelta,
27
+ ThinkingPart,
28
+ ThinkingPartDelta,
29
+ )
30
+
31
+ from pcode import retries
32
+ from pcode.cache_warnings import CacheBustEvent
33
+ from pcode.diagnostics import transient
34
+ from pcode.error_report import error_message
35
+ from pcode.filesystem import FileChangeEvent
36
+ from pcode.inspection import capture
37
+ from pcode.jobs import registry
38
+ from pcode.planning import PlanSnapshot
39
+ from pcode.preferences import SETTINGS, load_preferences
40
+ from pcode.retries import RequestCheckpoint
41
+ from pcode.runtime import ToolStarted, ToolSummary
42
+ from pcode.shell import result_projection
43
+ from pcode.tool_display import (
44
+ COMMAND_TOOLS,
45
+ assignment,
46
+ command_error,
47
+ execution_mode,
48
+ invocation,
49
+ result_detail,
50
+ stated_purpose,
51
+ stored_source,
52
+ subject,
53
+ target,
54
+ )
55
+
56
+ _parent: ContextVar[RunContext | None] = ContextVar("delegation_parent", default=None)
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ @dataclass(kw_only=True)
61
+ class ChildActivity(CapabilityEvent, namespace="pcode_delegation", name="activity"):
62
+ activity: str
63
+ child: ToolStarted | ToolSummary | None = None
64
+ # The child's whole plan, sent only when it changed.
65
+ plan: list[dict] | None = None
66
+
67
+
68
+ @dataclass(kw_only=True)
69
+ class ChildOutput(CapabilityEvent, namespace="pcode_delegation", name="output"):
70
+ """A slice of the child's prose or reasoning; `start` opens a new part."""
71
+
72
+ text: str
73
+ thinking: bool = False
74
+ start: bool = False
75
+
76
+
77
+ def _output(event) -> ChildOutput | None:
78
+ """The prose or reasoning a streamed part carries, if any."""
79
+ if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart | ThinkingPart):
80
+ return ChildOutput(
81
+ text=event.part.content, thinking=isinstance(event.part, ThinkingPart), start=True
82
+ )
83
+ if isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
84
+ return ChildOutput(text=event.delta.content_delta)
85
+ if isinstance(event, PartDeltaEvent) and isinstance(event.delta, ThinkingPartDelta):
86
+ if event.delta.content_delta:
87
+ return ChildOutput(text=event.delta.content_delta, thinking=True)
88
+ return None
89
+
90
+
91
+ class DelegationReporting(AbstractCapability):
92
+ async def wrap_tool_execute(self, ctx, *, call, tool_def, args, handler):
93
+ if call.tool_name != "delegate_task":
94
+ return await handler(args)
95
+ token = _parent.set(ctx)
96
+ try:
97
+ return await handler(args)
98
+ finally:
99
+ _parent.reset(token)
100
+
101
+
102
+ class ResumableAgent(WrapperAgent):
103
+ """Resend a sub-agent's request after a dropped connection, as a turn does.
104
+
105
+ Without this, one child's ECONNRESET fails its delegation outright. The
106
+ resend reuses `AgentRuntime.stream`'s rule: only a request that never got an
107
+ answer goes again, from the exact messages it carried, so no tool the child
108
+ already ran is run twice. A drop anywhere else still ends the child.
109
+ """
110
+
111
+ async def run(self, user_prompt=None, *, message_history=None, capabilities=None, **kwargs):
112
+ attempts = int(load_preferences().get("retry_attempts", SETTINGS["retry_attempts"].default))
113
+ attempt = 0
114
+ while True:
115
+ checkpoint = RequestCheckpoint()
116
+ try:
117
+ return await self.wrapped.run(
118
+ user_prompt,
119
+ message_history=message_history,
120
+ capabilities=[*(capabilities or ()), checkpoint],
121
+ **kwargs,
122
+ )
123
+ except Exception as error:
124
+ if attempt >= attempts or checkpoint.messages is None or not transient(error):
125
+ raise
126
+ attempt += 1
127
+ user_prompt, message_history = None, checkpoint.messages
128
+ # The panel shows the reconnect; a warning would print over it.
129
+ logger.debug(
130
+ "Sub-agent %r: %s Retrying %d/%d",
131
+ self.name,
132
+ error_message(error),
133
+ attempt,
134
+ attempts,
135
+ )
136
+ if (parent := _parent.get()) is not None:
137
+ await parent.emit(ChildActivity(activity=f"Reconnecting {attempt}/{attempts}"))
138
+ await asyncio.sleep(retries.RETRY_DELAY)
139
+
140
+
141
+ async def stream_child_activity(_ctx, events):
142
+ """Consume child streams: tool boundaries, phase changes, plans and prose."""
143
+ parent = _parent.get()
144
+ plan_items: list[dict] = []
145
+ tools = {}
146
+ # The child's calls by id, so its `read_tool_result` rows name what spilled.
147
+ called: dict[str, str] = {}
148
+ phase = ""
149
+ async for event in events:
150
+ if parent is None:
151
+ continue
152
+ if isinstance(event, FileChangeEvent):
153
+ await parent.emit(
154
+ FileChangeEvent(
155
+ change=replace(
156
+ event.change,
157
+ call_id=f"{parent.tool_call_id or ''}:{event.change.call_id}",
158
+ )
159
+ )
160
+ )
161
+ continue
162
+ if isinstance(event, CacheBustEvent):
163
+ await parent.emit(CacheBustEvent(text=f"Sub-agent: {event.text}"))
164
+ continue
165
+ if isinstance(event, PlanSnapshot):
166
+ # Its own planning announces the plan; the store stays private to it.
167
+ if event.items != plan_items:
168
+ plan_items = event.items
169
+ await parent.emit(ChildActivity(activity=phase, plan=event.items))
170
+ continue
171
+ if (output := _output(event)) is not None:
172
+ await parent.emit(output)
173
+ child = None
174
+ activity = phase
175
+ parent_id = parent.tool_call_id or ""
176
+ if isinstance(event, FunctionToolCallEvent):
177
+ part = event.part
178
+ try:
179
+ args = part.args_as_dict()
180
+ except (TypeError, ValueError):
181
+ args = {}
182
+ tools[part.tool_call_id] = (part.tool_name, args, monotonic())
183
+ called[part.tool_call_id] = part.tool_name
184
+ agent, task = assignment(part.tool_name, args)
185
+ # Resolved here, in the child's context, so an isolated worker's
186
+ # job ids are looked up among its own jobs, not the parent's.
187
+ command, purpose = subject(part.tool_name, args, registry())
188
+ child = ToolStarted(
189
+ part.tool_name,
190
+ target(part.tool_name, args, stored_source(args, called)),
191
+ f"{parent_id}:{part.tool_call_id}",
192
+ arguments=capture(args),
193
+ run_id=parent.run_id or "",
194
+ started_at=datetime.now(timezone.utc).isoformat(),
195
+ parent_call_id=parent_id,
196
+ command=command,
197
+ purpose=purpose,
198
+ execution=execution_mode(part.tool_name, args),
199
+ agent=agent,
200
+ task=task,
201
+ )
202
+ activity = "Working"
203
+ elif isinstance(event, FunctionToolResultEvent):
204
+ name, args, started = tools.pop(
205
+ event.tool_call_id, (event.part.tool_name or "tool", {}, monotonic())
206
+ )
207
+ outcome = "retry" if isinstance(event.part, RetryPromptPart) else event.part.outcome
208
+ detail, failed = result_detail(
209
+ name, args, event.part.content, outcome, stored_source(args, called)
210
+ )
211
+ content = (
212
+ result_projection(event.part.content) if name == "shell" else event.part.content
213
+ )
214
+ child = ToolSummary(
215
+ name,
216
+ detail,
217
+ failed=failed,
218
+ call_id=f"{parent_id}:{event.tool_call_id}",
219
+ elapsed_seconds=max(0, monotonic() - started),
220
+ command=invocation(name, args),
221
+ purpose=stated_purpose(args),
222
+ error=command_error(content) if failed and name in COMMAND_TOOLS else "",
223
+ result=capture(content),
224
+ run_id=parent.run_id or "",
225
+ outcome=outcome,
226
+ parent_call_id=parent_id,
227
+ )
228
+ activity = "Working" if tools else "Waiting for model"
229
+ elif isinstance(event, PartStartEvent):
230
+ if isinstance(event.part, ThinkingPart):
231
+ activity = "Thinking"
232
+ elif isinstance(event.part, TextPart):
233
+ activity = "Responding"
234
+ if child is not None or activity != phase:
235
+ await parent.emit(ChildActivity(activity=activity, child=child))
236
+ phase = activity