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,382 @@
|
|
|
1
|
+
"""signal-hub — the conductor's product-event hub + framework-event translator.
|
|
2
|
+
|
|
3
|
+
Two cooperating pieces sitting between the framework ``Agent`` loop and the
|
|
4
|
+
conductor's consumers:
|
|
5
|
+
|
|
6
|
+
- :class:`SignalHub` — a synchronous, insertion-ordered fan-out bus over the
|
|
7
|
+
:data:`SessionSignal` stream. Behavior modules emit into it; UI/print/RPC
|
|
8
|
+
consumers subscribe to it.
|
|
9
|
+
- :func:`translate_agent_event` — the pure projection that turns a single
|
|
10
|
+
framework ``AgentEvent`` into the zero-or-more :data:`SessionSignal` values
|
|
11
|
+
that flow through the hub. :data:`TRANSLATOR_TABLE` is the underlying
|
|
12
|
+
dispatch table (exported for inspection/testing).
|
|
13
|
+
|
|
14
|
+
Typical wiring: subscribe the framework loop, run each raw event through
|
|
15
|
+
``translate_agent_event``, and ``emit`` the results on a :class:`SignalHub`.
|
|
16
|
+
|
|
17
|
+
Hub design (deliberately the conductor's own, not the framework's loop
|
|
18
|
+
emitter; ported from TS ``src/conductor/signal-hub/hub.ts``):
|
|
19
|
+
|
|
20
|
+
- Handlers live in an insertion-ordered registry (a ``dict`` keyed by the
|
|
21
|
+
handler — Python's ordered-``dict`` stands in for the TS insertion-ordered
|
|
22
|
+
``Set``), so registration order is the delivery order and duplicate handler
|
|
23
|
+
identities collapse to one slot.
|
|
24
|
+
- :meth:`SignalHub.subscribe` returns an unsubscribe thunk; calling it (even
|
|
25
|
+
during a dispatch, even more than once) is safe and idempotent.
|
|
26
|
+
- :meth:`SignalHub.emit` fans out over a *snapshot* of the handler registry,
|
|
27
|
+
so a handler that subscribes or unsubscribes mid-dispatch never corrupts
|
|
28
|
+
the in-progress iteration — the change takes effect on the next emit.
|
|
29
|
+
- A throwing handler is isolated: its error is routed to the optional
|
|
30
|
+
``on_handler_error`` sink and the remaining handlers still run. One bad
|
|
31
|
+
consumer never silences the others.
|
|
32
|
+
|
|
33
|
+
Translator design (ported from TS ``src/conductor/signal-hub/translate.ts``):
|
|
34
|
+
|
|
35
|
+
- The mapping is keyed on the framework event's ``type`` discriminant, so it
|
|
36
|
+
is expressed as a ``dict[str, Callable]`` — one entry per framework event
|
|
37
|
+
tag. TS enforced exhaustiveness with a mapped type at compile time; the
|
|
38
|
+
Python port enforces it with a key-coverage test asserting the table's
|
|
39
|
+
keys equal the tags of every ``AgentEvent`` union member (exhaustiveness
|
|
40
|
+
moved compiler → test).
|
|
41
|
+
- Each rule is a pure, side-effect-free function over a single event.
|
|
42
|
+
- ``MessageUpdateEvent.assistantMessageEvent`` is a **best-effort dict** in
|
|
43
|
+
the Python framework (a small mapping such as
|
|
44
|
+
``{"type": "text_delta", "delta": ...}``), so the inner streaming dispatch
|
|
45
|
+
reads keys defensively — and every other payload probe accepts both
|
|
46
|
+
mapping- and attribute-shaped values, since the live agent passes frozen
|
|
47
|
+
dataclass messages while tests may pass plain dicts.
|
|
48
|
+
|
|
49
|
+
Mapping rules (framework ``type`` → product signals):
|
|
50
|
+
|
|
51
|
+
- ``agent_start`` / ``turn_start`` / ``message_start`` / ``agent_end``
|
|
52
|
+
→ ``[]`` (loop bookkeeping; not product-visible)
|
|
53
|
+
- ``message_end`` → one ``prompt`` for a *user* message (the turn
|
|
54
|
+
is now committed), one ``fault`` for an errored *assistant* message,
|
|
55
|
+
else ``[]``
|
|
56
|
+
- ``tool_execution_start`` → one ``tool_start`` (id, name)
|
|
57
|
+
- ``tool_execution_update`` → ``[]`` (partial tool progress not surfaced)
|
|
58
|
+
- ``tool_execution_end`` → one ``tool_end`` (id, ok = not isError)
|
|
59
|
+
- ``turn_end`` → one ``turn_end`` (usage)
|
|
60
|
+
- ``message_update`` → delegated to the inner streaming dispatch on
|
|
61
|
+
``assistantMessageEvent["type"]``:
|
|
62
|
+
``text_delta`` → ``text``; ``thinking_delta`` → ``thinking``;
|
|
63
|
+
``error`` → ``fault`` (model); everything else → ``[]``
|
|
64
|
+
|
|
65
|
+
Faults: a streaming ``error`` sub-event becomes a typed ``model`` fault. The
|
|
66
|
+
conductor layers its own ``aborted`` / ``tool`` / ``persistence`` /
|
|
67
|
+
``overflow`` faults elsewhere; the translator only mints the one fault the
|
|
68
|
+
framework stream actually carries.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
74
|
+
from typing import Any
|
|
75
|
+
|
|
76
|
+
from indusagi.agent import AgentEvent
|
|
77
|
+
from indusagi.ai import Usage, create_zero_usage
|
|
78
|
+
|
|
79
|
+
from .contract import (
|
|
80
|
+
FaultSignal,
|
|
81
|
+
PromptSignal,
|
|
82
|
+
SessionSignal,
|
|
83
|
+
SignalHandler,
|
|
84
|
+
TextSignal,
|
|
85
|
+
ThinkingSignal,
|
|
86
|
+
ToolEndSignal,
|
|
87
|
+
ToolStartSignal,
|
|
88
|
+
TurnEndSignal,
|
|
89
|
+
conductor_fault,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"SignalHub",
|
|
94
|
+
"TRANSLATOR_TABLE",
|
|
95
|
+
"translate_agent_event",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# SignalHub — the synchronous fan-out bus
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SignalHub:
|
|
105
|
+
"""A synchronous fan-out bus for :data:`SessionSignal` values.
|
|
106
|
+
|
|
107
|
+
Construct one per session; subscribe consumers; emit signals. Not a
|
|
108
|
+
framework type and not wired to one — purely the conductor's own event
|
|
109
|
+
surface.
|
|
110
|
+
|
|
111
|
+
:param on_handler_error: invoked when a subscribed handler raises during
|
|
112
|
+
:meth:`emit` — lets the owner observe/log faults in consumer code
|
|
113
|
+
without aborting the fan-out. If omitted, handler errors are
|
|
114
|
+
swallowed so a misbehaving consumer cannot break signal delivery to
|
|
115
|
+
the others. This sink is itself guarded — if it raises, the raise is
|
|
116
|
+
ignored. (The TS ``SignalHubOptions`` bag collapses to this single
|
|
117
|
+
keyword argument.)
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
on_handler_error: Callable[[Exception, SessionSignal], None] | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
# Insertion-ordered live registry of subscribed handlers. A dict
|
|
126
|
+
# keyed by the handler (values unused) — the Python stand-in for the
|
|
127
|
+
# TS insertion-ordered Set: registration order is delivery order and
|
|
128
|
+
# duplicate identities collapse to one slot.
|
|
129
|
+
self._handlers: dict[SignalHandler, None] = {}
|
|
130
|
+
self._on_handler_error = on_handler_error
|
|
131
|
+
|
|
132
|
+
def subscribe(self, handler: SignalHandler) -> Callable[[], None]:
|
|
133
|
+
"""Register a handler to receive every subsequently emitted signal.
|
|
134
|
+
|
|
135
|
+
Registering the same function reference twice is a no-op (the
|
|
136
|
+
registry collapses it). The returned thunk removes the handler; it is
|
|
137
|
+
idempotent and safe to call during a dispatch.
|
|
138
|
+
|
|
139
|
+
:param handler: the consumer callback
|
|
140
|
+
:returns: an unsubscribe function
|
|
141
|
+
"""
|
|
142
|
+
self._handlers[handler] = None
|
|
143
|
+
active = True
|
|
144
|
+
|
|
145
|
+
def unsubscribe() -> None:
|
|
146
|
+
nonlocal active
|
|
147
|
+
if not active:
|
|
148
|
+
return
|
|
149
|
+
active = False
|
|
150
|
+
self._handlers.pop(handler, None)
|
|
151
|
+
|
|
152
|
+
return unsubscribe
|
|
153
|
+
|
|
154
|
+
def emit(self, signal: SessionSignal) -> None:
|
|
155
|
+
"""Fan a signal out to every currently-subscribed handler, in
|
|
156
|
+
registration order.
|
|
157
|
+
|
|
158
|
+
Iterates a snapshot of the handler registry, so subscribe/unsubscribe
|
|
159
|
+
performed by a handler mid-dispatch affects only later emits. A
|
|
160
|
+
throwing handler is isolated via the ``on_handler_error`` sink; the
|
|
161
|
+
remaining handlers still run.
|
|
162
|
+
|
|
163
|
+
:param signal: the signal to deliver
|
|
164
|
+
"""
|
|
165
|
+
# Snapshot so concurrent (re-entrant) (un)subscribes don't disturb
|
|
166
|
+
# this pass.
|
|
167
|
+
for handler in list(self._handlers):
|
|
168
|
+
try:
|
|
169
|
+
handler(signal)
|
|
170
|
+
except Exception as error: # noqa: BLE001 — isolation is the point
|
|
171
|
+
self._report(error, signal)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def size(self) -> int:
|
|
175
|
+
"""Number of currently-subscribed handlers."""
|
|
176
|
+
return len(self._handlers)
|
|
177
|
+
|
|
178
|
+
def clear(self) -> None:
|
|
179
|
+
"""Remove every handler. Subsequent :meth:`emit` calls deliver to no
|
|
180
|
+
one."""
|
|
181
|
+
self._handlers.clear()
|
|
182
|
+
|
|
183
|
+
def _report(self, error: Exception, signal: SessionSignal) -> None:
|
|
184
|
+
"""Route a handler error to the sink, guarding the sink itself."""
|
|
185
|
+
if self._on_handler_error is None:
|
|
186
|
+
return
|
|
187
|
+
try:
|
|
188
|
+
self._on_handler_error(error, signal)
|
|
189
|
+
except Exception: # noqa: BLE001
|
|
190
|
+
# A throwing error-sink must not break the fan-out.
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# translate_agent_event — the framework-event → SessionSignal projector
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _field(obj: Any, name: str, default: Any = None) -> Any:
|
|
200
|
+
"""Defensive payload probe: read ``name`` off a mapping key or an
|
|
201
|
+
attribute, whichever the value carries.
|
|
202
|
+
|
|
203
|
+
The framework's events are frozen dataclasses with camelCase fields, but
|
|
204
|
+
their ``message`` / ``assistantMessageEvent`` payloads are best-effort
|
|
205
|
+
(dict-shaped in the shim, dataclass-shaped from the live loop) — and the
|
|
206
|
+
TS translator probed structurally for the same reason. Never raises on an
|
|
207
|
+
unexpected payload.
|
|
208
|
+
"""
|
|
209
|
+
if isinstance(obj, Mapping):
|
|
210
|
+
return obj.get(name, default)
|
|
211
|
+
return getattr(obj, name, default)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _usage_of(message: Any) -> Usage:
|
|
215
|
+
"""Pull the cumulative :class:`Usage` off a settled turn's message.
|
|
216
|
+
|
|
217
|
+
``turn_end.message`` is an ``AgentMessage`` (a union); only an assistant
|
|
218
|
+
message carries ``usage``. We structurally probe for it rather than
|
|
219
|
+
importing a guard, and fall back to a zero-usage value so the
|
|
220
|
+
``turn_end`` signal always carries a well-formed ``Usage``.
|
|
221
|
+
"""
|
|
222
|
+
usage = _field(message, "usage")
|
|
223
|
+
if usage is not None:
|
|
224
|
+
return usage
|
|
225
|
+
return create_zero_usage()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _prompt_text_of(content: Any) -> str:
|
|
229
|
+
"""Best-effort plain text of a user message's content.
|
|
230
|
+
|
|
231
|
+
A user ``AgentMessage`` carries its content either as a bare string or as
|
|
232
|
+
a sequence of content blocks; we want the readable text either way.
|
|
233
|
+
Blocks without a string ``text`` field (e.g. images) contribute nothing.
|
|
234
|
+
Structurally probed rather than imported so the translator stays
|
|
235
|
+
decoupled from the message shape, and never raises on an unexpected
|
|
236
|
+
payload.
|
|
237
|
+
"""
|
|
238
|
+
if isinstance(content, str):
|
|
239
|
+
return content
|
|
240
|
+
if isinstance(content, Sequence):
|
|
241
|
+
parts: list[str] = []
|
|
242
|
+
for block in content:
|
|
243
|
+
text = _field(block, "text")
|
|
244
|
+
parts.append(text if isinstance(text, str) else "")
|
|
245
|
+
return "".join(parts)
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _fault_message_of(error: Any) -> str:
|
|
250
|
+
"""Best-effort human-readable summary for a streaming-error payload."""
|
|
251
|
+
message = _field(error, "errorMessage")
|
|
252
|
+
if isinstance(message, str):
|
|
253
|
+
return message
|
|
254
|
+
return "model stream error"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _translate_stream_event(sub: Any) -> list[SessionSignal]:
|
|
258
|
+
"""Inner dispatch for ``message_update``: map the streaming
|
|
259
|
+
``AssistantMessageEvent`` sub-event to a product signal.
|
|
260
|
+
|
|
261
|
+
Keyed on the sub-event's own ``type`` discriminant — read defensively,
|
|
262
|
+
because the Python framework forwards it as a best-effort **dict** (e.g.
|
|
263
|
+
``{"type": "text_delta", "delta": ...}``). Only the three
|
|
264
|
+
product-visible sub-events (``text_delta``, ``thinking_delta``,
|
|
265
|
+
``error``) produce a signal; the lifecycle/partial sub-events
|
|
266
|
+
(``*_start``, ``*_end``, ``toolcall_*``, ``done``) are loop-internal and
|
|
267
|
+
map to ``[]``.
|
|
268
|
+
"""
|
|
269
|
+
sub_type = _field(sub, "type")
|
|
270
|
+
if sub_type == "text_delta":
|
|
271
|
+
return [TextSignal(delta=_field(sub, "delta"))]
|
|
272
|
+
if sub_type == "thinking_delta":
|
|
273
|
+
return [ThinkingSignal(delta=_field(sub, "delta"))]
|
|
274
|
+
if sub_type == "error":
|
|
275
|
+
error = _field(sub, "error")
|
|
276
|
+
return [
|
|
277
|
+
FaultSignal(fault=conductor_fault("model", _fault_message_of(error), error))
|
|
278
|
+
]
|
|
279
|
+
return []
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _none(_event: Any) -> list[SessionSignal]:
|
|
283
|
+
"""No product signal for this framework event."""
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _message_end(event: Any) -> list[SessionSignal]:
|
|
288
|
+
# Two settled-message cases are product-visible:
|
|
289
|
+
# 1. A user message_end means the just-submitted prompt has entered the
|
|
290
|
+
# conversation. Surface it as a `prompt` signal so a UI can echo the
|
|
291
|
+
# user turn the instant it is accepted — before the model's first
|
|
292
|
+
# token — and not appear to "hang" until the reply streams. (The
|
|
293
|
+
# agent appends the user message to its state on this same event, so
|
|
294
|
+
# by the time consumers react the message is already readable from
|
|
295
|
+
# `messages()`.)
|
|
296
|
+
# 2. An assistant message_end carries the failure when a call errored
|
|
297
|
+
# (e.g. a 404 for a retired model): surface it as a fault so the
|
|
298
|
+
# turn is not silently empty. The successful text itself streams via
|
|
299
|
+
# `message_update`/`text_delta`, not here.
|
|
300
|
+
message = _field(event, "message")
|
|
301
|
+
role = _field(message, "role")
|
|
302
|
+
if role == "user":
|
|
303
|
+
return [PromptSignal(text=_prompt_text_of(_field(message, "content")))]
|
|
304
|
+
error_message = _field(message, "errorMessage")
|
|
305
|
+
if role == "assistant" and (
|
|
306
|
+
_field(message, "stopReason") == "error" or isinstance(error_message, str)
|
|
307
|
+
):
|
|
308
|
+
return [
|
|
309
|
+
FaultSignal(
|
|
310
|
+
fault=conductor_fault(
|
|
311
|
+
"model",
|
|
312
|
+
error_message
|
|
313
|
+
if isinstance(error_message, str)
|
|
314
|
+
else "the model call failed",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
]
|
|
318
|
+
return []
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _turn_end(event: Any) -> list[SessionSignal]:
|
|
322
|
+
# Settled turn: surface cumulative usage.
|
|
323
|
+
return [TurnEndSignal(usage=_usage_of(_field(event, "message")))]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _message_update(event: Any) -> list[SessionSignal]:
|
|
327
|
+
# Streaming deltas + stream error: delegate to the inner dispatch.
|
|
328
|
+
return _translate_stream_event(_field(event, "assistantMessageEvent"))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _tool_execution_start(event: Any) -> list[SessionSignal]:
|
|
332
|
+
return [ToolStartSignal(id=_field(event, "toolCallId"), name=_field(event, "toolName"))]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _tool_execution_end(event: Any) -> list[SessionSignal]:
|
|
336
|
+
return [ToolEndSignal(id=_field(event, "toolCallId"), ok=not _field(event, "isError"))]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
#: The frozen mapping from each framework event tag to its product-signal
|
|
340
|
+
#: rule — exactly one rule per ``AgentEvent`` union member.
|
|
341
|
+
#:
|
|
342
|
+
#: TS declared this as a full mapped type so the compiler rejected the module
|
|
343
|
+
#: until every framework event variant had a rule; the Python port asserts
|
|
344
|
+
#: the same exhaustiveness with a key-coverage test
|
|
345
|
+
#: (``tests/conductor/test_contract_hub_skill.py``) comparing these keys to
|
|
346
|
+
#: the ``type`` tags of every ``AgentEvent`` union member.
|
|
347
|
+
#:
|
|
348
|
+
#: Exported for testing/inspection; production code calls
|
|
349
|
+
#: :func:`translate_agent_event`, which is the table applied.
|
|
350
|
+
TRANSLATOR_TABLE: dict[str, Callable[[Any], list[SessionSignal]]] = {
|
|
351
|
+
# --- Lifecycle / turn / message bookkeeping: not product-visible -------
|
|
352
|
+
"agent_start": _none,
|
|
353
|
+
"agent_end": _none,
|
|
354
|
+
"turn_start": _none,
|
|
355
|
+
"message_start": _none,
|
|
356
|
+
"message_end": _message_end,
|
|
357
|
+
# --- Settled turn: surface cumulative usage -----------------------------
|
|
358
|
+
"turn_end": _turn_end,
|
|
359
|
+
# --- Streaming deltas + stream error: delegate to the inner dispatch ----
|
|
360
|
+
"message_update": _message_update,
|
|
361
|
+
# --- Tool lifecycle ------------------------------------------------------
|
|
362
|
+
"tool_execution_start": _tool_execution_start,
|
|
363
|
+
"tool_execution_update": _none,
|
|
364
|
+
"tool_execution_end": _tool_execution_end,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def translate_agent_event(event: AgentEvent) -> list[SessionSignal]:
|
|
369
|
+
"""Translate a single framework ``AgentEvent`` into the product-level
|
|
370
|
+
:data:`SessionSignal` values it produces (zero, one, or — for future
|
|
371
|
+
fan-out — many).
|
|
372
|
+
|
|
373
|
+
Pure and total over the known event union: every framework event variant
|
|
374
|
+
has a rule (asserted by the key-coverage test), so this never raises on a
|
|
375
|
+
known event and returns ``[]`` for events that carry no product meaning.
|
|
376
|
+
|
|
377
|
+
:param event: a framework agent loop event
|
|
378
|
+
:returns: the ordered product signals derived from it
|
|
379
|
+
:raises KeyError: on an event tag outside the framework union (the TS
|
|
380
|
+
table was compiler-total; the Python port fails loud)
|
|
381
|
+
"""
|
|
382
|
+
return TRANSLATOR_TABLE[_field(event, "type")](event)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Skill-invocation block parser — an attribute-scanning hand parser.
|
|
2
|
+
|
|
3
|
+
The coding agent lets a turn carry a *skill invocation*: an XML-ish opener
|
|
4
|
+
tag that names a capability card to run, optionally locates its on-disk
|
|
5
|
+
file, and wraps a trailing user message (the body) the skill should act on.
|
|
6
|
+
The shape is the framework Agent-Skills convention::
|
|
7
|
+
|
|
8
|
+
<skill name="commit-helper" location="/abs/path/SKILL.md">
|
|
9
|
+
please tidy the staged diff and write a message
|
|
10
|
+
</skill>
|
|
11
|
+
|
|
12
|
+
This module re-derives that parse from the *format spec*, deliberately NOT
|
|
13
|
+
as one monolithic capture regex. It is a small hand-written scanner: it
|
|
14
|
+
walks the input, finds the opener, scans ``key=value`` attributes one at a
|
|
15
|
+
time (handling single- or double-quoted and bare values, with
|
|
16
|
+
``&``/``<``/``>``/``"``/``'`` entity decoding), then
|
|
17
|
+
captures the body up to the matching close tag. Reading attributes
|
|
18
|
+
incrementally — rather than matching the whole block in a single pattern —
|
|
19
|
+
is what makes this an independent implementation and lets the parser keep
|
|
20
|
+
*every* attribute the author wrote, not just a fixed ``name``/``location``
|
|
21
|
+
pair.
|
|
22
|
+
|
|
23
|
+
Block grammar (informal)::
|
|
24
|
+
|
|
25
|
+
block := WS? '<' TAG WS attrs? WS? '>' body '</' TAG WS? '>'
|
|
26
|
+
TAG := 'skill' (case-insensitive)
|
|
27
|
+
attrs := (attr WS?)*
|
|
28
|
+
attr := key ('=' value)?
|
|
29
|
+
key := [A-Za-z_][A-Za-z0-9_:.-]*
|
|
30
|
+
value := '"' ... '"' | '\\'' ... '\\'' | bare (bare stops at WS / '>')
|
|
31
|
+
body := any text, captured verbatim up to the close tag
|
|
32
|
+
|
|
33
|
+
A valid block must (a) be the leading content of the text (only leading
|
|
34
|
+
whitespace may precede the opener), (b) name the ``skill`` tag, (c) carry a
|
|
35
|
+
non-empty ``name`` attribute, and (d) be closed by ``</skill>``. Anything
|
|
36
|
+
else yields ``None``, signalling "this turn is ordinary text, not a skill
|
|
37
|
+
call".
|
|
38
|
+
|
|
39
|
+
Port note (TS ``src/conductor/skill-parse/parse.ts``): the index-walking
|
|
40
|
+
scanner ports verbatim, with one mechanical difference — TS ``text[i]`` past
|
|
41
|
+
the end is ``undefined`` while Python raises, so every cursor read carries
|
|
42
|
+
an explicit bounds guard where TS leaned on ``undefined``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import re
|
|
48
|
+
from collections.abc import Mapping
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from types import MappingProxyType
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"SkillInvocation",
|
|
54
|
+
"parse_skill_invocation",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# The recognised opener/closer tag name (compared case-insensitively).
|
|
59
|
+
_SKILL_TAG = "skill"
|
|
60
|
+
|
|
61
|
+
# The attribute whose value is promoted to the result's ``name`` field.
|
|
62
|
+
_NAME_ATTR = "name"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class SkillInvocation:
|
|
67
|
+
"""The structured result of a successful parse.
|
|
68
|
+
|
|
69
|
+
- ``name`` — the value of the required ``name`` attribute (the skill to
|
|
70
|
+
run).
|
|
71
|
+
- ``args`` — every attribute the opener carried, keyed by (lower-cased)
|
|
72
|
+
name, including ``name`` itself and any ``location``/custom keys. A
|
|
73
|
+
bare attribute with no ``=value`` maps to the empty string.
|
|
74
|
+
- ``body`` — the verbatim text between the opener and the ``</skill>``
|
|
75
|
+
close, with a single leading and trailing newline trimmed (the common
|
|
76
|
+
case where the opener/closer sit on their own lines) but inner
|
|
77
|
+
whitespace preserved.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
name: str
|
|
81
|
+
args: Mapping[str, str]
|
|
82
|
+
body: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_space(ch: str) -> bool:
|
|
86
|
+
"""True for ASCII whitespace the scanner skips between tokens."""
|
|
87
|
+
return ch in (" ", "\t", "\n", "\r", "\f", "\v")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_key_start(ch: str) -> bool:
|
|
91
|
+
"""True for the first character of an attribute key."""
|
|
92
|
+
return ("a" <= ch <= "z") or ("A" <= ch <= "Z") or ch == "_"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_key_char(ch: str) -> bool:
|
|
96
|
+
"""True for a continuing character of an attribute key."""
|
|
97
|
+
return _is_key_start(ch) or ("0" <= ch <= "9") or ch in ("-", ":", ".")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _skip_space(text: str, i: int) -> int:
|
|
101
|
+
"""Advance past any run of whitespace from ``i``; returns the new index."""
|
|
102
|
+
j = i
|
|
103
|
+
while j < len(text) and _is_space(text[j]):
|
|
104
|
+
j += 1
|
|
105
|
+
return j
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_ENTITY_RE = re.compile(r"&(#39|#x27|#34|#x22|amp|lt|gt|quot|apos);", re.IGNORECASE)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _decode_entities(raw: str) -> str:
|
|
112
|
+
"""Decode the small set of XML/HTML entities an author might write inside
|
|
113
|
+
an attribute value or the body. Only the canonical five (plus the two
|
|
114
|
+
common numeric apostrophe/quote forms) are recognised; everything else is
|
|
115
|
+
left as written so arbitrary text passes through untouched."""
|
|
116
|
+
if "&" not in raw:
|
|
117
|
+
return raw
|
|
118
|
+
|
|
119
|
+
def repl(match: re.Match[str]) -> str:
|
|
120
|
+
code = match.group(1).lower()
|
|
121
|
+
if code == "amp":
|
|
122
|
+
return "&"
|
|
123
|
+
if code == "lt":
|
|
124
|
+
return "<"
|
|
125
|
+
if code == "gt":
|
|
126
|
+
return ">"
|
|
127
|
+
if code in ("quot", "#34", "#x22"):
|
|
128
|
+
return '"'
|
|
129
|
+
if code in ("apos", "#39", "#x27"):
|
|
130
|
+
return "'"
|
|
131
|
+
return match.group(0)
|
|
132
|
+
|
|
133
|
+
return _ENTITY_RE.sub(repl, raw)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class _AttrScan:
|
|
138
|
+
"""The cursor result of scanning a single attribute."""
|
|
139
|
+
|
|
140
|
+
key: str
|
|
141
|
+
value: str
|
|
142
|
+
next: int
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _scan_attribute(text: str, i: int) -> _AttrScan | None:
|
|
146
|
+
"""Scan one ``key`` / ``key="value"`` / ``key='value'`` / ``key=bare``
|
|
147
|
+
attribute starting at ``i`` (which must sit on a key-start character).
|
|
148
|
+
Returns the decoded key/value and the index just past the attribute, or
|
|
149
|
+
``None`` if the key is malformed."""
|
|
150
|
+
j = i
|
|
151
|
+
key_start = j
|
|
152
|
+
if not _is_key_start(text[j]):
|
|
153
|
+
return None
|
|
154
|
+
j += 1
|
|
155
|
+
while j < len(text) and _is_key_char(text[j]):
|
|
156
|
+
j += 1
|
|
157
|
+
key = text[key_start:j].lower()
|
|
158
|
+
|
|
159
|
+
# Look for an `=` (whitespace may sit on either side of it).
|
|
160
|
+
after_key = _skip_space(text, j)
|
|
161
|
+
if after_key >= len(text) or text[after_key] != "=":
|
|
162
|
+
# Bare attribute: present, no value.
|
|
163
|
+
return _AttrScan(key=key, value="", next=j)
|
|
164
|
+
|
|
165
|
+
v = _skip_space(text, after_key + 1)
|
|
166
|
+
quote = text[v] if v < len(text) else None
|
|
167
|
+
if quote in ('"', "'"):
|
|
168
|
+
val_start = v + 1
|
|
169
|
+
k = val_start
|
|
170
|
+
while k < len(text) and text[k] != quote:
|
|
171
|
+
k += 1
|
|
172
|
+
if k >= len(text):
|
|
173
|
+
# Unterminated quote — treat the remainder as the value (best effort).
|
|
174
|
+
return _AttrScan(key=key, value=_decode_entities(text[val_start:]), next=len(text))
|
|
175
|
+
return _AttrScan(key=key, value=_decode_entities(text[val_start:k]), next=k + 1)
|
|
176
|
+
|
|
177
|
+
# Bare value: read until whitespace or the tag terminator.
|
|
178
|
+
val_start = v
|
|
179
|
+
while v < len(text) and not _is_space(text[v]) and text[v] not in (">", "/"):
|
|
180
|
+
v += 1
|
|
181
|
+
return _AttrScan(key=key, value=_decode_entities(text[val_start:v]), next=v)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _trim_body_edges(body: str) -> str:
|
|
185
|
+
"""Strip one leading and one trailing newline (with optional CR) from the
|
|
186
|
+
body."""
|
|
187
|
+
s = body
|
|
188
|
+
if s.startswith("\r\n"):
|
|
189
|
+
s = s[2:]
|
|
190
|
+
elif s.startswith("\n") or s.startswith("\r"):
|
|
191
|
+
s = s[1:]
|
|
192
|
+
if s.endswith("\r\n"):
|
|
193
|
+
s = s[:-2]
|
|
194
|
+
elif s.endswith("\n") or s.endswith("\r"):
|
|
195
|
+
s = s[:-1]
|
|
196
|
+
return s
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_skill_invocation(text: str) -> SkillInvocation | None:
|
|
200
|
+
"""Parse a skill-invocation block out of ``text``.
|
|
201
|
+
|
|
202
|
+
Walks the string by hand: skips leading whitespace, confirms a
|
|
203
|
+
``<skill …>`` opener, scans each attribute into an ``args`` record,
|
|
204
|
+
captures the body up to the matching ``</skill>``, and decodes entities.
|
|
205
|
+
Returns the structured invocation, or ``None`` when the text is not a
|
|
206
|
+
well-formed leading skill block (ordinary turns simply parse to
|
|
207
|
+
``None``).
|
|
208
|
+
|
|
209
|
+
:param text: the raw turn text that may begin with a skill block
|
|
210
|
+
:returns: the parsed :class:`SkillInvocation`, or ``None`` if not a
|
|
211
|
+
skill block
|
|
212
|
+
"""
|
|
213
|
+
i = _skip_space(text, 0)
|
|
214
|
+
|
|
215
|
+
# 1) Opener: '<' then the tag name (case-insensitive), bounded by a delimiter.
|
|
216
|
+
if i >= len(text) or text[i] != "<":
|
|
217
|
+
return None
|
|
218
|
+
i += 1
|
|
219
|
+
tag_start = i
|
|
220
|
+
while i < len(text) and _is_key_char(text[i]):
|
|
221
|
+
i += 1
|
|
222
|
+
tag = text[tag_start:i].lower()
|
|
223
|
+
if tag != _SKILL_TAG:
|
|
224
|
+
return None
|
|
225
|
+
# The tag must be followed by whitespace or the tag terminator, not more name.
|
|
226
|
+
after_tag = text[i] if i < len(text) else None
|
|
227
|
+
if after_tag is not None and not _is_space(after_tag) and after_tag not in (">", "/"):
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
# 2) Attributes: scan `key[=value]` pairs until the opener's '>'.
|
|
231
|
+
args: dict[str, str] = {}
|
|
232
|
+
while True:
|
|
233
|
+
i = _skip_space(text, i)
|
|
234
|
+
if i >= len(text):
|
|
235
|
+
return None # opener never closed
|
|
236
|
+
ch = text[i]
|
|
237
|
+
if ch == ">":
|
|
238
|
+
i += 1
|
|
239
|
+
break
|
|
240
|
+
if ch == "/":
|
|
241
|
+
# A self-closing `<skill .../>` carries no body; reject (a skill
|
|
242
|
+
# call needs one).
|
|
243
|
+
return None
|
|
244
|
+
if not _is_key_start(ch):
|
|
245
|
+
return None # junk where an attribute or '>' was expected
|
|
246
|
+
scan = _scan_attribute(text, i)
|
|
247
|
+
if scan is None:
|
|
248
|
+
return None
|
|
249
|
+
if scan.key not in args:
|
|
250
|
+
args[scan.key] = scan.value # first writer wins per key
|
|
251
|
+
i = scan.next
|
|
252
|
+
|
|
253
|
+
# 3) A skill call is identified by a non-empty `name` attribute.
|
|
254
|
+
name = args.get(_NAME_ATTR)
|
|
255
|
+
if name is None or len(name) == 0:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
# 4) Body: capture verbatim up to the matching close tag `</skill>`.
|
|
259
|
+
body_start = i
|
|
260
|
+
close = _find_close_tag(text, i)
|
|
261
|
+
if close is None:
|
|
262
|
+
return None
|
|
263
|
+
body = _trim_body_edges(_decode_entities(text[body_start:close]))
|
|
264
|
+
|
|
265
|
+
return SkillInvocation(name=name, args=MappingProxyType(args), body=body)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _find_close_tag(text: str, i: int) -> int | None:
|
|
269
|
+
"""From ``i``, find the index of the ``<`` that begins the matching
|
|
270
|
+
``</skill>`` close tag (case-insensitive, tolerant of whitespace before
|
|
271
|
+
the ``>``). Returns the index of that ``<``, or ``None`` if no close tag
|
|
272
|
+
is found."""
|
|
273
|
+
j = i
|
|
274
|
+
while j < len(text):
|
|
275
|
+
lt = text.find("<", j)
|
|
276
|
+
if lt == -1:
|
|
277
|
+
return None
|
|
278
|
+
k = lt + 1
|
|
279
|
+
if k >= len(text) or text[k] != "/":
|
|
280
|
+
j = lt + 1
|
|
281
|
+
continue
|
|
282
|
+
k = _skip_space(text, k + 1)
|
|
283
|
+
name_start = k
|
|
284
|
+
while k < len(text) and _is_key_char(text[k]):
|
|
285
|
+
k += 1
|
|
286
|
+
close_tag = text[name_start:k].lower()
|
|
287
|
+
if close_tag != _SKILL_TAG:
|
|
288
|
+
j = lt + 1
|
|
289
|
+
continue
|
|
290
|
+
k = _skip_space(text, k)
|
|
291
|
+
if k < len(text) and text[k] == ">":
|
|
292
|
+
return lt
|
|
293
|
+
j = lt + 1
|
|
294
|
+
return None
|