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
patchfeld/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
patchfeld/__main__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from patchfeld.app import PatchfeldApp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
prog="patchfeld",
|
|
10
|
+
description="Multi-agent Textual TUI for Claude Agent SDK.",
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--bypass-permissions",
|
|
14
|
+
action="store_true",
|
|
15
|
+
help=(
|
|
16
|
+
"Run all sessions (orchestrator + child agents) with "
|
|
17
|
+
"permission_mode=bypassPermissions. Default behavior is to "
|
|
18
|
+
"ask for confirmation via a Textual modal before every "
|
|
19
|
+
"tool call."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
return parser.parse_args(argv)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
27
|
+
PatchfeldApp(bypass_permissions=args.bypass_permissions).run()
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
raise SystemExit(main())
|
patchfeld/actions.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class ActionSpec:
|
|
7
|
+
name: str
|
|
8
|
+
callable: Callable
|
|
9
|
+
description: str
|
|
10
|
+
args_schema: dict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ActionRegistry:
|
|
14
|
+
"""Enumerable action registry — name → ActionSpec."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._actions: dict[str, ActionSpec] = {}
|
|
18
|
+
|
|
19
|
+
def register(self, name: str, fn: Callable, *, description: str, args_schema: dict) -> None:
|
|
20
|
+
self._actions[name] = ActionSpec(
|
|
21
|
+
name=name, callable=fn, description=description, args_schema=args_schema,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def get(self, name: str) -> ActionSpec:
|
|
25
|
+
if name not in self._actions:
|
|
26
|
+
raise KeyError(f"unknown action: {name}")
|
|
27
|
+
return self._actions[name]
|
|
28
|
+
|
|
29
|
+
def list(self) -> list[ActionSpec]:
|
|
30
|
+
return sorted(self._actions.values(), key=lambda s: s.name)
|
|
31
|
+
|
|
32
|
+
def invoke(self, name: str, args: dict) -> Any:
|
|
33
|
+
spec = self.get(name)
|
|
34
|
+
return spec.callable(**(args or {}))
|
|
File without changes
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ActivityKind:
|
|
9
|
+
"""String constants for entry kinds. Plain class instead of an Enum so
|
|
10
|
+
`entry.kind == ActivityKind.AGENT_SPAWNED` and `entry.kind == "agent.spawned"`
|
|
11
|
+
are both valid — keeps test tables and the mode-filter dict ergonomic."""
|
|
12
|
+
|
|
13
|
+
AGENT_SPAWNED = "agent.spawned"
|
|
14
|
+
AGENT_STATE = "agent.state"
|
|
15
|
+
AGENT_DONE = "agent.done"
|
|
16
|
+
AGENT_MESSAGE = "agent.message"
|
|
17
|
+
AGENT_TOOL = "agent.tool"
|
|
18
|
+
AGENT_ASK = "agent.ask"
|
|
19
|
+
AGENT_NOTIFY = "agent.notify"
|
|
20
|
+
AGENT_ARCHIVE = "agent.archive"
|
|
21
|
+
ORCH_USER = "orch.user"
|
|
22
|
+
ORCH_REPLY = "orch.reply"
|
|
23
|
+
ORCH_SESSION = "orch.session"
|
|
24
|
+
LAYOUT_APPLIED = "layout.applied"
|
|
25
|
+
LAYOUT_FAILED = "layout.failed"
|
|
26
|
+
TAB_ADDED = "tab.added"
|
|
27
|
+
TAB_CLOSED = "tab.closed"
|
|
28
|
+
TAB_SWITCHED = "tab.switched"
|
|
29
|
+
WORKSPACE_CWD = "workspace.cwd"
|
|
30
|
+
FILE_SELECTED = "file.selected"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ActivityEntry:
|
|
35
|
+
"""One normalized record in the ActivityLog. `kind` is one of the
|
|
36
|
+
ActivityKind dotted-string constants; `raw` is the original event object
|
|
37
|
+
for debugging/forensics."""
|
|
38
|
+
timestamp: datetime
|
|
39
|
+
kind: str
|
|
40
|
+
summary: str
|
|
41
|
+
detail: str | None
|
|
42
|
+
agent_id: str | None
|
|
43
|
+
tab_id: str | None
|
|
44
|
+
raw: object
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
from patchfeld.events import ( # noqa: E402 — deferred to avoid circular import
|
|
48
|
+
ActivityLogged, AgentArchiveChanged, AgentMessageAppended,
|
|
49
|
+
AgentNotifiedOrchestrator, AgentRequestedUserInput, AgentSpawned,
|
|
50
|
+
AgentStateChanged, EventBus, FileSelected, LayoutApplied, LayoutFailed,
|
|
51
|
+
OrchestratorReply, OrchestratorSessionSwitched, TabAdded, TabClosed,
|
|
52
|
+
TabSwitched, UserMessageToOrchestrator, WorkspaceCwdChanged,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ActivityLog:
|
|
57
|
+
"""App-singleton capture of curated EventBus events. Stores the last 500
|
|
58
|
+
normalized entries in a deque. Publishes ActivityLogged after every
|
|
59
|
+
append so subscribers can react incrementally without re-walking the
|
|
60
|
+
backlog. Mode filtering is the consumer's responsibility — the log
|
|
61
|
+
captures the union."""
|
|
62
|
+
|
|
63
|
+
BUFFER_SIZE = 500
|
|
64
|
+
|
|
65
|
+
def __init__(self, bus: EventBus) -> None:
|
|
66
|
+
self._bus = bus
|
|
67
|
+
self._entries: deque[ActivityEntry] = deque(maxlen=self.BUFFER_SIZE)
|
|
68
|
+
self._wire_subscriptions(bus)
|
|
69
|
+
|
|
70
|
+
def entries(self) -> tuple[ActivityEntry, ...]:
|
|
71
|
+
"""Snapshot of current entries, oldest first."""
|
|
72
|
+
return tuple(self._entries)
|
|
73
|
+
|
|
74
|
+
# --- subscriptions -----------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _wire_subscriptions(self, bus: EventBus) -> None:
|
|
77
|
+
bus.subscribe(AgentSpawned, self._on_agent_spawned)
|
|
78
|
+
bus.subscribe(AgentStateChanged, self._on_agent_state)
|
|
79
|
+
bus.subscribe(AgentMessageAppended, self._on_agent_message)
|
|
80
|
+
bus.subscribe(AgentRequestedUserInput, self._on_agent_ask)
|
|
81
|
+
bus.subscribe(AgentNotifiedOrchestrator, self._on_agent_notify)
|
|
82
|
+
bus.subscribe(AgentArchiveChanged, self._on_agent_archive)
|
|
83
|
+
bus.subscribe(UserMessageToOrchestrator, self._on_orch_user)
|
|
84
|
+
bus.subscribe(OrchestratorReply, self._on_orch_reply)
|
|
85
|
+
bus.subscribe(OrchestratorSessionSwitched, self._on_orch_session)
|
|
86
|
+
bus.subscribe(LayoutApplied, self._on_layout_applied)
|
|
87
|
+
bus.subscribe(LayoutFailed, self._on_layout_failed)
|
|
88
|
+
bus.subscribe(TabAdded, self._on_tab_added)
|
|
89
|
+
bus.subscribe(TabClosed, self._on_tab_closed)
|
|
90
|
+
bus.subscribe(TabSwitched, self._on_tab_switched)
|
|
91
|
+
bus.subscribe(WorkspaceCwdChanged, self._on_cwd_changed)
|
|
92
|
+
bus.subscribe(FileSelected, self._on_file_selected)
|
|
93
|
+
|
|
94
|
+
def _on_agent_spawned(self, event: AgentSpawned) -> None:
|
|
95
|
+
info = event.info
|
|
96
|
+
self._append(
|
|
97
|
+
kind=ActivityKind.AGENT_SPAWNED,
|
|
98
|
+
summary=f"{info.name} spawned",
|
|
99
|
+
detail=f"cwd: {info.cwd}",
|
|
100
|
+
agent_id=info.id,
|
|
101
|
+
tab_id=None,
|
|
102
|
+
raw=event,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _on_agent_state(self, event: AgentStateChanged) -> None:
|
|
106
|
+
info = event.info
|
|
107
|
+
kind = ActivityKind.AGENT_DONE if info.state.is_terminal else ActivityKind.AGENT_STATE
|
|
108
|
+
summary = f"{info.name}: {event.old_state.value} → {info.state.value}"
|
|
109
|
+
self._append(
|
|
110
|
+
kind=kind, summary=summary, detail=None,
|
|
111
|
+
agent_id=info.id, tab_id=None, raw=event,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _on_agent_message(self, event: AgentMessageAppended) -> None:
|
|
115
|
+
if event.role in ("user", "assistant"):
|
|
116
|
+
kind = ActivityKind.AGENT_MESSAGE
|
|
117
|
+
detail = event.text
|
|
118
|
+
elif event.role in ("tool_use", "tool_result"):
|
|
119
|
+
kind = ActivityKind.AGENT_TOOL
|
|
120
|
+
detail = event.tool_name or event.text
|
|
121
|
+
else:
|
|
122
|
+
return # thinking/system are not surfaced in the feed
|
|
123
|
+
self._append(
|
|
124
|
+
kind=kind,
|
|
125
|
+
summary=event.agent_id,
|
|
126
|
+
detail=detail,
|
|
127
|
+
agent_id=event.agent_id,
|
|
128
|
+
tab_id=None,
|
|
129
|
+
raw=event,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _on_agent_ask(self, event: AgentRequestedUserInput) -> None:
|
|
133
|
+
self._append(
|
|
134
|
+
kind=ActivityKind.AGENT_ASK,
|
|
135
|
+
summary=event.agent_id,
|
|
136
|
+
detail=event.question,
|
|
137
|
+
agent_id=event.agent_id,
|
|
138
|
+
tab_id=None,
|
|
139
|
+
raw=event,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _on_agent_notify(self, event: AgentNotifiedOrchestrator) -> None:
|
|
143
|
+
self._append(
|
|
144
|
+
kind=ActivityKind.AGENT_NOTIFY,
|
|
145
|
+
summary=event.agent_id,
|
|
146
|
+
detail=event.message,
|
|
147
|
+
agent_id=event.agent_id,
|
|
148
|
+
tab_id=None,
|
|
149
|
+
raw=event,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _on_agent_archive(self, event: AgentArchiveChanged) -> None:
|
|
153
|
+
info = event.info
|
|
154
|
+
self._append(
|
|
155
|
+
kind=ActivityKind.AGENT_ARCHIVE,
|
|
156
|
+
summary=f"{info.name} {'archived' if info.archived else 'unarchived'}",
|
|
157
|
+
detail=None,
|
|
158
|
+
agent_id=info.id,
|
|
159
|
+
tab_id=None,
|
|
160
|
+
raw=event,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _on_orch_user(self, event: UserMessageToOrchestrator) -> None:
|
|
164
|
+
self._append(
|
|
165
|
+
kind=ActivityKind.ORCH_USER, summary="user → orchestrator",
|
|
166
|
+
detail=event.text, agent_id=None, tab_id=None, raw=event,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _on_orch_reply(self, event: OrchestratorReply) -> None:
|
|
170
|
+
self._append(
|
|
171
|
+
kind=ActivityKind.ORCH_REPLY, summary="orchestrator → user",
|
|
172
|
+
detail=event.text, agent_id=None, tab_id=None, raw=event,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _on_orch_session(self, event: OrchestratorSessionSwitched) -> None:
|
|
176
|
+
self._append(
|
|
177
|
+
kind=ActivityKind.ORCH_SESSION,
|
|
178
|
+
summary=f"session → {event.session_id[:8]}",
|
|
179
|
+
detail=event.transcript_path, agent_id=None, tab_id=None, raw=event,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _on_layout_applied(self, event: LayoutApplied) -> None:
|
|
183
|
+
self._append(
|
|
184
|
+
kind=ActivityKind.LAYOUT_APPLIED,
|
|
185
|
+
summary=event.layout_name or "(unnamed)",
|
|
186
|
+
detail=None, agent_id=None, tab_id=event.tab_id, raw=event,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _on_layout_failed(self, event: LayoutFailed) -> None:
|
|
190
|
+
self._append(
|
|
191
|
+
kind=ActivityKind.LAYOUT_FAILED, summary="layout failed",
|
|
192
|
+
detail=event.error, agent_id=None, tab_id=event.tab_id, raw=event,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _on_tab_added(self, event: TabAdded) -> None:
|
|
196
|
+
self._append(
|
|
197
|
+
kind=ActivityKind.TAB_ADDED, summary=event.title, detail=None,
|
|
198
|
+
agent_id=None, tab_id=event.tab_id, raw=event,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _on_tab_closed(self, event: TabClosed) -> None:
|
|
202
|
+
self._append(
|
|
203
|
+
kind=ActivityKind.TAB_CLOSED, summary=event.tab_id, detail=None,
|
|
204
|
+
agent_id=None, tab_id=event.tab_id, raw=event,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _on_tab_switched(self, event: TabSwitched) -> None:
|
|
208
|
+
self._append(
|
|
209
|
+
kind=ActivityKind.TAB_SWITCHED, summary=event.title, detail=None,
|
|
210
|
+
agent_id=None, tab_id=event.tab_id, raw=event,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _on_cwd_changed(self, event: WorkspaceCwdChanged) -> None:
|
|
214
|
+
self._append(
|
|
215
|
+
kind=ActivityKind.WORKSPACE_CWD, summary=event.cwd, detail=None,
|
|
216
|
+
agent_id=None, tab_id=None, raw=event,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _on_file_selected(self, event: FileSelected) -> None:
|
|
220
|
+
self._append(
|
|
221
|
+
kind=ActivityKind.FILE_SELECTED, summary=event.path, detail=None,
|
|
222
|
+
agent_id=None, tab_id=None, raw=event,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# --- append ------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def _append(
|
|
228
|
+
self, *, kind: str, summary: str, detail: str | None,
|
|
229
|
+
agent_id: str | None, tab_id: str | None, raw: object,
|
|
230
|
+
) -> None:
|
|
231
|
+
entry = ActivityEntry(
|
|
232
|
+
timestamp=datetime.now(),
|
|
233
|
+
kind=kind, summary=summary, detail=detail,
|
|
234
|
+
agent_id=agent_id, tab_id=tab_id, raw=raw,
|
|
235
|
+
)
|
|
236
|
+
self._entries.append(entry)
|
|
237
|
+
self._bus.publish(ActivityLogged(entry=entry))
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
4
|
+
|
|
5
|
+
from patchfeld.agents.request_inbox import RequestInbox
|
|
6
|
+
from patchfeld.events import (
|
|
7
|
+
AgentNotifiedOrchestrator,
|
|
8
|
+
AgentRequestedUserInput,
|
|
9
|
+
EventBus,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_child_tools(*, agent_id: str, bus: EventBus, inbox: RequestInbox):
|
|
14
|
+
"""Return (notify_handler, ask_handler) — bare async callables for unit tests."""
|
|
15
|
+
|
|
16
|
+
async def notify_orchestrator(args: dict) -> dict:
|
|
17
|
+
message = args["message"]
|
|
18
|
+
bus.publish(AgentNotifiedOrchestrator(agent_id=agent_id, message=message))
|
|
19
|
+
return {"content": [{"type": "text", "text": "Notification delivered."}]}
|
|
20
|
+
|
|
21
|
+
async def ask_orchestrator(args: dict) -> dict:
|
|
22
|
+
question = args["question"]
|
|
23
|
+
timeout_s = float(args.get("timeout_s", 300))
|
|
24
|
+
request_id = inbox.register()
|
|
25
|
+
bus.publish(
|
|
26
|
+
AgentRequestedUserInput(
|
|
27
|
+
agent_id=agent_id, question=question, request_id=request_id
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
try:
|
|
31
|
+
response = await inbox.wait(request_id, timeout_s=timeout_s)
|
|
32
|
+
return {"content": [{"type": "text", "text": response}]}
|
|
33
|
+
except asyncio.TimeoutError:
|
|
34
|
+
return {
|
|
35
|
+
"content": [
|
|
36
|
+
{
|
|
37
|
+
"type": "text",
|
|
38
|
+
"text": (
|
|
39
|
+
f"ask_orchestrator timed out after {timeout_s}s "
|
|
40
|
+
"with no response."
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return notify_orchestrator, ask_orchestrator
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_child_mcp_server(*, agent_id: str, bus: EventBus, inbox: RequestInbox):
|
|
50
|
+
notify_h, ask_h = build_child_tools(agent_id=agent_id, bus=bus, inbox=inbox)
|
|
51
|
+
notify = tool(
|
|
52
|
+
"notify_orchestrator",
|
|
53
|
+
"Send a fire-and-forget notification to the orchestrator.",
|
|
54
|
+
{"message": str},
|
|
55
|
+
)(notify_h)
|
|
56
|
+
ask = tool(
|
|
57
|
+
"ask_orchestrator",
|
|
58
|
+
(
|
|
59
|
+
"Ask the orchestrator a question and block until they respond. "
|
|
60
|
+
"Optional timeout_s defaults to 300 seconds."
|
|
61
|
+
),
|
|
62
|
+
{"question": str}, # timeout_s is optional, not in schema
|
|
63
|
+
)(ask_h)
|
|
64
|
+
return create_sdk_mcp_server(
|
|
65
|
+
name="patchfeld_child", version="1.0.0", tools=[notify, ask]
|
|
66
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import AsyncIterator
|
|
2
|
+
|
|
3
|
+
from claude_agent_sdk import ClaudeAgentOptions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeSDKAdapter:
|
|
7
|
+
"""Replays canned message streams. One script per expected query call."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, scripts: list[list[object]]) -> None:
|
|
10
|
+
self._scripts = scripts
|
|
11
|
+
self._next_query_index = 0
|
|
12
|
+
self._pending: list[object] = []
|
|
13
|
+
self._started = False
|
|
14
|
+
|
|
15
|
+
async def start(self, *, options: ClaudeAgentOptions) -> None:
|
|
16
|
+
self._started = True
|
|
17
|
+
|
|
18
|
+
async def query(self, prompt: str) -> None:
|
|
19
|
+
assert self._started, "start() must be called before query()"
|
|
20
|
+
if self._next_query_index >= len(self._scripts):
|
|
21
|
+
raise IndexError(
|
|
22
|
+
f"FakeSDKAdapter has no script for query #{self._next_query_index} "
|
|
23
|
+
f"(only {len(self._scripts)} provided)"
|
|
24
|
+
)
|
|
25
|
+
self._pending = list(self._scripts[self._next_query_index])
|
|
26
|
+
self._next_query_index += 1
|
|
27
|
+
|
|
28
|
+
def stream(self) -> AsyncIterator[object]:
|
|
29
|
+
# Snapshot _pending into a local so calling stream() then mutating
|
|
30
|
+
# _pending mid-iteration doesn't cause skipping.
|
|
31
|
+
msgs = self._pending
|
|
32
|
+
self._pending = []
|
|
33
|
+
|
|
34
|
+
async def _agen() -> AsyncIterator[object]:
|
|
35
|
+
for m in msgs:
|
|
36
|
+
yield m
|
|
37
|
+
|
|
38
|
+
return _agen()
|
|
39
|
+
|
|
40
|
+
async def interrupt(self) -> None:
|
|
41
|
+
# No-op for the fake.
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
async def stop(self) -> None:
|
|
45
|
+
self._started = False
|