pcode 0.1.3__tar.gz → 0.1.5__tar.gz
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-0.1.3 → pcode-0.1.5}/PKG-INFO +1 -1
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/aside_ui.py +15 -5
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/messages.py +21 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/model.py +10 -6
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/session.py +135 -9
- pcode-0.1.5/src/pcode/claude_sdk/web.py +120 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/config_ui.py +171 -76
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/controller.py +33 -2
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/edit_transcript.py +76 -9
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/edit_ui.py +3 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/ext.py +8 -4
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/extension_guide.md +5 -2
- pcode-0.1.5/src/pcode/extensions/plan_mode.py +172 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/extensions/web_research.py +37 -9
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/inspector_ui.py +9 -3
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/jobs_ui.py +7 -1
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/popup_ui.py +70 -15
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/preferences.py +14 -1
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/session_ui.py +15 -2
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/stream_events.py +3 -2
- pcode-0.1.5/src/pcode/text_selection.py +365 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/tool_display.py +57 -17
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/tree_ui.py +7 -1
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/worker_ui.py +9 -1
- {pcode-0.1.3 → pcode-0.1.5}/.gitignore +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/LICENSE +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/README.md +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/pyproject.toml +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/__init__.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/_job_supervisor.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/agent.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/anthropic_oauth.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/app.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/appearance.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/aside.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/aside_guard.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/auth.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/background_delegation.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/block.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/browser.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/cache_diagnostics.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/cache_settings.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/cache_warnings.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/__init__.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/cli.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/errors.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/resume.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/session_pool.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/claude_sdk/workspace.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/cli.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/clipboard.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/code_mode.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/codex_login.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/command_transcript.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/commands.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/compaction.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/completion.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/config.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/context_breakdown.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/context_usage.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/conversation_ids.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/conversation_tree.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/copy_ui.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/delegation.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/delta.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/diagnostics.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/edit_preview.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/edits.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/email_remote/__init__.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/email_remote/cli.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/email_remote/listener.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/email_remote/mailbox.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/email_remote/outbound.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/email_remote/parsing.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/email_remote/render.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/email_remote/state.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/error_report.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/extensions/browser.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/extensions/ponytail.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/extensions/sandbox.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/extensions/session_history.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/file_refs.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/filesystem.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/frame.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/gateway.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/git_diff.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/history.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/history_cursor.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/history_embeddings.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/host.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/host_protocol.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/host_ui.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/input_keys.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/inspection.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/isolated_delegation.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/job_notices.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/jobs.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/keyboard_protocol.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/keymap.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/layout_speed.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/links.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/links_ui.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/live.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/llm_proxy.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/logins.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/mcp.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/mcp_notice.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/mcp_oauth.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/meridian.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/meridian_process.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/meridian_reminders.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/meridian_setup.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/model_metadata.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/model_ui.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/models.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/native_results.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/oauth_pages.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/output_limits.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/paste.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/path_completion.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/plan_preview.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/plan_sizes.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/planning.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/prefix_keys.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/proctitle.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/profile_benchmark.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/profiling.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/project_trust.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/prompt_keys.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/remote.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/remote_print.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/remote_profile.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/replay_benchmark.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/repo_context.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/retries.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/rpc.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/runtime.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/sandbox.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/session_naming.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/sessions.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/shell.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/shell_mode.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/shell_tools.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/skills.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/stall_log.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/startup_input.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/steering.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/stream_display.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/strict_tools.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/syntax.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/syntax_colors.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/task_prompt.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/task_worktrees.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/terminal_notify.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/terminal_text.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/theme.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/theme_gallery.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/thinking_markdown.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/token_accounting.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/tool_output_limits.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/tool_panel.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/transcript_log.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/transcript_notice.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/turn.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/ui.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/update_check.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/usage.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/word_wrap.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/workers.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/workspace.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/workspace_filesystem.py +0 -0
- {pcode-0.1.3 → pcode-0.1.5}/src/pcode/worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Terminal-native AI coding agent with background jobs, parallel worktrees, forkable sessions, and multi-model support
|
|
5
5
|
Project-URL: Homepage, https://github.com/cruxwell/pcode
|
|
6
6
|
Project-URL: Documentation, https://cruxwell.github.io/pcode/
|
|
@@ -51,6 +51,7 @@ from pcode.popup_ui import (
|
|
|
51
51
|
from pcode.prefix_keys import PrefixKeys
|
|
52
52
|
from pcode.session_ui import literal
|
|
53
53
|
from pcode.task_prompt import TaskPrompt
|
|
54
|
+
from pcode.text_selection import copy_drags
|
|
54
55
|
from pcode.tool_display import plain
|
|
55
56
|
from pcode.ui import StatusLine, status_parts, status_spinner
|
|
56
57
|
|
|
@@ -164,15 +165,22 @@ class AsideBrowser:
|
|
|
164
165
|
self._refreshing = False
|
|
165
166
|
# Enter on the list reads the selected thread full width; Esc brings
|
|
166
167
|
# the list back. A viewer opened on a lone thread starts reading, so
|
|
167
|
-
# one that arrives meanwhile does not pull the list in beside it.
|
|
168
|
-
|
|
168
|
+
# one that arrives meanwhile does not pull the list in beside it. One
|
|
169
|
+
# opened to type in (`edit`) starts reading the newest thread too, with
|
|
170
|
+
# the cursor in its editor; Esc shows the list.
|
|
171
|
+
self.reading = len(self.threads) <= 1 or self.edit
|
|
169
172
|
self._ids: list[str] = []
|
|
170
173
|
self.notice = ""
|
|
171
174
|
# The copy or link picker over the viewer, while one is open.
|
|
172
175
|
self.picker: CopyPicker | LinkPicker | None = None
|
|
173
176
|
self.list = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
|
|
177
|
+
copy_drags(self.list, lambda text: setattr(self, "notice", text))
|
|
174
178
|
self.list.window.cursorline = Always()
|
|
175
|
-
self.detail = RichPane(
|
|
179
|
+
self.detail = RichPane(
|
|
180
|
+
theme=rich_theme,
|
|
181
|
+
color_system=color_system,
|
|
182
|
+
notify=lambda text: setattr(self, "notice", text),
|
|
183
|
+
)
|
|
176
184
|
self.list.buffer.on_cursor_position_changed += lambda _: self.select()
|
|
177
185
|
self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
|
|
178
186
|
shortcuts.set_help(self.help)
|
|
@@ -243,7 +251,7 @@ class AsideBrowser:
|
|
|
243
251
|
submit=lambda text: self.finish(Bridge(thread, "summary", text)),
|
|
244
252
|
)
|
|
245
253
|
|
|
246
|
-
@shortcuts.add("t", "Merge
|
|
254
|
+
@shortcuts.add("t", "Merge into conversation", filter=idle)
|
|
247
255
|
def merge(event):
|
|
248
256
|
if self.bridgeable():
|
|
249
257
|
self.finish(Bridge(self.selected, "merge"))
|
|
@@ -447,7 +455,9 @@ class AsideBrowser:
|
|
|
447
455
|
lambda focus: bridge("summary", focus),
|
|
448
456
|
argument="[focus]",
|
|
449
457
|
),
|
|
450
|
-
PopupCommand(
|
|
458
|
+
PopupCommand(
|
|
459
|
+
"/merge", "Merge into the conversation", lambda _: bridge("merge", "")
|
|
460
|
+
),
|
|
451
461
|
]
|
|
452
462
|
commands.append(PopupCommand("/stop", "Stop running answers", lambda _: self.stop()))
|
|
453
463
|
return commands
|
|
@@ -95,9 +95,30 @@ def _render_block(block: dict) -> str:
|
|
|
95
95
|
return f"[Result of tool call {block.get('tool_use_id')}{status}]\n{_result_text(block)}"
|
|
96
96
|
if kind in ("thinking", "redacted_thinking"):
|
|
97
97
|
return ""
|
|
98
|
+
if kind == "server_tool_use":
|
|
99
|
+
arguments = json.dumps(block.get("input"), ensure_ascii=False, default=str)
|
|
100
|
+
return f"[Ran {block.get('name')} (id {block.get('id')}) with {arguments}]"
|
|
101
|
+
if kind in ("web_search_tool_result", "web_fetch_tool_result"):
|
|
102
|
+
return f"[Result of {block.get('tool_use_id')}]\n{_web_result_text(block.get('content'))}"
|
|
98
103
|
return f"[{kind or 'content'} omitted]"
|
|
99
104
|
|
|
100
105
|
|
|
106
|
+
def _web_result_text(content: Any) -> str:
|
|
107
|
+
"""A web search's hits or a fetched page, as `web.result_block` shaped them."""
|
|
108
|
+
if isinstance(content, list):
|
|
109
|
+
hits = [item for item in content if isinstance(item, dict)]
|
|
110
|
+
lines = [f"{h.get('title') or h['url']}: {h['url']}" for h in hits if h.get("url")]
|
|
111
|
+
summary = next((hit["summary"] for hit in hits if hit.get("summary")), "")
|
|
112
|
+
return "\n\n".join(filter(None, ("\n".join(lines), summary)))
|
|
113
|
+
if not isinstance(content, dict):
|
|
114
|
+
return ""
|
|
115
|
+
if "error_code" in content:
|
|
116
|
+
return f"(error: {content.get('message') or content['error_code']})"
|
|
117
|
+
document = content.get("content")
|
|
118
|
+
source = document.get("source") if isinstance(document, dict) else None
|
|
119
|
+
return str(source.get("data", "")) if isinstance(source, dict) else ""
|
|
120
|
+
|
|
121
|
+
|
|
101
122
|
def replay(messages: list[dict], open_tool_ids: tuple[str, ...] = ()) -> list[dict]:
|
|
102
123
|
"""One user turn carrying `messages`, for a CLI transcript that lacks them.
|
|
103
124
|
|
|
@@ -24,6 +24,7 @@ from pcode.claude_sdk.errors import MISSING_SDK, ClaudeSDKMissing
|
|
|
24
24
|
from pcode.claude_sdk.messages import _digest, lineage, normalize
|
|
25
25
|
from pcode.claude_sdk.session import SessionConfig
|
|
26
26
|
from pcode.claude_sdk.session_pool import pool
|
|
27
|
+
from pcode.claude_sdk.web import cli_tool_names
|
|
27
28
|
from pcode.claude_sdk.workspace import CWD_SETTING
|
|
28
29
|
|
|
29
30
|
PREFIX = "claude:"
|
|
@@ -71,16 +72,18 @@ KEEP_WARM_SETTING = "pcode_claude_keep_warm"
|
|
|
71
72
|
class ClaudeModel(AnthropicModel):
|
|
72
73
|
@cached_property
|
|
73
74
|
def profile(self):
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
# tool deferral is off: hidden tools are withheld
|
|
75
|
+
# No Anthropic server tool reaches the model through the CLI, but the
|
|
76
|
+
# CLI's own WebSearch and WebFetch stand in for those two (`web.py`).
|
|
77
|
+
# Like Meridian, wire tool deferral is off: hidden tools are withheld
|
|
78
|
+
# until found.
|
|
79
|
+
from pydantic_ai.native_tools import WebFetchTool, WebSearchTool
|
|
77
80
|
from pydantic_ai.profiles import merge_profile
|
|
78
81
|
from pydantic_ai.profiles.anthropic import AnthropicModelProfile
|
|
79
82
|
|
|
80
83
|
return merge_profile(
|
|
81
84
|
super().profile,
|
|
82
85
|
AnthropicModelProfile(
|
|
83
|
-
supported_native_tools=frozenset(),
|
|
86
|
+
supported_native_tools=frozenset({WebSearchTool, WebFetchTool}),
|
|
84
87
|
tool_deferral_mode=None,
|
|
85
88
|
tool_addition_mode=None,
|
|
86
89
|
# The system prompt is fixed per process; mid-conversation
|
|
@@ -89,7 +92,7 @@ class ClaudeModel(AnthropicModel):
|
|
|
89
92
|
),
|
|
90
93
|
)
|
|
91
94
|
|
|
92
|
-
def session_config(self, system, tools, settings) -> SessionConfig:
|
|
95
|
+
def session_config(self, system, tools, settings, native_tools=()) -> SessionConfig:
|
|
93
96
|
if isinstance(system, str):
|
|
94
97
|
prompt = system
|
|
95
98
|
else:
|
|
@@ -114,6 +117,7 @@ class ClaudeModel(AnthropicModel):
|
|
|
114
117
|
effort=effort if effort in EFFORTS else None,
|
|
115
118
|
thinking=json.dumps(thinking, sort_keys=True) if isinstance(thinking, dict) else None,
|
|
116
119
|
max_tokens=max_tokens if isinstance(max_tokens, int) and max_tokens > 0 else None,
|
|
120
|
+
web_tools=cli_tool_names(native_tools),
|
|
117
121
|
)
|
|
118
122
|
|
|
119
123
|
async def request(self, messages, model_settings, model_request_parameters):
|
|
@@ -140,7 +144,7 @@ class ClaudeModel(AnthropicModel):
|
|
|
140
144
|
settings = dict(settings or {})
|
|
141
145
|
system, mapped = await self._map_message(messages, parameters, settings)
|
|
142
146
|
tools, _ = self._prepare_tools_and_tool_choice(settings, parameters)
|
|
143
|
-
config = self.session_config(system, tools, settings)
|
|
147
|
+
config = self.session_config(system, tools, settings, parameters.native_tools)
|
|
144
148
|
mapped = normalize(mapped)
|
|
145
149
|
chain = lineage(mapped)
|
|
146
150
|
sessions = pool()
|
|
@@ -23,6 +23,7 @@ from pcode.claude_sdk.errors import (
|
|
|
23
23
|
)
|
|
24
24
|
from pcode.claude_sdk.messages import _blocks, _mcp_content, _result_ids
|
|
25
25
|
from pcode.claude_sdk.resume import ForkPoint
|
|
26
|
+
from pcode.claude_sdk.web import result_block, server_tool_use
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
@@ -41,6 +42,9 @@ CALL_TIMEOUT_SECONDS = 10.0
|
|
|
41
42
|
# that gap goes out with none (verified with 2.1.285), so wait for them.
|
|
42
43
|
TOOLS_READY_TIMEOUT_SECONDS = 30.0
|
|
43
44
|
TOOLS_READY_POLL_SECONDS = 0.02
|
|
45
|
+
# The CLI can run its web tools before a pcode call made beside them, so that call
|
|
46
|
+
# reaches pcode only once they finish. The CLI bounds each fetch and search itself.
|
|
47
|
+
WEB_TOOLS_TIMEOUT_SECONDS = 300.0
|
|
44
48
|
# Statuses for the CLI's error kinds when it reports no HTTP status itself.
|
|
45
49
|
# `server_error` has none on purpose: without a status it is a connection failure
|
|
46
50
|
# (`ClaudeConnectionError`), while a real 5xx or 529 always carries its status.
|
|
@@ -63,6 +67,8 @@ class SessionConfig:
|
|
|
63
67
|
thinking: str | None = None # canonical JSON of the thinking setting
|
|
64
68
|
# pcode's output ceiling; the CLI clamps it to the model's own limit.
|
|
65
69
|
max_tokens: int | None = None
|
|
70
|
+
# The CLI's own web tools to offer (`web.CLI_WEB_TOOLS`), which it runs itself.
|
|
71
|
+
web_tools: tuple[str, ...] = ()
|
|
66
72
|
|
|
67
73
|
def options(self, server, resume: ForkPoint | None, stderr, prompt_file: str) -> Any:
|
|
68
74
|
from claude_agent_sdk import ClaudeAgentOptions
|
|
@@ -71,13 +77,14 @@ class SessionConfig:
|
|
|
71
77
|
if thinking is not None and thinking.get("type") != "disabled":
|
|
72
78
|
thinking = {"display": "summarized", **thinking}
|
|
73
79
|
return ClaudeAgentOptions(
|
|
74
|
-
# No
|
|
75
|
-
#
|
|
76
|
-
tools
|
|
80
|
+
# No settings, CLAUDE.md, plugins, other MCP servers or built-in
|
|
81
|
+
# tools beyond the web ones pcode asked for: the model sees pcode's
|
|
82
|
+
# prompt and pcode's tools only.
|
|
83
|
+
tools=list(self.web_tools),
|
|
77
84
|
setting_sources=[],
|
|
78
85
|
strict_mcp_config=True,
|
|
79
86
|
mcp_servers={SERVER: {"type": "sdk", "name": SERVER, "instance": server}},
|
|
80
|
-
allowed_tools=[f"mcp__{SERVER}"],
|
|
87
|
+
allowed_tools=[f"mcp__{SERVER}", *self.web_tools],
|
|
81
88
|
system_prompt={"type": "file", "path": prompt_file},
|
|
82
89
|
include_partial_messages=True,
|
|
83
90
|
model=self.model,
|
|
@@ -107,6 +114,29 @@ def _client_factory(options) -> Any:
|
|
|
107
114
|
return ClaudeSDKClient(options)
|
|
108
115
|
|
|
109
116
|
|
|
117
|
+
def _block_events(block: dict, index: int) -> list[dict]:
|
|
118
|
+
"""A whole content block as the start and stop events that carry it."""
|
|
119
|
+
return [
|
|
120
|
+
{"type": "content_block_start", "index": index, "content_block": block},
|
|
121
|
+
{"type": "content_block_stop", "index": index},
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _sum(first: dict, second: dict) -> dict:
|
|
126
|
+
"""Two messages' usage as one: counts add up, anything else is the latest."""
|
|
127
|
+
total = dict(first)
|
|
128
|
+
for key, value in second.items():
|
|
129
|
+
if isinstance(value, dict):
|
|
130
|
+
earlier = total.get(key)
|
|
131
|
+
total[key] = _sum(earlier if isinstance(earlier, dict) else {}, value)
|
|
132
|
+
elif isinstance(value, int) and not isinstance(value, bool):
|
|
133
|
+
earlier = total.get(key)
|
|
134
|
+
total[key] = value + (earlier if isinstance(earlier, int) else 0)
|
|
135
|
+
else:
|
|
136
|
+
total[key] = value
|
|
137
|
+
return total
|
|
138
|
+
|
|
139
|
+
|
|
110
140
|
class _Diverged(ClaudeProcessError):
|
|
111
141
|
"""The CLI's transcript stopped matching pcode's history (it acted on its own)."""
|
|
112
142
|
|
|
@@ -145,6 +175,10 @@ class ClaudeSession:
|
|
|
145
175
|
# a released handler, or sent as input). Any other result is its own.
|
|
146
176
|
self._called: set[str] = set()
|
|
147
177
|
self._answered: set[str] = set()
|
|
178
|
+
# Web tool calls the CLI is running itself (id -> name, input), until
|
|
179
|
+
# their results arrive; and the tool names in the message streaming now.
|
|
180
|
+
self._web_calls: dict[str, tuple[str, dict]] = {}
|
|
181
|
+
self._message_tools: list[str] = []
|
|
148
182
|
# Pulsed on every change a waiter may be waiting for.
|
|
149
183
|
self._changed = asyncio.Event()
|
|
150
184
|
self._last_uuid: str | None = None
|
|
@@ -372,6 +406,8 @@ class ClaudeSession:
|
|
|
372
406
|
raise _Diverged(self.config.model, "Claude Code did not end its turn.") from None
|
|
373
407
|
self._ask()
|
|
374
408
|
self._turn_done.clear()
|
|
409
|
+
# The last turn ended, so any web call still listed will never answer.
|
|
410
|
+
self._web_calls.clear()
|
|
375
411
|
# A fork may start with results for the calls its transcript ends on.
|
|
376
412
|
self._answered.update(b["tool_use_id"] for b in content if b.get("type") == "tool_result")
|
|
377
413
|
await self._write(content)
|
|
@@ -384,9 +420,17 @@ class ClaudeSession:
|
|
|
384
420
|
Input written before the results is already queued when they arrive, so
|
|
385
421
|
the CLI sends both in one request (verified with 2.1.283).
|
|
386
422
|
"""
|
|
423
|
+
|
|
424
|
+
def reached() -> bool:
|
|
425
|
+
return bool(self._called & set(self.open_tool_ids)) or self.dead
|
|
426
|
+
|
|
387
427
|
try:
|
|
428
|
+
# The CLI may run its web tools first; until they finish, a call
|
|
429
|
+
# not reaching pcode yet is no sign of a refusal.
|
|
430
|
+
async with asyncio.timeout(WEB_TOOLS_TIMEOUT_SECONDS):
|
|
431
|
+
await self._until(lambda: reached() or not self._web_calls)
|
|
388
432
|
async with asyncio.timeout(CALL_TIMEOUT_SECONDS):
|
|
389
|
-
await self._until(
|
|
433
|
+
await self._until(reached)
|
|
390
434
|
except TimeoutError:
|
|
391
435
|
self._diverge("no tool call reached pcode")
|
|
392
436
|
self._ask()
|
|
@@ -431,20 +475,36 @@ class ClaudeSession:
|
|
|
431
475
|
kind = event.get("type")
|
|
432
476
|
if kind == "message_start":
|
|
433
477
|
stop_reason = None
|
|
478
|
+
self._message_tools = []
|
|
434
479
|
wanted, self._expecting = self._expecting, False
|
|
435
480
|
if not wanted:
|
|
436
481
|
# A request of its own (a nudge after a thinking-only
|
|
437
482
|
# reply, output-limit recovery, a retried tool call).
|
|
438
483
|
self._diverge("a message pcode did not ask for")
|
|
484
|
+
elif kind == "content_block_start":
|
|
485
|
+
block = event.get("content_block") or {}
|
|
486
|
+
if block.get("type") == "tool_use":
|
|
487
|
+
name = str(block.get("name", ""))
|
|
488
|
+
self._message_tools.append(name)
|
|
489
|
+
if name in self.config.web_tools:
|
|
490
|
+
self._web_calls[block.get("id")] = (name, {})
|
|
439
491
|
elif kind == "message_delta":
|
|
440
492
|
stop_reason = (event.get("delta") or {}).get("stop_reason") or stop_reason
|
|
441
493
|
elif kind == "message_stop" and stop_reason != "tool_use":
|
|
442
494
|
# The turn ends here; absorb its result message.
|
|
443
495
|
self._final = True
|
|
496
|
+
elif kind == "message_stop" and wanted and self._web_only():
|
|
497
|
+
# The CLI runs its web tools and asks again: that next
|
|
498
|
+
# message continues the response pcode asked for.
|
|
499
|
+
self._expecting = True
|
|
444
500
|
if wanted:
|
|
445
501
|
self._queue.put_nowait(("event", event, self._last_uuid))
|
|
446
502
|
elif isinstance(message, AssistantMessage):
|
|
447
503
|
self._last_uuid = message.uuid or self._last_uuid
|
|
504
|
+
for block in message.content:
|
|
505
|
+
call = getattr(block, "id", None)
|
|
506
|
+
if call in self._web_calls:
|
|
507
|
+
self._web_calls[call] = (block.name, dict(block.input or {}))
|
|
448
508
|
if message.error:
|
|
449
509
|
text = " ".join(getattr(b, "text", "") for b in message.content)
|
|
450
510
|
self._api_error = (message.error, text.strip())
|
|
@@ -486,14 +546,41 @@ class ClaudeSession:
|
|
|
486
546
|
content = message.content if isinstance(message.content, list) else []
|
|
487
547
|
for block in content:
|
|
488
548
|
tool_use_id = getattr(block, "tool_use_id", None)
|
|
489
|
-
if tool_use_id
|
|
549
|
+
if tool_use_id in self._web_calls:
|
|
550
|
+
# One of the CLI's own web calls: its result is part of the response.
|
|
551
|
+
name, args = self._web_calls.pop(tool_use_id)
|
|
552
|
+
result = result_block(name, tool_use_id, args, block.content, bool(block.is_error))
|
|
553
|
+
self._queue.put_nowait(("web_result", result, None))
|
|
554
|
+
self._changed.set()
|
|
555
|
+
elif tool_use_id and tool_use_id not in self._answered and not self._closed:
|
|
490
556
|
self._diverge(f"Claude Code answered tool call {tool_use_id} itself")
|
|
491
557
|
|
|
558
|
+
def _web_only(self) -> bool:
|
|
559
|
+
"""Whether the message just streamed called the CLI's web tools and nothing else."""
|
|
560
|
+
return bool(self._message_tools) and all(
|
|
561
|
+
name in self.config.web_tools for name in self._message_tools
|
|
562
|
+
)
|
|
563
|
+
|
|
492
564
|
async def response(self) -> AsyncIterator[dict]:
|
|
493
|
-
"""Raw stream events of the CLI's next assistant message, through message_stop.
|
|
565
|
+
"""Raw stream events of the CLI's next assistant message, through message_stop.
|
|
566
|
+
|
|
567
|
+
A message whose only calls are the CLI's web tools is not the end: the
|
|
568
|
+
CLI runs them and sends another, which is folded into this one as an
|
|
569
|
+
Anthropic server tool loop would be. Block indices run on across the
|
|
570
|
+
messages, the web calls become server tool blocks with their results
|
|
571
|
+
inserted after them, and usage is the sum of every message folded in.
|
|
572
|
+
Results of web calls made beside a pcode call arrive after that
|
|
573
|
+
response ended, so they open the next one.
|
|
574
|
+
"""
|
|
494
575
|
tool_ids: list[str] = []
|
|
576
|
+
web_ids: list[str] = []
|
|
495
577
|
stop_reason = None
|
|
496
578
|
self.stray_call = False
|
|
579
|
+
started = False
|
|
580
|
+
early: list[dict] = [] # web results that came before this message did
|
|
581
|
+
shift = emitted = 0 # added to the current message's indices; blocks so far
|
|
582
|
+
folded: dict = {} # usage of the messages already folded in
|
|
583
|
+
usage: dict = {} # usage of the current message, as it accumulates
|
|
497
584
|
while True:
|
|
498
585
|
kind, value, uuid = await self._queue.get()
|
|
499
586
|
if kind == "error":
|
|
@@ -502,18 +589,57 @@ class ClaudeSession:
|
|
|
502
589
|
if kind == "result":
|
|
503
590
|
self.dead = True
|
|
504
591
|
raise self._result_error(value)
|
|
592
|
+
if kind == "web_result":
|
|
593
|
+
if not started:
|
|
594
|
+
early.append(value)
|
|
595
|
+
continue
|
|
596
|
+
for event in _block_events(value, emitted):
|
|
597
|
+
yield event
|
|
598
|
+
emitted += 1
|
|
599
|
+
continue
|
|
505
600
|
event_type = value.get("type")
|
|
601
|
+
if event_type == "message_start":
|
|
602
|
+
tool_ids, web_ids, stop_reason = [], [], None
|
|
603
|
+
usage = dict((value.get("message") or {}).get("usage") or {})
|
|
604
|
+
if started:
|
|
605
|
+
# A continuation: its usage joins the response's, the rest is known.
|
|
606
|
+
yield {"type": "message_delta", "delta": {}, "usage": _sum(folded, usage)}
|
|
607
|
+
else:
|
|
608
|
+
started = True
|
|
609
|
+
yield value
|
|
610
|
+
for block in early:
|
|
611
|
+
for event in _block_events(block, emitted):
|
|
612
|
+
yield event
|
|
613
|
+
emitted += 1
|
|
614
|
+
shift = emitted
|
|
615
|
+
continue
|
|
616
|
+
if isinstance(value.get("index"), int):
|
|
617
|
+
value = {**value, "index": value["index"] + shift}
|
|
618
|
+
emitted = max(emitted, value["index"] + 1)
|
|
506
619
|
if event_type == "content_block_start":
|
|
507
620
|
block = value.get("content_block") or {}
|
|
508
|
-
|
|
621
|
+
wire = str(block.get("name", ""))
|
|
622
|
+
if block.get("type") == "tool_use" and wire in self.config.web_tools:
|
|
623
|
+
web_ids.append(block.get("id"))
|
|
624
|
+
value = {**value, "content_block": server_tool_use(block)}
|
|
625
|
+
elif block.get("type") == "tool_use":
|
|
509
626
|
tool_ids.append(block.get("id"))
|
|
510
|
-
wire = str(block.get("name", ""))
|
|
511
627
|
self.stray_call = self.stray_call or not wire.startswith(TOOL_PREFIX)
|
|
512
628
|
name = wire.removeprefix(TOOL_PREFIX)
|
|
513
629
|
value = {**value, "content_block": {**block, "name": name}}
|
|
514
630
|
elif event_type == "message_delta":
|
|
515
631
|
stop_reason = (value.get("delta") or {}).get("stop_reason") or stop_reason
|
|
632
|
+
usage.update(value.get("usage") or {})
|
|
633
|
+
folding = stop_reason == "tool_use" and web_ids and not tool_ids
|
|
634
|
+
if folding or folded:
|
|
635
|
+
delta = dict(value.get("delta") or {})
|
|
636
|
+
if folding:
|
|
637
|
+
delta.pop("stop_reason", None) # the response goes on
|
|
638
|
+
value = {**value, "delta": delta, "usage": _sum(folded, usage)}
|
|
516
639
|
elif event_type == "message_stop":
|
|
640
|
+
if stop_reason == "tool_use" and web_ids and not tool_ids:
|
|
641
|
+
folded = _sum(folded, usage)
|
|
642
|
+
continue
|
|
517
643
|
# Settled before handing it over, in case the reader stops here.
|
|
518
644
|
self.open_tool_ids = tuple(tool_ids) if stop_reason == "tool_use" else ()
|
|
519
645
|
self.response_uuid = uuid
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The CLI's own `WebSearch`/`WebFetch`, presented as Anthropic server tools.
|
|
2
|
+
|
|
3
|
+
The CLI runs both itself, between two API messages of one turn. pcode shows
|
|
4
|
+
them the way it shows Anthropic's native web tools: a `server_tool_use` block
|
|
5
|
+
and a result block, built here in the shapes Pydantic AI parses.
|
|
6
|
+
|
|
7
|
+
The blocks are pcode's record only; the model reads the CLI's own transcript.
|
|
8
|
+
They are tagged with the `claude` provider, so no other model is ever sent
|
|
9
|
+
them, and they are used just for display and for a fresh replay
|
|
10
|
+
(`messages.replay`).
|
|
11
|
+
|
|
12
|
+
What the CLI returns has no slot in Anthropic's shapes, so it rides in extra
|
|
13
|
+
fields, which Pydantic AI keeps: a search's summary on its first hit
|
|
14
|
+
(`summary`), and a failure's full text beside its code (`message`). Pydantic
|
|
15
|
+
AI also serializes these blocks against the SDK types, so `error_code` must
|
|
16
|
+
stay one of Anthropic's codes or every result logs a serializer warning.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# CLI tool name -> Anthropic server tool name (also the native tool's `kind`).
|
|
27
|
+
CLI_WEB_TOOLS = {"WebSearch": "web_search", "WebFetch": "web_fetch"}
|
|
28
|
+
|
|
29
|
+
# The CLI's search result opens with its query, then this JSON list of hits,
|
|
30
|
+
# then a summary its own sub-request wrote.
|
|
31
|
+
_LINKS = re.compile(r"^Links: (\[.*\])$", re.MULTILINE)
|
|
32
|
+
_QUERY = re.compile(r"\AWeb search results for query: .*$", re.MULTILINE)
|
|
33
|
+
# The CLI's closing instruction to its model, not part of the summary.
|
|
34
|
+
_REMINDER = re.compile(r"\n*REMINDER: You MUST include the sources.*\Z", re.DOTALL)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cli_tool_names(native_tools) -> tuple[str, ...]:
|
|
38
|
+
"""The CLI tools that stand in for the native tools a request asks for."""
|
|
39
|
+
wanted = {tool.kind for tool in native_tools}
|
|
40
|
+
return tuple(sorted(cli for cli, server in CLI_WEB_TOOLS.items() if server in wanted))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def server_tool_use(block: dict) -> dict:
|
|
44
|
+
"""A CLI web `tool_use` block as the `server_tool_use` block it stands for."""
|
|
45
|
+
return {**block, "type": "server_tool_use", "name": CLI_WEB_TOOLS[block["name"]]}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _text(content: object) -> str:
|
|
49
|
+
if isinstance(content, str):
|
|
50
|
+
return content
|
|
51
|
+
if isinstance(content, list):
|
|
52
|
+
return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _hit(title: str, url: str) -> dict:
|
|
57
|
+
return {
|
|
58
|
+
"type": "web_search_result",
|
|
59
|
+
"title": title,
|
|
60
|
+
"url": url,
|
|
61
|
+
# Pydantic AI's result model requires both; the CLI has neither.
|
|
62
|
+
"encrypted_content": "",
|
|
63
|
+
"page_age": None,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _hits(text: str) -> list[dict]:
|
|
68
|
+
"""The hits in the CLI's search text, the first carrying its summary.
|
|
69
|
+
|
|
70
|
+
Text with no parseable `Links:` line means the CLI changed its format (an
|
|
71
|
+
empty search still prints `Links: []`), so that is logged rather than
|
|
72
|
+
shown as a quiet "0 results"; the text is kept on a URL-less hit.
|
|
73
|
+
"""
|
|
74
|
+
match = _LINKS.search(text)
|
|
75
|
+
try:
|
|
76
|
+
links = json.loads(match.group(1)) if match else None
|
|
77
|
+
except ValueError:
|
|
78
|
+
links = None
|
|
79
|
+
if links is None:
|
|
80
|
+
logger.warning("Claude Code's WebSearch result has no parseable Links line: %.200r", text)
|
|
81
|
+
summary = _QUERY.sub("", text)
|
|
82
|
+
else:
|
|
83
|
+
summary = text[match.end() :] if match else ""
|
|
84
|
+
summary = _REMINDER.sub("", summary).strip()
|
|
85
|
+
hits = [
|
|
86
|
+
_hit(str(link.get("title") or ""), str(link["url"]))
|
|
87
|
+
for link in links or []
|
|
88
|
+
if isinstance(link, dict) and link.get("url")
|
|
89
|
+
]
|
|
90
|
+
if summary:
|
|
91
|
+
hits = hits or [_hit("", "")]
|
|
92
|
+
hits[0]["summary"] = summary
|
|
93
|
+
return hits
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def result_block(name: str, tool_use_id: str, args: dict, content: object, is_error: bool) -> dict:
|
|
97
|
+
"""The CLI's result for a web tool call as the server tool's result block."""
|
|
98
|
+
server = CLI_WEB_TOOLS[name]
|
|
99
|
+
text = _text(content)
|
|
100
|
+
if is_error:
|
|
101
|
+
body: object = {
|
|
102
|
+
"type": f"{server}_tool_result_error",
|
|
103
|
+
"error_code": "unavailable",
|
|
104
|
+
"message": text.strip(),
|
|
105
|
+
}
|
|
106
|
+
elif server == "web_search":
|
|
107
|
+
body = _hits(text)
|
|
108
|
+
else:
|
|
109
|
+
body = {
|
|
110
|
+
"type": "web_fetch_result",
|
|
111
|
+
"url": str(args.get("url") or ""),
|
|
112
|
+
# When the CLI handed the answer over, which is when it fetched.
|
|
113
|
+
"retrieved_at": datetime.now(timezone.utc).isoformat(),
|
|
114
|
+
"content": {
|
|
115
|
+
"type": "document",
|
|
116
|
+
"source": {"type": "text", "media_type": "text/plain", "data": text},
|
|
117
|
+
"title": None,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
return {"type": f"{server}_tool_result", "tool_use_id": tool_use_id, "content": body}
|