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,549 @@
|
|
|
1
|
+
"""Boot helper: assemble a :class:`~induscode.conductor.SessionConductor` for
|
|
2
|
+
a runner.
|
|
3
|
+
|
|
4
|
+
Port of TS ``src/boot/runners/session.ts`` — the agent-assembly choreography
|
|
5
|
+
all three runners share, so none of them re-derives the model id and session
|
|
6
|
+
options:
|
|
7
|
+
|
|
8
|
+
1. Prime the OAuth adapter registry (explicitly — never at import time) and
|
|
9
|
+
best-effort export stored vault keys into the provider env vars
|
|
10
|
+
(:func:`prime_provider_env`). The *primary* credential path is the
|
|
11
|
+
injected per-call key resolver (:func:`build_key_resolver`); env priming
|
|
12
|
+
is kept only as the compatibility belt for the framework's env fallback,
|
|
13
|
+
per the port plan ("prefer resolver injection over env mutation").
|
|
14
|
+
2. Resolve the model id (:func:`resolve_model_id`): an explicit ``--model``
|
|
15
|
+
wins; else the first authenticated provider's preferred *current* model;
|
|
16
|
+
else the catalog default.
|
|
17
|
+
3. Select the tool deck (``provision_deck("all")`` filtered by ``--tools`` /
|
|
18
|
+
``--no-tools``) and attach ``--mcp`` tools via the framework MCP pool.
|
|
19
|
+
4. Compose the system prompt: ``--system`` replaces the tool-aware briefing,
|
|
20
|
+
``--append-system`` appends.
|
|
21
|
+
5. Build the conductor with the cwd-scoped sessions directory
|
|
22
|
+
(:func:`session_scope_dir`) and the live condense hook
|
|
23
|
+
(:func:`condense_transcript`).
|
|
24
|
+
|
|
25
|
+
The conductor itself constructs its framework agent lazily, so no model
|
|
26
|
+
client exists until the first turn runs.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import contextlib
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
from collections.abc import Awaitable, Callable, Iterator
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Final
|
|
38
|
+
|
|
39
|
+
from indusagi.mcp import MCPClientPool, MCPClientPoolOptions, createMCPAgentToolFactory, loadMCPConfig
|
|
40
|
+
|
|
41
|
+
from induscode.briefing import BriefingContext, compose_briefing
|
|
42
|
+
from induscode.capability_deck import DeckContext, provision_deck
|
|
43
|
+
from induscode.conductor import (
|
|
44
|
+
AgentMessage,
|
|
45
|
+
AgentTool,
|
|
46
|
+
ConductorDeps,
|
|
47
|
+
MatchQuery,
|
|
48
|
+
ModelCatalog,
|
|
49
|
+
ModelMatcher,
|
|
50
|
+
SessionConductor,
|
|
51
|
+
SessionConductorOptions,
|
|
52
|
+
ThinkingLevel,
|
|
53
|
+
create_session_conductor,
|
|
54
|
+
)
|
|
55
|
+
from induscode.launch import register_built_in_oauth_providers
|
|
56
|
+
from induscode.launch.contract import AuthVault
|
|
57
|
+
from induscode.window_budget import BudgetPolicy, condense as condense_slice, plan_slice
|
|
58
|
+
from induscode.workspace import BRAND
|
|
59
|
+
|
|
60
|
+
from ..auth_vault import create_auth_vault
|
|
61
|
+
from ..contract import BootContext, Invocation
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"PREFERRED_DEFAULT",
|
|
65
|
+
"PROVIDER_ENV",
|
|
66
|
+
"build_key_resolver",
|
|
67
|
+
"build_session_conductor",
|
|
68
|
+
"condense_transcript",
|
|
69
|
+
"oneshot_prompts",
|
|
70
|
+
"prime_provider_env",
|
|
71
|
+
"resolve_model_id",
|
|
72
|
+
"session_scope_dir",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Model resolution
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_auth_data(ctx: BootContext) -> dict[str, Any]:
|
|
82
|
+
"""Parse the raw auth.json map (``{provider: {account: record}}``), or an
|
|
83
|
+
empty dict on any read/parse failure."""
|
|
84
|
+
import json
|
|
85
|
+
|
|
86
|
+
path = Path(ctx.workspace.auth_path)
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return {}
|
|
89
|
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
|
90
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def authenticated_providers(ctx: BootContext) -> list[str]:
|
|
94
|
+
"""The provider ids that have at least one stored credential in the
|
|
95
|
+
on-disk auth vault, in the order they were saved.
|
|
96
|
+
|
|
97
|
+
Read straight from the auth.json map so the resolver can prefer a model
|
|
98
|
+
the user can actually call. Any read/parse failure yields an empty list —
|
|
99
|
+
the caller then falls back to the catalog default.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
data = _read_auth_data(ctx)
|
|
103
|
+
return [
|
|
104
|
+
provider
|
|
105
|
+
for provider, accounts in data.items()
|
|
106
|
+
if len(provider) > 0 and isinstance(accounts, dict) and len(accounts) > 0
|
|
107
|
+
]
|
|
108
|
+
except Exception:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
#: Per-provider preferred default models, in priority order, by bare model id.
|
|
113
|
+
#:
|
|
114
|
+
#: A provider's catalog often lists deprecated models first. When defaulting
|
|
115
|
+
#: a session to an authenticated provider we pick the first of these the
|
|
116
|
+
#: catalog carries, so a fresh launch lands on a current, callable model
|
|
117
|
+
#: rather than a dead one. (TS table verbatim.)
|
|
118
|
+
PREFERRED_DEFAULT: Final[dict[str, tuple[str, ...]]] = {
|
|
119
|
+
"anthropic": (
|
|
120
|
+
"claude-sonnet-4-5",
|
|
121
|
+
"claude-haiku-4-5",
|
|
122
|
+
"claude-sonnet-4-6",
|
|
123
|
+
"claude-sonnet-4-0",
|
|
124
|
+
"claude-opus-4-5",
|
|
125
|
+
),
|
|
126
|
+
"openai": ("gpt-5.1", "gpt-4.1", "gpt-4o"),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _provider_default_model(catalog: ModelCatalog, provider: str) -> str | None:
|
|
131
|
+
"""Choose the default model id for a provider: a preferred current model
|
|
132
|
+
when the catalog has one, else the last catalog entry (newest by scan
|
|
133
|
+
order), else the first. ``None`` when the provider contributed no
|
|
134
|
+
models."""
|
|
135
|
+
cards = catalog.by_provider(provider)
|
|
136
|
+
if len(cards) == 0:
|
|
137
|
+
return None
|
|
138
|
+
for bare in PREFERRED_DEFAULT.get(provider, ()):
|
|
139
|
+
wanted = f"{provider}/{bare}"
|
|
140
|
+
for card in cards:
|
|
141
|
+
if card.id == wanted:
|
|
142
|
+
return card.id
|
|
143
|
+
return cards[-1].id
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve_model_id(ctx: BootContext) -> str:
|
|
147
|
+
"""Resolve the model id for this run.
|
|
148
|
+
|
|
149
|
+
Precedence: an explicit ``--model`` selector wins; otherwise a current
|
|
150
|
+
model of a provider the user has authenticated for (so a fresh launch
|
|
151
|
+
lands on a model the stored key can actually call); otherwise the catalog
|
|
152
|
+
default.
|
|
153
|
+
|
|
154
|
+
:param ctx: the boot context carrying the parsed invocation
|
|
155
|
+
:returns: the canonical model id to bind the session to
|
|
156
|
+
"""
|
|
157
|
+
explicit = ctx.invocation.model_id
|
|
158
|
+
if explicit is not None and len(explicit.strip()) > 0:
|
|
159
|
+
return explicit
|
|
160
|
+
catalog = ModelCatalog()
|
|
161
|
+
for provider in authenticated_providers(ctx):
|
|
162
|
+
id = _provider_default_model(catalog, provider)
|
|
163
|
+
if id is not None:
|
|
164
|
+
return id
|
|
165
|
+
card = ModelMatcher(catalog).resolve(MatchQuery())
|
|
166
|
+
return card.id if card is not None else ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Credentials
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
#: The environment variable the framework reads a provider's api key from.
|
|
174
|
+
#:
|
|
175
|
+
#: Mirrors the framework's own provider→env mapping. The framework ``Agent``
|
|
176
|
+
#: resolves a key via the injected resolver *or* its env lookup; the app
|
|
177
|
+
#: stores keys in its own vault, so :func:`prime_provider_env` bridges the
|
|
178
|
+
#: two by exporting each stored key into the variable the framework would
|
|
179
|
+
#: look it up under. (TS table verbatim.)
|
|
180
|
+
PROVIDER_ENV: Final[dict[str, str]] = {
|
|
181
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
182
|
+
"openai": "OPENAI_API_KEY",
|
|
183
|
+
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
|
|
184
|
+
"google": "GEMINI_API_KEY",
|
|
185
|
+
"groq": "GROQ_API_KEY",
|
|
186
|
+
"cerebras": "CEREBRAS_API_KEY",
|
|
187
|
+
"xai": "XAI_API_KEY",
|
|
188
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
189
|
+
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
|
190
|
+
"zai": "ZAI_API_KEY",
|
|
191
|
+
"mistral": "MISTRAL_API_KEY",
|
|
192
|
+
"minimax": "MINIMAX_API_KEY",
|
|
193
|
+
"minimax-cn": "MINIMAX_CN_API_KEY",
|
|
194
|
+
"opencode": "OPENCODE_API_KEY",
|
|
195
|
+
"sarvam": "SARVAM_API_KEY",
|
|
196
|
+
"krutrim": "KRUTRIM_API_KEY",
|
|
197
|
+
"nvidia": "NVIDIA_API_KEY",
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def prime_provider_env(ctx: BootContext) -> None:
|
|
202
|
+
"""Best-effort: export every stored credential into the environment the
|
|
203
|
+
framework reads.
|
|
204
|
+
|
|
205
|
+
Walks the on-disk vault and, for each provider with a usable key (an api
|
|
206
|
+
key, or an OAuth access token), sets the matching env var — unless one is
|
|
207
|
+
already present (an explicit ``ANTHROPIC_API_KEY=…`` in the shell always
|
|
208
|
+
wins). This is the *secondary* credential path: the injected per-call
|
|
209
|
+
resolver (:func:`build_key_resolver`) is primary; env priming only covers
|
|
210
|
+
framework code paths that consult the environment directly.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
data = _read_auth_data(ctx)
|
|
214
|
+
for provider, accounts in data.items():
|
|
215
|
+
if provider.startswith("_") or not isinstance(accounts, dict):
|
|
216
|
+
continue
|
|
217
|
+
env_var = PROVIDER_ENV.get(provider)
|
|
218
|
+
if env_var is None or os.environ.get(env_var):
|
|
219
|
+
continue
|
|
220
|
+
records = [a for a in accounts.values() if isinstance(a, dict)]
|
|
221
|
+
chosen = next((a for a in records if a.get("isDefault")), None)
|
|
222
|
+
if chosen is None and records:
|
|
223
|
+
chosen = records[0]
|
|
224
|
+
if chosen is None:
|
|
225
|
+
continue
|
|
226
|
+
value = chosen.get("key") if chosen.get("kind") == "apiKey" else chosen.get("access")
|
|
227
|
+
if isinstance(value, str) and len(value) > 0:
|
|
228
|
+
os.environ[env_var] = value
|
|
229
|
+
except Exception:
|
|
230
|
+
# Best-effort: a missing/corrupt vault just leaves the env untouched.
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_key_resolver(
|
|
235
|
+
vault: AuthVault,
|
|
236
|
+
requested_account: str | None = None,
|
|
237
|
+
) -> Callable[[str], Awaitable[str | None]]:
|
|
238
|
+
"""A per-call credential resolver backed by the on-disk auth vault.
|
|
239
|
+
|
|
240
|
+
The framework ``Agent`` calls this for every request. For a provider with
|
|
241
|
+
a stored account it returns the usable key — an api key, or a browser
|
|
242
|
+
sign-in access token that
|
|
243
|
+
:meth:`~induscode.boot.auth_vault.DiskAuthVault.read_usable_key`
|
|
244
|
+
refreshes on the fly when it has expired. This is the only credential
|
|
245
|
+
path for OAuth-only providers the framework has no env-var mapping for,
|
|
246
|
+
and it lets short-lived tokens rotate without restarting the session.
|
|
247
|
+
|
|
248
|
+
Returns ``None`` when nothing is stored, which lets the framework fall
|
|
249
|
+
back to its own environment lookup; never raises (a vault error degrades
|
|
250
|
+
to the env fallback rather than failing the turn).
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
async def get_api_key(provider: str) -> str | None:
|
|
254
|
+
try:
|
|
255
|
+
# Try, in order: the account `--account` requested (when given),
|
|
256
|
+
# the account flagged default, then the first stored account. The
|
|
257
|
+
# default/first fallback matters because re-logging into a
|
|
258
|
+
# provider that already had an account leaves fresh credentials
|
|
259
|
+
# usable but UNFLAGGED (`isDefault: False`) — without it a
|
|
260
|
+
# signed-in provider would resolve to no key and the turn would
|
|
261
|
+
# crash with "No API key for provider".
|
|
262
|
+
candidates: list[str | None] = [
|
|
263
|
+
requested_account,
|
|
264
|
+
await vault.default_account(provider),
|
|
265
|
+
next(iter(await vault.list_accounts(provider)), None),
|
|
266
|
+
]
|
|
267
|
+
for account in candidates:
|
|
268
|
+
if account is None:
|
|
269
|
+
continue
|
|
270
|
+
key = await vault.read_usable_key(provider, account)
|
|
271
|
+
if key:
|
|
272
|
+
return key
|
|
273
|
+
return None
|
|
274
|
+
except Exception:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
return get_api_key
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Flag application (thinking / system / tools / mcp)
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
#: The reasoning-effort rungs the conductor accepts, for validating
|
|
285
|
+
#: ``--thinking``.
|
|
286
|
+
_THINKING_LEVELS: Final[frozenset[str]] = frozenset(
|
|
287
|
+
{"off", "minimal", "low", "medium", "high", "xhigh"}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _resolve_thinking_level(raw: str | None) -> ThinkingLevel | None:
|
|
292
|
+
"""Narrow a raw ``--thinking`` value to a ``ThinkingLevel``, ignoring
|
|
293
|
+
junk."""
|
|
294
|
+
if raw is not None and raw in _THINKING_LEVELS:
|
|
295
|
+
return raw # type: ignore[return-value] # narrowed by the guard
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _resolve_text(value: str) -> str:
|
|
300
|
+
"""Resolve a ``--system`` / ``--append-system`` value: read it as a file
|
|
301
|
+
when it names one on disk, otherwise use it verbatim — matching the
|
|
302
|
+
reference agent's file-or-literal handling so either a prompt file or
|
|
303
|
+
inline text works."""
|
|
304
|
+
try:
|
|
305
|
+
path = Path(value)
|
|
306
|
+
if path.exists():
|
|
307
|
+
return path.read_text(encoding="utf-8")
|
|
308
|
+
except Exception:
|
|
309
|
+
# Not a readable file: fall through and treat the value as literal.
|
|
310
|
+
pass
|
|
311
|
+
return value
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _canon_tool_name(name: str) -> str:
|
|
315
|
+
"""Case-fold a tool id and strip ``_`` / ``-`` so user spellings line up
|
|
316
|
+
with deck ids (``web_fetch`` matches ``webfetch``)."""
|
|
317
|
+
return re.sub(r"[_-]", "", name.lower())
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def select_tools(cwd: str, inv: Invocation) -> list[AgentTool]:
|
|
321
|
+
"""Select the tool deck for the run, honouring ``--no-tools`` (empty) and
|
|
322
|
+
``--tools`` (allow-list). Tool ids are matched case-insensitively with
|
|
323
|
+
``_`` / ``-`` stripped."""
|
|
324
|
+
if inv.no_tools:
|
|
325
|
+
return []
|
|
326
|
+
all_tools = provision_deck("all", DeckContext(cwd=cwd)).tools()
|
|
327
|
+
if inv.tools is None or len(inv.tools) == 0:
|
|
328
|
+
return all_tools
|
|
329
|
+
allow = {_canon_tool_name(name) for name in inv.tools}
|
|
330
|
+
return [tool for tool in all_tools if _canon_tool_name(tool.name) in allow]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def compose_system(tools: list[AgentTool], inv: Invocation) -> str:
|
|
334
|
+
"""Compose the run's system prompt: ``--system`` replaces the built-in
|
|
335
|
+
briefing, ``--append-system`` adds a trailing block, and both compose
|
|
336
|
+
(override then append). With neither, it is the tool-aware built-in
|
|
337
|
+
briefing."""
|
|
338
|
+
base = (
|
|
339
|
+
_resolve_text(inv.system)
|
|
340
|
+
if inv.system is not None
|
|
341
|
+
else compose_briefing(BriefingContext(tools=tools))
|
|
342
|
+
)
|
|
343
|
+
if inv.append_system is not None:
|
|
344
|
+
return f"{base}\n\n{_resolve_text(inv.append_system)}"
|
|
345
|
+
return base
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@contextlib.contextmanager
|
|
349
|
+
def _silence_mcp_chatter() -> Iterator[None]:
|
|
350
|
+
"""Silence MCP connection chatter for the duration of the load.
|
|
351
|
+
|
|
352
|
+
The framework pool reports per-server progress on stdout/stderr and via
|
|
353
|
+
logging; both would corrupt the console render or the JSON-RPC stdout
|
|
354
|
+
stream. Per the port plan this is *not* a print monkey-patch: the streams
|
|
355
|
+
are scope-redirected (:func:`contextlib.redirect_stdout` /
|
|
356
|
+
``redirect_stderr``) and logging below ERROR is disabled, both restored
|
|
357
|
+
on exit. A set ``INDUSAGI_DEBUG`` keeps everything visible.
|
|
358
|
+
"""
|
|
359
|
+
if os.environ.get(BRAND.env_debug):
|
|
360
|
+
yield
|
|
361
|
+
return
|
|
362
|
+
previous_disable = logging.root.manager.disable
|
|
363
|
+
logging.disable(logging.ERROR)
|
|
364
|
+
try:
|
|
365
|
+
with open(os.devnull, "w", encoding="utf-8") as sink:
|
|
366
|
+
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
|
367
|
+
yield
|
|
368
|
+
finally:
|
|
369
|
+
logging.disable(previous_disable)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def load_mcp_tools(inv: Invocation) -> list[AgentTool]:
|
|
373
|
+
"""Connect the ``--mcp`` endpoints and return their tools as the
|
|
374
|
+
conductor's own ``AgentTool`` type (the framework MCP factory mints a
|
|
375
|
+
structurally compatible tool, so they concatenate onto the deck with no
|
|
376
|
+
adapter). Each path is a config file or a cwd to auto-detect under; a
|
|
377
|
+
server that fails to connect is skipped so the session stays usable.
|
|
378
|
+
Returns ``[]`` when ``--mcp`` was not given."""
|
|
379
|
+
paths = inv.mcp if inv.mcp is not None else ()
|
|
380
|
+
if len(paths) == 0:
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
tools: list[AgentTool] = []
|
|
384
|
+
with _silence_mcp_chatter():
|
|
385
|
+
for path in paths:
|
|
386
|
+
try:
|
|
387
|
+
servers = loadMCPConfig(path)
|
|
388
|
+
if len(servers) == 0:
|
|
389
|
+
continue
|
|
390
|
+
pool = MCPClientPool(MCPClientPoolOptions(servers=servers))
|
|
391
|
+
await pool.connectAll()
|
|
392
|
+
for client in pool.getAllClients():
|
|
393
|
+
defs = await client.listTools()
|
|
394
|
+
for definition in defs:
|
|
395
|
+
tools.append(createMCPAgentToolFactory(definition, client)())
|
|
396
|
+
except Exception:
|
|
397
|
+
# Skip this endpoint; a bad MCP config must not sink the run.
|
|
398
|
+
continue
|
|
399
|
+
return tools
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
# Session directory scoping
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def session_scope_dir(sessions_root: str | os.PathLike[str], cwd: str) -> str:
|
|
408
|
+
"""The cwd-scoped session directory under the workspace ``sessions/``
|
|
409
|
+
root.
|
|
410
|
+
|
|
411
|
+
Sessions are partitioned per working directory (so ``--continue`` means
|
|
412
|
+
"the most recent session in THIS directory"). The cwd is slugged — every
|
|
413
|
+
non-alphanumeric run collapsed to a single dash — and wrapped in
|
|
414
|
+
``--…--`` markers, matching the on-disk layout. Both the conductor
|
|
415
|
+
(writer) and the :class:`~induscode.sessions.SessionLibrary` (reader)
|
|
416
|
+
must agree on this, so it lives here and is shared by the repl runner.
|
|
417
|
+
|
|
418
|
+
:param sessions_root: the workspace ``sessions/`` directory
|
|
419
|
+
:param cwd: the run's working directory
|
|
420
|
+
"""
|
|
421
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", cwd).strip("-")
|
|
422
|
+
return os.path.join(os.fspath(sessions_root), f"--{slug}--")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
# Condense hook
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
#: Auto-compaction policy: condense only once the transcript outgrows a
|
|
430
|
+
#: recent ~6k-token tail, leaving that tail verbatim. Window-relative
|
|
431
|
+
#: defaults (0.75 / 6000 / 2048 — the window-budget defaults).
|
|
432
|
+
AUTO_CONDENSE_POLICY: Final[BudgetPolicy] = BudgetPolicy(
|
|
433
|
+
trigger_ratio=0.75, keep_recent=6000, reserve_tokens=2048
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _last_user_turn_start(messages: list[AgentMessage]) -> int:
|
|
438
|
+
"""Index where the final user turn begins (its ``user`` message), or -1
|
|
439
|
+
if none."""
|
|
440
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
441
|
+
message = messages[i]
|
|
442
|
+
role = message.get("role") if isinstance(message, dict) else getattr(message, "role", None)
|
|
443
|
+
if role == "user":
|
|
444
|
+
return i
|
|
445
|
+
return -1
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
async def condense_transcript(
|
|
449
|
+
messages: list[AgentMessage], force: bool = False
|
|
450
|
+
) -> list[AgentMessage]:
|
|
451
|
+
"""The conductor's condense hook — what ``/compact`` (and
|
|
452
|
+
auto-compaction) run.
|
|
453
|
+
|
|
454
|
+
Both fold older turns into one digest message and keep recent turns
|
|
455
|
+
verbatim; the difference is *how much* they keep:
|
|
456
|
+
|
|
457
|
+
- **Manual** (``force``): fold everything before the LAST user turn into
|
|
458
|
+
the digest, keeping only that final turn. This always reclaims context
|
|
459
|
+
on a multi-turn session — even a tiny one — which the planner's token
|
|
460
|
+
tail would leave untouched. Cutting at a user-message boundary keeps
|
|
461
|
+
tool call/result pairs intact (the prior turns are complete).
|
|
462
|
+
- **Auto**: budget-gated — fold only the head beyond the recent
|
|
463
|
+
~6k-token tail (:data:`AUTO_CONDENSE_POLICY`).
|
|
464
|
+
|
|
465
|
+
Network-free by design: the summarizer emits a deterministic local digest
|
|
466
|
+
when given no model, so compaction never depends on a provider key or a
|
|
467
|
+
round-trip. Returns the input unchanged when there is nothing older to
|
|
468
|
+
fold (a single-turn session), so the conductor leaves the transcript
|
|
469
|
+
as-is.
|
|
470
|
+
"""
|
|
471
|
+
if force:
|
|
472
|
+
cut = _last_user_turn_start(messages)
|
|
473
|
+
if cut <= 0:
|
|
474
|
+
return messages # 0 or 1 turn: nothing older to fold
|
|
475
|
+
summary = await condense_slice(messages[:cut])
|
|
476
|
+
return [summary.message, *messages[cut:]]
|
|
477
|
+
plan = plan_slice(messages, AUTO_CONDENSE_POLICY)
|
|
478
|
+
if plan.cut == 0 or len(plan.dropped) == 0:
|
|
479
|
+
return messages
|
|
480
|
+
summary = await condense_slice(list(plan.dropped))
|
|
481
|
+
return [summary.message, *plan.kept]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
# Conductor assembly
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async def build_session_conductor(ctx: BootContext) -> SessionConductor:
|
|
490
|
+
"""Build the :class:`~induscode.conductor.SessionConductor` the runners
|
|
491
|
+
drive.
|
|
492
|
+
|
|
493
|
+
``--account`` scopes the credential lookup; ``--tools`` / ``--no-tools``
|
|
494
|
+
pick the deck; ``--mcp`` attaches external tools; ``--system`` /
|
|
495
|
+
``--append-system`` shape the prompt; ``--thinking`` sets the reasoning
|
|
496
|
+
effort. Each is applied here so the CLI flags actually reach the session
|
|
497
|
+
rather than being parsed and dropped.
|
|
498
|
+
|
|
499
|
+
:param ctx: the boot context (invocation + resolved workspace/resources)
|
|
500
|
+
:returns: a conductor bound to the resolved model and scoped to the run
|
|
501
|
+
cwd
|
|
502
|
+
"""
|
|
503
|
+
# Prime the OAuth adapter registry explicitly (the vault's refresh path
|
|
504
|
+
# and the key resolver need it; nothing registers at import time).
|
|
505
|
+
register_built_in_oauth_providers()
|
|
506
|
+
prime_provider_env(ctx)
|
|
507
|
+
|
|
508
|
+
inv = ctx.invocation
|
|
509
|
+
model_id = resolve_model_id(ctx)
|
|
510
|
+
workspace = inv.cwd
|
|
511
|
+
cwd = workspace if workspace is not None else os.getcwd()
|
|
512
|
+
sessions_dir = session_scope_dir(ctx.workspace.sessions_dir, cwd)
|
|
513
|
+
|
|
514
|
+
get_api_key = build_key_resolver(
|
|
515
|
+
create_auth_vault(ctx.workspace.auth_path), inv.account
|
|
516
|
+
)
|
|
517
|
+
tools = [*select_tools(cwd, inv), *(await load_mcp_tools(inv))]
|
|
518
|
+
system = compose_system(tools, inv)
|
|
519
|
+
thinking = _resolve_thinking_level(inv.thinking)
|
|
520
|
+
|
|
521
|
+
return create_session_conductor(
|
|
522
|
+
SessionConductorOptions(
|
|
523
|
+
modelId=model_id,
|
|
524
|
+
tools=tools,
|
|
525
|
+
system=system,
|
|
526
|
+
getApiKey=get_api_key,
|
|
527
|
+
sessionsDir=sessions_dir,
|
|
528
|
+
thinking=thinking,
|
|
529
|
+
workspace=workspace,
|
|
530
|
+
),
|
|
531
|
+
# The live condense hook: `/compact` and auto-compaction fold older
|
|
532
|
+
# turns into a digest. (Tests that build conductors directly keep the
|
|
533
|
+
# no-op default.)
|
|
534
|
+
ConductorDeps(condense=condense_transcript),
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def oneshot_prompts(ctx: BootContext) -> list[str]:
|
|
539
|
+
"""The prompts a oneshot run submits: the positional first prompt when
|
|
540
|
+
present. Empty when the invocation carried no request text (the caller
|
|
541
|
+
decides what to do then).
|
|
542
|
+
|
|
543
|
+
:param ctx: the boot context carrying the parsed invocation
|
|
544
|
+
"""
|
|
545
|
+
prompts: list[str] = []
|
|
546
|
+
head = ctx.invocation.prompt
|
|
547
|
+
if head is not None and len(head) > 0:
|
|
548
|
+
prompts.append(head)
|
|
549
|
+
return prompts
|