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,585 @@
|
|
|
1
|
+
"""Channels contract — the FROZEN type surface of the non-interactive drivers
|
|
2
|
+
(port of TS ``src/channels/contract.ts``).
|
|
3
|
+
|
|
4
|
+
A *channel* is a way to talk to a
|
|
5
|
+
:class:`~induscode.conductor.contract.SessionConductor` from outside the
|
|
6
|
+
interactive terminal. Two channels share this contract:
|
|
7
|
+
|
|
8
|
+
- the **oneshot** channel runs a single request to settlement and writes the
|
|
9
|
+
result to a stream (clean text, or a streamed NDJSON event log);
|
|
10
|
+
- the **link** channel is a long-lived, bidirectional JSON-RPC 2.0 server plus
|
|
11
|
+
a typed driver (client) that drives a child process over NDJSON.
|
|
12
|
+
|
|
13
|
+
This module declares *only* shapes and a handful of inert, pure helpers — no
|
|
14
|
+
I/O, no process plumbing, no dispatch. The server, the driver, the framer, and
|
|
15
|
+
the oneshot runner are each written against the names declared here, so the
|
|
16
|
+
file is intentionally small, append-mostly, and stable.
|
|
17
|
+
|
|
18
|
+
Design stance (ported):
|
|
19
|
+
|
|
20
|
+
- The protocol is **one declarative operation registry**, not a hand-written
|
|
21
|
+
dispatch ladder mirrored by hand-written client methods. An :class:`Op`
|
|
22
|
+
pairs a wire ``method`` name with a typed ``handle``; an :data:`OpRegistry`
|
|
23
|
+
(the result of :func:`define_ops`) is consumed by *both* the server
|
|
24
|
+
(data-driven dispatch) and the link driver (a generated client whose method
|
|
25
|
+
set is derived from the same registry).
|
|
26
|
+
- The wire envelope is **JSON-RPC 2.0**: a request carries
|
|
27
|
+
``{jsonrpc, id?, method, params?}``, a reply carries ``{jsonrpc, id,
|
|
28
|
+
result}`` or ``{jsonrpc, id, error}``. Decoded frames are plain mappings
|
|
29
|
+
(what :func:`json.loads` yields); :func:`is_reply_ok` discriminates the
|
|
30
|
+
reply arms and :data:`OP_ERROR` pins the error codes.
|
|
31
|
+
- Framing is **NDJSON**, one JSON value per line. The framer (the
|
|
32
|
+
``encode_line`` / ``decode_lines`` pair bundled as :class:`NdjsonFramer`)
|
|
33
|
+
is correct by construction: it escapes the two line separators (U+2028,
|
|
34
|
+
U+2029) that are valid inside a JSON string but break a naive line
|
|
35
|
+
splitter, and it pulls lines with an async generator rather than an event
|
|
36
|
+
callback.
|
|
37
|
+
- The transport is **injectable**. A :class:`ChannelContext` carries the
|
|
38
|
+
conductor plus the streams and the dialog primitives, so tests drive the
|
|
39
|
+
channels over in-memory pipes with no real stdio.
|
|
40
|
+
|
|
41
|
+
Port notes
|
|
42
|
+
----------
|
|
43
|
+
- TS wire-frame interfaces (``OpRequest`` / ``ReplyOk`` / ``ReplyErr``) were
|
|
44
|
+
structural views over plain parsed objects; in Python the decoded frames
|
|
45
|
+
*are* plain dicts, so the envelope is documented here (and pinned by
|
|
46
|
+
:data:`OP_ERROR` / :data:`PROTOCOL_VERSION` / :func:`is_reply_ok`) while the
|
|
47
|
+
consumer-facing frames that cross Python API boundaries (:class:`Ask`,
|
|
48
|
+
:class:`AskAnswer`, :class:`Tell`, :class:`Signal`, :class:`OpError`) stay
|
|
49
|
+
typed frozen dataclasses.
|
|
50
|
+
- Wire field names/casing are kept **verbatim** from TS (``jsonrpc``,
|
|
51
|
+
``method``, ``params``, ``result``, ``error``, ``sessionId``,
|
|
52
|
+
``autoCondense``, ``messageCount``, ``queuedCount``, …) for cross-host
|
|
53
|
+
compatibility — a TS driver must be able to speak to a Python server and
|
|
54
|
+
vice versa. :data:`LINK_SNAPSHOT_FIELDS` pins the snapshot vocabulary.
|
|
55
|
+
- ``signal_to_wire`` is a Python-only helper: TS ``JSON.stringify`` serialized
|
|
56
|
+
a :data:`SessionSignal` verbatim, but the Python signal dataclasses carry
|
|
57
|
+
their ``kind`` tag as a ``ClassVar`` which the one app-wide codec
|
|
58
|
+
(:func:`~induscode.conductor.serialize.message_to_dict`, plan rule 2) does
|
|
59
|
+
not emit — this helper re-attaches it. No second serializer is introduced.
|
|
60
|
+
- Timings stay in **milliseconds** with the TS camelCase field names; asyncio
|
|
61
|
+
call sites divide by 1000.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping
|
|
67
|
+
from dataclasses import dataclass, replace
|
|
68
|
+
from types import MappingProxyType
|
|
69
|
+
from typing import Any, ClassVar, Final, Literal, Protocol, TypeAlias, TypeVar
|
|
70
|
+
|
|
71
|
+
from induscode.conductor.contract import (
|
|
72
|
+
ConductorState,
|
|
73
|
+
SessionConductor,
|
|
74
|
+
SessionSignal,
|
|
75
|
+
ThinkingLevel,
|
|
76
|
+
Usage,
|
|
77
|
+
)
|
|
78
|
+
from induscode.conductor.serialize import message_to_dict
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"Ask",
|
|
82
|
+
"AskAnswer",
|
|
83
|
+
"ChannelContext",
|
|
84
|
+
"ChannelTimings",
|
|
85
|
+
"ConductorState",
|
|
86
|
+
"DEFAULT_CHANNEL_TIMINGS",
|
|
87
|
+
"DialogBridge",
|
|
88
|
+
"LINK_SNAPSHOT_FIELDS",
|
|
89
|
+
"LinkSnapshot",
|
|
90
|
+
"NdjsonFramer",
|
|
91
|
+
"OP_ERROR",
|
|
92
|
+
"OneshotRequest",
|
|
93
|
+
"OneshotShape",
|
|
94
|
+
"OneshotStrategy",
|
|
95
|
+
"Op",
|
|
96
|
+
"OpError",
|
|
97
|
+
"OpRegistry",
|
|
98
|
+
"PROTOCOL_VERSION",
|
|
99
|
+
"REQUEST_ID_PREFIX",
|
|
100
|
+
"ReadableChunks",
|
|
101
|
+
"RequestId",
|
|
102
|
+
"SessionConductor",
|
|
103
|
+
"Signal",
|
|
104
|
+
"Tell",
|
|
105
|
+
"ThinkingLevel",
|
|
106
|
+
"Usage",
|
|
107
|
+
"WritableLine",
|
|
108
|
+
"define_ops",
|
|
109
|
+
"is_reply_ok",
|
|
110
|
+
"mint_wire_id",
|
|
111
|
+
"resolve_timings",
|
|
112
|
+
"signal_to_wire",
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
_T = TypeVar("_T")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# JSON-RPC 2.0 envelope
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
#: The protocol version literal stamped on every framed envelope.
|
|
123
|
+
PROTOCOL_VERSION: Final[str] = "2.0"
|
|
124
|
+
|
|
125
|
+
#: The id correlating a request to its reply.
|
|
126
|
+
#:
|
|
127
|
+
#: A request with an id expects exactly one reply bearing the same id; a
|
|
128
|
+
#: request without an id is a notification and is never replied to. Numeric
|
|
129
|
+
#: ids are accepted on the wire but the driver mints string ids.
|
|
130
|
+
RequestId: TypeAlias = str | int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class OpError:
|
|
135
|
+
"""A typed error returned by a failed operation.
|
|
136
|
+
|
|
137
|
+
The ``code`` is a small integer category (negative values follow the
|
|
138
|
+
JSON-RPC reserved ranges); ``message`` is a single-line human summary; the
|
|
139
|
+
optional ``data`` carries structured detail for logging without parsing
|
|
140
|
+
the message. On the wire this frames as ``{"code", "message", "data"?}``.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
#: Numeric error category.
|
|
144
|
+
code: int
|
|
145
|
+
#: Human-readable, single-line summary.
|
|
146
|
+
message: str
|
|
147
|
+
#: Optional structured detail.
|
|
148
|
+
data: Any = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_reply_ok(reply: Mapping[str, Any]) -> bool:
|
|
152
|
+
"""Whether a decoded reply frame is the success arm (``result`` present).
|
|
153
|
+
|
|
154
|
+
The reply envelope is either ``{jsonrpc, id, result}`` or ``{jsonrpc, id,
|
|
155
|
+
error}``; this discriminates by presence of the ``result`` key — the same
|
|
156
|
+
rule the TS ``isReplyOk`` guard applied.
|
|
157
|
+
"""
|
|
158
|
+
return "result" in reply
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
#: The closed set of error codes the channels mint (JSON-RPC reserved values,
|
|
162
|
+
#: exact — cross-cutting plan rule 8).
|
|
163
|
+
OP_ERROR: Final[Mapping[str, int]] = MappingProxyType(
|
|
164
|
+
{
|
|
165
|
+
# The framed line was not valid JSON.
|
|
166
|
+
"parse": -32700,
|
|
167
|
+
# The frame was not a well-formed request.
|
|
168
|
+
"invalidRequest": -32600,
|
|
169
|
+
# No operation is registered under the requested `method`.
|
|
170
|
+
"unknownOp": -32601,
|
|
171
|
+
# The `params` failed the operation schema.
|
|
172
|
+
"invalidParams": -32602,
|
|
173
|
+
# The operation handler threw.
|
|
174
|
+
"handlerFailed": -32000,
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Streams & transport
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class WritableLine(Protocol):
|
|
185
|
+
"""The minimal writable surface a channel emits onto.
|
|
186
|
+
|
|
187
|
+
Pinning the dependency to this one method (rather than an asyncio stream
|
|
188
|
+
writer) is what lets a test capture output into a list while production
|
|
189
|
+
passes a real stdout adapter. (The TS optional drain callback is dropped —
|
|
190
|
+
asyncio writers expose no such hook; flushing is the embedder's concern.)
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def write(self, chunk: str) -> object:
|
|
194
|
+
"""Write one already-framed chunk."""
|
|
195
|
+
...
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
#: The minimal readable surface a channel consumes: an async-iterable of
|
|
199
|
+
#: string or byte chunks — exactly what a stdio reader and an in-memory pipe
|
|
200
|
+
#: both satisfy. The framer turns this chunk stream into a line stream;
|
|
201
|
+
#: nothing else reads it directly.
|
|
202
|
+
ReadableChunks: TypeAlias = AsyncIterable[str | bytes]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# NDJSON framer
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True, slots=True)
|
|
211
|
+
class NdjsonFramer:
|
|
212
|
+
"""The NDJSON framer pair: the two halves of the line transport.
|
|
213
|
+
|
|
214
|
+
Bundled so the server, the driver, and the oneshot channel all share one
|
|
215
|
+
correct implementation rather than re-deriving framing at each call site.
|
|
216
|
+
The concrete pair lives in :mod:`induscode.channels.framer`; an embedder
|
|
217
|
+
may inject an alternate framer through the driver / server options.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
#: Serialize one value to a newline-terminated, separator-safe line.
|
|
221
|
+
encode_line: Callable[[Any], str]
|
|
222
|
+
#: Pull parsed values from a chunk stream, one per ``\n`` (an async
|
|
223
|
+
#: generator function over a :data:`ReadableChunks`).
|
|
224
|
+
decode_lines: Callable[[ReadableChunks], Any]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Dialog primitives (ask / tell)
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True, slots=True)
|
|
233
|
+
class Ask:
|
|
234
|
+
"""A blocking request from the agent to whoever is driving the channel.
|
|
235
|
+
|
|
236
|
+
``ask`` is the round-trip dialog primitive: the server emits an ``ask``
|
|
237
|
+
frame and suspends until a matching answer arrives (or the deadline
|
|
238
|
+
lapses). The ``kind`` names the interaction (a choice, a confirm, a
|
|
239
|
+
free-text prompt, an editor session); ``payload`` carries the
|
|
240
|
+
kind-specific options; the ``id`` correlates the eventual answer. Wire
|
|
241
|
+
form: ``{"type": "ask", "id", "kind", "payload"?}``.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
#: Frame discriminant on the wire.
|
|
245
|
+
type: ClassVar[Literal["ask"]] = "ask"
|
|
246
|
+
#: Correlation id for the answer.
|
|
247
|
+
id: str
|
|
248
|
+
#: The interaction kind (e.g. ``"select"``, ``"confirm"``, ``"input"``).
|
|
249
|
+
kind: str
|
|
250
|
+
#: Kind-specific options for the interaction.
|
|
251
|
+
payload: Any = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True, slots=True)
|
|
255
|
+
class AskAnswer:
|
|
256
|
+
"""The answer to an :class:`Ask`, correlated by its ``id``. Wire form:
|
|
257
|
+
``{"type": "answer", "id", "value"}``."""
|
|
258
|
+
|
|
259
|
+
#: Frame discriminant on the wire.
|
|
260
|
+
type: ClassVar[Literal["answer"]] = "answer"
|
|
261
|
+
#: The id of the :class:`Ask` this answers.
|
|
262
|
+
id: str
|
|
263
|
+
#: The supplied value (kind-specific; ``None`` when dismissed).
|
|
264
|
+
value: Any = None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True, slots=True)
|
|
268
|
+
class Tell:
|
|
269
|
+
"""A one-way notice from the agent to the driver — no answer expected.
|
|
270
|
+
|
|
271
|
+
``tell`` is the fire-and-forget dialog primitive: status updates, a
|
|
272
|
+
flashed notification, a title change. Wire form: ``{"type": "tell",
|
|
273
|
+
"kind", "payload"?}``.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
#: Frame discriminant on the wire.
|
|
277
|
+
type: ClassVar[Literal["tell"]] = "tell"
|
|
278
|
+
#: The notice kind (e.g. ``"notify"``, ``"status"``, ``"title"``).
|
|
279
|
+
kind: str
|
|
280
|
+
#: Kind-specific detail.
|
|
281
|
+
payload: Any = None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class DialogBridge(Protocol):
|
|
285
|
+
"""The dialog seam a channel exposes to the agent/extension layer.
|
|
286
|
+
|
|
287
|
+
One generic round-trip primitive (:meth:`ask`) plus one fire-and-forget
|
|
288
|
+
primitive (:meth:`tell`); every concrete dialog method (select, confirm,
|
|
289
|
+
input, notify, set-status, …) is expressed in terms of these two, so there
|
|
290
|
+
is no per-method choreography to repeat.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
async def ask(self, kind: str, payload: Any, fallback: _T) -> _T:
|
|
294
|
+
"""Emit an :class:`Ask` and resolve with the matching answer value.
|
|
295
|
+
|
|
296
|
+
Resolves with ``fallback`` if no answer arrives before the deadline,
|
|
297
|
+
so a disconnected or non-interactive driver never wedges the agent.
|
|
298
|
+
|
|
299
|
+
:param kind: the interaction kind
|
|
300
|
+
:param payload: kind-specific options
|
|
301
|
+
:param fallback: value to resolve with on timeout / dismissal
|
|
302
|
+
"""
|
|
303
|
+
...
|
|
304
|
+
|
|
305
|
+
def tell(self, kind: str, payload: Any = None) -> None:
|
|
306
|
+
"""Emit a :class:`Tell` and return immediately.
|
|
307
|
+
|
|
308
|
+
:param kind: the notice kind
|
|
309
|
+
:param payload: kind-specific detail
|
|
310
|
+
"""
|
|
311
|
+
...
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
# Channel context
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass(frozen=True, slots=True)
|
|
320
|
+
class ChannelContext:
|
|
321
|
+
"""The execution context handed to every :attr:`Op.handle` and shared by
|
|
322
|
+
both channels.
|
|
323
|
+
|
|
324
|
+
It bundles the :class:`SessionConductor` an operation delegates to, the
|
|
325
|
+
framed transport (``out`` to emit, ``framer`` to encode), and the
|
|
326
|
+
:class:`DialogBridge` for blocking/fire-and-forget interaction. Everything
|
|
327
|
+
is injected, so a test supplies a fake conductor, a list-backed
|
|
328
|
+
:class:`WritableLine`, and an in-memory dialog bridge with no real process
|
|
329
|
+
attached.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
#: The session this channel drives; every op delegates to it.
|
|
333
|
+
conductor: SessionConductor
|
|
334
|
+
#: The framed output sink for replies, signals, and dialog frames.
|
|
335
|
+
out: WritableLine
|
|
336
|
+
#: The shared NDJSON framer.
|
|
337
|
+
framer: NdjsonFramer
|
|
338
|
+
#: The dialog round-trip / notice bridge.
|
|
339
|
+
dialog: DialogBridge
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Operation registry
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass(frozen=True, slots=True)
|
|
348
|
+
class Op:
|
|
349
|
+
"""A single named operation in the link protocol.
|
|
350
|
+
|
|
351
|
+
An op binds a wire ``method`` name to a typed ``handle``: given the
|
|
352
|
+
request ``params`` (the decoded wire payload — ``None`` when the op takes
|
|
353
|
+
none) and the :class:`ChannelContext`, it produces a result. The server
|
|
354
|
+
invokes ``handle`` on a matching request and frames its resolved value
|
|
355
|
+
into a reply; the link driver exposes a method of the same name that
|
|
356
|
+
round-trips params to result. One declaration drives both halves.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
#: The wire method name; the registry key and the driver method name.
|
|
360
|
+
method: str
|
|
361
|
+
#: Run the operation against the live session.
|
|
362
|
+
handle: Callable[[Any, ChannelContext], Awaitable[Any]]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
#: A map of operation name to its :class:`Op` declaration — what
|
|
366
|
+
#: :func:`define_ops` produces and what both the server and the link driver
|
|
367
|
+
#: consume. Keyed by the same string used as each op's ``method``, so dispatch
|
|
368
|
+
#: is a single lookup and the driver's method set is exactly the key set.
|
|
369
|
+
OpRegistry: TypeAlias = Mapping[str, Op]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def define_ops(ops: Mapping[str, Op]) -> OpRegistry:
|
|
373
|
+
"""Freeze a set of operation declarations into an :data:`OpRegistry`.
|
|
374
|
+
|
|
375
|
+
The lone sanctioned way to mint a registry, so the dispatch map and the
|
|
376
|
+
driver method set always derive from one frozen source. Each entry's key
|
|
377
|
+
is the wire method name; the value is the :class:`Op`. The result is a
|
|
378
|
+
read-only mapping so neither half can mutate the protocol at runtime.
|
|
379
|
+
|
|
380
|
+
:param ops: a record of method-name → :class:`Op`
|
|
381
|
+
:returns: the frozen registry
|
|
382
|
+
"""
|
|
383
|
+
return MappingProxyType(dict(ops))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Session-state projection
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
#: The session-state projection sent over the link.
|
|
391
|
+
#:
|
|
392
|
+
#: A flat, serializable snapshot of everything a driver needs to mirror the
|
|
393
|
+
#: session without holding a live conductor: which model is bound, the
|
|
394
|
+
#: reasoning effort, the busy flags, the persisted location, and the queue
|
|
395
|
+
#: depth. It is the link's own vocabulary — derived from the conductor's
|
|
396
|
+
#: :class:`ConductorState` but shaped for the wire, not a passthrough of the
|
|
397
|
+
#: internal state object. On the wire (and in Python) it is a plain dict whose
|
|
398
|
+
#: keys are exactly :data:`LINK_SNAPSHOT_FIELDS`, produced by
|
|
399
|
+
#: :func:`induscode.channels.session_ops.project_snapshot`.
|
|
400
|
+
LinkSnapshot: TypeAlias = dict[str, Any]
|
|
401
|
+
|
|
402
|
+
#: The verbatim TS wire field names of a :data:`LinkSnapshot`, pinned for
|
|
403
|
+
#: cross-host compatibility (``sessionFile`` is optional and absent when the
|
|
404
|
+
#: session is not persisted).
|
|
405
|
+
LINK_SNAPSHOT_FIELDS: Final[tuple[str, ...]] = (
|
|
406
|
+
"model",
|
|
407
|
+
"thinking",
|
|
408
|
+
"streaming",
|
|
409
|
+
"condensing",
|
|
410
|
+
"faulted",
|
|
411
|
+
"sessionId",
|
|
412
|
+
"sessionFile",
|
|
413
|
+
"autoCondense",
|
|
414
|
+
"messageCount",
|
|
415
|
+
"queuedCount",
|
|
416
|
+
"usage",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
# Signals
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@dataclass(frozen=True, slots=True)
|
|
426
|
+
class Signal:
|
|
427
|
+
"""An uncorrelated event frame streamed from server to driver.
|
|
428
|
+
|
|
429
|
+
Distinct from a reply (which answers a specific request): a signal is
|
|
430
|
+
pushed as the turn progresses and carries no ``id``. The driver fans
|
|
431
|
+
signals out to its listener; the oneshot NDJSON shape writes them straight
|
|
432
|
+
to the sink. Wire form: ``{"type": "signal", "name", "body"}``.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
#: Frame discriminant on the wire.
|
|
436
|
+
type: ClassVar[Literal["signal"]] = "signal"
|
|
437
|
+
#: The signal name (the conductor signal kind, projected to the wire).
|
|
438
|
+
name: str
|
|
439
|
+
#: The signal's payload.
|
|
440
|
+
body: Any = None
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def signal_to_wire(signal: SessionSignal) -> dict[str, Any]:
|
|
444
|
+
"""Project a conductor :data:`SessionSignal` to its wire dict.
|
|
445
|
+
|
|
446
|
+
TS serialized the signal object verbatim (its ``kind`` is a plain
|
|
447
|
+
property); the Python signal dataclasses carry ``kind`` as a ``ClassVar``
|
|
448
|
+
which the one app-wide codec
|
|
449
|
+
(:func:`~induscode.conductor.serialize.message_to_dict`) does not emit, so
|
|
450
|
+
this helper re-attaches the discriminant. The field payload still flows
|
|
451
|
+
through that codec — no second serializer (plan rule 2).
|
|
452
|
+
"""
|
|
453
|
+
body = message_to_dict(signal)
|
|
454
|
+
wire: dict[str, Any] = {"kind": signal.kind}
|
|
455
|
+
if isinstance(body, Mapping):
|
|
456
|
+
for key, value in body.items():
|
|
457
|
+
wire[key] = value
|
|
458
|
+
return wire
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
# Oneshot channel
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
#: The two output shapes the oneshot channel can produce.
|
|
466
|
+
OneshotShape: TypeAlias = Literal["text", "ndjson"]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@dataclass(frozen=True, slots=True)
|
|
470
|
+
class OneshotRequest:
|
|
471
|
+
"""The request the oneshot channel runs.
|
|
472
|
+
|
|
473
|
+
One or more prompts run sequentially to settlement; ``images`` ride along
|
|
474
|
+
with the first. ``shape`` selects between clean final text and a streamed
|
|
475
|
+
NDJSON event log.
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
#: Output shape: clean final text, or a streamed NDJSON event log.
|
|
479
|
+
shape: OneshotShape
|
|
480
|
+
#: The prompts to run, in order.
|
|
481
|
+
prompts: tuple[str, ...]
|
|
482
|
+
#: Images attached to the first prompt, if any (framework
|
|
483
|
+
#: ``indusagi.ai.ImageContent`` values).
|
|
484
|
+
images: tuple[Any, ...] | None = None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@dataclass(frozen=True, slots=True)
|
|
488
|
+
class OneshotStrategy:
|
|
489
|
+
"""A pluggable per-shape strategy for the oneshot channel.
|
|
490
|
+
|
|
491
|
+
Each shape (:data:`OneshotShape`) supplies one of these: ``on_start`` runs
|
|
492
|
+
once before the prompts (e.g. emit a header line for NDJSON),
|
|
493
|
+
``on_signal`` reacts to each conductor signal (NDJSON emits; text
|
|
494
|
+
ignores), and ``finish`` turns the settled state into the process exit
|
|
495
|
+
code (0 ok, 1 on fault). Selecting a shape is choosing a strategy object —
|
|
496
|
+
there are no shape branches sprinkled through the runner body. ``on_start``
|
|
497
|
+
and ``finish`` may be sync or return an awaitable (the TS
|
|
498
|
+
``void | Promise<void>`` / ``number | Promise<number>`` latitude).
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
#: Produce the exit code from the settled state once all prompts resolve.
|
|
502
|
+
finish: Callable[[ConductorState, ChannelContext], Any]
|
|
503
|
+
#: Run once before any prompt is submitted.
|
|
504
|
+
on_start: Callable[[ChannelContext], Any] | None = None
|
|
505
|
+
#: React to one streamed signal as the turn progresses.
|
|
506
|
+
on_signal: Callable[[SessionSignal, ChannelContext], None] | None = None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
# Driver / server configuration
|
|
511
|
+
# ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
#: The request-id prefix the driver stamps on outgoing requests.
|
|
514
|
+
REQUEST_ID_PREFIX: Final[str] = "lnk-"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@dataclass(frozen=True, slots=True)
|
|
518
|
+
class ChannelTimings:
|
|
519
|
+
"""Timeouts and intervals the channels need, sourced from config rather
|
|
520
|
+
than hard-coded at the call sites.
|
|
521
|
+
|
|
522
|
+
Every duration is in **milliseconds** (field names and units verbatim from
|
|
523
|
+
TS; asyncio call sites divide by 1000). Defaults are supplied by
|
|
524
|
+
:data:`DEFAULT_CHANNEL_TIMINGS`; an embedder may override any of them when
|
|
525
|
+
it constructs a driver or server.
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
#: How long to wait for a spawned child to report ready before failing.
|
|
529
|
+
startupMs: int
|
|
530
|
+
#: Grace period after a soft terminate before a hard kill.
|
|
531
|
+
shutdownMs: int
|
|
532
|
+
#: How long a sent request waits for its reply before rejecting.
|
|
533
|
+
requestMs: int
|
|
534
|
+
#: How long :meth:`DialogBridge.ask` waits before resolving its fallback.
|
|
535
|
+
dialogMs: int
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
#: The default :class:`ChannelTimings` — deliberately distinct values, owned
|
|
539
|
+
#: here and overridable, not a copied constant set scattered across runners.
|
|
540
|
+
DEFAULT_CHANNEL_TIMINGS: Final[ChannelTimings] = ChannelTimings(
|
|
541
|
+
startupMs=1_500,
|
|
542
|
+
shutdownMs=1_200,
|
|
543
|
+
requestMs=45_000,
|
|
544
|
+
dialogMs=90_000,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def resolve_timings(overrides: Mapping[str, int] | None = None) -> ChannelTimings:
|
|
549
|
+
"""Merge partial timing overrides onto the frozen defaults.
|
|
550
|
+
|
|
551
|
+
(TS kept a private copy of this in both the server and the driver; the
|
|
552
|
+
Python port hosts the one merge here.)
|
|
553
|
+
|
|
554
|
+
:param overrides: any subset of :class:`ChannelTimings` field names
|
|
555
|
+
"""
|
|
556
|
+
if overrides is None or len(overrides) == 0:
|
|
557
|
+
return DEFAULT_CHANNEL_TIMINGS
|
|
558
|
+
return replace(DEFAULT_CHANNEL_TIMINGS, **dict(overrides))
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
# Id minting
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
_BASE36_DIGITS: Final[str] = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _base36(n: int) -> str:
|
|
569
|
+
"""Lowercase base-36 rendering of a non-negative int (TS ``toString(36)``)."""
|
|
570
|
+
if n <= 0:
|
|
571
|
+
return "0"
|
|
572
|
+
out: list[str] = []
|
|
573
|
+
while n > 0:
|
|
574
|
+
n, r = divmod(n, 36)
|
|
575
|
+
out.append(_BASE36_DIGITS[r])
|
|
576
|
+
return "".join(reversed(out))
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def mint_wire_id(prefix: str, now_ms: int, seq: int) -> str:
|
|
580
|
+
"""Mint a process-unique correlation id: ``<prefix><now36>-<seq36>``.
|
|
581
|
+
|
|
582
|
+
The same shape the TS driver (``lnk-…``) and dialog bridge (``ask-…``)
|
|
583
|
+
minted with ``Date.now().toString(36)`` + a sequence counter.
|
|
584
|
+
"""
|
|
585
|
+
return f"{prefix}{_base36(now_ms)}-{_base36(seq)}"
|