@longshot-harness/longshot-linux-x64 0.1.0-beta.1 → 0.1.0-beta.2
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.
- package/LICENSE.md +201 -201
- package/longshot +0 -0
- package/package.json +5 -2
- package/sidecars/claude/events.py +0 -457
- package/sidecars/claude/history.py +0 -133
- package/sidecars/claude/main.py +0 -367
- package/sidecars/claude/mcpserver.py +0 -96
- package/sidecars/claude/permissions.py +0 -89
- package/sidecars/claude/protocol.py +0 -217
- package/sidecars/claude/pyproject.toml +0 -21
- package/sidecars/claude/session.py +0 -855
package/sidecars/claude/main.py
DELETED
|
@@ -1,367 +0,0 @@
|
|
|
1
|
-
"""Entry point for the Claude sidecar process.
|
|
2
|
-
|
|
3
|
-
Speaks the frozen wire protocol documented in docs/claude-sidecar.md over
|
|
4
|
-
stdin/stdout; protocol.py owns the envelope and framing. This file owns the
|
|
5
|
-
hello gate, the SDK-presence check and the op dispatcher: session.py
|
|
6
|
-
(T16-S02) owns ClaudeAgentOptions and ClaudeSDKClient and, as of T18, the
|
|
7
|
-
three operations that drive a turn on it; events.py (T17) owns the event
|
|
8
|
-
stream; T20 adds tool execution.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import sys
|
|
12
|
-
|
|
13
|
-
# Must run before any other code. On Windows, stdout's default encoding is
|
|
14
|
-
# not UTF-8, and its text-mode newline translation turns "\n" into "\r\n"
|
|
15
|
-
# *inside* a frame; today's json parsing tolerates the stray "\r" as
|
|
16
|
-
# insignificant whitespace, but relying on that is out of scope here.
|
|
17
|
-
# Redirecting sys.stdout to stderr afterwards means one forgotten call to the
|
|
18
|
-
# print builtin lands in diagnostics instead of corrupting the frame stream;
|
|
19
|
-
# the real stdout stays reachable as _frames for the one thing that may still
|
|
20
|
-
# write to it.
|
|
21
|
-
_frames = sys.stdout
|
|
22
|
-
_frames.reconfigure(encoding="utf-8", newline="\n")
|
|
23
|
-
sys.stdout = sys.stderr
|
|
24
|
-
|
|
25
|
-
EXIT_OK = 0
|
|
26
|
-
EXIT_INTERNAL_ERROR = 1
|
|
27
|
-
EXIT_PROTOCOL_ERROR = 2
|
|
28
|
-
EXIT_ENVIRONMENT_ERROR = (
|
|
29
|
-
3 # "environment", not "protocol": no interpreter, no SDK, no CLI
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
# Before any other import: session.py imports claude_agent_sdk at module
|
|
33
|
-
# level too, and an uncaught ImportError there would print a Python
|
|
34
|
-
# traceback instead of the one readable diagnostic line T16-S03 requires.
|
|
35
|
-
# Checking here, first, means the frame stream is never opened at all when
|
|
36
|
-
# the SDK is missing - there is nothing left to open before sys.exit below
|
|
37
|
-
# runs. The reason reaches Longshot through the stderr drain that already
|
|
38
|
-
# exists for T16-S01 (that drain is why this write is enough; nothing here
|
|
39
|
-
# needs to reach stdout).
|
|
40
|
-
#
|
|
41
|
-
# The exception text is carried, not swallowed: a sidecar module shadowing
|
|
42
|
-
# one of the SDK's own dependencies raises ImportError here too, and without
|
|
43
|
-
# the real cause it reads as an absent package (`mcp.py` was exactly that).
|
|
44
|
-
try:
|
|
45
|
-
import claude_agent_sdk # noqa: F401
|
|
46
|
-
except ImportError as exc:
|
|
47
|
-
sys.stderr.write(
|
|
48
|
-
"claude sidecar: claude-agent-sdk is not installed; install it with "
|
|
49
|
-
"`pip install claude-agent-sdk` (see sidecars/claude/README.md); "
|
|
50
|
-
f"import failed with: {exc!r}\n"
|
|
51
|
-
)
|
|
52
|
-
sys.exit(EXIT_ENVIRONMENT_ERROR)
|
|
53
|
-
|
|
54
|
-
import asyncio # noqa: E402 (see the ordering comment above)
|
|
55
|
-
import importlib.metadata # noqa: E402
|
|
56
|
-
import os # noqa: E402
|
|
57
|
-
import shutil # noqa: E402
|
|
58
|
-
import subprocess # noqa: E402
|
|
59
|
-
|
|
60
|
-
from protocol import ( # noqa: E402
|
|
61
|
-
PROTOCOL_VERSION,
|
|
62
|
-
Code,
|
|
63
|
-
FrameError,
|
|
64
|
-
Op,
|
|
65
|
-
read_frame,
|
|
66
|
-
refusal,
|
|
67
|
-
resolve,
|
|
68
|
-
response,
|
|
69
|
-
write_frame,
|
|
70
|
-
)
|
|
71
|
-
from session import Refusal, Session, check_permission_mode # noqa: E402
|
|
72
|
-
|
|
73
|
-
# One event loop for the process lifetime, not one per call: the live
|
|
74
|
-
# ClaudeSDKClient a session.start answers with stays bound to whichever loop
|
|
75
|
-
# ran connect(), and session.read/session.close/session.close-on-EOF must
|
|
76
|
-
# reach the same client later.
|
|
77
|
-
_loop = asyncio.new_event_loop()
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _run(coro):
|
|
81
|
-
return _loop.run_until_complete(coro)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
_session = Session()
|
|
85
|
-
|
|
86
|
-
# Every operation dispatched to session.Session: the five T16-S02/T22
|
|
87
|
-
# session ones plus T18's three, which are session operations in the same
|
|
88
|
-
# sense - each needs the one live client and the one stream state that
|
|
89
|
-
# object already holds. Each handler takes the frame's params and returns
|
|
90
|
-
# the result dict to answer with; a session.Refusal it raises is turned
|
|
91
|
-
# into a wire refusal by main().
|
|
92
|
-
_SESSION_HANDLERS = {
|
|
93
|
-
Op.SESSION_START: lambda params: _run(_session.start(params)),
|
|
94
|
-
Op.SESSION_RESUME: lambda params: _run(_session.resume(params)),
|
|
95
|
-
Op.SESSION_READ: lambda params: _session.read(params.get("session_id", "")),
|
|
96
|
-
Op.SESSION_HISTORY: lambda params: _run(_session.history(params)),
|
|
97
|
-
Op.SESSION_CLOSE: lambda params: _run(_session.close(params.get("session_id", ""))),
|
|
98
|
-
Op.INPUT_SUBMIT: lambda params: _run(_session.submit(params)),
|
|
99
|
-
Op.TURN_INTERRUPT: lambda params: _run(
|
|
100
|
-
_session.interrupt(params.get("session_id", ""))
|
|
101
|
-
),
|
|
102
|
-
Op.MODEL_SET: lambda params: _run(_session.set_model(params)),
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
# The two ops that leave the session open and ready to accept input, and so
|
|
106
|
-
# emit session.ready once each. The event is written here, immediately after
|
|
107
|
-
# the op's own result frame, and never from inside the handler: the caller
|
|
108
|
-
# learns the session's id from that result, so an event naming a session
|
|
109
|
-
# Longshot has not been told about yet would be a frame out of order. The
|
|
110
|
-
# SDK's `system`/`init` message is not the trigger - it does not arrive until
|
|
111
|
-
# a query is sent, which would leave an open-but-unprompted session emitting
|
|
112
|
-
# nothing at all (events.session_ready's own docstring).
|
|
113
|
-
_READY_AFTER = {Op.SESSION_START, Op.SESSION_RESUME}
|
|
114
|
-
|
|
115
|
-
# Every op Longshot may send that this build does not implement yet, and the
|
|
116
|
-
# task that will. There is no "accept and stay silent": each answers
|
|
117
|
-
# `internal`, naming its owner, per docs/claude-sidecar.md's refusal table.
|
|
118
|
-
# The table is empty as of T18: permission.set left it when T21 landed, and
|
|
119
|
-
# input.submit, turn.interrupt and model.set when this one did, so every op
|
|
120
|
-
# in Op.KNOWN now has a handler above. It stays here, empty, because the
|
|
121
|
-
# refusal at the bottom of the dispatch loop is what an op added to
|
|
122
|
-
# Op.KNOWN without a handler answers - the alternative is a request that
|
|
123
|
-
# gets no frame back at all and a caller that waits forever.
|
|
124
|
-
_UNIMPLEMENTED_OWNERS: dict[str, str] = {}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _diag(message: str) -> None:
|
|
128
|
-
"""Write one diagnostic line to the real stderr. Never the print builtin
|
|
129
|
-
(see the ordering comment above) and never protocol data."""
|
|
130
|
-
sys.stderr.write(message)
|
|
131
|
-
sys.stderr.write("\n")
|
|
132
|
-
sys.stderr.flush()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def disconnect_client() -> None:
|
|
136
|
-
"""Disconnects the live ClaudeSDKClient, if one was ever connected. The
|
|
137
|
-
finally block below calls this unconditionally, on EOF as much as on a
|
|
138
|
-
clean session.close: this is the point where the sidecar releases its
|
|
139
|
-
`claude` grandchild (T16, "Отсутствие процесса-сироты")."""
|
|
140
|
-
_run(_session.disconnect())
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
# The one string reported when a version cannot be established. It is never
|
|
144
|
-
# a refusal: docs/claude-sidecar.md requires both members of the hello result
|
|
145
|
-
# to be present, and a handshake that failed because a version string could
|
|
146
|
-
# not be read would trade a working session for a diagnostic.
|
|
147
|
-
_UNKNOWN_VERSION = "unknown"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _sdk_version() -> str:
|
|
151
|
-
"""The Agent SDK version this sidecar drives. Free: the package is
|
|
152
|
-
already imported by the preamble above, so this reads its installed
|
|
153
|
-
metadata and spawns nothing."""
|
|
154
|
-
try:
|
|
155
|
-
return importlib.metadata.version("claude-agent-sdk")
|
|
156
|
-
except importlib.metadata.PackageNotFoundError:
|
|
157
|
-
return _UNKNOWN_VERSION
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _cli_version() -> str:
|
|
161
|
-
"""The Claude CLI version this sidecar drives, read once at hello.
|
|
162
|
-
|
|
163
|
-
`claude --version` starts no session and spends no inference, and it is
|
|
164
|
-
the only way to learn the number: the SDK reports it nowhere, and
|
|
165
|
-
get_server_info() is both session-scoped and silent about it. shutil.which
|
|
166
|
-
rather than a bare name in the argv, because on Windows the CLI is a
|
|
167
|
-
`.cmd` shim that CreateProcess would not find on its own.
|
|
168
|
-
"""
|
|
169
|
-
exe = shutil.which("claude")
|
|
170
|
-
if not exe:
|
|
171
|
-
return _UNKNOWN_VERSION
|
|
172
|
-
try:
|
|
173
|
-
out = subprocess.run( # noqa: S603
|
|
174
|
-
[exe, "--version"], capture_output=True, text=True, timeout=10, check=False
|
|
175
|
-
)
|
|
176
|
-
except (OSError, subprocess.SubprocessError):
|
|
177
|
-
return _UNKNOWN_VERSION
|
|
178
|
-
if out.returncode != 0:
|
|
179
|
-
return _UNKNOWN_VERSION
|
|
180
|
-
return out.stdout.strip() or _UNKNOWN_VERSION
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _hello_result() -> dict:
|
|
184
|
-
return {
|
|
185
|
-
"protocol": PROTOCOL_VERSION,
|
|
186
|
-
"sidecar": {"name": "claude-sidecar", "version": "0.1.0", "pid": os.getpid()},
|
|
187
|
-
"cli_version": _cli_version(),
|
|
188
|
-
"sdk_version": _sdk_version(),
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def _id_of(frame: dict) -> int:
|
|
193
|
-
frame_id = frame.get("id")
|
|
194
|
-
return frame_id if isinstance(frame_id, int) else 0
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def main() -> int:
|
|
198
|
-
greeted = False
|
|
199
|
-
try:
|
|
200
|
-
while True:
|
|
201
|
-
try:
|
|
202
|
-
# Under the loop, not on the bare main thread: this read is
|
|
203
|
-
# where the sidecar spends every idle moment, and the process
|
|
204
|
-
# has exactly one event loop, which only turns inside _run.
|
|
205
|
-
# Reading straight from sys.stdin here suspended
|
|
206
|
-
# events.run_stream's task for the whole wait, so no
|
|
207
|
-
# message.delta, tool.started, error or turn.completed could
|
|
208
|
-
# leave the process between operations - the frames were
|
|
209
|
-
# produced and then sat in a task nobody was pumping.
|
|
210
|
-
# asyncio.to_thread parks the blocking readline on a worker
|
|
211
|
-
# thread while run_until_complete turns the loop, so the
|
|
212
|
-
# stream task drains on its own. Only the read moves: the
|
|
213
|
-
# dispatch below stays synchronous, every handler keeps its
|
|
214
|
-
# own _run, and the writes here keep running on a main
|
|
215
|
-
# thread that is by construction not inside the loop.
|
|
216
|
-
frame = _run(asyncio.to_thread(read_frame, sys.stdin))
|
|
217
|
-
except FrameError as exc:
|
|
218
|
-
_diag(f"malformed frame: {exc}")
|
|
219
|
-
return EXIT_PROTOCOL_ERROR
|
|
220
|
-
|
|
221
|
-
if frame is None:
|
|
222
|
-
# EOF is the one unconditional exit point of this loop: a
|
|
223
|
-
# Longshot stopped any way at all, including a hard kill,
|
|
224
|
-
# closes this process's stdin, and that is the whole
|
|
225
|
-
# mechanism behind "no orphan sidecar" (T16).
|
|
226
|
-
return EXIT_OK
|
|
227
|
-
|
|
228
|
-
kind = frame.get("kind")
|
|
229
|
-
op = frame.get("op")
|
|
230
|
-
frame_id = _id_of(frame)
|
|
231
|
-
|
|
232
|
-
if not greeted:
|
|
233
|
-
if kind != "request" or op != Op.HELLO:
|
|
234
|
-
write_frame(
|
|
235
|
-
_frames,
|
|
236
|
-
refusal(
|
|
237
|
-
frame_id,
|
|
238
|
-
Code.HELLO_REQUIRED,
|
|
239
|
-
f"the sidecar channel received {op or ''!r} before completing the "
|
|
240
|
-
"hello handshake; a sidecar channel must complete hello first",
|
|
241
|
-
),
|
|
242
|
-
)
|
|
243
|
-
return EXIT_PROTOCOL_ERROR
|
|
244
|
-
|
|
245
|
-
params = frame.get("params") or {}
|
|
246
|
-
if params.get("protocol") != PROTOCOL_VERSION:
|
|
247
|
-
write_frame(
|
|
248
|
-
_frames,
|
|
249
|
-
refusal(
|
|
250
|
-
frame_id,
|
|
251
|
-
Code.UNSUPPORTED_VERSION,
|
|
252
|
-
f"sidecar protocol mismatch: this sidecar speaks protocol "
|
|
253
|
-
f"{PROTOCOL_VERSION}, longshot speaks protocol "
|
|
254
|
-
f"{params.get('protocol')!r}. Reinstall the sidecar that ships with "
|
|
255
|
-
"this longshot build, or run the longshot build that matches it.",
|
|
256
|
-
),
|
|
257
|
-
)
|
|
258
|
-
return EXIT_PROTOCOL_ERROR
|
|
259
|
-
|
|
260
|
-
write_frame(_frames, response(frame_id, _hello_result()))
|
|
261
|
-
greeted = True
|
|
262
|
-
continue
|
|
263
|
-
|
|
264
|
-
if kind == "request" and op == Op.HELLO:
|
|
265
|
-
# Rule 3: a second hello is malformedRequest and does NOT end
|
|
266
|
-
# the channel.
|
|
267
|
-
write_frame(
|
|
268
|
-
_frames,
|
|
269
|
-
refusal(
|
|
270
|
-
frame_id,
|
|
271
|
-
Code.MALFORMED_REQUEST,
|
|
272
|
-
"the sidecar received hello twice; hello is the first frame of a "
|
|
273
|
-
"channel and is sent once",
|
|
274
|
-
),
|
|
275
|
-
)
|
|
276
|
-
continue
|
|
277
|
-
|
|
278
|
-
if kind == "response":
|
|
279
|
-
# An answer to one of THIS sidecar's own requests -
|
|
280
|
-
# permission.request (T21) or tool.call (T20). One resolve()
|
|
281
|
-
# serves both, over protocol.py's one pending registry:
|
|
282
|
-
# routing them separately would be the second correlation
|
|
283
|
-
# mechanism T20-S02 refused. Each side keeps its own id
|
|
284
|
-
# counter (docs/claude-sidecar.md, "Correlation and
|
|
285
|
-
# identifiers"), so this is never confused with a response to
|
|
286
|
-
# a request Longshot sent. An id nobody is waiting on is
|
|
287
|
-
# dropped with a diagnostic, not refused: the waiter may
|
|
288
|
-
# already have given up (session ending), and there is no
|
|
289
|
-
# request id to answer on this path anyway.
|
|
290
|
-
if not resolve(frame):
|
|
291
|
-
_diag(
|
|
292
|
-
f"claude sidecar: response id={frame_id} matched no pending sidecar-initiated request"
|
|
293
|
-
)
|
|
294
|
-
continue
|
|
295
|
-
|
|
296
|
-
if kind != "request":
|
|
297
|
-
write_frame(
|
|
298
|
-
_frames,
|
|
299
|
-
refusal(
|
|
300
|
-
frame_id,
|
|
301
|
-
Code.MALFORMED_REQUEST,
|
|
302
|
-
f"the sidecar channel received a frame of kind {kind!r}, which "
|
|
303
|
-
"Longshot may not send",
|
|
304
|
-
),
|
|
305
|
-
)
|
|
306
|
-
continue
|
|
307
|
-
|
|
308
|
-
if op not in Op.KNOWN:
|
|
309
|
-
write_frame(
|
|
310
|
-
_frames,
|
|
311
|
-
refusal(
|
|
312
|
-
frame_id,
|
|
313
|
-
Code.UNKNOWN_OPERATION,
|
|
314
|
-
f"{op!r} is not an operation either side of the sidecar channel knows",
|
|
315
|
-
),
|
|
316
|
-
)
|
|
317
|
-
continue
|
|
318
|
-
|
|
319
|
-
params = frame.get("params") or {}
|
|
320
|
-
|
|
321
|
-
if op in _SESSION_HANDLERS:
|
|
322
|
-
try:
|
|
323
|
-
result = _SESSION_HANDLERS[op](params)
|
|
324
|
-
except Refusal as exc:
|
|
325
|
-
write_frame(_frames, refusal(frame_id, exc.code, exc.message))
|
|
326
|
-
continue
|
|
327
|
-
write_frame(_frames, response(frame_id, result))
|
|
328
|
-
if op in _READY_AFTER:
|
|
329
|
-
write_frame(_frames, _session.ready_event())
|
|
330
|
-
continue
|
|
331
|
-
|
|
332
|
-
if op == Op.PERMISSION_SET:
|
|
333
|
-
# T15 requires the permission_mode refusal here too, ahead of
|
|
334
|
-
# the actual mode change: the frame carries permission_mode,
|
|
335
|
-
# and I08 must hold on this side of the channel even though
|
|
336
|
-
# the other side (internal/backend/claude/settings.go)
|
|
337
|
-
# checks it as well - a check on one side is a check a
|
|
338
|
-
# refactor removes.
|
|
339
|
-
try:
|
|
340
|
-
check_permission_mode(params.get("permission_mode") or "")
|
|
341
|
-
result = _run(
|
|
342
|
-
_session.set_permission_mode(
|
|
343
|
-
params.get("session_id", ""),
|
|
344
|
-
params["permission_mode"],
|
|
345
|
-
)
|
|
346
|
-
)
|
|
347
|
-
except Refusal as exc:
|
|
348
|
-
write_frame(_frames, refusal(frame_id, exc.code, exc.message))
|
|
349
|
-
continue
|
|
350
|
-
write_frame(_frames, response(frame_id, result))
|
|
351
|
-
continue
|
|
352
|
-
|
|
353
|
-
owner = _UNIMPLEMENTED_OWNERS.get(op, "a later task")
|
|
354
|
-
write_frame(
|
|
355
|
-
_frames,
|
|
356
|
-
refusal(
|
|
357
|
-
frame_id,
|
|
358
|
-
Code.INTERNAL,
|
|
359
|
-
f"{op!r} is not implemented by this sidecar build yet; {owner} owns it",
|
|
360
|
-
),
|
|
361
|
-
)
|
|
362
|
-
finally:
|
|
363
|
-
disconnect_client()
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if __name__ == "__main__":
|
|
367
|
-
sys.exit(main())
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
"""Builds the in-process "longshot" MCP server ClaudeAgentOptions.mcp_servers
|
|
2
|
-
takes for one session, from the tool definitions session.start carries on
|
|
3
|
-
its additive `tools` field (T20-S01, docs/claude-sidecar.md's `session.start`
|
|
4
|
-
params table).
|
|
5
|
-
|
|
6
|
-
I12 is the whole shape of this module: the sidecar owns nothing here. Every
|
|
7
|
-
declared tool gets the exact same forwarding handler (_forward) — there is
|
|
8
|
-
no branch on a tool's name, no local execution of any tool, no cached
|
|
9
|
-
result, and no timeout of this module's own invention: a `tool.call` request
|
|
10
|
-
waits for its reply as long as it takes, by protocol v1 (T15's decision, not
|
|
11
|
-
this slice's — see docs/claude-sidecar.md, "Sidecar to Longshot: requests").
|
|
12
|
-
Description and input schema travel exactly as the definition carried them:
|
|
13
|
-
that dictionary belongs to the Go tool registry (`T20`'s projection), not to
|
|
14
|
-
this module.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
from collections.abc import Awaitable, Callable
|
|
20
|
-
from typing import Any
|
|
21
|
-
|
|
22
|
-
# Sends one tool.call request (docs/claude-sidecar.md, "Sidecar to Longshot:
|
|
23
|
-
# requests" -> "tool.call") and returns its result, {"content", "is_error"},
|
|
24
|
-
# once the matching response arrives, keyed by tool_name and arguments alone.
|
|
25
|
-
# session_id, turn_id and tool_id are ambient to whichever call_tool this
|
|
26
|
-
# module is handed (one sidecar serves one session, I03) — the request/
|
|
27
|
-
# response correlation by id belongs to protocol.py, not reinvented here.
|
|
28
|
-
CallTool = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
|
|
29
|
-
|
|
30
|
-
# Claude adds this prefix itself, from the MCP server name ("longshot"): a
|
|
31
|
-
# definition arrives as "mcp__longshot__<tool>" and registers on the server
|
|
32
|
-
# under "<tool>" alone.
|
|
33
|
-
_MCP_PREFIX = "mcp__longshot__"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _bare_name(full_name: str) -> str:
|
|
37
|
-
"""The name a tool registers under on the "longshot" MCP server."""
|
|
38
|
-
return full_name.removeprefix(_MCP_PREFIX)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
async def _forward(full_name: str, arguments: dict, call_tool: CallTool) -> dict:
|
|
42
|
-
"""Send one tool.call request and return its result. No branching on
|
|
43
|
-
full_name, no local fallback, no timeout of its own: the request has no
|
|
44
|
-
deadline by protocol v1, and that is T15's decision, not this slice's.
|
|
45
|
-
"""
|
|
46
|
-
result = await call_tool(full_name, arguments)
|
|
47
|
-
return {
|
|
48
|
-
"content": [{"type": "text", "text": result.get("content", "")}],
|
|
49
|
-
"is_error": bool(result.get("is_error", False)),
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _build_tool_specs(tool_definitions: list[dict], call_tool: CallTool) -> list[dict]:
|
|
54
|
-
"""One spec per definition, every one sharing _forward as its handler,
|
|
55
|
-
bound to that definition's own full name (never re-derived, so a name
|
|
56
|
-
that does not start with the expected prefix still forwards under its
|
|
57
|
-
real full name). Kept free of any claude-agent-sdk import so this
|
|
58
|
-
composition step — the whole of what this module decides — stays
|
|
59
|
-
testable without the SDK installed; build_longshot_server below is the
|
|
60
|
-
thin, SDK-facing wrapper around it.
|
|
61
|
-
"""
|
|
62
|
-
specs = []
|
|
63
|
-
for definition in tool_definitions:
|
|
64
|
-
full_name = definition["name"]
|
|
65
|
-
|
|
66
|
-
async def handler(arguments: dict, _full_name: str = full_name) -> dict:
|
|
67
|
-
return await _forward(_full_name, arguments, call_tool)
|
|
68
|
-
|
|
69
|
-
specs.append(
|
|
70
|
-
{
|
|
71
|
-
"name": _bare_name(full_name),
|
|
72
|
-
"description": definition["description"],
|
|
73
|
-
"input_schema": definition["input_schema"],
|
|
74
|
-
"handler": handler,
|
|
75
|
-
}
|
|
76
|
-
)
|
|
77
|
-
return specs
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def build_longshot_server(tool_definitions: list[dict], call_tool: CallTool) -> object:
|
|
81
|
-
"""Build the in-process "longshot" MCP server from the definitions that
|
|
82
|
-
arrived on session.start. Every tool gets the same forwarding handler:
|
|
83
|
-
this process implements none of them (I12). An empty tool_definitions is
|
|
84
|
-
a normal outcome: the returned server simply declares no tools.
|
|
85
|
-
"""
|
|
86
|
-
from claude_agent_sdk import create_sdk_mcp_server
|
|
87
|
-
from claude_agent_sdk import tool as sdk_tool
|
|
88
|
-
|
|
89
|
-
specs = _build_tool_specs(tool_definitions, call_tool)
|
|
90
|
-
sdk_tools = [
|
|
91
|
-
sdk_tool(spec["name"], spec["description"], spec["input_schema"])(
|
|
92
|
-
spec["handler"]
|
|
93
|
-
)
|
|
94
|
-
for spec in specs
|
|
95
|
-
]
|
|
96
|
-
return create_sdk_mcp_server("longshot", tools=sdk_tools)
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"""T21: the sidecar's half of the normalised permission.request round trip.
|
|
2
|
-
|
|
3
|
-
The sidecar carries no file-access level table
|
|
4
|
-
(docs/tasks/runtime/claude-adapter/tasks/T21-claude-permissions.md,
|
|
5
|
-
"Отображение уровней": "Таблицы уровней в Python нет и быть не должно").
|
|
6
|
-
Every can_use_tool invocation is forwarded to Longshot as one
|
|
7
|
-
permission.request frame and answered by whatever `behavior` Longshot's
|
|
8
|
-
decide() sends back - this module never decides anything itself, it only
|
|
9
|
-
carries the question and translates the answer.
|
|
10
|
-
|
|
11
|
-
Two things live here, split so the first is testable without
|
|
12
|
-
claude-agent-sdk installed at all (the same property events.py's
|
|
13
|
-
map_message keeps, per its own module docstring):
|
|
14
|
-
|
|
15
|
-
- the permission.request round trip (request_permission, and resolve
|
|
16
|
-
re-exported beside it): the correlation itself is protocol.request /
|
|
17
|
-
protocol.resolve, one registry shared with tool.call, so this module only
|
|
18
|
-
names the op and lays out its params;
|
|
19
|
-
- make_can_use_tool, which wraps that round trip into the callback shape
|
|
20
|
-
ClaudeAgentOptions.can_use_tool expects and translates the wire's
|
|
21
|
-
`behavior`/`message` into the SDK's own PermissionResultAllow /
|
|
22
|
-
PermissionResultDeny. updated_input is never sent back on the wire and
|
|
23
|
-
never read from the SDK side either: nobody rewrites a tool call's
|
|
24
|
-
arguments (T21, "Нормализованный запрос разрешения").
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
from __future__ import annotations
|
|
28
|
-
|
|
29
|
-
from collections.abc import Callable
|
|
30
|
-
from typing import Any
|
|
31
|
-
|
|
32
|
-
# resolve is re-exported, not redefined: it is half of the primitive this
|
|
33
|
-
# module's docstring names, and it now lives in protocol.py so tool.call
|
|
34
|
-
# shares the one registry with it. Importing it here keeps the name this
|
|
35
|
-
# module has always offered pointing at that one function.
|
|
36
|
-
from protocol import Emit, request, resolve # noqa: F401
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
async def request_permission(
|
|
40
|
-
emit: Emit, session_id: str, turn_id: str, tool_name: str, tool_input: Any
|
|
41
|
-
) -> dict:
|
|
42
|
-
"""Sends one permission.request frame and awaits Longshot's decision.
|
|
43
|
-
|
|
44
|
-
Longshot answers immediately and always (T21, "Правило отказа по
|
|
45
|
-
умолчанию": no branch of the rule asks and no branch waits), so the only
|
|
46
|
-
way this coroutine does not resolve is the channel going away entirely -
|
|
47
|
-
the same fate as any other in-flight request when that happens, and
|
|
48
|
-
protocol.fail_pending is where that fate is dealt out.
|
|
49
|
-
"""
|
|
50
|
-
return await request(
|
|
51
|
-
emit,
|
|
52
|
-
"permission.request",
|
|
53
|
-
{
|
|
54
|
-
"session_id": session_id,
|
|
55
|
-
"turn_id": turn_id,
|
|
56
|
-
"tool_name": tool_name,
|
|
57
|
-
"input": tool_input,
|
|
58
|
-
},
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def make_can_use_tool(emit: Emit, session_id: str, turn_id_of: Callable[[], str]):
|
|
63
|
-
"""Builds the can_use_tool callback ClaudeAgentOptions takes for one
|
|
64
|
-
session. turn_id_of is called fresh on every invocation - never captured
|
|
65
|
-
once - so the frame always names the turn actually in flight; a
|
|
66
|
-
ToolPermissionContext carries no turn id of its own to read instead.
|
|
67
|
-
|
|
68
|
-
claude_agent_sdk's own result types are imported inside the returned
|
|
69
|
-
callback, not at module level: everything above this function is
|
|
70
|
-
testable without the package installed at all, and this is the one place
|
|
71
|
-
in the module that actually needs it.
|
|
72
|
-
"""
|
|
73
|
-
|
|
74
|
-
async def handle_permission(tool_name: str, tool_input: Any, context: Any) -> Any:
|
|
75
|
-
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|
76
|
-
|
|
77
|
-
result = await request_permission(
|
|
78
|
-
emit, session_id, turn_id_of(), tool_name, tool_input
|
|
79
|
-
)
|
|
80
|
-
if result.get("behavior") == "allow":
|
|
81
|
-
return PermissionResultAllow()
|
|
82
|
-
# interrupt is left at its default False: a denied tool call ends
|
|
83
|
-
# that one call, not the turn it is inside (T21, "Отказ в разрешении
|
|
84
|
-
# посреди активного turn: turn продолжается").
|
|
85
|
-
return PermissionResultDeny(
|
|
86
|
-
message=result.get("message") or f"{tool_name} was refused"
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
return handle_permission
|