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,76 @@
|
|
|
1
|
+
"""Window-budget — public barrel (port of TS ``src/window-budget/index.ts``).
|
|
2
|
+
|
|
3
|
+
The context-window budgeting subsystem: token measurement, slice planning,
|
|
4
|
+
and transcript condensing (with branch-archival collapsed in behind a scope
|
|
5
|
+
flag). Re-exports the frozen contract types plus the live functions: the
|
|
6
|
+
budget math (``estimate_tokens`` / ``is_over_budget`` / ``plan_slice``), the
|
|
7
|
+
summarization core (``summarize`` / ``condense_scope``), and the
|
|
8
|
+
conductor-consumable condenser factory (``create_condenser`` / ``condense``).
|
|
9
|
+
|
|
10
|
+
This is the app's OWN compaction engine over :mod:`indusagi.ai` messages and
|
|
11
|
+
``complete_simple`` — it feeds the conductor's ``CondenseFn`` seam and is
|
|
12
|
+
deliberately NOT merged with the framework's :mod:`indusagi.runtime.memory`
|
|
13
|
+
compactor (different message types, policy semantics, prompts, and digest
|
|
14
|
+
headers; see :mod:`induscode.window_budget.condenser`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .budget import (
|
|
18
|
+
budget_limit,
|
|
19
|
+
estimate_message_tokens,
|
|
20
|
+
estimate_tokens,
|
|
21
|
+
is_over_budget,
|
|
22
|
+
plan_slice,
|
|
23
|
+
prefix_tokens,
|
|
24
|
+
)
|
|
25
|
+
from .condenser import DEFAULT_POLICY, condense, condense_scope, create_condenser
|
|
26
|
+
from .contract import (
|
|
27
|
+
AgentMessage,
|
|
28
|
+
Api,
|
|
29
|
+
BudgetPolicy,
|
|
30
|
+
CompleteFn,
|
|
31
|
+
CondensePlan,
|
|
32
|
+
Condenser,
|
|
33
|
+
CondenserDeps,
|
|
34
|
+
CondenseScope,
|
|
35
|
+
Model,
|
|
36
|
+
Summary,
|
|
37
|
+
TokenEstimate,
|
|
38
|
+
Usage,
|
|
39
|
+
)
|
|
40
|
+
from .summarize import (
|
|
41
|
+
CONDENSER_BRIEF,
|
|
42
|
+
SummarizeDeps,
|
|
43
|
+
build_summary_prompt,
|
|
44
|
+
flatten_transcript,
|
|
45
|
+
summarize,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"AgentMessage",
|
|
50
|
+
"Api",
|
|
51
|
+
"BudgetPolicy",
|
|
52
|
+
"CONDENSER_BRIEF",
|
|
53
|
+
"CompleteFn",
|
|
54
|
+
"CondensePlan",
|
|
55
|
+
"CondenseScope",
|
|
56
|
+
"Condenser",
|
|
57
|
+
"CondenserDeps",
|
|
58
|
+
"DEFAULT_POLICY",
|
|
59
|
+
"Model",
|
|
60
|
+
"SummarizeDeps",
|
|
61
|
+
"Summary",
|
|
62
|
+
"TokenEstimate",
|
|
63
|
+
"Usage",
|
|
64
|
+
"budget_limit",
|
|
65
|
+
"build_summary_prompt",
|
|
66
|
+
"condense",
|
|
67
|
+
"condense_scope",
|
|
68
|
+
"create_condenser",
|
|
69
|
+
"estimate_message_tokens",
|
|
70
|
+
"estimate_tokens",
|
|
71
|
+
"flatten_transcript",
|
|
72
|
+
"is_over_budget",
|
|
73
|
+
"plan_slice",
|
|
74
|
+
"prefix_tokens",
|
|
75
|
+
"summarize",
|
|
76
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Window-budget / budget — pure budget math (port of TS
|
|
2
|
+
``src/window-budget/budget/index.ts``).
|
|
3
|
+
|
|
4
|
+
The arithmetic core of the window-budget subsystem, free of I/O, prompts, and
|
|
5
|
+
model calls:
|
|
6
|
+
|
|
7
|
+
- :func:`estimate_tokens` / :func:`estimate_message_tokens` /
|
|
8
|
+
:func:`prefix_tokens` — heuristic token measurement and the forward
|
|
9
|
+
cumulative-token array.
|
|
10
|
+
- :func:`is_over_budget` / :func:`budget_limit` — the window-relative trigger.
|
|
11
|
+
- :func:`plan_slice` — forward prefix-sum + binary-search cut planning that
|
|
12
|
+
never splits a tool_call from its tool_result.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .estimate import estimate_message_tokens, estimate_tokens, prefix_tokens
|
|
16
|
+
from .gate import budget_limit, is_over_budget
|
|
17
|
+
from .slice import plan_slice
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"budget_limit",
|
|
21
|
+
"estimate_message_tokens",
|
|
22
|
+
"estimate_tokens",
|
|
23
|
+
"is_over_budget",
|
|
24
|
+
"plan_slice",
|
|
25
|
+
"prefix_tokens",
|
|
26
|
+
]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Token estimation — the heuristic "meter stick" for the window-budget
|
|
2
|
+
subsystem (port of TS ``src/window-budget/budget/estimate.ts``).
|
|
3
|
+
|
|
4
|
+
Before we can decide whether the transcript is over budget (``gate.py``) or
|
|
5
|
+
where to slice it (``slice.py``), we need a cheap, deterministic estimate of
|
|
6
|
+
how many tokens each :data:`AgentMessage` occupies. We do *not* call a
|
|
7
|
+
tokenizer; we walk the serialized content and apply a small, transparent
|
|
8
|
+
weighting model.
|
|
9
|
+
|
|
10
|
+
Design notes (clean-room, deliberately distinct from any chars/4 + flat-image
|
|
11
|
+
baseline):
|
|
12
|
+
|
|
13
|
+
- The estimate is computed over a *serialization* of each message's payload,
|
|
14
|
+
not over a JSON blob of the whole object — structural framing (braces,
|
|
15
|
+
quotes, field names) is accounted for with small per-block adds rather than
|
|
16
|
+
being measured character-for-character.
|
|
17
|
+
- Constants below are this module's OWN tunables (a fractional
|
|
18
|
+
bytes-per-token divisor plus per-block framing adds and a flat image cost).
|
|
19
|
+
They are intentionally not the legacy fingerprint values — and they are
|
|
20
|
+
NOT the framework's: ``indusagi``'s own estimator uses 4.0 chars/token;
|
|
21
|
+
this app-level meter keeps its independent 3.6. Do not conflate the two.
|
|
22
|
+
- The estimator is conservative (rounds up, adds framing) so the gate fires a
|
|
23
|
+
little early rather than a little late — overflowing the window is worse
|
|
24
|
+
than condensing one turn sooner than strictly necessary.
|
|
25
|
+
|
|
26
|
+
Port note: TS probed messages structurally (``isRecord`` + ``.role`` reads
|
|
27
|
+
over plain objects). Python transcripts are frozen dataclasses
|
|
28
|
+
(:mod:`indusagi.ai` messages plus the :mod:`indusagi.agent` custom kinds), so
|
|
29
|
+
the probing here is ``getattr`` duck typing over a ``role`` discriminator —
|
|
30
|
+
and the agent's custom messages (``bashExecution`` / ``custom`` /
|
|
31
|
+
``branchSummary`` / ``compactionSummary``) get explicit branches because the
|
|
32
|
+
Python ``indusagi.ai.AgentMessage`` alias does not include them (analysis 05
|
|
33
|
+
risk-2): falling through to generic serialization for known roles would
|
|
34
|
+
silently mis-weigh them.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import dataclasses
|
|
40
|
+
import json
|
|
41
|
+
import math
|
|
42
|
+
|
|
43
|
+
from ..contract import AgentMessage
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"estimate_message_tokens",
|
|
47
|
+
"estimate_tokens",
|
|
48
|
+
"prefix_tokens",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Tunable constants (this module's own heuristic values)
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
# Average serialized characters per token. English-ish text and JSON tend to
|
|
57
|
+
# pack a little under four characters per token for most tokenizers; we use a
|
|
58
|
+
# slightly conservative 3.6 so the estimate skews high. (App-OWN constant —
|
|
59
|
+
# the framework's estimator uses 4.0; keep them distinct.)
|
|
60
|
+
CHARS_PER_TOKEN = 3.6
|
|
61
|
+
|
|
62
|
+
# Flat token cost charged for one inline image block (resolution-agnostic).
|
|
63
|
+
IMAGE_TOKENS = 1024
|
|
64
|
+
|
|
65
|
+
# Tokens added per message to cover role/turn framing in the wire format.
|
|
66
|
+
MESSAGE_FRAMING_TOKENS = 4
|
|
67
|
+
|
|
68
|
+
# Tokens added per structured block (text/thinking/toolCall/toolResult part).
|
|
69
|
+
BLOCK_FRAMING_TOKENS = 2
|
|
70
|
+
|
|
71
|
+
# Tokens added per tool call to cover the call envelope (id + name framing).
|
|
72
|
+
TOOLCALL_ENVELOPE_TOKENS = 6
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Per-message serialization → character weight
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# Sentinel distinguishing "attribute absent" from "attribute is None" — the
|
|
80
|
+
# Python analogue of the TS `=== undefined` probe.
|
|
81
|
+
_MISSING = object()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _jsonable(value: object) -> object:
|
|
85
|
+
"""``json.dumps`` fallback for non-JSON values: dataclasses flatten to
|
|
86
|
+
their field dict; anything else degrades to ``str()`` (we only ever
|
|
87
|
+
measure the serialized *length*, fidelity is irrelevant)."""
|
|
88
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
89
|
+
return dataclasses.asdict(value)
|
|
90
|
+
return str(value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _safe_json(value: object) -> str:
|
|
94
|
+
"""JSON-serialize that never raises (cycles/exotic values → empty string).
|
|
95
|
+
Compact separators mirror ``JSON.stringify``'s no-spaces output so the
|
|
96
|
+
character weights stay comparable to the TS originals."""
|
|
97
|
+
try:
|
|
98
|
+
return json.dumps(value, default=_jsonable, separators=(",", ":"))
|
|
99
|
+
except Exception:
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _weigh_block(block: object) -> tuple[int, int]:
|
|
104
|
+
"""Sum the character weight of a single content block and report whether
|
|
105
|
+
it was an image (images are billed as a flat token cost, not by character
|
|
106
|
+
length).
|
|
107
|
+
|
|
108
|
+
Returns ``(chars, images)``: ``chars`` feeds the chars-per-token divisor;
|
|
109
|
+
``images`` is a count multiplied by :data:`IMAGE_TOKENS` later.
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(block, str):
|
|
112
|
+
return (len(block), 0)
|
|
113
|
+
|
|
114
|
+
block_type = getattr(block, "type", None)
|
|
115
|
+
if block_type == "text":
|
|
116
|
+
text = getattr(block, "text", None)
|
|
117
|
+
return (len(text) if isinstance(text, str) else 0, 0)
|
|
118
|
+
if block_type == "thinking":
|
|
119
|
+
thinking = getattr(block, "thinking", None)
|
|
120
|
+
return (len(thinking) if isinstance(thinking, str) else 0, 0)
|
|
121
|
+
if block_type == "image":
|
|
122
|
+
# Flat cost; do NOT measure the (often base64) `data` length.
|
|
123
|
+
return (0, 1)
|
|
124
|
+
if block_type == "toolCall":
|
|
125
|
+
name = getattr(block, "name", None)
|
|
126
|
+
chars = len(name) if isinstance(name, str) else 0
|
|
127
|
+
arguments = getattr(block, "arguments", _MISSING)
|
|
128
|
+
if arguments is not _MISSING:
|
|
129
|
+
chars += len(_safe_json(arguments))
|
|
130
|
+
return (chars, 0)
|
|
131
|
+
|
|
132
|
+
# Unknown/custom block: fall back to measuring its serialized form.
|
|
133
|
+
return (len(_safe_json(block)), 0)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _weigh_content(content: object) -> tuple[int, int, int]:
|
|
137
|
+
"""Weigh a string-or-blocks ``content`` field: ``(chars, images,
|
|
138
|
+
framing)`` where framing counts one :data:`BLOCK_FRAMING_TOKENS` per
|
|
139
|
+
block (a bare string counts as one block)."""
|
|
140
|
+
chars = 0
|
|
141
|
+
images = 0
|
|
142
|
+
framing = 0
|
|
143
|
+
if isinstance(content, str):
|
|
144
|
+
chars += len(content)
|
|
145
|
+
framing += BLOCK_FRAMING_TOKENS
|
|
146
|
+
elif isinstance(content, (list, tuple)):
|
|
147
|
+
for block in content:
|
|
148
|
+
block_chars, block_images = _weigh_block(block)
|
|
149
|
+
chars += block_chars
|
|
150
|
+
images += block_images
|
|
151
|
+
framing += BLOCK_FRAMING_TOKENS
|
|
152
|
+
return (chars, images, framing)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _weigh_message(message: AgentMessage) -> tuple[int, int, int]:
|
|
156
|
+
"""Total raw character weight + image count + framing-token total for one
|
|
157
|
+
message, dispatched by ``role``. Truly unknown roles fall through to a
|
|
158
|
+
generic serialization so they still contribute to the estimate.
|
|
159
|
+
"""
|
|
160
|
+
chars = 0
|
|
161
|
+
images = 0
|
|
162
|
+
framing = MESSAGE_FRAMING_TOKENS
|
|
163
|
+
|
|
164
|
+
role = getattr(message, "role", None)
|
|
165
|
+
|
|
166
|
+
if role == "user":
|
|
167
|
+
content_chars, content_images, content_framing = _weigh_content(
|
|
168
|
+
getattr(message, "content", None)
|
|
169
|
+
)
|
|
170
|
+
return (chars + content_chars, images + content_images, framing + content_framing)
|
|
171
|
+
|
|
172
|
+
if role == "assistant":
|
|
173
|
+
content = getattr(message, "content", None)
|
|
174
|
+
if isinstance(content, (list, tuple)):
|
|
175
|
+
for block in content:
|
|
176
|
+
block_chars, block_images = _weigh_block(block)
|
|
177
|
+
chars += block_chars
|
|
178
|
+
images += block_images
|
|
179
|
+
framing += BLOCK_FRAMING_TOKENS
|
|
180
|
+
if getattr(block, "type", None) == "toolCall":
|
|
181
|
+
framing += TOOLCALL_ENVELOPE_TOKENS
|
|
182
|
+
error_message = getattr(message, "errorMessage", None)
|
|
183
|
+
if isinstance(error_message, str):
|
|
184
|
+
chars += len(error_message)
|
|
185
|
+
return (chars, images, framing)
|
|
186
|
+
|
|
187
|
+
if role == "toolResult":
|
|
188
|
+
tool_name = getattr(message, "toolName", None)
|
|
189
|
+
if isinstance(tool_name, str):
|
|
190
|
+
chars += len(tool_name)
|
|
191
|
+
content_chars, content_images, content_framing = _weigh_content(
|
|
192
|
+
getattr(message, "content", None)
|
|
193
|
+
)
|
|
194
|
+
chars += content_chars
|
|
195
|
+
images += content_images
|
|
196
|
+
framing += content_framing + TOOLCALL_ENVELOPE_TOKENS
|
|
197
|
+
return (chars, images, framing)
|
|
198
|
+
|
|
199
|
+
# --- agent custom session messages (EXPLICIT branches — the Python
|
|
200
|
+
# `indusagi.ai.AgentMessage` alias excludes these; see contract.py) -----
|
|
201
|
+
|
|
202
|
+
if role == "bashExecution":
|
|
203
|
+
command = getattr(message, "command", None)
|
|
204
|
+
output = getattr(message, "output", None)
|
|
205
|
+
if isinstance(command, str):
|
|
206
|
+
chars += len(command)
|
|
207
|
+
if isinstance(output, str):
|
|
208
|
+
chars += len(output)
|
|
209
|
+
return (chars, images, framing + BLOCK_FRAMING_TOKENS)
|
|
210
|
+
|
|
211
|
+
if role == "custom":
|
|
212
|
+
content_chars, content_images, content_framing = _weigh_content(
|
|
213
|
+
getattr(message, "content", None)
|
|
214
|
+
)
|
|
215
|
+
return (chars + content_chars, images + content_images, framing + content_framing)
|
|
216
|
+
|
|
217
|
+
if role in ("branchSummary", "compactionSummary"):
|
|
218
|
+
summary = getattr(message, "summary", None)
|
|
219
|
+
if isinstance(summary, str):
|
|
220
|
+
chars += len(summary)
|
|
221
|
+
return (chars, images, framing + BLOCK_FRAMING_TOKENS)
|
|
222
|
+
|
|
223
|
+
# Unrecognized role: serialize generically (matches the TS default arm).
|
|
224
|
+
chars += len(_safe_json(message))
|
|
225
|
+
return (chars, images, framing + BLOCK_FRAMING_TOKENS)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Public API
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def estimate_message_tokens(message: AgentMessage) -> int:
|
|
234
|
+
"""Estimate the token cost of one message in isolation. Exposed so the
|
|
235
|
+
slice planner can build a prefix-sum without re-deriving the per-message
|
|
236
|
+
weight.
|
|
237
|
+
|
|
238
|
+
Cost = ``ceil(serialized_chars / CHARS_PER_TOKEN)`` + framing tokens +
|
|
239
|
+
``images * IMAGE_TOKENS``. Always ``>= 0`` and biased high (conservative).
|
|
240
|
+
"""
|
|
241
|
+
chars, images, framing = _weigh_message(message)
|
|
242
|
+
text_tokens = math.ceil(chars / CHARS_PER_TOKEN)
|
|
243
|
+
return text_tokens + framing + images * IMAGE_TOKENS
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def estimate_tokens(messages: list[AgentMessage]) -> int:
|
|
247
|
+
"""Estimate the total token cost of a transcript: the sum of every
|
|
248
|
+
message's :func:`estimate_message_tokens`. This is a pure heuristic — the
|
|
249
|
+
gate prefers a provider-anchored figure when one exists, and only falls
|
|
250
|
+
back to this.
|
|
251
|
+
|
|
252
|
+
Note: an empty transcript estimates to exactly ``0`` (no per-message
|
|
253
|
+
framing is charged when there are no messages).
|
|
254
|
+
"""
|
|
255
|
+
total = 0
|
|
256
|
+
for message in messages:
|
|
257
|
+
total += estimate_message_tokens(message)
|
|
258
|
+
return total
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def prefix_tokens(messages: list[AgentMessage]) -> list[int]:
|
|
262
|
+
"""Build a FORWARD prefix-sum array of per-message token estimates.
|
|
263
|
+
|
|
264
|
+
Returns a list of length ``len(messages) + 1`` where ``result[i]`` is the
|
|
265
|
+
total estimated tokens of ``messages[0:i]`` (so ``result[0] == 0`` and
|
|
266
|
+
``result[len(messages)] == estimate_tokens(messages)``). This
|
|
267
|
+
monotonically non-decreasing array is exactly what ``plan_slice``
|
|
268
|
+
binary-searches to find the cut boundary in O(log n).
|
|
269
|
+
"""
|
|
270
|
+
prefix: list[int] = [0] * (len(messages) + 1)
|
|
271
|
+
for index, message in enumerate(messages):
|
|
272
|
+
prefix[index + 1] = prefix[index] + estimate_message_tokens(message)
|
|
273
|
+
return prefix
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Budget gate — the trigger predicate for the window-budget subsystem
|
|
2
|
+
(port of TS ``src/window-budget/budget/gate.ts``).
|
|
3
|
+
|
|
4
|
+
:func:`is_over_budget` answers the single question "is the transcript large
|
|
5
|
+
enough that we should condense before the next turn?" by comparing a token
|
|
6
|
+
estimate against a window-relative threshold derived from the model's
|
|
7
|
+
``contextWindow`` and the caller-supplied
|
|
8
|
+
:class:`~induscode.window_budget.contract.BudgetPolicy`.
|
|
9
|
+
|
|
10
|
+
The threshold is computed as::
|
|
11
|
+
|
|
12
|
+
limit = (contextWindow - reserve_tokens?) * trigger_ratio
|
|
13
|
+
|
|
14
|
+
i.e. optional fixed headroom is carved off the window first, then the
|
|
15
|
+
remaining capacity is scaled by the window-relative trigger ratio. This keeps
|
|
16
|
+
the trigger free of baked-in token counts — ``trigger_ratio`` lives in
|
|
17
|
+
``(0, 1]`` and ``reserve_tokens`` is an optional config value, never a
|
|
18
|
+
fingerprint constant.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ..contract import AgentMessage, BudgetPolicy, Model
|
|
24
|
+
from .estimate import estimate_tokens
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"budget_limit",
|
|
28
|
+
"is_over_budget",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def budget_limit(model: Model, policy: BudgetPolicy) -> float:
|
|
33
|
+
"""Compute the absolute token limit at which condensing should fire for a
|
|
34
|
+
given model and policy. Exposed for callers that want the number itself
|
|
35
|
+
(e.g. to display headroom) rather than just the boolean.
|
|
36
|
+
|
|
37
|
+
``reserve_tokens`` (when present) is subtracted from the window before
|
|
38
|
+
the ratio is applied; the result is floored at ``0`` so a misconfigured
|
|
39
|
+
reserve larger than the window can never yield a negative limit.
|
|
40
|
+
"""
|
|
41
|
+
reserve = policy.reserve_tokens if policy.reserve_tokens is not None else 0
|
|
42
|
+
usable = max(0, model.contextWindow - reserve)
|
|
43
|
+
return usable * policy.trigger_ratio
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_over_budget(
|
|
47
|
+
messages: list[AgentMessage],
|
|
48
|
+
model: Model,
|
|
49
|
+
policy: BudgetPolicy,
|
|
50
|
+
) -> bool:
|
|
51
|
+
"""Decide whether the transcript is over budget for the given model and
|
|
52
|
+
policy.
|
|
53
|
+
|
|
54
|
+
Uses the heuristic :func:`~.estimate.estimate_tokens` over the serialized
|
|
55
|
+
transcript and compares it to :func:`budget_limit`. Returns ``True`` once
|
|
56
|
+
the estimate strictly exceeds the limit — the signal the conductor uses
|
|
57
|
+
to kick off a condense.
|
|
58
|
+
"""
|
|
59
|
+
estimate = estimate_tokens(messages)
|
|
60
|
+
return estimate > budget_limit(model, policy)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Slice planner — decides *where* to cut the transcript when it is over
|
|
2
|
+
budget (port of TS ``src/window-budget/budget/slice.ts``).
|
|
3
|
+
|
|
4
|
+
:func:`plan_slice` partitions a transcript into a ``dropped`` prefix (older
|
|
5
|
+
messages destined for a summary) and a ``kept`` suffix (the verbatim recent
|
|
6
|
+
tail), choosing the boundary so the tail holds roughly ``policy.keep_recent``
|
|
7
|
+
tokens. It does this with a FORWARD prefix-sum + BINARY SEARCH (not a
|
|
8
|
+
backward accumulate-and-snap):
|
|
9
|
+
|
|
10
|
+
1. Build the cumulative-token array ``prefix`` (forward, monotonic).
|
|
11
|
+
2. The total is ``prefix[n]``; we want the tail to be ~``keep_recent``
|
|
12
|
+
tokens, so the *ideal* cut sits at the first index ``i`` whose suffix
|
|
13
|
+
``prefix[n] - prefix[i]`` has fallen to ``<= keep_recent``. Equivalently,
|
|
14
|
+
the first ``i`` with ``prefix[i] >= prefix[n] - keep_recent``. That
|
|
15
|
+
predicate is monotonic in ``i``, so we BINARY-SEARCH the lower bound of
|
|
16
|
+
the threshold ``prefix[n] - keep_recent`` over ``prefix``.
|
|
17
|
+
3. Snap the resulting index FORWARD to the nearest *legal* boundary so we
|
|
18
|
+
never land between a tool_call and its matching tool_result. A boundary is
|
|
19
|
+
legal iff the message it lands on is not a ``toolResult`` (a tool result
|
|
20
|
+
always belongs with the assistant turn that issued the call, so the tail
|
|
21
|
+
may never begin with one).
|
|
22
|
+
|
|
23
|
+
If the whole transcript already fits within ``keep_recent``, or snapping
|
|
24
|
+
forward consumes everything, the cut is ``0`` (nothing condensable — keep it
|
|
25
|
+
all).
|
|
26
|
+
|
|
27
|
+
Port note: role probing is ``getattr`` duck typing over the message
|
|
28
|
+
dataclasses (the TS structural ``isRecord``/``.role`` reads), so the agent's
|
|
29
|
+
custom session messages — which carry roles like ``bashExecution`` /
|
|
30
|
+
``branchSummary`` — are all legal tail starts; only ``toolResult`` is glued
|
|
31
|
+
to its issuing assistant turn.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from ..contract import AgentMessage, BudgetPolicy, CondensePlan
|
|
37
|
+
from .estimate import prefix_tokens
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"plan_slice",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Boundary legality
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_legal_tail_start(message: AgentMessage) -> bool:
|
|
50
|
+
"""A ``toolResult`` message must stay glued to the assistant turn that
|
|
51
|
+
issued its ``toolCall``. So the *kept tail* may never begin with a tool
|
|
52
|
+
result — that would orphan it from the (dropped) call. Every other role
|
|
53
|
+
is a legal tail-start.
|
|
54
|
+
|
|
55
|
+
:param message: the message that would become ``kept[0]`` for a candidate cut
|
|
56
|
+
:returns: ``True`` if a cut landing on this message is legal
|
|
57
|
+
"""
|
|
58
|
+
return getattr(message, "role", None) != "toolResult"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _snap_forward(messages: list[AgentMessage], candidate: int) -> int:
|
|
62
|
+
"""Snap a candidate cut index FORWARD to the nearest legal boundary:
|
|
63
|
+
advance past any leading ``toolResult`` messages so the kept tail starts
|
|
64
|
+
on a user/assistant/custom turn, never mid tool-call group.
|
|
65
|
+
|
|
66
|
+
:returns: the snapped index in ``[candidate, len(messages)]``
|
|
67
|
+
"""
|
|
68
|
+
cut = candidate
|
|
69
|
+
while cut < len(messages) and not _is_legal_tail_start(messages[cut]):
|
|
70
|
+
cut += 1
|
|
71
|
+
return cut
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Forward prefix-sum + binary search
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _lower_bound(prefix: list[int], threshold: float) -> int:
|
|
80
|
+
"""Binary-search the lower bound: the smallest index ``i`` in
|
|
81
|
+
``[0, len(prefix))`` with ``prefix[i] >= threshold``. ``prefix`` is
|
|
82
|
+
monotonically non-decreasing, so the predicate flips exactly once and a
|
|
83
|
+
standard half-open bisection finds it in O(log n). Returns
|
|
84
|
+
``len(prefix) - 1`` if no index satisfies it.
|
|
85
|
+
"""
|
|
86
|
+
lo = 0
|
|
87
|
+
hi = len(prefix) - 1
|
|
88
|
+
while lo < hi:
|
|
89
|
+
mid = (lo + hi) // 2
|
|
90
|
+
if prefix[mid] >= threshold:
|
|
91
|
+
hi = mid
|
|
92
|
+
else:
|
|
93
|
+
lo = mid + 1
|
|
94
|
+
return lo
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Public API
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def plan_slice(messages: list[AgentMessage], policy: BudgetPolicy) -> CondensePlan:
|
|
103
|
+
"""Plan the condense slice for a transcript under a
|
|
104
|
+
:class:`~induscode.window_budget.contract.BudgetPolicy`.
|
|
105
|
+
|
|
106
|
+
Chooses the cut via forward prefix-sum + binary search for the boundary
|
|
107
|
+
that keeps ~``policy.keep_recent`` recent tokens, then snaps that
|
|
108
|
+
boundary forward to a legal point (never between a tool_call and its
|
|
109
|
+
tool_result).
|
|
110
|
+
|
|
111
|
+
:returns: a :class:`~induscode.window_budget.contract.CondensePlan` with
|
|
112
|
+
the chosen ``cut``, the ``kept`` tail (``messages[cut:]``) and the
|
|
113
|
+
``dropped`` head (``messages[:cut]``). ``cut == 0`` when nothing is
|
|
114
|
+
condensable.
|
|
115
|
+
"""
|
|
116
|
+
n = len(messages)
|
|
117
|
+
if n == 0:
|
|
118
|
+
return CondensePlan(cut=0, kept=[], dropped=[])
|
|
119
|
+
|
|
120
|
+
# Forward cumulative-token array: prefix[i] = tokens of messages[0:i].
|
|
121
|
+
prefix = prefix_tokens(messages)
|
|
122
|
+
total = prefix[n]
|
|
123
|
+
|
|
124
|
+
# If the whole transcript already fits in the recent-tail budget, keep all.
|
|
125
|
+
if total <= policy.keep_recent:
|
|
126
|
+
return CondensePlan(cut=0, kept=list(messages), dropped=[])
|
|
127
|
+
|
|
128
|
+
# Ideal cut: first index whose suffix has shrunk to <= keep_recent, i.e.
|
|
129
|
+
# the lower bound of (total - keep_recent) over the forward prefix array.
|
|
130
|
+
threshold = total - policy.keep_recent
|
|
131
|
+
ideal = _lower_bound(prefix, threshold)
|
|
132
|
+
|
|
133
|
+
# Snap forward to a legal boundary (don't orphan a tool result from its call).
|
|
134
|
+
cut = _snap_forward(messages, ideal)
|
|
135
|
+
|
|
136
|
+
# Snapping forward can swallow everything (e.g. a long trailing
|
|
137
|
+
# tool-result run): then there is nothing to drop — keep all.
|
|
138
|
+
if cut >= n:
|
|
139
|
+
return CondensePlan(cut=0, kept=list(messages), dropped=[])
|
|
140
|
+
|
|
141
|
+
return CondensePlan(
|
|
142
|
+
cut=cut,
|
|
143
|
+
kept=list(messages[cut:]),
|
|
144
|
+
dropped=list(messages[:cut]),
|
|
145
|
+
)
|