powerailabs-contextkit 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: powerailabs-contextkit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Assemble: declare prioritized, pinnable context blocks; pack them to a token budget with an inspectable receipt.
5
5
  Author: Raghav Mishra
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "powerailabs-contextkit"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Assemble: declare prioritized, pinnable context blocks; pack them to a token budget with an inspectable receipt."
5
5
  requires-python = ">=3.11"
6
6
  license = "MIT"
@@ -65,10 +65,11 @@ class AssemblyReport:
65
65
  reserved_output: int
66
66
  model: str
67
67
  decisions: list[BlockDecision] = field(default_factory=list)
68
+ order: str = "default"
68
69
 
69
70
  def __str__(self) -> str:
70
71
  lines = [
71
- f"AssemblyReport(model={self.model}) "
72
+ f"AssemblyReport(model={self.model}, order={self.order}) "
72
73
  f"budget={self.budget} reserved_output={self.reserved_output} "
73
74
  f"used={self.used}/{self.budget - self.reserved_output}",
74
75
  ]
@@ -79,12 +80,22 @@ class AssemblyReport:
79
80
  return "\n".join(lines)
80
81
 
81
82
 
82
- # Render order: system first, conversational/context middle, the user turn last.
83
+ # Default render order: system first, conversational/context middle, the user turn last.
83
84
  _ROLE_RANK = {"system": 0, "tool": 1, "assistant": 2, "user": 3}
85
+ _ORDERS = ("default", "attention", "cache")
84
86
 
85
87
 
86
88
  class Context:
87
- """A token-budgeted, declarative context assembler. See docs/contextkit.md §3, §5."""
89
+ """A token-budgeted, declarative context assembler. See docs/contextkit.md §3, §5.
90
+
91
+ ``order`` controls how kept blocks are arranged in the final messages (docs/contextkit.md §2):
92
+
93
+ - ``"default"`` — role-grouped: system → context/history → the user turn.
94
+ - ``"attention"`` — "lost-in-the-middle": highest-priority context blocks ride the edges
95
+ (just after system / just before the user turn), weakest in the dead center.
96
+ - ``"cache"`` — stable prefix first (pinned, high-priority blocks lead) to maximize provider
97
+ prompt-cache / KV-cache hits across calls.
98
+ """
88
99
 
89
100
  def __init__(
90
101
  self,
@@ -92,11 +103,15 @@ class Context:
92
103
  model: str,
93
104
  reserve_output: int = 0,
94
105
  compressor: Any = None,
106
+ order: str = "default",
95
107
  ) -> None:
108
+ if order not in _ORDERS:
109
+ raise ValueError(f"order must be one of {_ORDERS}, got {order!r}")
96
110
  self.budget_tokens = budget_tokens
97
111
  self.model = model
98
112
  self.reserve_output = reserve_output
99
113
  self._compressor = compressor
114
+ self.order = order
100
115
  self._blocks: list[Block] = []
101
116
  self._report: AssemblyReport | None = None
102
117
  self._messages: list[dict] = []
@@ -151,7 +166,7 @@ class Context:
151
166
 
152
167
  used = 0
153
168
  decisions: list[BlockDecision] = []
154
- kept: list[tuple[int, str, str]] = [] # (insertion_index, role, rendered_content)
169
+ kept: list[tuple[int, Block, str]] = [] # (insertion_index, block, rendered_content)
155
170
 
156
171
  for idx, block in order:
157
172
  text = block.content if isinstance(block.content, str) else str(block.content)
@@ -160,7 +175,7 @@ class Context:
160
175
 
161
176
  if before <= remaining:
162
177
  used += before
163
- kept.append((idx, block.role, text))
178
+ kept.append((idx, block, text))
164
179
  decisions.append(BlockDecision(block.role, "kept", before, before))
165
180
  continue
166
181
 
@@ -176,17 +191,18 @@ class Context:
176
191
  continue
177
192
  after = tokens.count(new_text, self.model)
178
193
  used += after
179
- kept.append((idx, block.role, new_text))
194
+ kept.append((idx, block, new_text))
180
195
  decisions.append(BlockDecision(block.role, action, before, after, note))
181
196
 
182
- kept.sort(key=lambda k: (_ROLE_RANK.get(k[1], 1), k[0]))
183
- messages = [{"role": role, "content": content} for _, role, content in kept]
197
+ ordered = _order_blocks(kept, self.order)
198
+ messages = [{"role": block.role, "content": content} for _, block, content in ordered]
184
199
  report = AssemblyReport(
185
200
  budget=budget_tokens,
186
201
  used=used,
187
202
  reserved_output=self.reserve_output,
188
203
  model=self.model,
189
204
  decisions=decisions,
205
+ order=self.order,
190
206
  )
191
207
  if emit:
192
208
  bus.emit(report)
@@ -243,6 +259,36 @@ class Context:
243
259
  return None
244
260
 
245
261
 
262
+ def _order_blocks(kept: list[tuple[int, Block, str]], mode: str) -> list[tuple[int, Block, str]]:
263
+ """Arrange kept blocks for rendering per the chosen strategy. Deterministic."""
264
+ if mode == "cache":
265
+ # Stable prefix: pinned, high-priority blocks lead so the prompt prefix is reused.
266
+ return sorted(kept, key=lambda k: (not k[1].pin, -k[1].priority, k[0]))
267
+ if mode == "attention":
268
+ systems = sorted(
269
+ (k for k in kept if k[1].role == "system"), key=lambda k: (-k[1].priority, k[0])
270
+ )
271
+ finals = sorted(
272
+ (k for k in kept if k[1].role == "user"), key=lambda k: (k[1].priority, k[0])
273
+ ) # ascending -> highest-priority user turn ends up last (strongest end position)
274
+ middles = sorted(
275
+ (k for k in kept if k[1].role not in ("system", "user")),
276
+ key=lambda k: (-k[1].priority, k[0]),
277
+ )
278
+ return [*systems, *_edge_load(middles), *finals]
279
+ # default: role-grouped, insertion order within a role.
280
+ return sorted(kept, key=lambda k: (_ROLE_RANK.get(k[1].role, 1), k[0]))
281
+
282
+
283
+ def _edge_load(items: list) -> list:
284
+ """Edge-load a priority-descending list: highest at both edges, lowest in the center."""
285
+ left: list = []
286
+ right: list = []
287
+ for i, item in enumerate(items):
288
+ (left if i % 2 == 0 else right).append(item)
289
+ return left + right[::-1]
290
+
291
+
246
292
  def _call_compressor(compressor: Any, text: str, target: int, model: str) -> str:
247
293
  """Call either a Compressor-protocol object or a ``squeeze.compress``-style callable."""
248
294
  if hasattr(compressor, "compress"):
@@ -104,6 +104,48 @@ def test_report_emitted_on_bus():
104
104
  assert len(seen) == 1 and seen[0].model == "gpt-4o"
105
105
 
106
106
 
107
+ def test_attention_order_edge_loads_priority():
108
+ # Lost-in-the-middle: highest-priority context blocks ride the edges, weakest in the center.
109
+ ctx = Context(budget_tokens=1000, model="gpt-4o", order="attention")
110
+ ctx.add(Block("SYS", priority=100, pin=True, role="system"))
111
+ ctx.add(Block("p9", priority=9, role="assistant"))
112
+ ctx.add(Block("p1", priority=1, role="assistant"))
113
+ ctx.add(Block("p5", priority=5, role="assistant"))
114
+ ctx.add(Block("p7", priority=7, role="assistant"))
115
+ ctx.add(Block("USER", priority=10, pin=True, role="user"))
116
+ msgs = ctx.assemble()
117
+ assert msgs[0]["content"] == "SYS" # system anchored first
118
+ assert msgs[-1]["content"] == "USER" # user turn anchored last
119
+ middle = [m["content"] for m in msgs[1:-1]]
120
+ # desc by priority = [p9,p7,p5,p1] -> edge-loaded -> [p9,p5,p1,p7]: strongest on the edges
121
+ assert middle[0] == "p9" and middle[-1] == "p7"
122
+ assert middle[len(middle) // 2] == "p1" # weakest in the center
123
+ assert ctx.report().order == "attention"
124
+
125
+
126
+ def test_cache_order_puts_pinned_prefix_first():
127
+ ctx = Context(budget_tokens=1000, model="gpt-4o", order="cache")
128
+ ctx.add(Block("volatile", priority=8, pin=False, role="user"))
129
+ ctx.add(Block("stable-hi", priority=10, pin=True, role="system"))
130
+ ctx.add(Block("stable-lo", priority=2, pin=True, role="assistant"))
131
+ msgs = ctx.assemble()
132
+ # pinned blocks form the stable prefix (priority desc), volatile trails
133
+ assert [m["content"] for m in msgs] == ["stable-hi", "stable-lo", "volatile"]
134
+
135
+
136
+ def test_invalid_order_rejected():
137
+ with pytest.raises(ValueError):
138
+ Context(budget_tokens=10, model="gpt-4o", order="bogus")
139
+
140
+
141
+ def test_default_order_unchanged():
142
+ ctx = Context(budget_tokens=1000, model="gpt-4o") # default
143
+ ctx.add(Block("u", priority=9, role="user"))
144
+ ctx.add(Block("s", priority=1, role="system"))
145
+ assert [m["role"] for m in ctx.assemble()] == ["system", "user"]
146
+ assert ctx.report().order == "default"
147
+
148
+
107
149
  def test_for_anthropic_splits_system():
108
150
  ctx = Context(budget_tokens=1000, model="claude-opus-4-8")
109
151
  ctx.add(Block("you are helpful", priority=10, pin=True, role="system"))