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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,1084 @@
1
+ """SessionConductor — the product-level orchestrator over the framework
2
+ ``Agent`` (port of TS ``src/conductor/conductor.ts``).
3
+
4
+ The framework ``Agent`` (from :mod:`indusagi.agent`) is a raw LLM conversation
5
+ loop: it streams a fine-grained ``AgentEvent`` surface and owns a flat message
6
+ list. The :class:`SessionConductorImpl` wraps one such agent and turns it into
7
+ a *coding-agent session*: it drives turns, projects the loop's events down to
8
+ the stable :data:`~induscode.conductor.contract.SessionSignal` stream (via
9
+ :func:`~induscode.conductor.signal_hub.translate_agent_event` + the
10
+ :class:`~induscode.conductor.signal_hub.SignalHub`), persists every produced
11
+ message into a branchable
12
+ :class:`~induscode.conductor.transcript_store.TranscriptStore`, retries
13
+ transient model faults, condenses the transcript when it nears the window, and
14
+ resolves an immutable :class:`~induscode.conductor.contract.ConductorState`
15
+ per turn.
16
+
17
+ Design stance (the clean-room divergence, carried over from the TS source):
18
+
19
+ - **Immutable state reducer.** The conductor never mutates a state object in
20
+ place. Each transition runs through
21
+ :func:`~induscode.conductor.contract.reduce_state`, which returns a fresh
22
+ ``ConductorState``; :meth:`SessionConductorImpl.snapshot` hands out that
23
+ value.
24
+ - **Ordered in-flight tool list.** Live tool calls are tracked as an ordered
25
+ list (start appends, end removes) — not a set of pending ids.
26
+ - **Typed discriminated faults.** Failures surface as a ``ConductorFault`` on
27
+ a ``fault`` signal — never as a fabricated assistant message and never as a
28
+ string sentinel.
29
+ - **Pluggable condense seam.** Auto-compaction emits a ``compacted`` signal
30
+ and calls an injectable ``CondenseFn``; the real window-budget engine plugs
31
+ in as a non-default hook (the framework's own ``runtime.memory`` compactor
32
+ is *never* wired here — the two compaction engines stay distinct, plan
33
+ rule 6). The default :func:`noop_condense` is an identity transform.
34
+
35
+ Everything heavy is injectable so tests run with no network and no disk: pass
36
+ a scripted fake ``AgentLike`` (same submit/subscribe/event surface), an
37
+ in-memory ``TranscriptStore``, a ``SignalHub``, a ``ModelMatcher`` over a
38
+ stubbed catalog, and a counted no-op ``sleep``.
39
+
40
+ Port notes
41
+ ----------
42
+ - TS ``#private`` fields become ``_attrs``; the ``#sleep`` injectable becomes
43
+ an ``async def sleep(ms)`` callable so retry backoff never actually waits in
44
+ tests.
45
+ - ``asyncio.CancelledError`` is **never swallowed**: every defensive
46
+ ``except Exception`` in the turn loop, persistence, condense, and bash paths
47
+ lets cancellation propagate (``CancelledError`` derives from
48
+ ``BaseException`` on 3.11+, and the bash runner re-raises it explicitly).
49
+ - ``child_process.spawn`` becomes :func:`asyncio.create_subprocess_shell`
50
+ with stderr folded into stdout — the same merged-output contract.
51
+ - The TS ``LazyAgent`` ``require``-style deferral is kept as a first-use
52
+ constructing wrapper, so the conductor module stays importable (and a
53
+ pure-fake test context never touches the live framework agent).
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import asyncio
59
+ import inspect
60
+ import os
61
+ import random
62
+ import time
63
+ from collections.abc import Awaitable, Callable, Sequence
64
+ from dataclasses import dataclass, replace
65
+ from typing import Any
66
+
67
+ from indusagi.agent import Agent, AgentEvent
68
+ from indusagi.ai import UsageCost, UserMessage, create_zero_usage
69
+
70
+ from induscode.conductor.catalog import ModelCatalog
71
+ from induscode.conductor.contract import (
72
+ AgentLike,
73
+ AgentMessage,
74
+ BashOutcome,
75
+ CompactedSignal,
76
+ CondenseFn,
77
+ ConductorState,
78
+ ExecuteBashOptions,
79
+ FaultAction,
80
+ FaultSignal,
81
+ HeadAction,
82
+ IdleSignal,
83
+ ModelAction,
84
+ ModelCardRef,
85
+ PersistedSignal,
86
+ PhaseAction,
87
+ QueueMode,
88
+ QueueSignal,
89
+ QueuedInput,
90
+ RetryPolicy,
91
+ SessionConductor,
92
+ SessionConductorOptions,
93
+ SessionStats,
94
+ SettledAction,
95
+ SignalHandler,
96
+ StateAction,
97
+ ThinkingLevel,
98
+ TokenTally,
99
+ Usage,
100
+ UsageAction,
101
+ conductor_fault,
102
+ reduce_state,
103
+ )
104
+ from induscode.conductor.matcher import ModelMatcher
105
+ from induscode.conductor.signal_hub import SignalHub, translate_agent_event
106
+ from induscode.conductor.skill_parse import parse_skill_invocation
107
+ from induscode.conductor.transcript_store import TranscriptStore, fs_backend
108
+
109
+ __all__ = [
110
+ "ConductorDeps",
111
+ "DEFAULT_RETRY",
112
+ "LazyAgent",
113
+ "SessionConductorImpl",
114
+ "create_session_conductor",
115
+ "noop_condense",
116
+ ]
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Condense seam
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def noop_condense(
125
+ messages: Sequence[AgentMessage], force: bool = False
126
+ ) -> Sequence[AgentMessage]:
127
+ """The default condense hook: returns the input unchanged (no-op)."""
128
+ return messages
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Retry policy + assembly defaults
133
+ # ---------------------------------------------------------------------------
134
+
135
+ #: Default transient-fault auto-retry tuning (TS ``DEFAULT_RETRY``).
136
+ DEFAULT_RETRY: RetryPolicy = RetryPolicy(maxAttempts=2, baseDelayMs=250)
137
+
138
+ #: Soft threshold (in branch length) past which auto-compaction is considered.
139
+ _DEFAULT_COMPACT_AT = 200
140
+
141
+ #: The reasoning-effort ladder the conductor cycles through. Ordered low→high
142
+ #: so :meth:`SessionConductorImpl.cycle_thinking_level` advances and wraps
143
+ #: predictably. The framework clamps an unsupported level to the active
144
+ #: model's ceiling.
145
+ _THINKING_LADDER: tuple[ThinkingLevel, ...] = ("off", "low", "medium", "high")
146
+
147
+ #: Default reasoning effort when none is configured at assembly.
148
+ _DEFAULT_THINKING: ThinkingLevel = "off"
149
+
150
+
151
+ async def _default_sleep(ms: float) -> None:
152
+ """The live sleep primitive (tests inject a counted no-op instead)."""
153
+ await asyncio.sleep(ms / 1000)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Dependency bundle
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ @dataclass(frozen=True, slots=True)
162
+ class ConductorDeps:
163
+ """Injectable collaborators (TS ``ConductorDeps``). Every field is
164
+ optional; the factory supplies a live default (a real ``Agent``, an
165
+ in-memory ``TranscriptStore``, a fresh ``SignalHub``, a matcher over the
166
+ live catalog). Tests override exactly what they need."""
167
+
168
+ #: The framework agent (or a scripted fake).
169
+ agent: AgentLike | None = None
170
+ #: Where the transcript persists.
171
+ store: TranscriptStore | None = None
172
+ #: The product-signal fan-out.
173
+ hub: SignalHub | None = None
174
+ #: Resolves model ids/patterns to catalog cards.
175
+ matcher: ModelMatcher | None = None
176
+ #: The condense hook used by the auto-compaction seam.
177
+ condense: CondenseFn | None = None
178
+ #: Auto-retry tuning.
179
+ retry: RetryPolicy | None = None
180
+ #: Branch length past which auto-compaction fires (default 200).
181
+ compactAt: int | None = None
182
+ #: Sleep primitive in milliseconds (injected so tests don't actually
183
+ #: wait).
184
+ sleep: Callable[[float], Awaitable[None]] | None = None
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Usage accumulation
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def _context_tokens_of(turn: Usage) -> int:
193
+ """Tokens occupying the context window as reported by a single turn's
194
+ usage.
195
+
196
+ Mirrors the framework's context-size accounting: a turn's ``totalTokens``
197
+ when present, else the sum of input + output + cache reads/writes. Applied
198
+ to the LATEST turn (not summed across turns), this is the live context
199
+ occupancy the footer's ``ctx:%`` reflects — distinct from the cumulative
200
+ session ``Usage``.
201
+ """
202
+ return turn.totalTokens or (
203
+ turn.input + turn.output + turn.cacheRead + turn.cacheWrite
204
+ )
205
+
206
+
207
+ def _accumulate_usage(running: Usage, delta: Usage) -> Usage:
208
+ """Fold a per-turn ``Usage`` into a running cumulative total."""
209
+ return Usage(
210
+ input=running.input + delta.input,
211
+ output=running.output + delta.output,
212
+ cacheRead=running.cacheRead + delta.cacheRead,
213
+ cacheWrite=running.cacheWrite + delta.cacheWrite,
214
+ totalTokens=running.totalTokens + delta.totalTokens,
215
+ cost=UsageCost(
216
+ input=running.cost.input + delta.cost.input,
217
+ output=running.cost.output + delta.cost.output,
218
+ cacheRead=running.cost.cacheRead + delta.cost.cacheRead,
219
+ cacheWrite=running.cost.cacheWrite + delta.cost.cacheWrite,
220
+ total=running.cost.total + delta.cost.total,
221
+ ),
222
+ )
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Transient-fault classification
227
+ # ---------------------------------------------------------------------------
228
+
229
+ _TRANSIENT_MARKERS: tuple[str, ...] = (
230
+ "429",
231
+ "rate limit",
232
+ "overloaded",
233
+ "timeout",
234
+ "timed out",
235
+ "econnreset",
236
+ "etimedout",
237
+ "503",
238
+ "502",
239
+ "500",
240
+ "unavailable",
241
+ "temporarily",
242
+ )
243
+
244
+
245
+ def _is_transient(error: object) -> bool:
246
+ """Heuristic: is this thrown error a transient model fault worth
247
+ retrying?"""
248
+ text = str(error).lower()
249
+ return any(marker in text for marker in _TRANSIENT_MARKERS)
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Tolerant event probing (raw events are frozen dataclasses from the live
254
+ # loop, but may be dict-shaped in tests — same defensive stance as the
255
+ # signal-hub translator)
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ def _event_field(event: Any, name: str, default: Any = None) -> Any:
260
+ if isinstance(event, dict):
261
+ return event.get(name, default)
262
+ return getattr(event, name, default)
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # The conductor implementation
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ class SessionConductorImpl:
271
+ """The :class:`~induscode.conductor.contract.SessionConductor` of a single
272
+ coding-agent session (TS ``SessionConductorImpl``).
273
+
274
+ Construct through :func:`create_session_conductor`, which resolves every
275
+ collaborator; the class itself only orchestrates.
276
+ """
277
+
278
+ __slots__ = (
279
+ "_agent",
280
+ "_store",
281
+ "_hub",
282
+ "_matcher",
283
+ "_condense",
284
+ "_retry",
285
+ "_compact_at",
286
+ "_auto_compact",
287
+ "_sleep",
288
+ "_state",
289
+ "_in_flight_tools",
290
+ "_busy",
291
+ "_bound_model",
292
+ "_queue",
293
+ "_draining",
294
+ "_thinking",
295
+ "_session_name",
296
+ "_workspace",
297
+ )
298
+
299
+ def __init__(
300
+ self,
301
+ options: SessionConductorOptions,
302
+ deps: ConductorDeps,
303
+ agent: AgentLike,
304
+ store: TranscriptStore,
305
+ ) -> None:
306
+ self._agent = agent
307
+ self._store = store
308
+ self._hub = deps.hub if deps.hub is not None else SignalHub()
309
+ self._matcher = deps.matcher
310
+ self._condense = deps.condense if deps.condense is not None else noop_condense
311
+ self._retry = deps.retry if deps.retry is not None else DEFAULT_RETRY
312
+ self._compact_at = (
313
+ deps.compactAt if deps.compactAt is not None else _DEFAULT_COMPACT_AT
314
+ )
315
+ self._auto_compact = options.autoCompact if options.autoCompact is not None else True
316
+ self._sleep = deps.sleep if deps.sleep is not None else _default_sleep
317
+ self._thinking: ThinkingLevel = (
318
+ options.thinking if options.thinking is not None else _DEFAULT_THINKING
319
+ )
320
+ self._workspace = options.workspace if options.workspace is not None else os.getcwd()
321
+
322
+ # The single source of truth, swapped wholesale by `_dispatch`.
323
+ self._state = ConductorState(
324
+ phase="idle",
325
+ head=store.head,
326
+ usage=create_zero_usage(),
327
+ contextTokens=0,
328
+ modelId=options.modelId,
329
+ # no fault initially
330
+ )
331
+
332
+ # Ordered list of in-flight tool calls (start appends; end removes by
333
+ # id), as (id, name) pairs.
334
+ self._in_flight_tools: list[tuple[str, str]] = []
335
+ # Whether a turn is currently in flight (guards re-entrant submit).
336
+ self._busy = False
337
+ # Inputs awaiting a turn, oldest first. Drained after the active turn
338
+ # settles.
339
+ self._queue: list[QueuedInput] = []
340
+ # Whether the queue drain loop is already running (re-entrancy guard).
341
+ self._draining = False
342
+ # The optional human-readable session name.
343
+ self._session_name: str | None = None
344
+
345
+ # Resolve the full model object for the initial selection. Prefer the
346
+ # matcher's catalog record (carries the framework model), falling back
347
+ # to whatever the agent was assembled with.
348
+ initial = (
349
+ self._matcher.resolve_card(options.modelId)
350
+ if self._matcher is not None
351
+ else None
352
+ )
353
+ self._bound_model: Any = initial.model if initial is not None else agent.state.model
354
+
355
+ # ---- public surface ----
356
+
357
+ def subscribe(self, handler: SignalHandler) -> Callable[[], None]:
358
+ return self._hub.subscribe(handler)
359
+
360
+ def snapshot(self) -> ConductorState:
361
+ return self._state
362
+
363
+ def messages(self) -> Sequence[AgentMessage]:
364
+ return self._agent.state.messages
365
+
366
+ def model(self) -> Any:
367
+ return self._bound_model
368
+
369
+ def is_busy(self) -> bool:
370
+ return self._busy
371
+
372
+ def available_models(self) -> list[ModelCardRef]:
373
+ if self._matcher is None:
374
+ return []
375
+ # An empty selector yields the whole field as ranked card refs.
376
+ return list(self._matcher.resolve_all(""))
377
+
378
+ def abort(self) -> None:
379
+ self._agent.abort()
380
+ self._dispatch(
381
+ FaultAction(fault=conductor_fault("aborted", "turn aborted by caller"))
382
+ )
383
+ self._hub.emit(
384
+ FaultSignal(fault=conductor_fault("aborted", "turn aborted by caller"))
385
+ )
386
+ self._hub.emit(IdleSignal())
387
+
388
+ async def submit(self, input: str) -> ConductorState:
389
+ # Busy: do not drop the input and do not fault. Queue it to run as a
390
+ # later turn and hand back the current snapshot. The drain loop,
391
+ # started by the in-flight turn once it settles, runs it
392
+ # automatically.
393
+ if self._busy:
394
+ self.enqueue(input)
395
+ return self._state
396
+ self._busy = True
397
+ try:
398
+ state = await self._run_turn(input)
399
+ # The just-finished turn owns draining whatever piled up while it
400
+ # ran.
401
+ await self._drain_queue()
402
+ return state
403
+ finally:
404
+ self._busy = False
405
+
406
+ def enqueue(self, input: str, mode: QueueMode = "followUp") -> None:
407
+ self._queue.append(QueuedInput(mode=mode, text=input))
408
+ self._hub.emit(QueueSignal(count=len(self._queue)))
409
+
410
+ def pending_count(self) -> int:
411
+ return len(self._queue)
412
+
413
+ def pending_inputs(self) -> Sequence[QueuedInput]:
414
+ return list(self._queue)
415
+
416
+ def clear_queue(self) -> None:
417
+ if len(self._queue) == 0:
418
+ return
419
+ self._queue.clear()
420
+ self._hub.emit(QueueSignal(count=0))
421
+
422
+ def dequeue_last(self) -> str | None:
423
+ if len(self._queue) == 0:
424
+ return None
425
+ popped = self._queue.pop()
426
+ self._hub.emit(QueueSignal(count=len(self._queue)))
427
+ return popped.text
428
+
429
+ async def _drain_queue(self) -> None:
430
+ """Drain the pending-input queue in order, running each entry as its
431
+ own turn. ``steer``-mode entries are taken ahead of plain follow-ups
432
+ so an interrupt is honored first. Re-entrancy is guarded so a
433
+ ``submit`` that fires mid-drain (it merely enqueues) does not start a
434
+ second loop. Runs under the busy flag the active turn already holds.
435
+ """
436
+ if self._draining:
437
+ return
438
+ self._draining = True
439
+ try:
440
+ while len(self._queue) > 0:
441
+ next_input = self._take_next()
442
+ self._hub.emit(QueueSignal(count=len(self._queue)))
443
+ await self._run_turn(next_input.text)
444
+ finally:
445
+ self._draining = False
446
+
447
+ def _take_next(self) -> QueuedInput:
448
+ """Pop the next queued input, preferring a ``steer`` entry over a
449
+ follow-up."""
450
+ for at, queued in enumerate(self._queue):
451
+ if queued.mode == "steer":
452
+ return self._queue.pop(at)
453
+ return self._queue.pop(0)
454
+
455
+ async def _run_turn(self, input: str) -> ConductorState:
456
+ """Run a single prompt turn to settlement: unwrap a skill block, drive
457
+ the agent through the retry loop, then persist + condense + settle.
458
+ Returns the resulting state. Does not touch the busy flag or the
459
+ queue — those are the caller's concern."""
460
+ # A leading <skill …> block is a product-level invocation; we expand
461
+ # it to the body text the agent actually sends. (Routing the named
462
+ # skill to its handler is a higher layer's job; here we only unwrap
463
+ # the carried message.)
464
+ skill = parse_skill_invocation(input)
465
+ prompt = skill.body if skill is not None else input
466
+
467
+ self._in_flight_tools.clear()
468
+ self._dispatch(PhaseAction(phase="streaming"))
469
+
470
+ baseline = len(self._agent.state.messages)
471
+ clean = await self._run_with_retry(prompt)
472
+
473
+ # A faulted turn keeps its typed fault and stays `faulted`: we do NOT
474
+ # persist a partial tail, condense, or settle to idle on top of it.
475
+ # The caller reads `fault` off the returned state and decides what to
476
+ # do.
477
+ if not clean:
478
+ self._hub.emit(IdleSignal())
479
+ return self._state
480
+
481
+ await self._persist_tail(baseline)
482
+ await self._maybe_condense()
483
+
484
+ # Persistence/condense may themselves fault; only settle if still
485
+ # clean.
486
+ if self._state.phase != "faulted":
487
+ self._dispatch(SettledAction())
488
+ self._dispatch(HeadAction(head=self._store.head))
489
+ self._hub.emit(IdleSignal())
490
+ return self._state
491
+
492
+ async def new_session(self) -> None:
493
+ # Stop any in-flight stream so it cannot append into the session we
494
+ # are about to abandon (abort is a no-op when idle).
495
+ self._agent.abort()
496
+ self._busy = False
497
+ self._draining = False
498
+ self._queue.clear()
499
+ self._in_flight_tools.clear()
500
+
501
+ # Drop the conversation and open a brand-new session id; the prior
502
+ # transcript file is left untouched on disk.
503
+ self._agent.replace_messages([])
504
+ self._store.start_new_session(_new_session_id())
505
+ self._session_name = None
506
+
507
+ self._state = ConductorState(
508
+ phase="idle",
509
+ head=self._store.head,
510
+ usage=create_zero_usage(),
511
+ contextTokens=0,
512
+ modelId=self._state.modelId,
513
+ )
514
+
515
+ # Pulse the stream so a subscribed UI re-reads messages() (now empty)
516
+ # and the fresh idle state.
517
+ self._hub.emit(IdleSignal())
518
+
519
+ async def resume(self, sessionId: str) -> None:
520
+ loaded = await self._store.load(sessionId)
521
+ if not loaded:
522
+ fault = conductor_fault("persistence", f'no session "{sessionId}" to resume')
523
+ self._dispatch(FaultAction(fault=fault))
524
+ self._hub.emit(FaultSignal(fault=fault))
525
+ return
526
+ # Rehydrate the agent from the restored active branch.
527
+ branch = self._store.path_to()
528
+ self._agent.replace_messages([entry.content for entry in branch])
529
+ self._dispatch(HeadAction(head=self._store.head))
530
+ self._dispatch(SettledAction())
531
+ self._hub.emit(IdleSignal())
532
+
533
+ def cycle_model(self, id: str) -> None:
534
+ self._bind_model(id)
535
+
536
+ def select_model(self, id: str) -> None:
537
+ self._bind_model(id)
538
+
539
+ def _bind_model(self, id: str) -> None:
540
+ """Resolve a model by canonical id, bind it on the agent for
541
+ subsequent turns, track the full model object, and record the new id
542
+ in state. A miss surfaces a typed ``model`` fault and leaves the
543
+ current selection untouched."""
544
+ card = self._matcher.resolve_card(id) if self._matcher is not None else None
545
+ if card is None:
546
+ fault = conductor_fault("model", f'no model matches "{id}"')
547
+ self._dispatch(FaultAction(fault=fault))
548
+ self._hub.emit(FaultSignal(fault=fault))
549
+ return
550
+ self._agent.set_model(card.model)
551
+ self._bound_model = card.model
552
+ # Re-apply the tracked reasoning effort to the freshly bound model;
553
+ # the framework clamps it to the model's ceiling.
554
+ self._apply_thinking(self._thinking)
555
+ self._dispatch(ModelAction(modelId=card.id))
556
+
557
+ # ---- turn driving ----
558
+
559
+ async def _run_with_retry(self, prompt: str) -> bool:
560
+ """Run one prompt turn, retrying transient model faults with
561
+ exponential backoff. A non-transient raise, or exhausting the retry
562
+ budget, surfaces a typed ``ConductorFault`` (never a fabricated
563
+ assistant message) and ends the loop.
564
+
565
+ Returns ``True`` when the turn completed without a raised fault,
566
+ ``False`` when a terminal model fault was dispatched. (A streaming
567
+ ``error`` sub-event that does *not* raise is handled separately — it
568
+ dispatches a fault from :meth:`_on_agent_event` and the caller's
569
+ ``phase`` guard prevents a settle on top of it.)
570
+ ``asyncio.CancelledError`` always propagates — never retried, never
571
+ converted to a fault.
572
+ """
573
+ attempt = 0
574
+ while True:
575
+ unsubscribe = self._agent.subscribe(self._on_agent_event)
576
+ try:
577
+ await self._agent.prompt(prompt)
578
+ except asyncio.CancelledError:
579
+ unsubscribe()
580
+ raise
581
+ except Exception as error: # noqa: BLE001 — classified below
582
+ unsubscribe()
583
+ if attempt < self._retry.maxAttempts and _is_transient(error):
584
+ attempt += 1
585
+ self._hub.emit(
586
+ FaultSignal(
587
+ fault=conductor_fault(
588
+ "model", f"transient model fault; retry {attempt}", error
589
+ )
590
+ )
591
+ )
592
+ await self._sleep(self._retry.baseDelayMs * 2 ** (attempt - 1))
593
+ continue
594
+ fault = conductor_fault("model", "model turn failed", error)
595
+ self._dispatch(FaultAction(fault=fault))
596
+ self._hub.emit(FaultSignal(fault=fault))
597
+ return False
598
+ else:
599
+ unsubscribe()
600
+ return True
601
+
602
+ def _on_agent_event(self, event: AgentEvent) -> None:
603
+ """Translate one raw framework event to product signals, emit them,
604
+ and keep the conductor's own bookkeeping (ordered in-flight tools,
605
+ cumulative usage, tooling/streaming phase) in step."""
606
+ # Phase + in-flight tracking driven off the raw event before
607
+ # projection.
608
+ event_type = _event_field(event, "type")
609
+ if event_type == "tool_execution_start":
610
+ self._in_flight_tools.append(
611
+ (_event_field(event, "toolCallId"), _event_field(event, "toolName"))
612
+ )
613
+ self._dispatch(PhaseAction(phase="tooling"))
614
+ elif event_type == "tool_execution_end":
615
+ tool_call_id = _event_field(event, "toolCallId")
616
+ for at, (tool_id, _name) in enumerate(self._in_flight_tools):
617
+ if tool_id == tool_call_id:
618
+ del self._in_flight_tools[at]
619
+ break
620
+ if len(self._in_flight_tools) == 0 and self._state.phase == "tooling":
621
+ self._dispatch(PhaseAction(phase="streaming"))
622
+
623
+ for signal in translate_agent_event(event):
624
+ if signal.kind == "turn_end":
625
+ self._dispatch(
626
+ UsageAction(
627
+ usage=_accumulate_usage(self._state.usage, signal.usage),
628
+ contextTokens=_context_tokens_of(signal.usage),
629
+ )
630
+ )
631
+ elif signal.kind == "fault":
632
+ self._dispatch(FaultAction(fault=signal.fault))
633
+ self._hub.emit(signal)
634
+
635
+ # ---- persistence ----
636
+
637
+ async def _persist_tail(self, baseline: int) -> None:
638
+ """Persist every message the turn appended beyond ``baseline`` into
639
+ the transcript store (user prompt, assistant turns, tool results).
640
+ Emits one ``persisted`` signal per committed node so consumers can
641
+ correlate."""
642
+ messages = self._agent.state.messages
643
+ for i in range(baseline, len(messages)):
644
+ message = messages[i]
645
+ try:
646
+ entry = await self._store.append(message)
647
+ self._hub.emit(PersistedSignal(entryId=entry.id))
648
+ except asyncio.CancelledError:
649
+ raise
650
+ except Exception as error: # noqa: BLE001 — surfaced as a fault
651
+ fault = conductor_fault(
652
+ "persistence", "failed to persist transcript node", error
653
+ )
654
+ self._dispatch(FaultAction(fault=fault))
655
+ self._hub.emit(FaultSignal(fault=fault))
656
+ return
657
+
658
+ # ---- auto-compaction seam ----
659
+
660
+ async def _maybe_condense(self) -> None:
661
+ """The auto-compaction seam. When enabled and the active branch has
662
+ grown past the soft threshold, emit a ``compacted`` signal, run the
663
+ pluggable condense hook over the branch messages, and (if it shrank
664
+ the list) rebind the agent's context to the condensed messages. The
665
+ real window-budget engine plugs in here as a non-default
666
+ ``CondenseFn``."""
667
+ if not self._auto_compact:
668
+ return
669
+ branch = self._store.path_to()
670
+ if len(branch) < self._compact_at:
671
+ return
672
+ await self._run_condense(False)
673
+
674
+ async def condense(self) -> None:
675
+ """Manually drive the same condense path the auto-compactor uses,
676
+ regardless of the auto-compaction flag or the soft threshold. Emits a
677
+ ``compacted`` signal and rebinds the agent's context when the hook
678
+ shrinks the branch. Safe to invoke while idle."""
679
+ # A manual `/compact` forces aggressive compaction regardless of size
680
+ # — the user explicitly asked to reclaim context, so the hook keeps
681
+ # only a small recent tail rather than waiting for the auto-compaction
682
+ # threshold.
683
+ await self._run_condense(True)
684
+
685
+ # ---- branch navigation ----
686
+
687
+ async def fork(self, entryId: str) -> None:
688
+ try:
689
+ await self._store.branch_at(entryId)
690
+ except asyncio.CancelledError:
691
+ raise
692
+ except Exception as error: # noqa: BLE001 — surfaced as a fault
693
+ fault = conductor_fault("persistence", f'cannot fork from "{entryId}"', error)
694
+ self._dispatch(FaultAction(fault=fault))
695
+ self._hub.emit(FaultSignal(fault=fault))
696
+ return
697
+ self._rebind_branch()
698
+
699
+ async def navigate_tree(self, nodeId: str) -> None:
700
+ try:
701
+ await self._store.branch_at(nodeId)
702
+ except asyncio.CancelledError:
703
+ raise
704
+ except Exception as error: # noqa: BLE001 — surfaced as a fault
705
+ fault = conductor_fault(
706
+ "persistence", f'cannot navigate to "{nodeId}"', error
707
+ )
708
+ self._dispatch(FaultAction(fault=fault))
709
+ self._hub.emit(FaultSignal(fault=fault))
710
+ return
711
+ self._rebind_branch()
712
+
713
+ def _rebind_branch(self) -> None:
714
+ """Rebuild the active root→leaf branch and replay it onto the agent,
715
+ then sync the head into state. Shared by :meth:`fork` and
716
+ :meth:`navigate_tree` after the store's leaf has moved."""
717
+ branch = self._store.path_to()
718
+ self._agent.replace_messages([entry.content for entry in branch])
719
+ self._dispatch(HeadAction(head=self._store.head))
720
+ self._dispatch(SettledAction())
721
+ self._hub.emit(IdleSignal())
722
+
723
+ # ---- shell ----
724
+
725
+ async def execute_bash(
726
+ self, command: str, opts: ExecuteBashOptions | None = None
727
+ ) -> BashOutcome:
728
+ options = opts if opts is not None else ExecuteBashOptions()
729
+ outcome = await _run_shell_command(command, self._workspace)
730
+ if not options.excludeFromContext:
731
+ try:
732
+ await self._store.append(
733
+ _bash_note_message(command, outcome),
734
+ "note",
735
+ {"command": command, "exitCode": outcome.exitCode},
736
+ )
737
+ self._dispatch(HeadAction(head=self._store.head))
738
+ except asyncio.CancelledError:
739
+ raise
740
+ except Exception as error: # noqa: BLE001 — surfaced as a fault
741
+ fault = conductor_fault(
742
+ "persistence", "failed to record bash output", error
743
+ )
744
+ self._dispatch(FaultAction(fault=fault))
745
+ self._hub.emit(FaultSignal(fault=fault))
746
+ return outcome
747
+
748
+ # ---- statistics ----
749
+
750
+ def stats(self) -> SessionStats:
751
+ messages = self._agent.state.messages
752
+ user_messages = 0
753
+ assistant_messages = 0
754
+ tool_results = 0
755
+ tool_calls = 0
756
+ for message in messages:
757
+ role = _event_field(message, "role")
758
+ if role == "user":
759
+ user_messages += 1
760
+ elif role == "assistant":
761
+ assistant_messages += 1
762
+ content = _event_field(message, "content") or ()
763
+ if isinstance(content, Sequence) and not isinstance(content, str):
764
+ for part in content:
765
+ if _event_field(part, "type") == "toolCall":
766
+ tool_calls += 1
767
+ elif role == "toolResult":
768
+ tool_results += 1
769
+ usage = self._state.usage
770
+ return SessionStats(
771
+ sessionId=self._state.head.sessionId,
772
+ userMessages=user_messages,
773
+ assistantMessages=assistant_messages,
774
+ toolCalls=tool_calls,
775
+ toolResults=tool_results,
776
+ totalMessages=len(messages),
777
+ tokens=TokenTally(
778
+ input=usage.input,
779
+ output=usage.output,
780
+ cacheRead=usage.cacheRead,
781
+ cacheWrite=usage.cacheWrite,
782
+ total=usage.totalTokens,
783
+ ),
784
+ cost=usage.cost.total,
785
+ )
786
+
787
+ # ---- reasoning effort ----
788
+
789
+ def thinking_level(self) -> ThinkingLevel:
790
+ return self._thinking
791
+
792
+ def set_thinking_level(self, level: ThinkingLevel) -> None:
793
+ self._thinking = level
794
+ self._apply_thinking(level)
795
+
796
+ def cycle_thinking_level(self) -> ThinkingLevel:
797
+ # TS `indexOf` yields -1 on a miss, advancing to the ladder's first
798
+ # rung; `.index` raises, so probe first.
799
+ at = (
800
+ _THINKING_LADDER.index(self._thinking)
801
+ if self._thinking in _THINKING_LADDER
802
+ else -1
803
+ )
804
+ next_level = _THINKING_LADDER[(at + 1) % len(_THINKING_LADDER)]
805
+ self.set_thinking_level(next_level)
806
+ return next_level
807
+
808
+ def _apply_thinking(self, level: ThinkingLevel) -> None:
809
+ """Apply a reasoning effort to the agent when it exposes a setter
810
+ (the TS optional ``setThinkingLevel?.()`` probe); otherwise the level
811
+ stays stored and is re-applied on the next model bind."""
812
+ setter = getattr(self._agent, "set_thinking_level", None)
813
+ if callable(setter):
814
+ setter(level)
815
+
816
+ # ---- session name ----
817
+
818
+ def session_name(self) -> str | None:
819
+ return self._session_name
820
+
821
+ def set_session_name(self, name: str) -> None:
822
+ self._session_name = name
823
+
824
+ async def _run_condense(self, force: bool) -> None:
825
+ """The shared condense body: flip to ``condensing``, emit the
826
+ ``compacted`` signal, run the pluggable hook over the active branch,
827
+ and rebind the agent's context when the hook returns a shorter list. A
828
+ raise surfaces an ``overflow`` fault."""
829
+ branch = self._store.path_to()
830
+ self._dispatch(PhaseAction(phase="condensing"))
831
+ self._hub.emit(CompactedSignal())
832
+ try:
833
+ before = [entry.content for entry in branch]
834
+ result = self._condense(before, force)
835
+ after = await result if inspect.isawaitable(result) else result
836
+ if len(after) < len(before):
837
+ self._agent.replace_messages(after)
838
+ except asyncio.CancelledError:
839
+ raise
840
+ except Exception as error: # noqa: BLE001 — surfaced as a fault
841
+ fault = conductor_fault("overflow", "auto-condense failed", error)
842
+ self._dispatch(FaultAction(fault=fault))
843
+ self._hub.emit(FaultSignal(fault=fault))
844
+
845
+ # ---- reducer plumbing ----
846
+
847
+ def _dispatch(self, action: StateAction) -> None:
848
+ """Run an action through the immutable reducer and swap in the new
849
+ state."""
850
+ self._state = reduce_state(self._state, action)
851
+
852
+
853
+ # ---------------------------------------------------------------------------
854
+ # Factory
855
+ # ---------------------------------------------------------------------------
856
+
857
+
858
+ def create_session_conductor(
859
+ options: SessionConductorOptions,
860
+ deps: ConductorDeps | None = None,
861
+ ) -> SessionConductor:
862
+ """Construct a :class:`~induscode.conductor.contract.SessionConductor`.
863
+
864
+ Resolves (or accepts) every collaborator, then returns the orchestrator:
865
+
866
+ - **Agent** — ``deps.agent`` if supplied (the scripted-fake seam tests
867
+ use), else a live framework ``Agent`` bound to the model
868
+ ``options.modelId`` resolves to via the ``ModelMatcher`` over the
869
+ ``ModelCatalog``.
870
+ - **Store** — ``deps.store`` if supplied, else a fresh in-memory
871
+ ``TranscriptStore`` for a new session id (filesystem-backed when
872
+ ``options.sessionsDir`` is set).
873
+ - **Hub / matcher / condense / retry** — defaulted from ``deps`` or
874
+ fresh.
875
+
876
+ The conductor's public verbs (``submit``, ``subscribe``, ``abort``,
877
+ ``snapshot``, ``resume``, ``cycle_model``) all read against this assembly.
878
+
879
+ :param options: session configuration (``modelId`` required;
880
+ system/tools/thinking/workspace/autoCompact optional)
881
+ :param deps: injectable collaborators; all optional, live defaults
882
+ supplied
883
+ """
884
+ resolved = deps if deps is not None else ConductorDeps()
885
+ # Persist to disk when a sessions directory is supplied (the live CLI
886
+ # path), so the conversation can be resumed; otherwise keep the in-memory
887
+ # default (tests, headless probes, or an explicitly injected store).
888
+ store = (
889
+ resolved.store
890
+ if resolved.store is not None
891
+ else TranscriptStore(
892
+ _new_session_id(),
893
+ backend=fs_backend(options.sessionsDir)
894
+ if options.sessionsDir is not None
895
+ else None,
896
+ )
897
+ )
898
+ # On the live path (no injected agent) default a matcher over the
899
+ # framework catalog and thread it into the conductor instance — otherwise
900
+ # `_matcher` is None and `available_models()` / model switching come up
901
+ # empty. A test that injects its own agent keeps whatever matcher it
902
+ # passed (possibly none).
903
+ matcher = (
904
+ resolved.matcher
905
+ if resolved.matcher is not None
906
+ else (ModelMatcher(ModelCatalog()) if resolved.agent is None else None)
907
+ )
908
+ resolved_deps = replace(resolved, matcher=matcher)
909
+ agent = (
910
+ resolved.agent
911
+ if resolved.agent is not None
912
+ else _build_live_agent(options, resolved_deps)
913
+ )
914
+ # Seed the live agent's context (system/tools/thinking) from options when
915
+ # we own it.
916
+ if resolved.agent is None:
917
+ if options.system is not None:
918
+ agent.set_system_prompt(options.system)
919
+ if options.tools is not None:
920
+ agent.set_tools(options.tools)
921
+ if options.thinking is not None:
922
+ setter = getattr(agent, "set_thinking_level", None)
923
+ if callable(setter):
924
+ setter(options.thinking)
925
+ return SessionConductorImpl(options, resolved_deps, agent, store)
926
+
927
+
928
+ # ---------------------------------------------------------------------------
929
+ # Live-agent assembly (only reached when no agent is injected)
930
+ # ---------------------------------------------------------------------------
931
+
932
+
933
+ def _build_live_agent(
934
+ options: SessionConductorOptions, deps: ConductorDeps
935
+ ) -> AgentLike:
936
+ """Build a live framework ``Agent`` bound to the model ``options.modelId``
937
+ resolves to. The construction itself is deferred behind :class:`LazyAgent`
938
+ so a conductor assembled on the live path stays cheap until the first
939
+ turn."""
940
+ matcher = deps.matcher if deps.matcher is not None else ModelMatcher(ModelCatalog())
941
+ card = matcher.resolve_card(options.modelId)
942
+ if card is None:
943
+ raise ValueError(
944
+ f'create_session_conductor: no model matches "{options.modelId}"'
945
+ )
946
+ return LazyAgent(card.model, options)
947
+
948
+
949
+ class LazyAgent:
950
+ """A thin ``AgentLike`` that constructs the real framework ``Agent`` on
951
+ first use (TS ``LazyAgent``). The wrapper presents the plain ``AgentLike``
952
+ surface the conductor expects; tests bypass it entirely by passing
953
+ ``deps.agent``."""
954
+
955
+ __slots__ = ("_real", "_model", "_options")
956
+
957
+ def __init__(self, model: Any, options: SessionConductorOptions) -> None:
958
+ self._real: AgentLike | None = None
959
+ self._model = model
960
+ self._options = options
961
+
962
+ def _ensure(self) -> AgentLike:
963
+ if self._real is None:
964
+ self._real = _make_agent(self._model, self._options)
965
+ return self._real
966
+
967
+ def subscribe(self, fn: Callable[[AgentEvent], None]) -> Callable[[], None]:
968
+ return self._ensure().subscribe(fn)
969
+
970
+ async def prompt(self, input: str) -> None:
971
+ await self._ensure().prompt(input)
972
+
973
+ def abort(self) -> None:
974
+ self._ensure().abort()
975
+
976
+ @property
977
+ def state(self) -> Any:
978
+ return self._ensure().state
979
+
980
+ def replace_messages(self, messages: Sequence[AgentMessage]) -> None:
981
+ self._ensure().replace_messages(messages)
982
+
983
+ def set_model(self, model: Any) -> None:
984
+ self._ensure().set_model(model)
985
+
986
+ def set_system_prompt(self, prompt: str) -> None:
987
+ self._ensure().set_system_prompt(prompt)
988
+
989
+ def set_tools(self, tools: Sequence[Any]) -> None:
990
+ self._ensure().set_tools(tools)
991
+
992
+ def set_thinking_level(self, level: ThinkingLevel) -> None:
993
+ setter = getattr(self._ensure(), "set_thinking_level", None)
994
+ if callable(setter):
995
+ setter(level)
996
+
997
+
998
+ def _make_agent(model: Any, options: SessionConductorOptions) -> AgentLike:
999
+ """Instantiate the framework ``Agent`` and shape it to ``AgentLike``.
1000
+
1001
+ Constructed from :class:`indusagi.agent.Agent`, seeded with the resolved
1002
+ model and the optional system/tools/thinking from ``options`` via
1003
+ ``initial_state`` (the framework's ``AgentState`` field spellings).
1004
+ ``get_api_key`` is threaded through so the framework can rotate
1005
+ short-lived OAuth tokens and authenticate providers with no env-var
1006
+ mapping; omitted entirely when no resolver was supplied so the framework
1007
+ keeps its default env-only behaviour.
1008
+ """
1009
+ initial_state: dict[str, Any] = {"model": model}
1010
+ if options.system is not None:
1011
+ initial_state["systemPrompt"] = options.system
1012
+ if options.tools is not None:
1013
+ initial_state["tools"] = list(options.tools)
1014
+ if options.thinking is not None:
1015
+ initial_state["thinkingLevel"] = options.thinking
1016
+ kwargs: dict[str, Any] = {}
1017
+ if options.getApiKey is not None:
1018
+ kwargs["get_api_key"] = options.getApiKey
1019
+ agent = Agent(initial_state=initial_state, **kwargs)
1020
+ # The framework class satisfies AgentLike structurally (the TS cast).
1021
+ return agent # type: ignore[return-value]
1022
+
1023
+
1024
+ # ---------------------------------------------------------------------------
1025
+ # Helpers
1026
+ # ---------------------------------------------------------------------------
1027
+
1028
+ _BASE36_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
1029
+
1030
+
1031
+ def _to_base36(value: int) -> str:
1032
+ """``Number.prototype.toString(36)`` for a non-negative integer."""
1033
+ if value == 0:
1034
+ return "0"
1035
+ out: list[str] = []
1036
+ while value > 0:
1037
+ value, rem = divmod(value, 36)
1038
+ out.append(_BASE36_DIGITS[rem])
1039
+ return "".join(reversed(out))
1040
+
1041
+
1042
+ def _new_session_id() -> str:
1043
+ """Mint a fresh session id (timestamp-sorted, TS
1044
+ ``s_<epoch36>_<rand8>``)."""
1045
+ stamp = _to_base36(int(time.time() * 1000))
1046
+ suffix = "".join(random.choices(_BASE36_DIGITS, k=8))
1047
+ return f"s_{stamp}_{suffix}"
1048
+
1049
+
1050
+ async def _run_shell_command(command: str, cwd: str) -> BashOutcome:
1051
+ """Run a shell command in ``cwd``, resolving to its combined
1052
+ stdout+stderr and exit code. Never raises (cancellation excepted): a
1053
+ spawn failure (binary missing, permission) resolves to a non-zero
1054
+ :class:`BashOutcome` carrying the error text."""
1055
+ try:
1056
+ process = await asyncio.create_subprocess_shell(
1057
+ command,
1058
+ cwd=cwd,
1059
+ stdout=asyncio.subprocess.PIPE,
1060
+ # Fold stderr into stdout — the TS runner concatenated both
1061
+ # streams into one buffer.
1062
+ stderr=asyncio.subprocess.STDOUT,
1063
+ )
1064
+ stdout, _stderr = await process.communicate()
1065
+ output = stdout.decode("utf-8", errors="replace") if stdout else ""
1066
+ exit_code = process.returncode if process.returncode is not None else 1
1067
+ return BashOutcome(output=output, exitCode=exit_code)
1068
+ except asyncio.CancelledError:
1069
+ raise
1070
+ except Exception as error: # noqa: BLE001 — never-raise contract
1071
+ return BashOutcome(output=_error_text(error), exitCode=1)
1072
+
1073
+
1074
+ def _error_text(error: object) -> str:
1075
+ """Render a raised value to a single readable line."""
1076
+ return str(error)
1077
+
1078
+
1079
+ def _bash_note_message(command: str, outcome: BashOutcome) -> AgentMessage:
1080
+ """Build a user-role ``AgentMessage`` capturing a bash command and its
1081
+ output, so a recorded run re-enters the agent's context as plain context
1082
+ text."""
1083
+ body = f"$ {command}\n{outcome.output}".rstrip()
1084
+ return UserMessage(content=body, timestamp=int(time.time() * 1000))