patchfeld 0.2.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.
- patchfeld/__init__.py +1 -0
- patchfeld/__main__.py +32 -0
- patchfeld/actions.py +34 -0
- patchfeld/activity/__init__.py +0 -0
- patchfeld/activity/log.py +237 -0
- patchfeld/agents/__init__.py +0 -0
- patchfeld/agents/child_tools.py +66 -0
- patchfeld/agents/fake_sdk_adapter.py +45 -0
- patchfeld/agents/manager.py +365 -0
- patchfeld/agents/permission_grants.py +98 -0
- patchfeld/agents/permission_inbox.py +91 -0
- patchfeld/agents/request_inbox.py +65 -0
- patchfeld/agents/sdk_adapter.py +49 -0
- patchfeld/agents/session.py +250 -0
- patchfeld/agents/sort.py +66 -0
- patchfeld/agents/state.py +81 -0
- patchfeld/app.py +1433 -0
- patchfeld/config.py +128 -0
- patchfeld/events.py +260 -0
- patchfeld/layout/__init__.py +0 -0
- patchfeld/layout/custom_widgets.py +82 -0
- patchfeld/layout/defaults.py +33 -0
- patchfeld/layout/engine.py +241 -0
- patchfeld/layout/local_widgets.py +188 -0
- patchfeld/layout/registry.py +69 -0
- patchfeld/layout/spec.py +104 -0
- patchfeld/layout/splitter.py +170 -0
- patchfeld/layout/titles.py +70 -0
- patchfeld/orchestrator/__init__.py +0 -0
- patchfeld/orchestrator/formatting.py +15 -0
- patchfeld/orchestrator/session.py +785 -0
- patchfeld/orchestrator/tabs_tools.py +149 -0
- patchfeld/orchestrator/tools.py +976 -0
- patchfeld/persistence/__init__.py +0 -0
- patchfeld/persistence/agents_index.py +68 -0
- patchfeld/persistence/atomic.py +47 -0
- patchfeld/persistence/layout_store.py +25 -0
- patchfeld/persistence/layouts_store.py +61 -0
- patchfeld/persistence/orchestrator_sessions.py +127 -0
- patchfeld/persistence/paths.py +48 -0
- patchfeld/persistence/themes_store.py +44 -0
- patchfeld/persistence/transcript_store.py +64 -0
- patchfeld/persistence/workspace_store.py +25 -0
- patchfeld/theme/__init__.py +0 -0
- patchfeld/theme/engine.py +75 -0
- patchfeld/theme/spec.py +31 -0
- patchfeld/widgets/__init__.py +0 -0
- patchfeld/widgets/_file_lang.py +36 -0
- patchfeld/widgets/_terminal_keys.py +89 -0
- patchfeld/widgets/_terminal_render.py +147 -0
- patchfeld/widgets/activity_feed.py +365 -0
- patchfeld/widgets/agent_table.py +236 -0
- patchfeld/widgets/agent_transcript.py +85 -0
- patchfeld/widgets/change_cwd_screen.py +39 -0
- patchfeld/widgets/chrome.py +210 -0
- patchfeld/widgets/diff_viewer.py +52 -0
- patchfeld/widgets/file_editor.py +258 -0
- patchfeld/widgets/file_tree.py +33 -0
- patchfeld/widgets/file_viewer.py +77 -0
- patchfeld/widgets/history_screen.py +58 -0
- patchfeld/widgets/layout_switcher.py +126 -0
- patchfeld/widgets/log_tail.py +113 -0
- patchfeld/widgets/markdown.py +65 -0
- patchfeld/widgets/new_tab_screen.py +31 -0
- patchfeld/widgets/notebook.py +45 -0
- patchfeld/widgets/orchestrator_chat.py +73 -0
- patchfeld/widgets/permission_modal.py +185 -0
- patchfeld/widgets/permission_request_bar.py +90 -0
- patchfeld/widgets/resume_screen.py +179 -0
- patchfeld/widgets/rich_transcript.py +606 -0
- patchfeld/widgets/system_usage.py +244 -0
- patchfeld/widgets/terminal.py +251 -0
- patchfeld/widgets/theme_switcher.py +63 -0
- patchfeld/widgets/transcript_screen.py +39 -0
- patchfeld/workspace/__init__.py +3 -0
- patchfeld/workspace/spec.py +72 -0
- patchfeld-0.2.0.dist-info/METADATA +584 -0
- patchfeld-0.2.0.dist-info/RECORD +81 -0
- patchfeld-0.2.0.dist-info/WHEEL +4 -0
- patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
- patchfeld-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from claude_agent_sdk import (
|
|
10
|
+
AssistantMessage,
|
|
11
|
+
CanUseTool,
|
|
12
|
+
ClaudeAgentOptions,
|
|
13
|
+
PermissionResultAllow,
|
|
14
|
+
PermissionResultDeny,
|
|
15
|
+
TextBlock,
|
|
16
|
+
ToolPermissionContext,
|
|
17
|
+
query as sdk_query,
|
|
18
|
+
)
|
|
19
|
+
from claude_agent_sdk.types import SystemPromptPreset
|
|
20
|
+
|
|
21
|
+
from patchfeld.agents.manager import AgentManager
|
|
22
|
+
from patchfeld.agents.permission_grants import PermissionGrants
|
|
23
|
+
from patchfeld.agents.permission_inbox import PermissionInbox
|
|
24
|
+
from patchfeld.agents.sdk_adapter import RealSDKAdapter, SDKAdapter
|
|
25
|
+
from patchfeld.agents.session import AgentSession
|
|
26
|
+
from patchfeld.agents.state import AgentInfo
|
|
27
|
+
from patchfeld.events import (
|
|
28
|
+
AgentMessageAppended,
|
|
29
|
+
AgentNotifiedOrchestrator,
|
|
30
|
+
AgentRequestedUserInput,
|
|
31
|
+
AgentTokensTouched,
|
|
32
|
+
EventBus,
|
|
33
|
+
OpenResumePicker,
|
|
34
|
+
OrchestratorReply,
|
|
35
|
+
OrchestratorSessionSwitched,
|
|
36
|
+
PermissionRequested,
|
|
37
|
+
PermissionResolved,
|
|
38
|
+
UserMessageToOrchestrator,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from patchfeld.orchestrator.tools import build_orchestrator_mcp_server
|
|
42
|
+
from patchfeld.persistence.orchestrator_sessions import (
|
|
43
|
+
OrchestratorSessionEntry,
|
|
44
|
+
OrchestratorSessionsIndex,
|
|
45
|
+
)
|
|
46
|
+
from patchfeld.persistence.paths import orchestrator_session_transcript_path
|
|
47
|
+
from patchfeld.persistence.transcript_store import AgentTranscript
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
_RESET_RE = re.compile(r"^/reset(?:\s|$)")
|
|
52
|
+
_RESUME_BARE_RE = re.compile(r"^/resume\s*$")
|
|
53
|
+
_RESUME_ID_RE = re.compile(r"^/resume\s+(\S+)\s*$")
|
|
54
|
+
# /rename <title> — current session
|
|
55
|
+
# /rename <session_id> <title> — specific session (id is non-space; title is rest)
|
|
56
|
+
_RENAME_RE = re.compile(r"^/rename(?:\s+(.*))?$")
|
|
57
|
+
_HELP_RE = re.compile(r"^/help\s*$")
|
|
58
|
+
_CD_RE = re.compile(r"^/cd\s+(.+?)\s*$")
|
|
59
|
+
_BYPASS_PERMS_RE = re.compile(r"^/bypass-permissions\s*$")
|
|
60
|
+
_REQUIRE_PERMS_RE = re.compile(r"^/require-permissions\s*$")
|
|
61
|
+
|
|
62
|
+
_HELP_TEXT = (
|
|
63
|
+
"Available commands:\n"
|
|
64
|
+
" /reset Start a fresh orchestrator session\n"
|
|
65
|
+
" /resume [<session_id>] Resume a past session (no arg → picker)\n"
|
|
66
|
+
" /rename [<id>] <title> Rename the active or a specific session\n"
|
|
67
|
+
" /cd <path> Re-root the workspace at <path>\n"
|
|
68
|
+
" /bypass-permissions Disable the permission modal (agents run freely)\n"
|
|
69
|
+
" /require-permissions Re-enable the permission modal (default)\n"
|
|
70
|
+
" /help Show this list"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_TITLE_PROMPT = (
|
|
74
|
+
"Summarize the following user message in 5-7 words for use as a session "
|
|
75
|
+
"title. Respond with ONLY the title — no quotes, no punctuation, no "
|
|
76
|
+
"preamble. Message:\n\n{message}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Appended to the default Claude Code system prompt for the orchestrator
|
|
80
|
+
# session. It tells the model to reach for the patchfeld MCP tools (the ones
|
|
81
|
+
# registered on the `patchfeld_orchestrator` MCP server, visible to the model
|
|
82
|
+
# as `mcp__patchfeld_orchestrator__<name>`) before falling back to generic
|
|
83
|
+
# Bash/Edit/Write/Read/Grep when a patchfeld tool already covers the job.
|
|
84
|
+
_ORCHESTRATOR_SYSTEM_APPEND = """\
|
|
85
|
+
## Tool Preference (patchfeld)
|
|
86
|
+
|
|
87
|
+
You are running inside the patchfeld TUI. A `patchfeld_orchestrator` MCP server
|
|
88
|
+
exposes tools (visible as `mcp__patchfeld_orchestrator__<name>`) that mutate
|
|
89
|
+
the running app safely. **When a patchfeld tool can accomplish the task, call
|
|
90
|
+
it before falling back to Bash/Edit/Write/Read/Grep.** Editing the underlying
|
|
91
|
+
files or shelling out usually requires a restart and can desync the live UI.
|
|
92
|
+
|
|
93
|
+
- Layout / tabs: prefer `set_layout`, `get_layout`, `add_tab`, `close_tab`,
|
|
94
|
+
`switch_tab`, `rename_tab`, `reorder_tabs`, `save_layout`, `load_layout`,
|
|
95
|
+
`list_layouts`, `list_tabs`, `list_widgets` over editing workspace.json or
|
|
96
|
+
layout files by hand.
|
|
97
|
+
- Agents: prefer `spawn_agent`, `kill_agent`, `interrupt_agent`,
|
|
98
|
+
`send_to_agent`, `read_agent_transcript`, `respond_to_agent_request`,
|
|
99
|
+
`list_agents` over restarting the app or grepping log files.
|
|
100
|
+
- Workspace cwd: prefer `change_cwd` over editing config files or telling
|
|
101
|
+
the user to relaunch.
|
|
102
|
+
- Theme / config / keys: prefer `set_theme`, `save_theme`, `load_theme`,
|
|
103
|
+
`set_config`, `bind_key`, `unbind_key` over editing config.toml or theme
|
|
104
|
+
files directly.
|
|
105
|
+
- Custom widgets: prefer `save_widget` (persists to
|
|
106
|
+
~/.config/patchfeld/widgets/ and registers live for use in the same
|
|
107
|
+
conversation) over `Write`-ing the file via the generic tool. For
|
|
108
|
+
one-off, throwaway widgets that should NOT be persisted, embed the
|
|
109
|
+
source in `LayoutSpec.custom_widgets` instead.
|
|
110
|
+
|
|
111
|
+
Generic tools (Bash, Edit, Write, Read, Grep) remain appropriate for
|
|
112
|
+
arbitrary source-code edits, running tests, git operations, and anything
|
|
113
|
+
no patchfeld tool covers.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class OrchestratorSession:
|
|
118
|
+
"""The user's manager-Claude session. An AgentSession with extra MCP tools."""
|
|
119
|
+
|
|
120
|
+
AGENT_ID = "orchestrator"
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
cwd: Path,
|
|
126
|
+
bus: EventBus,
|
|
127
|
+
manager: AgentManager,
|
|
128
|
+
adapter: SDKAdapter | None = None,
|
|
129
|
+
model: str | None = None,
|
|
130
|
+
apply_layout=None,
|
|
131
|
+
layouts_store=None,
|
|
132
|
+
themes_store=None,
|
|
133
|
+
config_store=None,
|
|
134
|
+
actions=None,
|
|
135
|
+
rebind_keys=None,
|
|
136
|
+
widget_registry=None,
|
|
137
|
+
current_layout=None,
|
|
138
|
+
app=None,
|
|
139
|
+
permission_grants: PermissionGrants | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
self._cwd = cwd
|
|
142
|
+
self._bus = bus
|
|
143
|
+
self._manager = manager
|
|
144
|
+
self._model = model
|
|
145
|
+
self._adapter = adapter or RealSDKAdapter()
|
|
146
|
+
self._apply_layout = apply_layout
|
|
147
|
+
self._layouts_store = layouts_store
|
|
148
|
+
self._themes_store = themes_store
|
|
149
|
+
self._config_store = config_store
|
|
150
|
+
self._actions = actions
|
|
151
|
+
self._rebind_keys = rebind_keys
|
|
152
|
+
self._widget_registry = widget_registry
|
|
153
|
+
self._current_layout = current_layout
|
|
154
|
+
self._app = app
|
|
155
|
+
self._index = OrchestratorSessionsIndex(cwd=cwd)
|
|
156
|
+
self._sdk_session_id: str | None = None
|
|
157
|
+
self._active_transcript_path: Path | None = None
|
|
158
|
+
self._switching_lock = asyncio.Lock()
|
|
159
|
+
self._info = AgentInfo(
|
|
160
|
+
id=self.AGENT_ID,
|
|
161
|
+
name="orchestrator",
|
|
162
|
+
cwd=str(cwd),
|
|
163
|
+
started_at=time.time(),
|
|
164
|
+
)
|
|
165
|
+
self._current_session_first_message: str | None = None
|
|
166
|
+
self._current_session_num_turns: int = 0
|
|
167
|
+
self._inner: AgentSession | None = None # built in start()
|
|
168
|
+
self._unsub_user: callable = lambda: None
|
|
169
|
+
self._unsub_msg: callable = lambda: None
|
|
170
|
+
self._unsub_notify: callable = lambda: None
|
|
171
|
+
self._unsub_ask: callable = lambda: None
|
|
172
|
+
# Test-only seam: when set, used as the adapter factory for the next
|
|
173
|
+
# swap (during /reset or /resume). Production wiring uses RealSDKAdapter.
|
|
174
|
+
self._next_adapter_factory: "Callable[[], SDKAdapter] | None" = None
|
|
175
|
+
self._send_tasks: list[asyncio.Task] = []
|
|
176
|
+
# Production sets this to True after construction so new sessions
|
|
177
|
+
# get auto-titled. Defaults to False so tests don't spawn real Claude
|
|
178
|
+
# CLI subprocesses.
|
|
179
|
+
self._auto_title_enabled: bool = False
|
|
180
|
+
self._title_task: asyncio.Task | None = None
|
|
181
|
+
self._grants = permission_grants
|
|
182
|
+
self._can_use_tool_callback: CanUseTool | None = None
|
|
183
|
+
if permission_grants is not None:
|
|
184
|
+
self._perm_inbox: PermissionInbox | None = PermissionInbox(
|
|
185
|
+
on_pending_changed=self._on_perm_changed,
|
|
186
|
+
)
|
|
187
|
+
self._can_use_tool_callback = self._make_can_use_tool()
|
|
188
|
+
else:
|
|
189
|
+
self._perm_inbox = None
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def active_transcript_path(self) -> "Path | None":
|
|
193
|
+
return self._active_transcript_path
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def info(self) -> AgentInfo:
|
|
197
|
+
"""The AgentInfo accumulating tokens, cost, and last-activity for the
|
|
198
|
+
orchestrator's own SDK session. Shared by reference with the inner
|
|
199
|
+
AgentSession, so reads always reflect the latest counters."""
|
|
200
|
+
return self._info
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def permission_grants(self) -> PermissionGrants | None:
|
|
204
|
+
return self._grants
|
|
205
|
+
|
|
206
|
+
def get_permission_inbox(self) -> PermissionInbox | None:
|
|
207
|
+
return self._perm_inbox
|
|
208
|
+
|
|
209
|
+
def _on_perm_changed(self, count: int) -> None:
|
|
210
|
+
# Until _inner exists, no state to mark. After start(), forward to
|
|
211
|
+
# _inner — same shape as the manager's _on_perm_changed.
|
|
212
|
+
inner = getattr(self, "_inner", None)
|
|
213
|
+
if inner is None:
|
|
214
|
+
return
|
|
215
|
+
if count > 0:
|
|
216
|
+
inner._mark_awaiting_permission()
|
|
217
|
+
else:
|
|
218
|
+
inner._mark_done_permission()
|
|
219
|
+
|
|
220
|
+
def _make_can_use_tool(self) -> CanUseTool:
|
|
221
|
+
bus = self._bus
|
|
222
|
+
grants = self._grants
|
|
223
|
+
agent_name = self.AGENT_ID # "orchestrator"
|
|
224
|
+
get_inbox = lambda: self._perm_inbox
|
|
225
|
+
TIMEOUT_S = 30 * 60
|
|
226
|
+
|
|
227
|
+
async def callback(
|
|
228
|
+
tool_name: str,
|
|
229
|
+
tool_input: dict,
|
|
230
|
+
ctx: ToolPermissionContext,
|
|
231
|
+
):
|
|
232
|
+
assert grants is not None
|
|
233
|
+
decision = grants.lookup(agent_name=agent_name, tool_name=tool_name)
|
|
234
|
+
if decision == "allow":
|
|
235
|
+
return PermissionResultAllow()
|
|
236
|
+
if decision == "deny":
|
|
237
|
+
return PermissionResultDeny(message="denied by saved rule")
|
|
238
|
+
inbox = get_inbox()
|
|
239
|
+
if inbox is None:
|
|
240
|
+
return PermissionResultDeny(message="orchestrator gone", interrupt=True)
|
|
241
|
+
request_id = inbox.register(
|
|
242
|
+
tool_name=tool_name, tool_input=tool_input,
|
|
243
|
+
title=getattr(ctx, "title", None),
|
|
244
|
+
description=getattr(ctx, "description", None),
|
|
245
|
+
)
|
|
246
|
+
bus.publish(PermissionRequested(
|
|
247
|
+
agent_id="orchestrator", agent_name=agent_name,
|
|
248
|
+
request_id=request_id, tool_name=tool_name,
|
|
249
|
+
tool_input=tool_input,
|
|
250
|
+
title=getattr(ctx, "title", None),
|
|
251
|
+
description=getattr(ctx, "description", None),
|
|
252
|
+
))
|
|
253
|
+
try:
|
|
254
|
+
result = await inbox.wait(request_id, timeout_s=TIMEOUT_S)
|
|
255
|
+
except asyncio.CancelledError:
|
|
256
|
+
task = asyncio.current_task()
|
|
257
|
+
if task is not None and task.cancelling() > 0:
|
|
258
|
+
raise
|
|
259
|
+
bus.publish(PermissionResolved(
|
|
260
|
+
agent_id="orchestrator", request_id=request_id,
|
|
261
|
+
behavior="cancelled",
|
|
262
|
+
))
|
|
263
|
+
return PermissionResultDeny(message="cancelled", interrupt=True)
|
|
264
|
+
except asyncio.TimeoutError:
|
|
265
|
+
bus.publish(PermissionResolved(
|
|
266
|
+
agent_id="orchestrator", request_id=request_id,
|
|
267
|
+
behavior="deny",
|
|
268
|
+
))
|
|
269
|
+
return PermissionResultDeny(message="timed out")
|
|
270
|
+
bus.publish(PermissionResolved(
|
|
271
|
+
agent_id="orchestrator", request_id=request_id,
|
|
272
|
+
behavior="allow" if isinstance(result, PermissionResultAllow) else "deny",
|
|
273
|
+
))
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
return callback
|
|
277
|
+
|
|
278
|
+
async def start(self) -> None:
|
|
279
|
+
# One-time migration of any pre-existing orchestrator.jsonl.
|
|
280
|
+
self._index.migrate_legacy_if_needed()
|
|
281
|
+
|
|
282
|
+
# Decide: resume vs new
|
|
283
|
+
prior = self._index.most_recent()
|
|
284
|
+
resume_id: str | None = None
|
|
285
|
+
if prior is not None and not prior.legacy:
|
|
286
|
+
resume_id = prior.session_id
|
|
287
|
+
session_id_for_options = None
|
|
288
|
+
transcript_path = orchestrator_session_transcript_path(
|
|
289
|
+
self._cwd, prior.session_id
|
|
290
|
+
)
|
|
291
|
+
self._sdk_session_id = prior.session_id
|
|
292
|
+
else:
|
|
293
|
+
# Canonical UUID form (8-4-4-4-12) is what Claude CLI expects on
|
|
294
|
+
# --session-id; bare hex (uuid.hex) is rejected at startup.
|
|
295
|
+
new_id = str(uuid.uuid4())
|
|
296
|
+
session_id_for_options = new_id
|
|
297
|
+
transcript_path = orchestrator_session_transcript_path(self._cwd, new_id)
|
|
298
|
+
self._sdk_session_id = new_id
|
|
299
|
+
self._active_transcript_path = transcript_path
|
|
300
|
+
|
|
301
|
+
await self._build_and_start_inner(
|
|
302
|
+
resume=resume_id, new_session_id=session_id_for_options,
|
|
303
|
+
transcript_path=transcript_path,
|
|
304
|
+
)
|
|
305
|
+
# Seed per-session counters from the resumed entry (or zero for a
|
|
306
|
+
# fresh start) so the StatusBar reflects the running total of the
|
|
307
|
+
# conversation we just attached to.
|
|
308
|
+
self._seed_counters_from(prior if resume_id is not None else None)
|
|
309
|
+
|
|
310
|
+
self._unsub_user = self._bus.subscribe(
|
|
311
|
+
UserMessageToOrchestrator, self._on_user_message
|
|
312
|
+
)
|
|
313
|
+
self._unsub_msg = self._bus.subscribe(
|
|
314
|
+
AgentMessageAppended, self._on_message_appended
|
|
315
|
+
)
|
|
316
|
+
self._unsub_notify = self._bus.subscribe(
|
|
317
|
+
AgentNotifiedOrchestrator, self._on_child_notified
|
|
318
|
+
)
|
|
319
|
+
self._unsub_ask = self._bus.subscribe(
|
|
320
|
+
AgentRequestedUserInput, self._on_child_asked
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
async def _build_and_start_inner(
|
|
324
|
+
self,
|
|
325
|
+
*,
|
|
326
|
+
resume: str | None,
|
|
327
|
+
new_session_id: str | None,
|
|
328
|
+
transcript_path: Path,
|
|
329
|
+
) -> None:
|
|
330
|
+
mcp_server = build_orchestrator_mcp_server(
|
|
331
|
+
self._manager,
|
|
332
|
+
apply_layout=self._apply_layout,
|
|
333
|
+
layouts_store=self._layouts_store,
|
|
334
|
+
themes_store=self._themes_store,
|
|
335
|
+
config_store=self._config_store,
|
|
336
|
+
actions=self._actions,
|
|
337
|
+
rebind_keys=self._rebind_keys,
|
|
338
|
+
widget_registry=self._widget_registry,
|
|
339
|
+
current_layout=self._current_layout,
|
|
340
|
+
app=self._app,
|
|
341
|
+
)
|
|
342
|
+
options_kwargs: dict = {
|
|
343
|
+
"cwd": str(self._cwd),
|
|
344
|
+
"mcp_servers": {"patchfeld_orchestrator": mcp_server},
|
|
345
|
+
# Append a routing nudge to the default Claude Code system prompt
|
|
346
|
+
# so the model reaches for patchfeld_orchestrator MCP tools before
|
|
347
|
+
# falling back to Bash/Edit/Write/Read/Grep when a patchfeld tool
|
|
348
|
+
# already covers the task.
|
|
349
|
+
"system_prompt": SystemPromptPreset(
|
|
350
|
+
type="preset",
|
|
351
|
+
preset="claude_code",
|
|
352
|
+
append=_ORCHESTRATOR_SYSTEM_APPEND,
|
|
353
|
+
),
|
|
354
|
+
}
|
|
355
|
+
# Permission posture mirrors AgentManager (see manager.py): when
|
|
356
|
+
# the user launches without --bypass-permissions, self._grants is
|
|
357
|
+
# set and we attach can_use_tool. The orchestrator routes through
|
|
358
|
+
# the same PermissionModal as child agents — the user reviews each
|
|
359
|
+
# tool call the orchestrator's Claude wants to make.
|
|
360
|
+
if self._grants is None:
|
|
361
|
+
options_kwargs["permission_mode"] = "bypassPermissions"
|
|
362
|
+
else:
|
|
363
|
+
options_kwargs["can_use_tool"] = self._can_use_tool_callback
|
|
364
|
+
if resume is not None:
|
|
365
|
+
options_kwargs["resume"] = resume
|
|
366
|
+
if new_session_id is not None:
|
|
367
|
+
options_kwargs["session_id"] = new_session_id
|
|
368
|
+
if self._model is not None:
|
|
369
|
+
options_kwargs["model"] = self._model
|
|
370
|
+
|
|
371
|
+
transcript = AgentTranscript(
|
|
372
|
+
cwd=self._cwd, agent_id=self.AGENT_ID, path=transcript_path,
|
|
373
|
+
)
|
|
374
|
+
self._inner = AgentSession(
|
|
375
|
+
info=self._info,
|
|
376
|
+
adapter=self._adapter,
|
|
377
|
+
transcript=transcript,
|
|
378
|
+
bus=self._bus,
|
|
379
|
+
on_session_id=self._on_session_id_observed,
|
|
380
|
+
)
|
|
381
|
+
await self._inner.start(options=ClaudeAgentOptions(**options_kwargs))
|
|
382
|
+
|
|
383
|
+
def _on_session_id_observed(self, session_id: str) -> None:
|
|
384
|
+
# Update in-memory pointer to whatever the SDK actually attached us to.
|
|
385
|
+
if self._sdk_session_id != session_id:
|
|
386
|
+
log.warning(
|
|
387
|
+
"orchestrator session_id mismatch: passed %s observed %s",
|
|
388
|
+
self._sdk_session_id, session_id,
|
|
389
|
+
)
|
|
390
|
+
self._sdk_session_id = session_id
|
|
391
|
+
# Note: _active_transcript_path is NOT re-pointed here — the
|
|
392
|
+
# AgentTranscript was already opened at the original path and
|
|
393
|
+
# all writes go there. We keep _active_transcript_path stable
|
|
394
|
+
# so callers (e.g. OrchestratorChat) can read from the right file.
|
|
395
|
+
|
|
396
|
+
existing = self._index.get(session_id)
|
|
397
|
+
now = time.time()
|
|
398
|
+
is_new_entry = existing is None
|
|
399
|
+
if existing is None:
|
|
400
|
+
entry = OrchestratorSessionEntry(
|
|
401
|
+
session_id=session_id,
|
|
402
|
+
transcript_path=str(self._active_transcript_path),
|
|
403
|
+
started_at=self._info.started_at,
|
|
404
|
+
last_activity=now,
|
|
405
|
+
first_user_message=self._current_session_first_message,
|
|
406
|
+
num_turns=self._current_session_num_turns,
|
|
407
|
+
tokens_in=self._info.tokens_in,
|
|
408
|
+
tokens_out=self._info.tokens_out,
|
|
409
|
+
cost=self._info.cost,
|
|
410
|
+
legacy=False,
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
existing.last_activity = now
|
|
414
|
+
existing.tokens_in = self._info.tokens_in
|
|
415
|
+
existing.tokens_out = self._info.tokens_out
|
|
416
|
+
existing.cost = self._info.cost
|
|
417
|
+
entry = existing
|
|
418
|
+
self._index.upsert(entry)
|
|
419
|
+
|
|
420
|
+
# New session + first prompt available → fire async title summarizer.
|
|
421
|
+
if (
|
|
422
|
+
is_new_entry
|
|
423
|
+
and self._auto_title_enabled
|
|
424
|
+
and entry.title is None
|
|
425
|
+
and self._current_session_first_message
|
|
426
|
+
):
|
|
427
|
+
self._title_task = asyncio.create_task(
|
|
428
|
+
self._generate_title_async(
|
|
429
|
+
session_id, self._current_session_first_message,
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def interrupt(self) -> None:
|
|
434
|
+
"""Cancel the SDK's currently-running query, if any.
|
|
435
|
+
|
|
436
|
+
Safe to call when the orchestrator is idle — the underlying
|
|
437
|
+
adapter's interrupt is a no-op in that case.
|
|
438
|
+
"""
|
|
439
|
+
if self._inner is not None:
|
|
440
|
+
await self._inner.interrupt()
|
|
441
|
+
|
|
442
|
+
async def wait_idle(self) -> None:
|
|
443
|
+
# queue_send eagerly clears _idle_event synchronously, so we no longer
|
|
444
|
+
# need sleep yields to drain the create_task scheduling gap.
|
|
445
|
+
# Wait for every outstanding send task to complete so that all queued
|
|
446
|
+
# messages have been fully processed (including the second+ messages
|
|
447
|
+
# that are serialised behind the AgentSession._send_lock).
|
|
448
|
+
if self._send_tasks:
|
|
449
|
+
pending = [t for t in self._send_tasks if not t.done()]
|
|
450
|
+
if pending:
|
|
451
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
452
|
+
self._send_tasks.clear()
|
|
453
|
+
if self._inner is not None:
|
|
454
|
+
await self._inner.wait_idle()
|
|
455
|
+
|
|
456
|
+
async def stop(self) -> None:
|
|
457
|
+
if self._perm_inbox is not None:
|
|
458
|
+
self._perm_inbox.cancel_all()
|
|
459
|
+
self._unsub_user()
|
|
460
|
+
self._unsub_msg()
|
|
461
|
+
self._unsub_notify()
|
|
462
|
+
self._unsub_ask()
|
|
463
|
+
if self._inner is not None:
|
|
464
|
+
await self._inner.stop()
|
|
465
|
+
|
|
466
|
+
# --- internals --------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
def _on_user_message(self, event: UserMessageToOrchestrator) -> None:
|
|
469
|
+
if self._inner is None:
|
|
470
|
+
return
|
|
471
|
+
text = event.text
|
|
472
|
+
# Slash-command interception. Only triggers on bare-prefix matches —
|
|
473
|
+
# synthetic messages from child agents are wrapped in "[from agent ...]"
|
|
474
|
+
# and so cannot match.
|
|
475
|
+
if _RESET_RE.match(text):
|
|
476
|
+
self._send_tasks = [t for t in self._send_tasks if not t.done()]
|
|
477
|
+
self._send_tasks.append(asyncio.create_task(self.reset()))
|
|
478
|
+
return
|
|
479
|
+
if _RESUME_BARE_RE.match(text):
|
|
480
|
+
self._bus.publish(OpenResumePicker())
|
|
481
|
+
return
|
|
482
|
+
m = _RESUME_ID_RE.match(text)
|
|
483
|
+
if m:
|
|
484
|
+
self._send_tasks = [t for t in self._send_tasks if not t.done()]
|
|
485
|
+
self._send_tasks.append(asyncio.create_task(self.resume(m.group(1))))
|
|
486
|
+
return
|
|
487
|
+
m = _RENAME_RE.match(text)
|
|
488
|
+
if m:
|
|
489
|
+
self._handle_rename_command(m.group(1) or "")
|
|
490
|
+
return
|
|
491
|
+
m = _CD_RE.match(text)
|
|
492
|
+
if m and self._app is not None:
|
|
493
|
+
path = m.group(1).strip()
|
|
494
|
+
self._send_tasks = [t for t in self._send_tasks if not t.done()]
|
|
495
|
+
self._send_tasks.append(
|
|
496
|
+
asyncio.create_task(self._handle_cd_command(path))
|
|
497
|
+
)
|
|
498
|
+
return
|
|
499
|
+
if _BYPASS_PERMS_RE.match(text) and self._app is not None:
|
|
500
|
+
self._send_tasks = [t for t in self._send_tasks if not t.done()]
|
|
501
|
+
self._send_tasks.append(
|
|
502
|
+
asyncio.create_task(self._handle_set_permissions(bypass=True))
|
|
503
|
+
)
|
|
504
|
+
return
|
|
505
|
+
if _REQUIRE_PERMS_RE.match(text) and self._app is not None:
|
|
506
|
+
self._send_tasks = [t for t in self._send_tasks if not t.done()]
|
|
507
|
+
self._send_tasks.append(
|
|
508
|
+
asyncio.create_task(self._handle_set_permissions(bypass=False))
|
|
509
|
+
)
|
|
510
|
+
return
|
|
511
|
+
if _HELP_RE.match(text):
|
|
512
|
+
self._publish_notice(_HELP_TEXT)
|
|
513
|
+
return
|
|
514
|
+
# Fall through: ordinary prompt.
|
|
515
|
+
if self._current_session_first_message is None:
|
|
516
|
+
self._current_session_first_message = text
|
|
517
|
+
self._send_tasks = [t for t in self._send_tasks if not t.done()]
|
|
518
|
+
task = self._inner.queue_send(text)
|
|
519
|
+
self._send_tasks.append(task)
|
|
520
|
+
|
|
521
|
+
def _handle_rename_command(self, args: str) -> None:
|
|
522
|
+
"""Handle /rename invocations.
|
|
523
|
+
|
|
524
|
+
Forms:
|
|
525
|
+
/rename → notice on missing title
|
|
526
|
+
/rename <title> → renames the active session
|
|
527
|
+
/rename <session_id> <title> → renames a specific session
|
|
528
|
+
"""
|
|
529
|
+
args = args.strip()
|
|
530
|
+
if not args:
|
|
531
|
+
self._publish_notice(
|
|
532
|
+
"Usage: /rename <new title> or /rename <session_id> <title>"
|
|
533
|
+
)
|
|
534
|
+
return
|
|
535
|
+
# If the first token matches a known session_id, treat it as
|
|
536
|
+
# /rename <id> <title>; otherwise the whole thing is the active title.
|
|
537
|
+
first, _, rest = args.partition(" ")
|
|
538
|
+
rest = rest.strip()
|
|
539
|
+
candidate_entry = self._index.get(first)
|
|
540
|
+
if candidate_entry is not None and rest:
|
|
541
|
+
target_id = first
|
|
542
|
+
new_title: str | None = rest
|
|
543
|
+
else:
|
|
544
|
+
target_id = self._sdk_session_id or ""
|
|
545
|
+
new_title = args
|
|
546
|
+
if not target_id:
|
|
547
|
+
self._publish_notice("No active session to rename.")
|
|
548
|
+
return
|
|
549
|
+
if not new_title:
|
|
550
|
+
new_title = None # explicit clear
|
|
551
|
+
ok = self._index.set_title(target_id, new_title)
|
|
552
|
+
if not ok:
|
|
553
|
+
self._publish_notice(f"No such session: {target_id}")
|
|
554
|
+
return
|
|
555
|
+
label = new_title if new_title else "(cleared)"
|
|
556
|
+
self._publish_notice(f"Renamed session to: {label}")
|
|
557
|
+
|
|
558
|
+
async def _handle_cd_command(self, path: str) -> None:
|
|
559
|
+
if self._app is None:
|
|
560
|
+
return
|
|
561
|
+
result = await self._app.change_cwd(path)
|
|
562
|
+
if "error" in result:
|
|
563
|
+
err = result["error"]
|
|
564
|
+
if err == "agents_running":
|
|
565
|
+
names = ", ".join(a["name"] for a in result.get("agents", []))
|
|
566
|
+
self._publish_notice(
|
|
567
|
+
f"Refusing /cd: agents still running ({names})."
|
|
568
|
+
)
|
|
569
|
+
elif err == "invalid_path":
|
|
570
|
+
self._publish_notice(
|
|
571
|
+
f"Invalid path: {result.get('path') or result.get('detail')}"
|
|
572
|
+
)
|
|
573
|
+
else:
|
|
574
|
+
self._publish_notice(f"/cd failed: {err}")
|
|
575
|
+
elif "unchanged" in result:
|
|
576
|
+
self._publish_notice("cwd unchanged.")
|
|
577
|
+
else:
|
|
578
|
+
self._publish_notice(f"cwd → {result['changed']}")
|
|
579
|
+
|
|
580
|
+
async def _handle_set_permissions(self, *, bypass: bool) -> None:
|
|
581
|
+
if self._app is None:
|
|
582
|
+
return
|
|
583
|
+
result = await self._app.set_bypass_permissions(bypass)
|
|
584
|
+
if "error" in result:
|
|
585
|
+
err = result["error"]
|
|
586
|
+
if err == "agents_running":
|
|
587
|
+
names = ", ".join(a["name"] for a in result.get("agents", []))
|
|
588
|
+
self._publish_notice(
|
|
589
|
+
f"Refusing /{'bypass' if bypass else 'require'}-permissions: "
|
|
590
|
+
f"agents still running ({names})."
|
|
591
|
+
)
|
|
592
|
+
else:
|
|
593
|
+
self._publish_notice(
|
|
594
|
+
f"/{'bypass' if bypass else 'require'}-permissions failed: {err}"
|
|
595
|
+
)
|
|
596
|
+
elif "unchanged" in result:
|
|
597
|
+
mode = "bypass" if bypass else "require"
|
|
598
|
+
self._publish_notice(f"Permission mode already: {mode}")
|
|
599
|
+
else:
|
|
600
|
+
mode = result["changed"]
|
|
601
|
+
self._publish_notice(f"Permission mode is now: {mode}")
|
|
602
|
+
|
|
603
|
+
async def _generate_title_async(self, session_id: str, first_user_message: str) -> None:
|
|
604
|
+
"""Issue a one-shot SDK query to summarize the first message into a
|
|
605
|
+
5-7 word title. Silently no-ops on any failure."""
|
|
606
|
+
try:
|
|
607
|
+
text = await self._summarize_for_title(first_user_message)
|
|
608
|
+
except Exception:
|
|
609
|
+
log.exception("title summarization failed for %s", session_id)
|
|
610
|
+
return
|
|
611
|
+
if not text:
|
|
612
|
+
return
|
|
613
|
+
# Strip stray quotes/punctuation a model might add despite instructions.
|
|
614
|
+
title = text.strip().strip('"\'').rstrip(".").strip()
|
|
615
|
+
if not title:
|
|
616
|
+
return
|
|
617
|
+
# Cap to a reasonable display length even if the model went over.
|
|
618
|
+
if len(title) > 80:
|
|
619
|
+
title = title[:79] + "…"
|
|
620
|
+
self._index.set_title(session_id, title)
|
|
621
|
+
|
|
622
|
+
async def _summarize_for_title(self, first_user_message: str) -> str:
|
|
623
|
+
"""One-shot SDK query that returns the model's title text.
|
|
624
|
+
|
|
625
|
+
Isolated as its own method so tests can monkeypatch it without
|
|
626
|
+
spawning a real subprocess.
|
|
627
|
+
"""
|
|
628
|
+
prompt = _TITLE_PROMPT.format(message=first_user_message)
|
|
629
|
+
options = ClaudeAgentOptions(
|
|
630
|
+
cwd=str(self._cwd),
|
|
631
|
+
permission_mode="bypassPermissions",
|
|
632
|
+
model="claude-haiku-4-5",
|
|
633
|
+
)
|
|
634
|
+
chunks: list[str] = []
|
|
635
|
+
async for msg in sdk_query(prompt=prompt, options=options):
|
|
636
|
+
if isinstance(msg, AssistantMessage):
|
|
637
|
+
for block in msg.content:
|
|
638
|
+
if isinstance(block, TextBlock):
|
|
639
|
+
chunks.append(block.text)
|
|
640
|
+
return " ".join(chunks).strip()
|
|
641
|
+
|
|
642
|
+
async def reset(self) -> None:
|
|
643
|
+
async with self._switching_lock:
|
|
644
|
+
await self._swap_inner(resume=None)
|
|
645
|
+
self._seed_counters_from(None)
|
|
646
|
+
|
|
647
|
+
async def resume(self, session_id: str) -> None:
|
|
648
|
+
async with self._switching_lock:
|
|
649
|
+
entry = self._index.get(session_id)
|
|
650
|
+
if entry is None:
|
|
651
|
+
self._publish_notice(f"No such session: {session_id}")
|
|
652
|
+
return
|
|
653
|
+
if entry.legacy:
|
|
654
|
+
self._publish_notice(
|
|
655
|
+
"This session predates SDK resume support; starting a fresh session."
|
|
656
|
+
)
|
|
657
|
+
await self._swap_inner(resume=None)
|
|
658
|
+
self._seed_counters_from(None)
|
|
659
|
+
return
|
|
660
|
+
try:
|
|
661
|
+
await self._swap_inner(resume=session_id)
|
|
662
|
+
self._seed_counters_from(entry)
|
|
663
|
+
except Exception:
|
|
664
|
+
log.exception("SDK rejected resume=%s; falling back to fresh", session_id)
|
|
665
|
+
self._publish_notice(
|
|
666
|
+
f"Could not resume {session_id}; starting a fresh session."
|
|
667
|
+
)
|
|
668
|
+
self._inner = None
|
|
669
|
+
await self._swap_inner(resume=None)
|
|
670
|
+
self._seed_counters_from(None)
|
|
671
|
+
|
|
672
|
+
def _seed_counters_from(self, entry: "OrchestratorSessionEntry | None") -> None:
|
|
673
|
+
"""Reset (entry=None) or seed (entry=resumed entry) the per-session
|
|
674
|
+
token / cost counters on self._info, then publish AgentTokensTouched
|
|
675
|
+
so the StatusBar aggregator picks up the change."""
|
|
676
|
+
if entry is None:
|
|
677
|
+
self._info.tokens_in = 0
|
|
678
|
+
self._info.tokens_out = 0
|
|
679
|
+
self._info.cost = 0.0
|
|
680
|
+
else:
|
|
681
|
+
self._info.tokens_in = entry.tokens_in
|
|
682
|
+
self._info.tokens_out = entry.tokens_out
|
|
683
|
+
self._info.cost = entry.cost
|
|
684
|
+
self._bus.publish(AgentTokensTouched(agent_id=self._info.id))
|
|
685
|
+
|
|
686
|
+
def _publish_notice(self, text: str) -> None:
|
|
687
|
+
# Toast for the running app (production UI surface).
|
|
688
|
+
if self._app is not None:
|
|
689
|
+
try:
|
|
690
|
+
self._app.notify(text, title="orchestrator")
|
|
691
|
+
except Exception:
|
|
692
|
+
pass
|
|
693
|
+
# OrchestratorReply event for tests + bus subscribers.
|
|
694
|
+
self._bus.publish(OrchestratorReply(text))
|
|
695
|
+
|
|
696
|
+
async def _swap_inner(self, *, resume: str | None) -> None:
|
|
697
|
+
if self._perm_inbox is not None:
|
|
698
|
+
self._perm_inbox.cancel_all()
|
|
699
|
+
# Stop current, start a new inner with either resume=<id> or a fresh id.
|
|
700
|
+
if self._inner is not None:
|
|
701
|
+
try:
|
|
702
|
+
await self._inner.interrupt()
|
|
703
|
+
except Exception:
|
|
704
|
+
pass
|
|
705
|
+
await self._inner.stop()
|
|
706
|
+
|
|
707
|
+
if resume is not None:
|
|
708
|
+
new_session_id = None
|
|
709
|
+
transcript_path = orchestrator_session_transcript_path(self._cwd, resume)
|
|
710
|
+
self._sdk_session_id = resume
|
|
711
|
+
else:
|
|
712
|
+
new_id = str(uuid.uuid4())
|
|
713
|
+
new_session_id = new_id
|
|
714
|
+
transcript_path = orchestrator_session_transcript_path(self._cwd, new_id)
|
|
715
|
+
self._sdk_session_id = new_id
|
|
716
|
+
self._current_session_first_message = None
|
|
717
|
+
self._current_session_num_turns = 0
|
|
718
|
+
self._active_transcript_path = transcript_path
|
|
719
|
+
|
|
720
|
+
# Pull a fresh adapter. In production this comes from the
|
|
721
|
+
# RealSDKAdapter factory; tests can inject _next_adapter_factory.
|
|
722
|
+
if self._next_adapter_factory is not None:
|
|
723
|
+
self._adapter = self._next_adapter_factory()
|
|
724
|
+
self._next_adapter_factory = None
|
|
725
|
+
else:
|
|
726
|
+
self._adapter = RealSDKAdapter()
|
|
727
|
+
|
|
728
|
+
await self._build_and_start_inner(
|
|
729
|
+
resume=resume, new_session_id=new_session_id,
|
|
730
|
+
transcript_path=transcript_path,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
self._bus.publish(OrchestratorSessionSwitched(
|
|
734
|
+
session_id=self._sdk_session_id,
|
|
735
|
+
transcript_path=str(self._active_transcript_path),
|
|
736
|
+
))
|
|
737
|
+
|
|
738
|
+
def _on_message_appended(self, event: AgentMessageAppended) -> None:
|
|
739
|
+
if event.agent_id != self.AGENT_ID:
|
|
740
|
+
return
|
|
741
|
+
# RichTranscript subscribes to AgentMessageAppended directly for tool
|
|
742
|
+
# use/result/thinking — only re-publish assistant text, which is the
|
|
743
|
+
# public "the orchestrator said something" signal other code asserts on.
|
|
744
|
+
if event.role == "assistant":
|
|
745
|
+
self._bus.publish(OrchestratorReply(event.text))
|
|
746
|
+
self._current_session_num_turns += 1
|
|
747
|
+
self._refresh_session_summary()
|
|
748
|
+
|
|
749
|
+
def _refresh_session_summary(self) -> None:
|
|
750
|
+
"""Update the index entry for the active session with current
|
|
751
|
+
first_user_message + num_turns + activity. No-op if the session
|
|
752
|
+
hasn't been confirmed (_sdk_session_id not yet observed) or
|
|
753
|
+
if the entry hasn't been created yet (handled by
|
|
754
|
+
_on_session_id_observed)."""
|
|
755
|
+
if self._sdk_session_id is None:
|
|
756
|
+
return
|
|
757
|
+
existing = self._index.get(self._sdk_session_id)
|
|
758
|
+
if existing is None:
|
|
759
|
+
return
|
|
760
|
+
existing.last_activity = time.time()
|
|
761
|
+
# Only set first_user_message if not already set — the very first
|
|
762
|
+
# prompt of the session is the canonical answer; later prompts
|
|
763
|
+
# don't overwrite.
|
|
764
|
+
if existing.first_user_message is None and self._current_session_first_message:
|
|
765
|
+
existing.first_user_message = self._current_session_first_message
|
|
766
|
+
existing.num_turns = max(existing.num_turns, self._current_session_num_turns)
|
|
767
|
+
existing.tokens_in = self._info.tokens_in
|
|
768
|
+
existing.tokens_out = self._info.tokens_out
|
|
769
|
+
existing.cost = self._info.cost
|
|
770
|
+
self._index.upsert(existing)
|
|
771
|
+
|
|
772
|
+
def _on_child_notified(self, event: AgentNotifiedOrchestrator) -> None:
|
|
773
|
+
synthetic = (
|
|
774
|
+
f"[from agent {event.agent_id}] {event.message}"
|
|
775
|
+
)
|
|
776
|
+
self._bus.publish(UserMessageToOrchestrator(synthetic))
|
|
777
|
+
|
|
778
|
+
def _on_child_asked(self, event: AgentRequestedUserInput) -> None:
|
|
779
|
+
synthetic = (
|
|
780
|
+
f"[agent {event.agent_id} is blocked waiting for your reply, "
|
|
781
|
+
f"request_id={event.request_id}] question: {event.question}\n"
|
|
782
|
+
f"Use respond_to_agent_request(agent_id={event.agent_id!r}, "
|
|
783
|
+
f"request_id={event.request_id!r}, response=...) to unblock."
|
|
784
|
+
)
|
|
785
|
+
self._bus.publish(UserMessageToOrchestrator(synthetic))
|