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,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))
|