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,1035 @@
|
|
|
1
|
+
"""Conductor contract — the FROZEN type surface of the agent runtime core.
|
|
2
|
+
|
|
3
|
+
This module is the single typed seam between the coding-agent *product* (the
|
|
4
|
+
UI/channels that drive a session) and the framework ``Agent`` (the raw LLM
|
|
5
|
+
conversation loop, published by :mod:`indusagi.agent`). It declares *only*
|
|
6
|
+
shapes plus two tiny inert helpers (:func:`conductor_fault` and the pure
|
|
7
|
+
:func:`reduce_state` transition) — no I/O, no orchestration. Every later
|
|
8
|
+
conductor module (the signal hub, the transcript store, the model
|
|
9
|
+
catalog/matcher, the conductor factory, and the :class:`SessionConductor`
|
|
10
|
+
itself) is written against the names declared here, so the file is
|
|
11
|
+
intentionally small, append-mostly, and stable.
|
|
12
|
+
|
|
13
|
+
Design stance (ported from TS ``src/conductor/contract.ts``):
|
|
14
|
+
|
|
15
|
+
- The conductor *wraps* the framework ``Agent``. The framework emits a
|
|
16
|
+
fine-grained ``AgentEvent`` stream for its own loop; the conductor consumes
|
|
17
|
+
that internally and **re-emits a distinct, product-level**
|
|
18
|
+
:data:`SessionSignal` **stream** to consumers. The two are deliberately not
|
|
19
|
+
the same union: ``SessionSignal`` is the stable surface the app renders,
|
|
20
|
+
free to evolve independently of the framework's loop events.
|
|
21
|
+
- Faults are **typed discriminated values** (:class:`ConductorFault`), never
|
|
22
|
+
string sentinels. A consumer switches on ``fault.kind``, not on substring
|
|
23
|
+
matching of a message.
|
|
24
|
+
- Persistence uses a **fresh on-disk vocabulary** (:class:`TranscriptEntry`,
|
|
25
|
+
:class:`SessionHead`, :data:`TRANSCRIPT_SCHEMA`). The node is a
|
|
26
|
+
``parent``-linked tree, the version is a namespaced string, and the field
|
|
27
|
+
names are the conductor's own — not the framework's session-manager schema.
|
|
28
|
+
- State is exposed as an **immutable snapshot** (:class:`ConductorState`);
|
|
29
|
+
consumers read it, they never mutate it. Every transition runs through the
|
|
30
|
+
pure :func:`reduce_state` reducer, which always returns a brand-new object.
|
|
31
|
+
|
|
32
|
+
Port notes
|
|
33
|
+
----------
|
|
34
|
+
- TS discriminated unions become frozen ``slots`` dataclasses carrying a
|
|
35
|
+
``ClassVar`` ``Literal`` tag (``kind`` for signals/faults, ``type`` for
|
|
36
|
+
reducer actions) — the same tag spelling as TS, so a ``match`` on the tag
|
|
37
|
+
reads like the TS ``switch``. Exhaustiveness moved compiler → test (see
|
|
38
|
+
the key-coverage tests in ``tests/conductor``).
|
|
39
|
+
- Field names keep the TS camelCase spelling (``contextTokens``,
|
|
40
|
+
``createdAt``, ``entryId``, ``modelId`` …) — matching the framework's own
|
|
41
|
+
camelCase dataclasses so the two vocabularies read alike. Functions are
|
|
42
|
+
snake_case.
|
|
43
|
+
- TS ``SignalOf<K>`` (type-level Extract) has no Python analogue; the
|
|
44
|
+
runtime equivalent is an ``isinstance`` check against the variant class,
|
|
45
|
+
and :data:`SIGNAL_KINDS` names the closed kind set for guards/tests.
|
|
46
|
+
- ``reduce_state`` and the ``AgentLike`` / ``CondenseFn`` / ``RetryPolicy``
|
|
47
|
+
seams live in TS ``conductor.ts``; they are *types-and-pure-logic*, so the
|
|
48
|
+
Python port hosts them here in the contract — the wave-2 ``conductor.py``
|
|
49
|
+
(turn loop, queue drain, retry, persist tail) imports them from this
|
|
50
|
+
module.
|
|
51
|
+
|
|
52
|
+
Framework anchors (all from the ``indusagi`` package — the sibling rebuilt
|
|
53
|
+
framework this app targets):
|
|
54
|
+
|
|
55
|
+
- ``AgentEvent``, ``AgentTool``, ``ThinkingLevel`` ← ``indusagi.agent``
|
|
56
|
+
- ``AgentMessage``, ``Model``, ``Usage``, ``KnownProvider`` ← ``indusagi.ai``
|
|
57
|
+
(note: ``AgentMessage`` lives in ``indusagi.ai`` in the Python framework,
|
|
58
|
+
not ``indusagi.agent`` as in TS)
|
|
59
|
+
|
|
60
|
+
The conductor never re-declares these; it composes them.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
66
|
+
from dataclasses import dataclass, replace
|
|
67
|
+
from typing import Any, ClassVar, Final, Literal, Protocol, TypeAlias
|
|
68
|
+
|
|
69
|
+
from indusagi.agent import AgentEvent, AgentTool, ThinkingLevel
|
|
70
|
+
from indusagi.ai import AgentMessage, KnownProvider, Model, Usage
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"AgentLike",
|
|
74
|
+
"AgentMessage",
|
|
75
|
+
"AgentStateLike",
|
|
76
|
+
"AgentTool",
|
|
77
|
+
"BashOutcome",
|
|
78
|
+
"CompactedSignal",
|
|
79
|
+
"CondenseFn",
|
|
80
|
+
"ConductorFault",
|
|
81
|
+
"ConductorPhase",
|
|
82
|
+
"ConductorState",
|
|
83
|
+
"ExecuteBashOptions",
|
|
84
|
+
"FAULT_KINDS",
|
|
85
|
+
"FaultAction",
|
|
86
|
+
"FaultKind",
|
|
87
|
+
"FaultSignal",
|
|
88
|
+
"HeadAction",
|
|
89
|
+
"IdleSignal",
|
|
90
|
+
"KnownProvider",
|
|
91
|
+
"MatchQuery",
|
|
92
|
+
"Model",
|
|
93
|
+
"ModelAction",
|
|
94
|
+
"ModelCardRef",
|
|
95
|
+
"PersistedSignal",
|
|
96
|
+
"PhaseAction",
|
|
97
|
+
"PromptSignal",
|
|
98
|
+
"QUEUE_MODES",
|
|
99
|
+
"QueueMode",
|
|
100
|
+
"QueueSignal",
|
|
101
|
+
"QueuedInput",
|
|
102
|
+
"RetryPolicy",
|
|
103
|
+
"SIGNAL_KINDS",
|
|
104
|
+
"SessionConductor",
|
|
105
|
+
"SessionConductorOptions",
|
|
106
|
+
"SessionHead",
|
|
107
|
+
"SessionSignal",
|
|
108
|
+
"SessionStats",
|
|
109
|
+
"SettledAction",
|
|
110
|
+
"SignalHandler",
|
|
111
|
+
"SignalKind",
|
|
112
|
+
"StateAction",
|
|
113
|
+
"TRANSCRIPT_ROLES",
|
|
114
|
+
"TRANSCRIPT_SCHEMA",
|
|
115
|
+
"TextSignal",
|
|
116
|
+
"ThinkingLevel",
|
|
117
|
+
"ThinkingSignal",
|
|
118
|
+
"TokenTally",
|
|
119
|
+
"ToolEndSignal",
|
|
120
|
+
"ToolStartSignal",
|
|
121
|
+
"TranscriptEntry",
|
|
122
|
+
"TranscriptRole",
|
|
123
|
+
"TranscriptSchema",
|
|
124
|
+
"TurnEndSignal",
|
|
125
|
+
"Usage",
|
|
126
|
+
"UsageAction",
|
|
127
|
+
"conductor_fault",
|
|
128
|
+
"reduce_state",
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Faults
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
#: The closed set of failure categories the conductor can surface.
|
|
137
|
+
#:
|
|
138
|
+
#: Each is a distinct recovery story, so the kind is a discriminant — not a
|
|
139
|
+
#: free-form string:
|
|
140
|
+
#:
|
|
141
|
+
#: - ``model`` — the LLM call itself failed (transport, provider, decode).
|
|
142
|
+
#: - ``tool`` — a tool invocation threw or returned a hard error.
|
|
143
|
+
#: - ``persistence`` — writing/reading the on-disk transcript failed.
|
|
144
|
+
#: - ``aborted`` — the caller cancelled the in-flight turn via abort.
|
|
145
|
+
#: - ``overflow`` — the context window was exceeded and not condensable.
|
|
146
|
+
FaultKind: TypeAlias = Literal["model", "tool", "persistence", "aborted", "overflow"]
|
|
147
|
+
|
|
148
|
+
#: Every :data:`FaultKind` value, as a frozen tuple for guards and tests.
|
|
149
|
+
FAULT_KINDS: Final[tuple[FaultKind, ...]] = (
|
|
150
|
+
"model",
|
|
151
|
+
"tool",
|
|
152
|
+
"persistence",
|
|
153
|
+
"aborted",
|
|
154
|
+
"overflow",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True, slots=True)
|
|
159
|
+
class ConductorFault:
|
|
160
|
+
"""A typed, discriminated failure value emitted on the
|
|
161
|
+
:data:`SessionSignal` stream and attached to faulted states.
|
|
162
|
+
|
|
163
|
+
``kind`` selects the category; ``message`` is a human-readable summary;
|
|
164
|
+
the optional ``cause`` carries the underlying error (or any structured
|
|
165
|
+
detail) for logging without forcing consumers to parse the message
|
|
166
|
+
string. It is a *value*, never raised — construct one with
|
|
167
|
+
:func:`conductor_fault`.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
#: Failure category — the discriminant consumers switch on.
|
|
171
|
+
kind: FaultKind
|
|
172
|
+
#: Human-readable, single-line summary of what went wrong.
|
|
173
|
+
message: str
|
|
174
|
+
#: Underlying error or structured detail, if any (TS optional ``cause``).
|
|
175
|
+
cause: object | None = None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def conductor_fault(
|
|
179
|
+
kind: FaultKind, message: str, cause: object | None = None
|
|
180
|
+
) -> ConductorFault:
|
|
181
|
+
"""Construct a :class:`ConductorFault`. The single sanctioned way to mint
|
|
182
|
+
a fault, so the shape stays uniform across every producer.
|
|
183
|
+
|
|
184
|
+
:param kind: the failure category
|
|
185
|
+
:param message: a human-readable, single-line summary
|
|
186
|
+
:param cause: optional underlying error or structured detail
|
|
187
|
+
"""
|
|
188
|
+
return ConductorFault(kind=kind, message=message, cause=cause)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Product-level signal stream
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
#
|
|
195
|
+
# The product-level event stream the conductor emits to its consumers (the
|
|
196
|
+
# interactive UI, the print/JSON mode, the JSON-RPC link). This is the
|
|
197
|
+
# conductor's **re-emitted surface** — distinct from the framework
|
|
198
|
+
# ``AgentEvent`` union. The conductor subscribes to the raw framework loop,
|
|
199
|
+
# layers persistence / auto-condense / fault handling on top, and projects the
|
|
200
|
+
# result down to this small, stable set of discriminated signals. Consumers
|
|
201
|
+
# switch on ``kind`` and never see a framework loop event directly.
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True, slots=True)
|
|
205
|
+
class PromptSignal:
|
|
206
|
+
"""The user's turn was committed to the conversation; ``text`` is the
|
|
207
|
+
submitted prompt. Emitted the instant the turn is accepted (before the
|
|
208
|
+
model replies) so a UI can echo the user message immediately rather than
|
|
209
|
+
waiting for the first assistant token."""
|
|
210
|
+
|
|
211
|
+
kind: ClassVar[Literal["prompt"]] = "prompt"
|
|
212
|
+
text: str
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True, slots=True)
|
|
216
|
+
class TextSignal:
|
|
217
|
+
"""A chunk of assistant answer text streamed in."""
|
|
218
|
+
|
|
219
|
+
kind: ClassVar[Literal["text"]] = "text"
|
|
220
|
+
delta: str
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class ThinkingSignal:
|
|
225
|
+
"""A chunk of reasoning/thinking text streamed in."""
|
|
226
|
+
|
|
227
|
+
kind: ClassVar[Literal["thinking"]] = "thinking"
|
|
228
|
+
delta: str
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True, slots=True)
|
|
232
|
+
class ToolStartSignal:
|
|
233
|
+
"""A tool invocation began (correlate by ``id``)."""
|
|
234
|
+
|
|
235
|
+
kind: ClassVar[Literal["tool_start"]] = "tool_start"
|
|
236
|
+
id: str
|
|
237
|
+
name: str
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True, slots=True)
|
|
241
|
+
class ToolEndSignal:
|
|
242
|
+
"""A tool invocation finished (``ok`` = no error)."""
|
|
243
|
+
|
|
244
|
+
kind: ClassVar[Literal["tool_end"]] = "tool_end"
|
|
245
|
+
id: str
|
|
246
|
+
ok: bool
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True, slots=True)
|
|
250
|
+
class TurnEndSignal:
|
|
251
|
+
"""The assistant turn settled; ``usage`` reports token spend."""
|
|
252
|
+
|
|
253
|
+
kind: ClassVar[Literal["turn_end"]] = "turn_end"
|
|
254
|
+
usage: Usage
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(frozen=True, slots=True)
|
|
258
|
+
class PersistedSignal:
|
|
259
|
+
"""The latest node was committed to the transcript (``entryId``)."""
|
|
260
|
+
|
|
261
|
+
kind: ClassVar[Literal["persisted"]] = "persisted"
|
|
262
|
+
entryId: str
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True, slots=True)
|
|
266
|
+
class CompactedSignal:
|
|
267
|
+
"""The transcript was condensed to fit the context window."""
|
|
268
|
+
|
|
269
|
+
kind: ClassVar[Literal["compacted"]] = "compacted"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True, slots=True)
|
|
273
|
+
class FaultSignal:
|
|
274
|
+
"""A typed :class:`ConductorFault` occurred."""
|
|
275
|
+
|
|
276
|
+
kind: ClassVar[Literal["fault"]] = "fault"
|
|
277
|
+
fault: ConductorFault
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, slots=True)
|
|
281
|
+
class QueueSignal:
|
|
282
|
+
"""The pending-input queue changed; ``count`` is its new depth."""
|
|
283
|
+
|
|
284
|
+
kind: ClassVar[Literal["queue"]] = "queue"
|
|
285
|
+
count: int
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(frozen=True, slots=True)
|
|
289
|
+
class IdleSignal:
|
|
290
|
+
"""The conductor has no in-flight work and is ready for input."""
|
|
291
|
+
|
|
292
|
+
kind: ClassVar[Literal["idle"]] = "idle"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
#: The product-level signal union — the conductor's stable consumer surface.
|
|
296
|
+
SessionSignal: TypeAlias = (
|
|
297
|
+
PromptSignal
|
|
298
|
+
| TextSignal
|
|
299
|
+
| ThinkingSignal
|
|
300
|
+
| ToolStartSignal
|
|
301
|
+
| ToolEndSignal
|
|
302
|
+
| TurnEndSignal
|
|
303
|
+
| PersistedSignal
|
|
304
|
+
| CompactedSignal
|
|
305
|
+
| FaultSignal
|
|
306
|
+
| QueueSignal
|
|
307
|
+
| IdleSignal
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
#: The discriminant literals of :data:`SessionSignal`, for filtering/logging.
|
|
311
|
+
SignalKind: TypeAlias = Literal[
|
|
312
|
+
"prompt",
|
|
313
|
+
"text",
|
|
314
|
+
"thinking",
|
|
315
|
+
"tool_start",
|
|
316
|
+
"tool_end",
|
|
317
|
+
"turn_end",
|
|
318
|
+
"persisted",
|
|
319
|
+
"compacted",
|
|
320
|
+
"fault",
|
|
321
|
+
"queue",
|
|
322
|
+
"idle",
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
#: Every :data:`SignalKind` value, in declaration order (the runtime stand-in
|
|
326
|
+
#: for TS ``SignalOf`` extraction — pair with an ``isinstance`` check).
|
|
327
|
+
SIGNAL_KINDS: Final[tuple[SignalKind, ...]] = (
|
|
328
|
+
"prompt",
|
|
329
|
+
"text",
|
|
330
|
+
"thinking",
|
|
331
|
+
"tool_start",
|
|
332
|
+
"tool_end",
|
|
333
|
+
"turn_end",
|
|
334
|
+
"persisted",
|
|
335
|
+
"compacted",
|
|
336
|
+
"fault",
|
|
337
|
+
"queue",
|
|
338
|
+
"idle",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
#: A subscriber callback registered with :meth:`SessionConductor.subscribe`.
|
|
342
|
+
SignalHandler: TypeAlias = Callable[[SessionSignal], None]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
# On-disk transcript schema
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
#: The on-disk transcript schema namespace + version.
|
|
350
|
+
#:
|
|
351
|
+
#: A namespaced string (not a bare integer) so the format is self-describing
|
|
352
|
+
#: and can evolve without colliding with any other versioned artifact in the
|
|
353
|
+
#: app. This is deliberately the conductor's own vocabulary.
|
|
354
|
+
TRANSCRIPT_SCHEMA: Final[str] = "indus/transcript@1"
|
|
355
|
+
|
|
356
|
+
#: The literal type of :data:`TRANSCRIPT_SCHEMA`.
|
|
357
|
+
TranscriptSchema: TypeAlias = Literal["indus/transcript@1"]
|
|
358
|
+
|
|
359
|
+
#: The conversational role a :class:`TranscriptEntry` node carries.
|
|
360
|
+
#:
|
|
361
|
+
#: Spans both the LLM-facing turns (``user``/``assistant``/``tool``) and the
|
|
362
|
+
#: conductor's own bookkeeping nodes (``system`` seed, ``condense`` markers,
|
|
363
|
+
#: and ``note`` for app-injected context). Kept open at the product layer so
|
|
364
|
+
#: the transcript can hold more than the framework's message roles.
|
|
365
|
+
TranscriptRole: TypeAlias = Literal["user", "assistant", "tool", "system", "condense", "note"]
|
|
366
|
+
|
|
367
|
+
#: Every :data:`TranscriptRole` value, as a frozen tuple for guards and tests.
|
|
368
|
+
TRANSCRIPT_ROLES: Final[tuple[TranscriptRole, ...]] = (
|
|
369
|
+
"user",
|
|
370
|
+
"assistant",
|
|
371
|
+
"tool",
|
|
372
|
+
"system",
|
|
373
|
+
"condense",
|
|
374
|
+
"note",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass(frozen=True, slots=True)
|
|
379
|
+
class TranscriptEntry:
|
|
380
|
+
"""A single node in the on-disk transcript tree.
|
|
381
|
+
|
|
382
|
+
The transcript is an append-only **tree**: every node names its
|
|
383
|
+
``parent`` (a root has ``parent = None``), and the active leaf is tracked
|
|
384
|
+
separately in :class:`SessionHead`. Branching is moving the head to an
|
|
385
|
+
earlier node; the next append becomes that node's child. ``content``
|
|
386
|
+
holds the framework ``AgentMessage`` payload so the node round-trips back
|
|
387
|
+
into the agent loop; ``meta`` carries optional, non-LLM annotations
|
|
388
|
+
(labels, condense bookkeeping, model/reasoning markers).
|
|
389
|
+
|
|
390
|
+
Field names are the conductor's own (``parent``, ``createdAt``, ``meta``)
|
|
391
|
+
— not the framework's persistence schema.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
#: Stable unique node id (e.g. a ULID).
|
|
395
|
+
id: str
|
|
396
|
+
#: Parent node id, or ``None`` for the transcript root.
|
|
397
|
+
parent: str | None
|
|
398
|
+
#: Conversational role of this node.
|
|
399
|
+
role: TranscriptRole
|
|
400
|
+
#: The framework message payload this node persists.
|
|
401
|
+
content: AgentMessage
|
|
402
|
+
#: ISO-8601 creation timestamp.
|
|
403
|
+
createdAt: str
|
|
404
|
+
#: Optional, non-LLM annotations keyed by name.
|
|
405
|
+
meta: Mapping[str, Any] | None = None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@dataclass(frozen=True, slots=True)
|
|
409
|
+
class SessionHead:
|
|
410
|
+
"""The head record of a persisted transcript: which session, and where
|
|
411
|
+
its active leaf currently points.
|
|
412
|
+
|
|
413
|
+
The ``leaf`` is the id of the most recently appended (or branched-to)
|
|
414
|
+
node; walking ``parent`` links from ``leaf`` to a root reconstructs the
|
|
415
|
+
active branch. ``None`` means an empty transcript (no nodes yet).
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
#: Stable identifier of the session this transcript belongs to.
|
|
419
|
+
sessionId: str
|
|
420
|
+
#: Id of the active leaf node, or ``None`` for an empty transcript.
|
|
421
|
+
leaf: str | None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
# Model catalog / matcher
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass(frozen=True, slots=True)
|
|
430
|
+
class ModelCardRef:
|
|
431
|
+
"""A lightweight, resolved reference to one model card in the catalog.
|
|
432
|
+
|
|
433
|
+
This is the *display/identity* projection of a framework ``Model`` — the
|
|
434
|
+
minimum a UI needs to list, label, and select a model without holding the
|
|
435
|
+
full model object. The matcher produces these; the conductor resolves the
|
|
436
|
+
chosen one back to a full ``Model`` when it configures the agent.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
#: Canonical ``"provider/modelId"`` identifier (the catalog key).
|
|
440
|
+
id: str
|
|
441
|
+
#: Owning provider (a :data:`KnownProvider` or any provider string).
|
|
442
|
+
provider: str
|
|
443
|
+
#: Provider-scoped model id (e.g. ``"claude-sonnet-4"``).
|
|
444
|
+
modelId: str
|
|
445
|
+
#: Human-readable display name.
|
|
446
|
+
name: str
|
|
447
|
+
#: Whether this model exposes a reasoning/thinking budget.
|
|
448
|
+
reasoning: bool
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@dataclass(frozen=True, slots=True)
|
|
452
|
+
class MatchQuery:
|
|
453
|
+
"""A query against the model catalog/matcher.
|
|
454
|
+
|
|
455
|
+
Resolution is a prioritized candidate pipeline: an explicit
|
|
456
|
+
``provider`` + ``modelId`` pins a single card; otherwise ``pattern`` is
|
|
457
|
+
matched (exact id, ``provider/`` prefix, then glob/fuzzy) and narrowed by
|
|
458
|
+
the optional capability filters. All fields default to ``None`` so an
|
|
459
|
+
empty query means "the default candidate".
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
#: Free-form selector: an id, an alias, or a glob pattern.
|
|
463
|
+
pattern: str | None = None
|
|
464
|
+
#: Restrict candidates to this provider.
|
|
465
|
+
provider: str | None = None
|
|
466
|
+
#: Pin a specific provider-scoped model id (used with ``provider``).
|
|
467
|
+
modelId: str | None = None
|
|
468
|
+
#: Require reasoning/thinking support.
|
|
469
|
+
reasoning: bool | None = None
|
|
470
|
+
#: Require image input support.
|
|
471
|
+
supportsImageInput: bool | None = None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
# Conductor lifecycle phase & state
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
#: The coarse lifecycle phase of the conductor at a point in time.
|
|
479
|
+
#:
|
|
480
|
+
#: - ``idle`` — assembled and ready; no turn in flight.
|
|
481
|
+
#: - ``streaming`` — an assistant turn is producing text/thinking.
|
|
482
|
+
#: - ``tooling`` — a tool invocation is executing mid-turn.
|
|
483
|
+
#: - ``condensing`` — the transcript is being condensed to fit the window.
|
|
484
|
+
#: - ``faulted`` — the last turn ended in a :class:`ConductorFault`.
|
|
485
|
+
ConductorPhase: TypeAlias = Literal["idle", "streaming", "tooling", "condensing", "faulted"]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@dataclass(frozen=True, slots=True)
|
|
489
|
+
class ConductorState:
|
|
490
|
+
"""An immutable snapshot of the conductor's observable state.
|
|
491
|
+
|
|
492
|
+
Returned by :meth:`SessionConductor.snapshot` and resolved by
|
|
493
|
+
:meth:`SessionConductor.submit`. It is a value, not a live view: the
|
|
494
|
+
dataclass is frozen and the object reflects the instant it was taken.
|
|
495
|
+
Re-read with a fresh ``snapshot()`` to observe later changes.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
#: Coarse lifecycle phase at snapshot time.
|
|
499
|
+
phase: ConductorPhase
|
|
500
|
+
#: The active transcript head (session id + current leaf).
|
|
501
|
+
head: SessionHead
|
|
502
|
+
#: Cumulative token/cost spend across the session so far.
|
|
503
|
+
usage: Usage
|
|
504
|
+
#: Tokens occupying the model's context window as of the most recent turn
|
|
505
|
+
#: — the last assistant turn's reported usage, NOT the cumulative session
|
|
506
|
+
#: spend. This is what the footer's ``ctx:%`` divides by the context
|
|
507
|
+
#: window; ``usage.totalTokens`` grows unbounded across turns and would
|
|
508
|
+
#: inflate it.
|
|
509
|
+
contextTokens: int
|
|
510
|
+
#: Canonical id of the model currently bound to the session.
|
|
511
|
+
modelId: str
|
|
512
|
+
#: The fault from the most recent turn, when ``phase`` is ``"faulted"``.
|
|
513
|
+
fault: ConductorFault | None = None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
# Immutable state reducer
|
|
518
|
+
# ---------------------------------------------------------------------------
|
|
519
|
+
#
|
|
520
|
+
# The transitions the reducer understands — fresh vocabulary, all typed.
|
|
521
|
+
# (TS hosts these in conductor.ts; they are pure shapes + a pure function, so
|
|
522
|
+
# the Python port keeps them on the contract for the wave-2 conductor to
|
|
523
|
+
# import.)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclass(frozen=True, slots=True)
|
|
527
|
+
class PhaseAction:
|
|
528
|
+
"""Move to a new lifecycle phase."""
|
|
529
|
+
|
|
530
|
+
type: ClassVar[Literal["phase"]] = "phase"
|
|
531
|
+
phase: ConductorPhase
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@dataclass(frozen=True, slots=True)
|
|
535
|
+
class HeadAction:
|
|
536
|
+
"""Point the state at a new transcript head."""
|
|
537
|
+
|
|
538
|
+
type: ClassVar[Literal["head"]] = "head"
|
|
539
|
+
head: SessionHead
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@dataclass(frozen=True, slots=True)
|
|
543
|
+
class UsageAction:
|
|
544
|
+
"""Record the cumulative usage *and* the latest turn's context occupancy.
|
|
545
|
+
|
|
546
|
+
``usage`` is the running cumulative total (grows unbounded across turns);
|
|
547
|
+
``contextTokens`` is the latest turn's window occupancy and **replaces**
|
|
548
|
+
the prior value rather than accumulating — the ``ctx:%`` fix.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
type: ClassVar[Literal["usage"]] = "usage"
|
|
552
|
+
usage: Usage
|
|
553
|
+
contextTokens: int
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@dataclass(frozen=True, slots=True)
|
|
557
|
+
class ModelAction:
|
|
558
|
+
"""Bind a different canonical model id."""
|
|
559
|
+
|
|
560
|
+
type: ClassVar[Literal["model"]] = "model"
|
|
561
|
+
modelId: str
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@dataclass(frozen=True, slots=True)
|
|
565
|
+
class FaultAction:
|
|
566
|
+
"""Record a fault and flip the phase to ``faulted``."""
|
|
567
|
+
|
|
568
|
+
type: ClassVar[Literal["fault"]] = "fault"
|
|
569
|
+
fault: ConductorFault
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@dataclass(frozen=True, slots=True)
|
|
573
|
+
class SettledAction:
|
|
574
|
+
"""Turn ended cleanly: clear any prior fault, go idle."""
|
|
575
|
+
|
|
576
|
+
type: ClassVar[Literal["settled"]] = "settled"
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
#: The reducer's action union.
|
|
580
|
+
StateAction: TypeAlias = (
|
|
581
|
+
PhaseAction | HeadAction | UsageAction | ModelAction | FaultAction | SettledAction
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def reduce_state(prev: ConductorState, action: StateAction) -> ConductorState:
|
|
586
|
+
"""Pure transition over :class:`ConductorState`.
|
|
587
|
+
|
|
588
|
+
Always returns a brand-new object; the previous state is never mutated.
|
|
589
|
+
``settled`` clears a stale fault and returns to ``idle``; ``fault``
|
|
590
|
+
records the fault and flips to ``faulted``.
|
|
591
|
+
|
|
592
|
+
:param prev: the state to transition from (left untouched)
|
|
593
|
+
:param action: the typed transition to apply
|
|
594
|
+
:raises ValueError: on an action outside :data:`StateAction` (the TS
|
|
595
|
+
``switch`` was compiler-exhaustive; the Python port fails loud)
|
|
596
|
+
"""
|
|
597
|
+
tag = action.type
|
|
598
|
+
if tag == "phase":
|
|
599
|
+
return replace(prev, phase=action.phase)
|
|
600
|
+
if tag == "head":
|
|
601
|
+
return replace(prev, head=action.head)
|
|
602
|
+
if tag == "usage":
|
|
603
|
+
return replace(prev, usage=action.usage, contextTokens=action.contextTokens)
|
|
604
|
+
if tag == "model":
|
|
605
|
+
return replace(prev, modelId=action.modelId)
|
|
606
|
+
if tag == "fault":
|
|
607
|
+
return replace(prev, phase="faulted", fault=action.fault)
|
|
608
|
+
if tag == "settled":
|
|
609
|
+
return replace(prev, phase="idle", fault=None)
|
|
610
|
+
raise ValueError(f"unknown state action: {tag!r}")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
# Pending-input queue
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
#: How a queued input rejoins the conversation once the active turn settles.
|
|
618
|
+
#:
|
|
619
|
+
#: - ``steer`` — interrupt-style input meant to redirect the agent;
|
|
620
|
+
#: drained ahead of plain follow-ups.
|
|
621
|
+
#: - ``followUp`` — input that simply waits its turn after the current one.
|
|
622
|
+
#:
|
|
623
|
+
#: The conductor enqueues input under one of these modes when
|
|
624
|
+
#: :meth:`SessionConductor.submit` is called while a turn is in flight, then
|
|
625
|
+
#: drains the queue in order. (Deliberately the conductor's own vocabulary —
|
|
626
|
+
#: distinct from the framework's ``indusagi.agent.QueueMode``.)
|
|
627
|
+
QueueMode: TypeAlias = Literal["steer", "followUp"]
|
|
628
|
+
|
|
629
|
+
#: Every :data:`QueueMode` value, as a frozen tuple for guards and tests.
|
|
630
|
+
QUEUE_MODES: Final[tuple[QueueMode, ...]] = ("steer", "followUp")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@dataclass(frozen=True, slots=True)
|
|
634
|
+
class QueuedInput:
|
|
635
|
+
"""One entry in the conductor's pending-input queue: the
|
|
636
|
+
:data:`QueueMode` it was filed under and the raw user ``text``. Surfaced
|
|
637
|
+
by :meth:`SessionConductor.pending_inputs` so a UI can render what is
|
|
638
|
+
waiting."""
|
|
639
|
+
|
|
640
|
+
#: How this input will rejoin the conversation when drained.
|
|
641
|
+
mode: QueueMode
|
|
642
|
+
#: The raw user message text held for a later turn.
|
|
643
|
+
text: str
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# ---------------------------------------------------------------------------
|
|
647
|
+
# Session statistics
|
|
648
|
+
# ---------------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@dataclass(frozen=True, slots=True)
|
|
652
|
+
class TokenTally:
|
|
653
|
+
"""Cumulative token spend, broken out by category and totalled (the
|
|
654
|
+
anonymous ``tokens`` object on the TS ``SessionStats``)."""
|
|
655
|
+
|
|
656
|
+
input: int
|
|
657
|
+
output: int
|
|
658
|
+
cacheRead: int
|
|
659
|
+
cacheWrite: int
|
|
660
|
+
total: int
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@dataclass(frozen=True, slots=True)
|
|
664
|
+
class SessionStats:
|
|
665
|
+
"""A point-in-time tally of the active session: message counts by role,
|
|
666
|
+
tool activity, cumulative token spend, and total cost. Computed by
|
|
667
|
+
:meth:`SessionConductor.stats` from the live message list plus the
|
|
668
|
+
running usage carried on :class:`ConductorState`."""
|
|
669
|
+
|
|
670
|
+
#: Identifier of the session these figures describe.
|
|
671
|
+
sessionId: str
|
|
672
|
+
#: Number of user-role messages in the active branch.
|
|
673
|
+
userMessages: int
|
|
674
|
+
#: Number of assistant-role messages in the active branch.
|
|
675
|
+
assistantMessages: int
|
|
676
|
+
#: Number of tool invocations the assistant issued.
|
|
677
|
+
toolCalls: int
|
|
678
|
+
#: Number of tool-result messages produced in reply.
|
|
679
|
+
toolResults: int
|
|
680
|
+
#: Total message count across all roles.
|
|
681
|
+
totalMessages: int
|
|
682
|
+
#: Cumulative token spend, broken out by category and totalled.
|
|
683
|
+
tokens: TokenTally
|
|
684
|
+
#: Cumulative monetary cost of the session so far.
|
|
685
|
+
cost: float
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ---------------------------------------------------------------------------
|
|
689
|
+
# Bash execution
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@dataclass(frozen=True, slots=True)
|
|
694
|
+
class ExecuteBashOptions:
|
|
695
|
+
"""Options for :meth:`SessionConductor.execute_bash`."""
|
|
696
|
+
|
|
697
|
+
#: When ``True``, the command's output is *not* recorded as a transcript
|
|
698
|
+
#: note, so it never re-enters the agent's context.
|
|
699
|
+
excludeFromContext: bool = False
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@dataclass(frozen=True, slots=True)
|
|
703
|
+
class BashOutcome:
|
|
704
|
+
"""The settled result of :meth:`SessionConductor.execute_bash`."""
|
|
705
|
+
|
|
706
|
+
#: Combined stdout + stderr of the command.
|
|
707
|
+
output: str
|
|
708
|
+
#: Process exit code (``0`` on success; non-zero, or ``1`` on a thrown
|
|
709
|
+
#: error).
|
|
710
|
+
exitCode: int
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
# Injectable agent surface
|
|
715
|
+
# ---------------------------------------------------------------------------
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class AgentStateLike(Protocol):
|
|
719
|
+
"""The slice of the framework ``AgentState`` the conductor reads
|
|
720
|
+
(message list, bound model, streaming flag). Field names are the
|
|
721
|
+
framework's own camelCase."""
|
|
722
|
+
|
|
723
|
+
@property
|
|
724
|
+
def messages(self) -> Sequence[AgentMessage]: ...
|
|
725
|
+
|
|
726
|
+
@property
|
|
727
|
+
def model(self) -> Any: ...
|
|
728
|
+
|
|
729
|
+
@property
|
|
730
|
+
def isStreaming(self) -> bool: ...
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
class AgentLike(Protocol):
|
|
734
|
+
"""The slice of the framework ``Agent`` the conductor drives.
|
|
735
|
+
|
|
736
|
+
The real ``Agent`` from :mod:`indusagi.agent` satisfies this
|
|
737
|
+
structurally; tests pass a scripted fake exposing the same
|
|
738
|
+
submit/subscribe/event surface with no network. Keeping the dependency to
|
|
739
|
+
a Protocol (not the concrete class) is what makes the conductor
|
|
740
|
+
unit-testable.
|
|
741
|
+
|
|
742
|
+
Port note: TS also declared an optional ``setThinkingLevel?``. A Python
|
|
743
|
+
Protocol cannot mark a method optional, so it is omitted here; the
|
|
744
|
+
conductor probes for ``set_thinking_level`` with ``getattr`` and, when
|
|
745
|
+
the agent lacks it, stores the level and applies it on the next model
|
|
746
|
+
bind — same behavior as TS.
|
|
747
|
+
"""
|
|
748
|
+
|
|
749
|
+
def subscribe(self, fn: Callable[[AgentEvent], None]) -> Callable[[], None]:
|
|
750
|
+
"""Subscribe to the raw framework event stream; returns an
|
|
751
|
+
unsubscribe thunk."""
|
|
752
|
+
...
|
|
753
|
+
|
|
754
|
+
async def prompt(self, input: str) -> None:
|
|
755
|
+
"""Run one prompt turn to settlement."""
|
|
756
|
+
...
|
|
757
|
+
|
|
758
|
+
def abort(self) -> None:
|
|
759
|
+
"""Cancel the in-flight turn, if any."""
|
|
760
|
+
...
|
|
761
|
+
|
|
762
|
+
@property
|
|
763
|
+
def state(self) -> AgentStateLike:
|
|
764
|
+
"""The agent's current state (message list, bound model, streaming
|
|
765
|
+
flag)."""
|
|
766
|
+
...
|
|
767
|
+
|
|
768
|
+
def replace_messages(self, messages: Sequence[AgentMessage]) -> None:
|
|
769
|
+
"""Replace the agent's message list (used on resume)."""
|
|
770
|
+
...
|
|
771
|
+
|
|
772
|
+
def set_model(self, model: Any) -> None:
|
|
773
|
+
"""Bind a different model for subsequent turns."""
|
|
774
|
+
...
|
|
775
|
+
|
|
776
|
+
def set_system_prompt(self, prompt: str) -> None:
|
|
777
|
+
"""Seed/replace the system prompt."""
|
|
778
|
+
...
|
|
779
|
+
|
|
780
|
+
def set_tools(self, tools: Sequence[AgentTool]) -> None:
|
|
781
|
+
"""Set the tools available to the loop."""
|
|
782
|
+
...
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
# ---------------------------------------------------------------------------
|
|
786
|
+
# Condense seam + retry policy
|
|
787
|
+
# ---------------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
class CondenseFn(Protocol):
|
|
791
|
+
"""The pluggable condense hook. Given the active branch's messages, it
|
|
792
|
+
returns the (smaller) message list to replace it with — synchronously or
|
|
793
|
+
as an awaitable. The real window-budget engine
|
|
794
|
+
(:mod:`induscode.window_budget`) is *structurally* one of these; the
|
|
795
|
+
default in the wave-2 conductor is an identity no-op.
|
|
796
|
+
|
|
797
|
+
Note: the framework's own ``runtime.memory`` compactor is **never** wired
|
|
798
|
+
in here — the two compaction engines stay distinct (plan rule 6).
|
|
799
|
+
"""
|
|
800
|
+
|
|
801
|
+
def __call__(
|
|
802
|
+
self, messages: Sequence[AgentMessage], force: bool = False
|
|
803
|
+
) -> Sequence[AgentMessage] | Awaitable[Sequence[AgentMessage]]: ...
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@dataclass(frozen=True, slots=True)
|
|
807
|
+
class RetryPolicy:
|
|
808
|
+
"""Tuning for the transient-fault auto-retry."""
|
|
809
|
+
|
|
810
|
+
#: Maximum retry attempts after the first try (default 2 at assembly).
|
|
811
|
+
maxAttempts: int
|
|
812
|
+
#: Base backoff in ms; doubled per attempt (default 250 at assembly).
|
|
813
|
+
baseDelayMs: int
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
# ---------------------------------------------------------------------------
|
|
817
|
+
# The conductor
|
|
818
|
+
# ---------------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
@dataclass(frozen=True, slots=True)
|
|
822
|
+
class SessionConductorOptions:
|
|
823
|
+
"""Options that configure a :class:`SessionConductor` at assembly time.
|
|
824
|
+
|
|
825
|
+
Only ``modelId`` is required; everything else has a sensible default
|
|
826
|
+
resolved by the conductor factory. The shape is intentionally small —
|
|
827
|
+
richer wiring (MCP, memory, provider routing) is attached by the factory,
|
|
828
|
+
not passed through this surface.
|
|
829
|
+
"""
|
|
830
|
+
|
|
831
|
+
#: Canonical id of the model to bind the session to.
|
|
832
|
+
modelId: str
|
|
833
|
+
#: Initial system prompt seeding the conversation.
|
|
834
|
+
system: str | None = None
|
|
835
|
+
#: Tools made available to the agent for this session.
|
|
836
|
+
tools: Sequence[AgentTool] | None = None
|
|
837
|
+
#: Initial reasoning effort for models that support it.
|
|
838
|
+
thinking: ThinkingLevel | None = None
|
|
839
|
+
#: Working directory the session is scoped to (defaults to process cwd).
|
|
840
|
+
workspace: str | None = None
|
|
841
|
+
#: Directory to persist the transcript into. When set, the conductor
|
|
842
|
+
#: backs its transcript store with a filesystem backend rooted here (one
|
|
843
|
+
#: ``<sessionId>.ndjson`` per session) so the conversation survives the
|
|
844
|
+
#: process and can be resumed. ``None`` (or an injected ``store`` dep)
|
|
845
|
+
#: keeps the default in-memory store — nothing is written to disk.
|
|
846
|
+
sessionsDir: str | None = None
|
|
847
|
+
#: Condense the transcript automatically when it nears the window
|
|
848
|
+
#: (default on; ``None`` means "factory default").
|
|
849
|
+
autoCompact: bool | None = None
|
|
850
|
+
#: Resolve the credential for a provider on each call. Threaded to the
|
|
851
|
+
#: framework ``Agent``, which calls it per request so short-lived OAuth
|
|
852
|
+
#: access tokens can be refreshed and providers with no env-var mapping
|
|
853
|
+
#: still authenticate. Returning ``None`` lets the framework fall back to
|
|
854
|
+
#: its own environment lookup. May be sync or async.
|
|
855
|
+
getApiKey: Callable[[str], Awaitable[str | None] | str | None] | None = None
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
class SessionConductor(Protocol):
|
|
859
|
+
"""The conductor of a single coding-agent session.
|
|
860
|
+
|
|
861
|
+
It owns the framework ``Agent``, threads persistence and auto-condense
|
|
862
|
+
through the turn loop, and exposes a small product API: submit input,
|
|
863
|
+
subscribe to the :data:`SessionSignal` stream, abort the in-flight turn,
|
|
864
|
+
read an immutable :class:`ConductorState` snapshot, resume a persisted
|
|
865
|
+
session, and optionally rotate the active model. This is the surface all
|
|
866
|
+
three run modes drive.
|
|
867
|
+
|
|
868
|
+
Port note: TS declared ``cycleModel?`` as optional; the Python Protocol
|
|
869
|
+
includes ``cycle_model`` and assemblies that do not support mid-session
|
|
870
|
+
model changes may leave it unimplemented — callers probe with ``getattr``.
|
|
871
|
+
"""
|
|
872
|
+
|
|
873
|
+
async def submit(self, input: str) -> ConductorState:
|
|
874
|
+
"""Submit user input as a new turn and run the agent to settle.
|
|
875
|
+
|
|
876
|
+
Streams :data:`SessionSignal` values to subscribers as the turn
|
|
877
|
+
progresses and resolves to the immutable :class:`ConductorState`
|
|
878
|
+
once the turn settles (success or fault).
|
|
879
|
+
|
|
880
|
+
When a turn is already in flight the input is **not** dropped: it is
|
|
881
|
+
handed to :meth:`enqueue` and run automatically as a later turn once
|
|
882
|
+
the current one settles. In that case ``submit`` resolves immediately
|
|
883
|
+
with the current snapshot rather than waiting for the queued turn.
|
|
884
|
+
"""
|
|
885
|
+
...
|
|
886
|
+
|
|
887
|
+
def enqueue(self, input: str, mode: QueueMode = "followUp") -> None:
|
|
888
|
+
"""Queue an input to run as a future turn. Used directly, or reached
|
|
889
|
+
via :meth:`submit` when the conductor is busy. Queued items drain in
|
|
890
|
+
order after the active turn settles, each running as its own turn.
|
|
891
|
+
Emits a ``queue`` signal so a UI can reflect the new depth."""
|
|
892
|
+
...
|
|
893
|
+
|
|
894
|
+
def pending_count(self) -> int:
|
|
895
|
+
"""How many inputs are currently waiting in the pending-input
|
|
896
|
+
queue."""
|
|
897
|
+
...
|
|
898
|
+
|
|
899
|
+
def pending_inputs(self) -> Sequence[QueuedInput]:
|
|
900
|
+
"""A read-only view of the queued inputs, oldest first."""
|
|
901
|
+
...
|
|
902
|
+
|
|
903
|
+
def clear_queue(self) -> None:
|
|
904
|
+
"""Discard every queued input. Emits a ``queue`` signal."""
|
|
905
|
+
...
|
|
906
|
+
|
|
907
|
+
def dequeue_last(self) -> str | None:
|
|
908
|
+
"""Remove and return the text of the most-recently queued input, or
|
|
909
|
+
``None`` when the queue is empty. Lets a UI pop the last entry back
|
|
910
|
+
into its prompt."""
|
|
911
|
+
...
|
|
912
|
+
|
|
913
|
+
def messages(self) -> Sequence[AgentMessage]:
|
|
914
|
+
"""The live transcript messages for the active branch.
|
|
915
|
+
|
|
916
|
+
A read-through onto the wrapped agent's running message list — what
|
|
917
|
+
the interactive UI renders as the conversation. The returned sequence
|
|
918
|
+
is the current contents at call time; re-read to observe later
|
|
919
|
+
turns."""
|
|
920
|
+
...
|
|
921
|
+
|
|
922
|
+
def model(self) -> Model | None:
|
|
923
|
+
"""The full framework ``Model`` object currently bound to the
|
|
924
|
+
session, or ``None`` when none could be resolved. Tracks the active
|
|
925
|
+
selection across :meth:`select_model` / ``cycle_model`` changes."""
|
|
926
|
+
...
|
|
927
|
+
|
|
928
|
+
def is_busy(self) -> bool:
|
|
929
|
+
"""Whether a turn is currently in flight (guards re-entrant
|
|
930
|
+
submit)."""
|
|
931
|
+
...
|
|
932
|
+
|
|
933
|
+
def available_models(self) -> list[ModelCardRef]:
|
|
934
|
+
"""The model catalog entries a picker lists, best-first. Derived from
|
|
935
|
+
the configured model matcher; returns ``[]`` when no matcher is wired
|
|
936
|
+
in."""
|
|
937
|
+
...
|
|
938
|
+
|
|
939
|
+
def select_model(self, id: str) -> None:
|
|
940
|
+
"""Bind a model by canonical id for subsequent turns. The companion
|
|
941
|
+
of ``cycle_model``; both route through the same selection path."""
|
|
942
|
+
...
|
|
943
|
+
|
|
944
|
+
async def condense(self) -> None:
|
|
945
|
+
"""Manually run the same transcript-condense path the auto-compactor
|
|
946
|
+
uses, emitting the existing ``compacted`` signal. Safe to call when
|
|
947
|
+
idle; a no-op when the condense hook returns the branch unchanged."""
|
|
948
|
+
...
|
|
949
|
+
|
|
950
|
+
async def fork(self, entryId: str) -> None:
|
|
951
|
+
"""Branch the transcript from a prior node. A new branch is opened
|
|
952
|
+
whose parent is ``entryId``; the agent's message list is rebound to
|
|
953
|
+
that branch's root→leaf path. The conductor's head advances onto the
|
|
954
|
+
chosen node."""
|
|
955
|
+
...
|
|
956
|
+
|
|
957
|
+
async def navigate_tree(self, nodeId: str) -> None:
|
|
958
|
+
"""Move the active leaf to ``nodeId``, rebuild that branch's
|
|
959
|
+
root→leaf path, and rebind the agent's message list to it. Used to
|
|
960
|
+
walk between existing branches without forking a new one."""
|
|
961
|
+
...
|
|
962
|
+
|
|
963
|
+
async def execute_bash(
|
|
964
|
+
self, command: str, opts: ExecuteBashOptions | None = None
|
|
965
|
+
) -> BashOutcome:
|
|
966
|
+
"""Run a shell command in the session workspace, returning its
|
|
967
|
+
combined stdout+stderr and exit code. Unless
|
|
968
|
+
``opts.excludeFromContext`` is set, the output is recorded as a
|
|
969
|
+
transcript note so it re-enters the agent's context. Never raises: a
|
|
970
|
+
spawn/exec failure resolves to a non-zero :class:`BashOutcome`."""
|
|
971
|
+
...
|
|
972
|
+
|
|
973
|
+
def stats(self) -> SessionStats:
|
|
974
|
+
"""A point-in-time :class:`SessionStats` tally for the active
|
|
975
|
+
session."""
|
|
976
|
+
...
|
|
977
|
+
|
|
978
|
+
def thinking_level(self) -> ThinkingLevel:
|
|
979
|
+
"""The reasoning effort currently applied to the session."""
|
|
980
|
+
...
|
|
981
|
+
|
|
982
|
+
def set_thinking_level(self, level: ThinkingLevel) -> None:
|
|
983
|
+
"""Set the reasoning effort for subsequent turns. Applied to the
|
|
984
|
+
agent when it exposes a setter; otherwise stored and applied on the
|
|
985
|
+
next model bind."""
|
|
986
|
+
...
|
|
987
|
+
|
|
988
|
+
def cycle_thinking_level(self) -> ThinkingLevel:
|
|
989
|
+
"""Advance the reasoning effort to the next level in the cycle,
|
|
990
|
+
applying it, and return the newly-selected level."""
|
|
991
|
+
...
|
|
992
|
+
|
|
993
|
+
def session_name(self) -> str | None:
|
|
994
|
+
"""The human-readable session name, or ``None`` when none is set."""
|
|
995
|
+
...
|
|
996
|
+
|
|
997
|
+
def set_session_name(self, name: str) -> None:
|
|
998
|
+
"""Assign a human-readable name to the session."""
|
|
999
|
+
...
|
|
1000
|
+
|
|
1001
|
+
def subscribe(self, handler: SignalHandler) -> Callable[[], None]:
|
|
1002
|
+
"""Register a handler for the :data:`SessionSignal` stream. Returns
|
|
1003
|
+
an unsubscribe function that removes the handler."""
|
|
1004
|
+
...
|
|
1005
|
+
|
|
1006
|
+
def abort(self) -> None:
|
|
1007
|
+
"""Cancel the in-flight turn, if any; emits an ``aborted`` fault
|
|
1008
|
+
signal."""
|
|
1009
|
+
...
|
|
1010
|
+
|
|
1011
|
+
def snapshot(self) -> ConductorState:
|
|
1012
|
+
"""Read an immutable snapshot of the current
|
|
1013
|
+
:class:`ConductorState`."""
|
|
1014
|
+
...
|
|
1015
|
+
|
|
1016
|
+
async def resume(self, sessionId: str) -> None:
|
|
1017
|
+
"""Restore a previously persisted session, replacing the current
|
|
1018
|
+
transcript and rebinding the agent to the restored model/leaf."""
|
|
1019
|
+
...
|
|
1020
|
+
|
|
1021
|
+
async def new_session(self) -> None:
|
|
1022
|
+
"""Abandon the current conversation and start a fresh, empty session.
|
|
1023
|
+
|
|
1024
|
+
Drops the agent's message history, opens a new session id (so later
|
|
1025
|
+
turns persist separately, leaving the prior transcript intact on
|
|
1026
|
+
disk), zeroes the usage tally, clears the pending-input queue and
|
|
1027
|
+
session name, and settles to ``idle``. Emits an ``idle`` signal so a
|
|
1028
|
+
subscribed UI re-renders the now-empty conversation. This is what
|
|
1029
|
+
``/clear`` (and ``/new``) drive."""
|
|
1030
|
+
...
|
|
1031
|
+
|
|
1032
|
+
def cycle_model(self, id: str) -> None:
|
|
1033
|
+
"""Rotate the active model for subsequent turns. Optional in TS: not
|
|
1034
|
+
every assembly supports mid-session model changes."""
|
|
1035
|
+
...
|