induscode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Link driver — the generated client over the JSON-RPC link
|
|
2
|
+
(port of TS ``src/channels/link/driver.ts``).
|
|
3
|
+
|
|
4
|
+
The driver is the mirror of the server: it writes framed requests and reads
|
|
5
|
+
framed replies. Crucially it is *generated*, not hand-written — there is no
|
|
6
|
+
per-op client method. The TS ``Proxy`` becomes a ``__getattr__`` trap: any
|
|
7
|
+
attribute access on the client yields a thunk that frames ``{id, method,
|
|
8
|
+
params} -> awaits {id, result | error}``, so the client mirrors whatever op
|
|
9
|
+
registry the server dispatches with a single body. Adding an op to the
|
|
10
|
+
registry adds a callable method to the driver for free.
|
|
11
|
+
|
|
12
|
+
The driver owns the single reader over the inbound stream. Every decoded
|
|
13
|
+
frame is one of: a correlated reply (settles the pending request under its
|
|
14
|
+
id), an uncorrelated signal (fanned out to the ``on_signal`` hook), or an ask
|
|
15
|
+
(handed to the ``on_ask`` answerer that posts an answer back). One reader,
|
|
16
|
+
three routes — no competing consumers of the transport.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import inspect
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any, Callable, Coroutine
|
|
27
|
+
|
|
28
|
+
from induscode.channels.contract import (
|
|
29
|
+
PROTOCOL_VERSION,
|
|
30
|
+
REQUEST_ID_PREFIX,
|
|
31
|
+
Ask,
|
|
32
|
+
ChannelTimings,
|
|
33
|
+
NdjsonFramer,
|
|
34
|
+
OpError,
|
|
35
|
+
ReadableChunks,
|
|
36
|
+
RequestId,
|
|
37
|
+
Signal,
|
|
38
|
+
WritableLine,
|
|
39
|
+
mint_wire_id,
|
|
40
|
+
resolve_timings,
|
|
41
|
+
)
|
|
42
|
+
from induscode.channels.framer import ndjson_framer
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"LinkClient",
|
|
46
|
+
"LinkDriverHandle",
|
|
47
|
+
"LinkDriverIo",
|
|
48
|
+
"LinkRequestError",
|
|
49
|
+
"create_link_driver",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Transport seams
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class LinkDriverIo:
|
|
60
|
+
"""The injectable transport pair the driver writes to and reads from.
|
|
61
|
+
|
|
62
|
+
(TS named the inbound field ``in``; that is a Python keyword, hence
|
|
63
|
+
``in_``.)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
#: Sink the driver writes framed requests to (the child stdin in production).
|
|
67
|
+
out: WritableLine
|
|
68
|
+
#: Source the driver reads framed replies / signals from (the child stdout).
|
|
69
|
+
in_: ReadableChunks
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class _PendingRequest:
|
|
74
|
+
"""A pending request awaiting its correlated reply."""
|
|
75
|
+
|
|
76
|
+
future: asyncio.Future[Any]
|
|
77
|
+
timer: asyncio.TimerHandle
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LinkRequestError(Exception):
|
|
81
|
+
"""A failed reply turned into a raisable error that carries the wire code."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, error: OpError) -> None:
|
|
84
|
+
super().__init__(error.message)
|
|
85
|
+
#: The JSON-RPC error code from the :class:`OpError`.
|
|
86
|
+
self.code: int = error.code
|
|
87
|
+
#: The structured detail, when the server supplied any.
|
|
88
|
+
self.data: Any = error.data
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Frame classification
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_reply_frame(frame: Any) -> bool:
|
|
97
|
+
"""Whether a decoded frame is a correlated reply."""
|
|
98
|
+
return (
|
|
99
|
+
isinstance(frame, Mapping)
|
|
100
|
+
and "id" in frame
|
|
101
|
+
and ("result" in frame or "error" in frame)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_signal_frame(frame: Any) -> bool:
|
|
106
|
+
"""Whether a decoded frame is an uncorrelated signal."""
|
|
107
|
+
return isinstance(frame, Mapping) and frame.get("type") == "signal"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_ask_frame(frame: Any) -> bool:
|
|
111
|
+
"""Whether a decoded frame is a server-initiated ask."""
|
|
112
|
+
return (
|
|
113
|
+
isinstance(frame, Mapping)
|
|
114
|
+
and frame.get("type") == "ask"
|
|
115
|
+
and isinstance(frame.get("id"), str)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Generated client
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class LinkClient:
|
|
125
|
+
"""The generated client: every attribute access is a request thunk.
|
|
126
|
+
|
|
127
|
+
The TS ``Proxy`` trap becomes ``__getattr__``: reading attribute ``m``
|
|
128
|
+
yields a thunk ``(params=None) -> awaitable result`` that round-trips the
|
|
129
|
+
method named ``m`` through the link. No method is hand-written; the op
|
|
130
|
+
registry on the server side is the single source of the callable surface
|
|
131
|
+
(an unregistered name simply rejects with the JSON-RPC ``unknownOp``
|
|
132
|
+
error, exactly as the TS proxy did).
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
__slots__ = ("_request",)
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self, request: Callable[[str, Any], Coroutine[Any, Any, Any]]
|
|
139
|
+
) -> None:
|
|
140
|
+
object.__setattr__(self, "_request", request)
|
|
141
|
+
|
|
142
|
+
def __getattr__(self, name: str) -> Callable[..., Coroutine[Any, Any, Any]]:
|
|
143
|
+
if name.startswith("_"):
|
|
144
|
+
raise AttributeError(name)
|
|
145
|
+
request = self._request
|
|
146
|
+
|
|
147
|
+
def thunk(params: Any = None) -> Coroutine[Any, Any, Any]:
|
|
148
|
+
return request(name, params)
|
|
149
|
+
|
|
150
|
+
thunk.__name__ = name
|
|
151
|
+
return thunk
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Driver handle
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class LinkDriverHandle:
|
|
160
|
+
"""A live driver: the generated client plus its lifecycle.
|
|
161
|
+
|
|
162
|
+
``client`` is the ``__getattr__``-generated :class:`LinkClient`; ``done``
|
|
163
|
+
resolves when the inbound stream ends; :meth:`close` rejects every
|
|
164
|
+
still-pending request and stops accepting replies.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
__slots__ = ("client", "done", "_close")
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
client: LinkClient,
|
|
172
|
+
done: asyncio.Task[None],
|
|
173
|
+
close: Callable[[str], None],
|
|
174
|
+
) -> None:
|
|
175
|
+
#: The generated, registry-mirroring client.
|
|
176
|
+
self.client = client
|
|
177
|
+
#: Resolves when the reader loop drains (the inbound stream ended).
|
|
178
|
+
self.done = done
|
|
179
|
+
self._close = close
|
|
180
|
+
|
|
181
|
+
def close(self, reason: str = "link driver closed by caller") -> None:
|
|
182
|
+
"""Reject all pending requests with ``reason`` and stop accepting
|
|
183
|
+
replies."""
|
|
184
|
+
self._close(reason)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def create_link_driver(
|
|
188
|
+
io: LinkDriverIo,
|
|
189
|
+
*,
|
|
190
|
+
framer: NdjsonFramer | None = None,
|
|
191
|
+
timings: Mapping[str, int] | None = None,
|
|
192
|
+
on_signal: Callable[[Signal], None] | None = None,
|
|
193
|
+
on_ask: Callable[[Ask], Any] | None = None,
|
|
194
|
+
) -> LinkDriverHandle:
|
|
195
|
+
"""Build a :class:`LinkDriverHandle` over an injected transport.
|
|
196
|
+
|
|
197
|
+
The returned ``client`` generates a thunk per attribute access: calling
|
|
198
|
+
``client.m(params)`` mints an id (prefixed with
|
|
199
|
+
:data:`REQUEST_ID_PREFIX`), writes the framed request, and resolves when
|
|
200
|
+
the matching reply arrives (or raises on an error reply or timeout). The
|
|
201
|
+
reader loop classifies inbound frames into replies, signals, and asks and
|
|
202
|
+
routes each. Must be called with a running event loop (the pump task
|
|
203
|
+
starts immediately).
|
|
204
|
+
|
|
205
|
+
:param io: the injected request sink + reply source
|
|
206
|
+
:param framer: override the NDJSON framer (defaults to the shared one)
|
|
207
|
+
:param timings: override any subset of the channel timings
|
|
208
|
+
:param on_signal: invoked for every uncorrelated signal the server streams
|
|
209
|
+
:param on_ask: invoked when the server asks a question; returns the answer
|
|
210
|
+
value (sync or awaitable). When omitted the driver dismisses every ask
|
|
211
|
+
(posts ``None``), so a non-interactive driver never wedges the server.
|
|
212
|
+
"""
|
|
213
|
+
the_framer = framer if framer is not None else ndjson_framer
|
|
214
|
+
the_timings: ChannelTimings = resolve_timings(timings)
|
|
215
|
+
|
|
216
|
+
pending: dict[RequestId, _PendingRequest] = {}
|
|
217
|
+
state = {"seq": 0, "closed": False}
|
|
218
|
+
|
|
219
|
+
def next_id() -> str:
|
|
220
|
+
state["seq"] += 1
|
|
221
|
+
return mint_wire_id(REQUEST_ID_PREFIX, int(time.time() * 1000), state["seq"])
|
|
222
|
+
|
|
223
|
+
async def request(method: str, params: Any = None) -> Any:
|
|
224
|
+
if state["closed"]:
|
|
225
|
+
raise RuntimeError("link driver is closed")
|
|
226
|
+
request_id = next_id()
|
|
227
|
+
frame: dict[str, Any] = {"jsonrpc": PROTOCOL_VERSION, "id": request_id, "method": method}
|
|
228
|
+
if params is not None: # JSON.stringify dropped the undefined params
|
|
229
|
+
frame["params"] = params
|
|
230
|
+
|
|
231
|
+
loop = asyncio.get_running_loop()
|
|
232
|
+
future: asyncio.Future[Any] = loop.create_future()
|
|
233
|
+
|
|
234
|
+
def on_timeout() -> None:
|
|
235
|
+
pending.pop(request_id, None)
|
|
236
|
+
if not future.done():
|
|
237
|
+
future.set_exception(TimeoutError(f"link request timed out: {method}"))
|
|
238
|
+
|
|
239
|
+
timer = loop.call_later(the_timings.requestMs / 1000, on_timeout)
|
|
240
|
+
pending[request_id] = _PendingRequest(future=future, timer=timer)
|
|
241
|
+
io.out.write(the_framer.encode_line(frame))
|
|
242
|
+
return await future
|
|
243
|
+
|
|
244
|
+
def settle_reply(reply: Mapping[str, Any]) -> None:
|
|
245
|
+
entry = pending.pop(reply["id"], None)
|
|
246
|
+
if entry is None:
|
|
247
|
+
return
|
|
248
|
+
entry.timer.cancel()
|
|
249
|
+
if entry.future.done():
|
|
250
|
+
return
|
|
251
|
+
if "result" in reply:
|
|
252
|
+
entry.future.set_result(reply["result"])
|
|
253
|
+
else:
|
|
254
|
+
error = reply.get("error")
|
|
255
|
+
wire = error if isinstance(error, Mapping) else {}
|
|
256
|
+
entry.future.set_exception(
|
|
257
|
+
LinkRequestError(
|
|
258
|
+
OpError(
|
|
259
|
+
code=int(wire.get("code", 0)),
|
|
260
|
+
message=str(wire.get("message", "")),
|
|
261
|
+
data=wire.get("data"),
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def answer_ask(frame: Mapping[str, Any]) -> None:
|
|
267
|
+
ask = Ask(id=frame["id"], kind=str(frame.get("kind", "")), payload=frame.get("payload"))
|
|
268
|
+
value: Any = None
|
|
269
|
+
if on_ask is not None:
|
|
270
|
+
value = on_ask(ask)
|
|
271
|
+
if inspect.isawaitable(value):
|
|
272
|
+
value = await value
|
|
273
|
+
io.out.write(the_framer.encode_line({"type": "answer", "id": ask.id, "value": value}))
|
|
274
|
+
|
|
275
|
+
def reject_all(reason: str) -> None:
|
|
276
|
+
for entry in pending.values():
|
|
277
|
+
entry.timer.cancel()
|
|
278
|
+
if not entry.future.done():
|
|
279
|
+
entry.future.set_exception(RuntimeError(reason))
|
|
280
|
+
pending.clear()
|
|
281
|
+
|
|
282
|
+
async def pump() -> None:
|
|
283
|
+
try:
|
|
284
|
+
async for frame in the_framer.decode_lines(io.in_):
|
|
285
|
+
if _is_reply_frame(frame):
|
|
286
|
+
settle_reply(frame)
|
|
287
|
+
continue
|
|
288
|
+
if _is_signal_frame(frame):
|
|
289
|
+
if on_signal is not None:
|
|
290
|
+
on_signal(Signal(name=str(frame.get("name", "")), body=frame.get("body")))
|
|
291
|
+
continue
|
|
292
|
+
if _is_ask_frame(frame):
|
|
293
|
+
await answer_ask(frame)
|
|
294
|
+
continue
|
|
295
|
+
# Unknown frame shape: forward-compatible no-op.
|
|
296
|
+
finally:
|
|
297
|
+
state["closed"] = True
|
|
298
|
+
reject_all("link closed before reply")
|
|
299
|
+
|
|
300
|
+
def close(reason: str) -> None:
|
|
301
|
+
state["closed"] = True
|
|
302
|
+
reject_all(reason)
|
|
303
|
+
|
|
304
|
+
return LinkDriverHandle(
|
|
305
|
+
client=LinkClient(request),
|
|
306
|
+
done=asyncio.create_task(pump()),
|
|
307
|
+
close=close,
|
|
308
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Link server — the data-driven JSON-RPC 2.0 dispatch loop
|
|
2
|
+
(port of TS ``src/channels/link/server.ts``).
|
|
3
|
+
|
|
4
|
+
The server owns the single reader over the inbound stream. For each framed
|
|
5
|
+
line it decides what the frame is and routes it: a request frame is
|
|
6
|
+
dispatched through the :data:`~induscode.channels.contract.OpRegistry` (a map
|
|
7
|
+
lookup, not a command switch) and its outcome is framed back as a reply; an
|
|
8
|
+
inbound answer frame is routed to the dialog bridge to settle a suspended
|
|
9
|
+
``ask``. One round of the protocol is ``{id, method, params} -> {id,
|
|
10
|
+
result | error}``.
|
|
11
|
+
|
|
12
|
+
Everything that touches the outside world is injected: the request source,
|
|
13
|
+
the reply sink, the framer, and the conductor all arrive through the context
|
|
14
|
+
and options, so a test serves the link over an in-memory pipe pair with no
|
|
15
|
+
stdio. The dispatch itself is delegated to
|
|
16
|
+
:func:`~induscode.channels.ops.dispatch` from the ops module, so the server
|
|
17
|
+
holds no per-method knowledge.
|
|
18
|
+
|
|
19
|
+
Concurrency pin (plan rule 8): a request handler may itself suspend on a
|
|
20
|
+
dialog ``ask``, whose answer arrives as a later inbound frame **on this very
|
|
21
|
+
stream**. So requests are dispatched as concurrent ``asyncio`` tasks (tracked
|
|
22
|
+
in a set, gathered at stream end) rather than awaited in line — otherwise the
|
|
23
|
+
reader would block on a handler that needs the reader to make progress,
|
|
24
|
+
deadlocking the round trip. Answer frames are always delivered immediately so
|
|
25
|
+
a suspended ``ask`` can settle.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from induscode.channels.contract import (
|
|
36
|
+
OP_ERROR,
|
|
37
|
+
PROTOCOL_VERSION,
|
|
38
|
+
AskAnswer,
|
|
39
|
+
ChannelContext,
|
|
40
|
+
NdjsonFramer,
|
|
41
|
+
OpError,
|
|
42
|
+
OpRegistry,
|
|
43
|
+
ReadableChunks,
|
|
44
|
+
RequestId,
|
|
45
|
+
SessionConductor,
|
|
46
|
+
WritableLine,
|
|
47
|
+
resolve_timings,
|
|
48
|
+
)
|
|
49
|
+
from induscode.channels.framer import ndjson_framer
|
|
50
|
+
from induscode.channels.link.dialog import (
|
|
51
|
+
DialogBridgeDeps,
|
|
52
|
+
LinkedDialogBridge,
|
|
53
|
+
create_dialog_bridge,
|
|
54
|
+
)
|
|
55
|
+
from induscode.channels.ops import UnknownOpError, dispatch
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"LinkServer",
|
|
59
|
+
"LinkServerIo",
|
|
60
|
+
"create_link_server",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Transport seams
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class LinkServerIo:
|
|
71
|
+
"""The injectable transport pair the server reads from and writes to.
|
|
72
|
+
|
|
73
|
+
Bundles the inbound request source and the outbound reply/signal sink so
|
|
74
|
+
tests pass in-memory pipes and production passes stdin / stdout adapters.
|
|
75
|
+
(TS named the inbound field ``in``; that is a Python keyword, hence
|
|
76
|
+
``in_``.)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
#: Framed inbound request / answer lines (stdin in production).
|
|
80
|
+
in_: ReadableChunks
|
|
81
|
+
#: Framed outbound reply / signal / dialog sink (stdout in production).
|
|
82
|
+
out: WritableLine
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class LinkServer:
|
|
87
|
+
"""A running server: a task that settles when the inbound stream ends."""
|
|
88
|
+
|
|
89
|
+
#: Resolves when the request stream is exhausted and the loop has drained.
|
|
90
|
+
done: asyncio.Task[None]
|
|
91
|
+
#: The dialog bridge the running server feeds inbound answers to.
|
|
92
|
+
dialog: LinkedDialogBridge
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Frame classification
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_request_frame(frame: Any) -> bool:
|
|
101
|
+
"""Whether a decoded frame looks like a JSON-RPC request (``method``
|
|
102
|
+
present as a string)."""
|
|
103
|
+
return isinstance(frame, Mapping) and isinstance(frame.get("method"), str)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_answer_frame(frame: Any) -> bool:
|
|
107
|
+
"""Whether a decoded frame is an inbound answer to a suspended ``ask``."""
|
|
108
|
+
return (
|
|
109
|
+
isinstance(frame, Mapping)
|
|
110
|
+
and frame.get("type") == "answer"
|
|
111
|
+
and isinstance(frame.get("id"), str)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _to_op_error(error: BaseException) -> OpError:
|
|
116
|
+
"""Map a raised exception to a framed :class:`OpError`, preserving a
|
|
117
|
+
known code."""
|
|
118
|
+
if isinstance(error, UnknownOpError):
|
|
119
|
+
return OpError(code=error.code, message=str(error), data={"method": error.method})
|
|
120
|
+
return OpError(code=OP_ERROR["handlerFailed"], message=str(error))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Server
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def create_link_server(
|
|
129
|
+
registry: OpRegistry,
|
|
130
|
+
conductor: SessionConductor,
|
|
131
|
+
io: LinkServerIo,
|
|
132
|
+
*,
|
|
133
|
+
framer: NdjsonFramer | None = None,
|
|
134
|
+
timings: Mapping[str, int] | None = None,
|
|
135
|
+
) -> LinkServer:
|
|
136
|
+
"""Start a link server over an op registry, a conductor, and an injected
|
|
137
|
+
transport.
|
|
138
|
+
|
|
139
|
+
Builds the shared :class:`ChannelContext` (conductor + framer + the
|
|
140
|
+
freshly wired dialog bridge), then consumes the inbound stream with a
|
|
141
|
+
single ``async for`` loop. Each frame is classified:
|
|
142
|
+
|
|
143
|
+
- an answer frame is delivered to the dialog bridge;
|
|
144
|
+
- a request frame is dispatched through ``registry`` as a concurrent
|
|
145
|
+
task; a request carrying an ``id`` gets exactly one correlated reply
|
|
146
|
+
(result or error); a notification (no ``id``) is dispatched for its
|
|
147
|
+
effect and never replied to;
|
|
148
|
+
- any other frame is ignored (a forward-compatible no-op).
|
|
149
|
+
|
|
150
|
+
Must be called with a running event loop (the pump task starts
|
|
151
|
+
immediately — the analogue of the TS promise returned by ``pump()``).
|
|
152
|
+
|
|
153
|
+
:param registry: the frozen :data:`OpRegistry` that drives dispatch
|
|
154
|
+
:param conductor: the session every op delegates to
|
|
155
|
+
:param io: the injected request source + reply sink
|
|
156
|
+
:param framer: override the NDJSON framer (defaults to the shared one)
|
|
157
|
+
:param timings: override any subset of the channel timings
|
|
158
|
+
:returns: a handle whose ``done`` resolves when the inbound stream ends
|
|
159
|
+
"""
|
|
160
|
+
the_framer = framer if framer is not None else ndjson_framer
|
|
161
|
+
the_timings = resolve_timings(timings)
|
|
162
|
+
|
|
163
|
+
dialog = create_dialog_bridge(
|
|
164
|
+
DialogBridgeDeps(out=io.out, framer=the_framer, timings=the_timings)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
ctx = ChannelContext(conductor=conductor, out=io.out, framer=the_framer, dialog=dialog)
|
|
168
|
+
|
|
169
|
+
def write_reply(reply: Mapping[str, Any]) -> None:
|
|
170
|
+
io.out.write(the_framer.encode_line(reply))
|
|
171
|
+
|
|
172
|
+
def reply_ok(request_id: RequestId, result: Any) -> None:
|
|
173
|
+
write_reply({"jsonrpc": PROTOCOL_VERSION, "id": request_id, "result": result})
|
|
174
|
+
|
|
175
|
+
def reply_err(request_id: RequestId, error: OpError) -> None:
|
|
176
|
+
wire_error: dict[str, Any] = {"code": error.code, "message": error.message}
|
|
177
|
+
if error.data is not None:
|
|
178
|
+
wire_error["data"] = error.data
|
|
179
|
+
write_reply({"jsonrpc": PROTOCOL_VERSION, "id": request_id, "error": wire_error})
|
|
180
|
+
|
|
181
|
+
async def handle_request(frame: Mapping[str, Any]) -> None:
|
|
182
|
+
wants_reply = "id" in frame
|
|
183
|
+
try:
|
|
184
|
+
result = await dispatch(registry, frame["method"], frame.get("params"), ctx)
|
|
185
|
+
if wants_reply:
|
|
186
|
+
reply_ok(frame["id"], result)
|
|
187
|
+
except Exception as error: # noqa: BLE001 — every handler fault frames as a reply
|
|
188
|
+
if wants_reply:
|
|
189
|
+
reply_err(frame["id"], _to_op_error(error))
|
|
190
|
+
|
|
191
|
+
async def pump() -> None:
|
|
192
|
+
# A request handler may itself suspend on a dialog `ask`, whose answer
|
|
193
|
+
# arrives as a later inbound frame on this very stream. So requests
|
|
194
|
+
# are dispatched concurrently (their tasks tracked) rather than
|
|
195
|
+
# awaited in line — otherwise the reader would block on a handler
|
|
196
|
+
# that needs the reader to make progress, deadlocking the round trip.
|
|
197
|
+
# Answer frames are always delivered immediately so a suspended `ask`
|
|
198
|
+
# can settle.
|
|
199
|
+
inflight: set[asyncio.Task[None]] = set()
|
|
200
|
+
try:
|
|
201
|
+
async for frame in the_framer.decode_lines(io.in_):
|
|
202
|
+
if _is_answer_frame(frame):
|
|
203
|
+
dialog.deliver(AskAnswer(id=frame["id"], value=frame.get("value")))
|
|
204
|
+
continue
|
|
205
|
+
if _is_request_frame(frame):
|
|
206
|
+
task = asyncio.create_task(handle_request(frame))
|
|
207
|
+
inflight.add(task)
|
|
208
|
+
task.add_done_callback(inflight.discard)
|
|
209
|
+
continue
|
|
210
|
+
# Unknown frame shape: forward-compatible no-op.
|
|
211
|
+
# Let any still-running handlers settle before the loop reports done.
|
|
212
|
+
if inflight:
|
|
213
|
+
await asyncio.gather(*inflight)
|
|
214
|
+
finally:
|
|
215
|
+
dialog.drain()
|
|
216
|
+
|
|
217
|
+
return LinkServer(done=asyncio.create_task(pump()), dialog=dialog)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Oneshot channel — a single non-interactive run to settlement
|
|
2
|
+
(port of TS ``src/channels/oneshot.ts``).
|
|
3
|
+
|
|
4
|
+
Replaces the print mode. Given a
|
|
5
|
+
:class:`~induscode.channels.contract.ChannelContext` and a
|
|
6
|
+
:class:`~induscode.channels.contract.OneshotRequest`, it submits every prompt
|
|
7
|
+
to the conductor in order, streams the answer to the channel sink, and
|
|
8
|
+
resolves a process exit code.
|
|
9
|
+
|
|
10
|
+
The shape (clean final text vs a streamed NDJSON event log) is chosen by
|
|
11
|
+
selecting an :class:`~induscode.channels.contract.OneshotStrategy` object
|
|
12
|
+
rather than branching on a flag through the runner body: each strategy
|
|
13
|
+
supplies an optional ``on_start``, an optional ``on_signal``, and a mandatory
|
|
14
|
+
``finish`` that turns the settled state into the exit code. The runner itself
|
|
15
|
+
is shape-agnostic — it subscribes the strategy to the conductor signal
|
|
16
|
+
stream, runs the prompts, and hands the final state to ``finish``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
from typing import Any, Final, Mapping
|
|
23
|
+
|
|
24
|
+
from induscode.channels.contract import (
|
|
25
|
+
ChannelContext,
|
|
26
|
+
ConductorState,
|
|
27
|
+
DialogBridge,
|
|
28
|
+
OneshotRequest,
|
|
29
|
+
OneshotShape,
|
|
30
|
+
OneshotStrategy,
|
|
31
|
+
signal_to_wire,
|
|
32
|
+
)
|
|
33
|
+
from induscode.conductor.contract import SessionSignal
|
|
34
|
+
from induscode.conductor.serialize import message_to_dict
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"inert_dialog",
|
|
38
|
+
"run_oneshot",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _InertDialog:
|
|
43
|
+
"""A dialog bridge for a non-interactive oneshot run: no driver is
|
|
44
|
+
attached to answer a question, so every ``ask`` resolves immediately with
|
|
45
|
+
its caller-supplied fallback and every ``tell`` is dropped. Keeps the
|
|
46
|
+
agent from wedging on a prompt during a headless single run while never
|
|
47
|
+
writing dialog frames to the sink."""
|
|
48
|
+
|
|
49
|
+
async def ask(self, kind: str, payload: Any, fallback: Any) -> Any:
|
|
50
|
+
return fallback
|
|
51
|
+
|
|
52
|
+
def tell(self, kind: str, payload: Any = None) -> None:
|
|
53
|
+
# Fire-and-forget with no listener: intentionally discarded.
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
#: The shared inert bridge instance (TS frozen ``inertDialog``).
|
|
58
|
+
inert_dialog: Final[DialogBridge] = _InertDialog()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#: Exit code for a clean settlement.
|
|
62
|
+
_EXIT_OK: Final[int] = 0
|
|
63
|
+
|
|
64
|
+
#: Exit code for a faulted settlement.
|
|
65
|
+
_EXIT_FAULT: Final[int] = 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _exit_code_for(state: ConductorState) -> int:
|
|
69
|
+
"""Pick the exit code from a settled :class:`ConductorState`: a faulted
|
|
70
|
+
phase is a failure, anything else is success. Shared by both strategies so
|
|
71
|
+
the success rule lives in one place."""
|
|
72
|
+
return _EXIT_FAULT if state.phase == "faulted" else _EXIT_OK
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _text_strategy() -> OneshotStrategy:
|
|
76
|
+
"""The text strategy: accumulate streamed answer text and emit it once,
|
|
77
|
+
clean.
|
|
78
|
+
|
|
79
|
+
``on_signal`` collects every ``text`` delta into a buffer; ``finish``
|
|
80
|
+
writes the accumulated answer as a single trailing line (so a caller
|
|
81
|
+
capturing stdout gets the final answer, not a token-by-token dribble) and
|
|
82
|
+
returns the exit code. Thinking deltas, tool frames, and bookkeeping
|
|
83
|
+
signals are ignored.
|
|
84
|
+
"""
|
|
85
|
+
parts: list[str] = []
|
|
86
|
+
|
|
87
|
+
def on_signal(signal: SessionSignal, ctx: ChannelContext) -> None:
|
|
88
|
+
if signal.kind == "text":
|
|
89
|
+
parts.append(signal.delta) # type: ignore[union-attr]
|
|
90
|
+
|
|
91
|
+
def finish(state: ConductorState, ctx: ChannelContext) -> int:
|
|
92
|
+
answer = "".join(parts)
|
|
93
|
+
ctx.out.write(answer if answer.endswith("\n") else answer + "\n")
|
|
94
|
+
return _exit_code_for(state)
|
|
95
|
+
|
|
96
|
+
return OneshotStrategy(finish=finish, on_signal=on_signal)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ndjson_strategy() -> OneshotStrategy:
|
|
100
|
+
"""The NDJSON strategy: stream every conductor signal as one framed line.
|
|
101
|
+
|
|
102
|
+
``on_start`` emits an opening frame announcing the run; ``on_signal``
|
|
103
|
+
frames each :data:`SessionSignal` (projected through ``signal_to_wire``)
|
|
104
|
+
via the shared framer; ``finish`` emits a closing frame carrying the
|
|
105
|
+
settled phase and usage, then returns the exit code. Every line is
|
|
106
|
+
separator-safe by construction (the framer escapes the two Unicode line
|
|
107
|
+
separators).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def on_start(ctx: ChannelContext) -> None:
|
|
111
|
+
ctx.out.write(ctx.framer.encode_line({"type": "signal", "name": "start", "body": {}}))
|
|
112
|
+
|
|
113
|
+
def on_signal(signal: SessionSignal, ctx: ChannelContext) -> None:
|
|
114
|
+
ctx.out.write(
|
|
115
|
+
ctx.framer.encode_line(
|
|
116
|
+
{"type": "signal", "name": signal.kind, "body": signal_to_wire(signal)}
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def finish(state: ConductorState, ctx: ChannelContext) -> int:
|
|
121
|
+
body: dict[str, Any] = {
|
|
122
|
+
"phase": state.phase,
|
|
123
|
+
"usage": message_to_dict(state.usage),
|
|
124
|
+
}
|
|
125
|
+
# TS framed `fault: undefined` away via JSON.stringify; omit None here.
|
|
126
|
+
if state.fault is not None:
|
|
127
|
+
body["fault"] = message_to_dict(state.fault)
|
|
128
|
+
ctx.out.write(ctx.framer.encode_line({"type": "signal", "name": "end", "body": body}))
|
|
129
|
+
return _exit_code_for(state)
|
|
130
|
+
|
|
131
|
+
return OneshotStrategy(finish=finish, on_start=on_start, on_signal=on_signal)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
#: The strategy table: one entry per :data:`OneshotShape`. Selecting a shape
|
|
135
|
+
#: is looking up its row, not threading an ``if`` through the runner.
|
|
136
|
+
_STRATEGIES: Final[Mapping[OneshotShape, Any]] = {
|
|
137
|
+
"text": _text_strategy,
|
|
138
|
+
"ndjson": _ndjson_strategy,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def run_oneshot(ctx: ChannelContext, opts: OneshotRequest) -> int:
|
|
143
|
+
"""Run one non-interactive request to settlement.
|
|
144
|
+
|
|
145
|
+
Selects the :class:`OneshotStrategy` for ``opts.shape``, subscribes it to
|
|
146
|
+
the conductor signal stream, runs ``on_start``, then submits each prompt
|
|
147
|
+
in order and awaits its settlement. The state from the final prompt is
|
|
148
|
+
handed to the strategy's ``finish``, whose return value is the resolved
|
|
149
|
+
exit code. The subscription is always torn down before returning.
|
|
150
|
+
|
|
151
|
+
:param ctx: the channel context (conductor, sink, framer, dialog) — fully
|
|
152
|
+
injected
|
|
153
|
+
:param opts: the request: output shape, the prompts to run, and any images
|
|
154
|
+
:returns: the process exit code (0 on a clean settlement, 1 on a fault)
|
|
155
|
+
"""
|
|
156
|
+
strategy: OneshotStrategy = _STRATEGIES[opts.shape]()
|
|
157
|
+
|
|
158
|
+
def relay(signal: SessionSignal) -> None:
|
|
159
|
+
if strategy.on_signal is not None:
|
|
160
|
+
strategy.on_signal(signal, ctx)
|
|
161
|
+
|
|
162
|
+
unsubscribe = ctx.conductor.subscribe(relay)
|
|
163
|
+
try:
|
|
164
|
+
if strategy.on_start is not None:
|
|
165
|
+
started = strategy.on_start(ctx)
|
|
166
|
+
if inspect.isawaitable(started):
|
|
167
|
+
await started
|
|
168
|
+
|
|
169
|
+
state: ConductorState = ctx.conductor.snapshot()
|
|
170
|
+
for prompt in opts.prompts:
|
|
171
|
+
state = await ctx.conductor.submit(prompt)
|
|
172
|
+
|
|
173
|
+
finished = strategy.finish(state, ctx)
|
|
174
|
+
if inspect.isawaitable(finished):
|
|
175
|
+
finished = await finished
|
|
176
|
+
return int(finished)
|
|
177
|
+
finally:
|
|
178
|
+
unsubscribe()
|