Skip to content

Commit 1c4ac53

Browse files
authored
feat: add middle out context strategy (#141)
1 parent e9631ed commit 1c4ac53

9 files changed

Lines changed: 585 additions & 3 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""
2+
End-to-end exercise for the `middle_out` edit strategy.
3+
4+
This script is meant to be run against a live Acontext API instance.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import sys
11+
from pathlib import Path
12+
from typing import Any
13+
14+
15+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16+
17+
from acontext import AcontextClient
18+
19+
20+
LARGE_CONTENT_CHARS = 8_000
21+
22+
23+
def resolve_credentials() -> tuple[str, str]:
24+
api_key = (
25+
os.getenv("ACONTEXT_API_KEY")
26+
or os.getenv("API_KEY")
27+
or "sk-ac-your-root-api-bearer-token"
28+
)
29+
base_url = (
30+
os.getenv("ACONTEXT_BASE_URL") or os.getenv("BASE_URL") or "http://localhost:8029/api/v1"
31+
)
32+
return api_key, base_url
33+
34+
35+
def banner(title: str) -> None:
36+
print("\n" + "=" * 80)
37+
print(title)
38+
print("=" * 80)
39+
40+
41+
def ensure(condition: bool, message: str) -> None:
42+
if not condition:
43+
raise AssertionError(message)
44+
45+
46+
def store_text(client: AcontextClient, session_id: str, text: str) -> None:
47+
client.sessions.store_message(
48+
session_id,
49+
format="acontext",
50+
blob={"role": "user", "parts": [{"type": "text", "text": text}]},
51+
)
52+
53+
54+
def get_acontext_items(
55+
client: AcontextClient,
56+
session_id: str,
57+
edit_strategies: list[dict[str, Any]] | None = None,
58+
) -> list[Any]:
59+
resp = client.sessions.get_messages(
60+
session_id,
61+
format="acontext",
62+
edit_strategies=edit_strategies, # type: ignore[arg-type]
63+
)
64+
return resp.items
65+
66+
67+
def get_text_parts(items: list[Any]) -> list[str]:
68+
texts: list[str] = []
69+
for message in items:
70+
for part in getattr(message, "parts", []) or []:
71+
if getattr(part, "type", None) == "text" and getattr(part, "text", None):
72+
texts.append(part.text)
73+
return texts
74+
75+
76+
def has_tool_call(items: list[Any], tool_call_id: str) -> bool:
77+
for message in items:
78+
for part in getattr(message, "parts", []) or []:
79+
if getattr(part, "type", None) != "tool-call":
80+
continue
81+
meta = getattr(part, "meta", None) or {}
82+
if meta.get("id") == tool_call_id:
83+
return True
84+
return False
85+
86+
87+
def has_tool_result(items: list[Any], tool_call_id: str) -> bool:
88+
for message in items:
89+
for part in getattr(message, "parts", []) or []:
90+
if getattr(part, "type", None) != "tool-result":
91+
continue
92+
meta = getattr(part, "meta", None) or {}
93+
if meta.get("tool_call_id") == tool_call_id:
94+
return True
95+
return False
96+
97+
98+
def exercise_basic_middle_out(client: AcontextClient) -> None:
99+
banner("middle_out preserves head/tail and removes middle")
100+
101+
session_id = client.sessions.create(configs={"mode": "sdk-e2e-middle-out"}).id
102+
try:
103+
for i in range(30):
104+
if 10 <= i <= 19:
105+
payload = f"msg-{i} " + ("x" * LARGE_CONTENT_CHARS)
106+
else:
107+
payload = f"msg-{i} short"
108+
store_text(client, session_id, payload)
109+
110+
items = get_acontext_items(
111+
client,
112+
session_id,
113+
edit_strategies=[{"type": "middle_out", "params": {"token_reduce_to": 2000}}],
114+
)
115+
texts = get_text_parts(items)
116+
joined = "\n".join(texts)
117+
118+
ensure("msg-0 short" in joined, "Expected earliest messages to be kept")
119+
ensure("msg-1 short" in joined, "Expected earliest messages to be kept")
120+
ensure("msg-28 short" in joined, "Expected most recent messages to be kept")
121+
ensure("msg-29 short" in joined, "Expected most recent messages to be kept")
122+
ensure("msg-15 " not in joined, "Expected middle message to be removed")
123+
finally:
124+
client.sessions.delete(session_id)
125+
126+
127+
def exercise_even_determinism(client: AcontextClient) -> None:
128+
banner("even-count determinism (right-middle removed)")
129+
130+
session_id = client.sessions.create(configs={"mode": "sdk-e2e-middle-out"}).id
131+
try:
132+
store_text(client, session_id, "m0")
133+
store_text(client, session_id, "m1")
134+
store_text(client, session_id, "m2 " + ("x" * LARGE_CONTENT_CHARS))
135+
store_text(client, session_id, "m3")
136+
137+
items = get_acontext_items(
138+
client,
139+
session_id,
140+
edit_strategies=[{"type": "middle_out", "params": {"token_reduce_to": 200}}],
141+
)
142+
texts = get_text_parts(items)
143+
joined = "\n".join(texts)
144+
145+
ensure("m0" in joined, "Expected head to be kept")
146+
ensure("m1" in joined, "Expected head to be kept")
147+
ensure("m2 " not in joined, "Expected right-middle to be removed")
148+
ensure("m3" in joined, "Expected tail to be kept")
149+
finally:
150+
client.sessions.delete(session_id)
151+
152+
153+
def exercise_keep_tail(client: AcontextClient) -> None:
154+
banner("keep-tail fallback (2 messages)")
155+
156+
session_id = client.sessions.create(configs={"mode": "sdk-e2e-middle-out"}).id
157+
try:
158+
store_text(client, session_id, "old " + ("x" * LARGE_CONTENT_CHARS))
159+
store_text(client, session_id, "new")
160+
161+
items = get_acontext_items(
162+
client,
163+
session_id,
164+
edit_strategies=[{"type": "middle_out", "params": {"token_reduce_to": 200}}],
165+
)
166+
texts = get_text_parts(items)
167+
joined = "\n".join(texts)
168+
169+
ensure("old " not in joined, "Expected keep-tail fallback to drop oldest")
170+
ensure("new" in joined, "Expected keep-tail fallback to keep newest")
171+
finally:
172+
client.sessions.delete(session_id)
173+
174+
175+
def exercise_tool_pairing(client: AcontextClient) -> None:
176+
banner("tool-call / tool-result pairing")
177+
178+
session_id = client.sessions.create(configs={"mode": "sdk-e2e-middle-out"}).id
179+
try:
180+
store_text(client, session_id, "prefix")
181+
182+
client.sessions.store_message(
183+
session_id,
184+
format="openai",
185+
blob={
186+
"role": "assistant",
187+
"tool_calls": [
188+
{
189+
"id": "call_1",
190+
"type": "function",
191+
"function": {"name": "f", "arguments": "x" * LARGE_CONTENT_CHARS},
192+
}
193+
],
194+
},
195+
)
196+
client.sessions.store_message(
197+
session_id,
198+
format="openai",
199+
blob={"role": "tool", "tool_call_id": "call_1", "content": "ok"},
200+
)
201+
202+
store_text(client, session_id, "suffix")
203+
204+
items = get_acontext_items(
205+
client,
206+
session_id,
207+
edit_strategies=[{"type": "middle_out", "params": {"token_reduce_to": 500}}],
208+
)
209+
210+
ensure(not has_tool_call(items, "call_1"), "Expected tool-call to be removed")
211+
ensure(not has_tool_result(items, "call_1"), "Expected tool-result to be removed")
212+
finally:
213+
client.sessions.delete(session_id)
214+
215+
216+
def exercise_validation_errors(client: AcontextClient) -> None:
217+
banner("validation errors")
218+
219+
session_id = client.sessions.create(configs={"mode": "sdk-e2e-middle-out"}).id
220+
try:
221+
cases = [
222+
{"type": "middle_out", "params": {}},
223+
{"type": "middle_out", "params": {"token_reduce_to": "x"}},
224+
{"type": "middle_out", "params": {"token_reduce_to": 0}},
225+
]
226+
227+
for c in cases:
228+
try:
229+
client.sessions.get_messages(
230+
session_id,
231+
format="acontext",
232+
edit_strategies=[c], # type: ignore[arg-type]
233+
)
234+
except Exception:
235+
continue
236+
raise AssertionError(f"Expected validation error for: {c}")
237+
finally:
238+
client.sessions.delete(session_id)
239+
240+
241+
def main() -> None:
242+
api_key, base_url = resolve_credentials()
243+
client = AcontextClient(api_key=api_key, base_url=base_url)
244+
245+
exercise_basic_middle_out(client)
246+
exercise_even_determinism(client)
247+
exercise_keep_tail(client)
248+
exercise_tool_pairing(client)
249+
exercise_validation_errors(client)
250+
251+
banner("OK")
252+
253+
254+
if __name__ == "__main__":
255+
main()

src/client/acontext-py/src/acontext/resources/async_sessions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ async def get_messages(
294294
Each strategy is a dict with 'type' and 'params' keys.
295295
Examples:
296296
- Remove tool results: [{"type": "remove_tool_result", "params": {"keep_recent_n_tool_results": 3}}]
297+
- Middle out: [{"type": "middle_out", "params": {"token_reduce_to": 5000}}]
297298
- Token limit: [{"type": "token_limit", "params": {"limit_tokens": 20000}}]
298299
Defaults to None.
299300
pin_editing_strategies_at_message: Message ID to pin editing strategies at.

src/client/acontext-py/src/acontext/resources/sessions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ def get_messages(
294294
Each strategy is a dict with 'type' and 'params' keys.
295295
Examples:
296296
- Remove tool results: [{"type": "remove_tool_result", "params": {"keep_recent_n_tool_results": 3}}]
297+
- Middle out: [{"type": "middle_out", "params": {"token_reduce_to": 5000}}]
297298
- Token limit: [{"type": "token_limit", "params": {"limit_tokens": 20000}}]
298299
Defaults to None.
299300
pin_editing_strategies_at_message: Message ID to pin editing strategies at.

src/client/acontext-py/src/acontext/types/session.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,34 @@ class TokenLimitStrategy(TypedDict):
8989
params: TokenLimitParams
9090

9191

92+
class MiddleOutParams(TypedDict):
93+
"""Parameters for the middle_out edit strategy.
94+
95+
Attributes:
96+
token_reduce_to: Target token count to reduce the prompt to. Required parameter.
97+
"""
98+
99+
token_reduce_to: int
100+
101+
102+
class MiddleOutStrategy(TypedDict):
103+
"""Edit strategy to reduce prompt size by removing middle messages.
104+
105+
Example:
106+
{"type": "middle_out", "params": {"token_reduce_to": 5000}}
107+
"""
108+
109+
type: Literal["middle_out"]
110+
params: MiddleOutParams
111+
112+
92113
# Union type for all edit strategies
93114
# When adding new strategies, add them to this Union: EditStrategy = Union[RemoveToolResultStrategy, OtherStrategy, ...]
94115
EditStrategy = Union[
95-
RemoveToolResultStrategy, RemoveToolCallParamsStrategy, TokenLimitStrategy
116+
RemoveToolResultStrategy,
117+
RemoveToolCallParamsStrategy,
118+
TokenLimitStrategy,
119+
MiddleOutStrategy,
96120
]
97121

98122

src/client/acontext-ts/src/resources/sessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ export class SessionsAPI {
206206
* @param options.editStrategies - Optional list of edit strategies to apply before format conversion.
207207
* Examples:
208208
* - Remove tool results: [{ type: 'remove_tool_result', params: { keep_recent_n_tool_results: 3 } }]
209+
* - Middle out: [{ type: 'middle_out', params: { token_reduce_to: 5000 } }]
209210
* - Token limit: [{ type: 'token_limit', params: { limit_tokens: 20000 } }]
210211
* @param options.pinEditingStrategiesAtMessage - Message ID to pin editing strategies at.
211212
* When provided, strategies are only applied to messages up to and including this message ID,
@@ -299,4 +300,3 @@ export class SessionsAPI {
299300
return MessageObservingStatusSchema.parse(data);
300301
}
301302
}
302-

src/client/acontext-ts/src/types/session.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,27 @@ export const TokenLimitStrategySchema = z.object({
247247

248248
export type TokenLimitStrategy = z.infer<typeof TokenLimitStrategySchema>;
249249

250+
/**
251+
* Parameters for the middle_out edit strategy.
252+
*/
253+
export const MiddleOutParamsSchema = z.object({
254+
token_reduce_to: z.number(),
255+
});
256+
257+
export type MiddleOutParams = z.infer<typeof MiddleOutParamsSchema>;
258+
259+
/**
260+
* Edit strategy to reduce prompt size by removing middle messages.
261+
*
262+
* Example: { type: 'middle_out', params: { token_reduce_to: 5000 } }
263+
*/
264+
export const MiddleOutStrategySchema = z.object({
265+
type: z.literal('middle_out'),
266+
params: MiddleOutParamsSchema,
267+
});
268+
269+
export type MiddleOutStrategy = z.infer<typeof MiddleOutStrategySchema>;
270+
250271
/**
251272
* Union schema for all edit strategies.
252273
* When adding new strategies, extend this union: z.union([RemoveToolResultStrategySchema, OtherStrategySchema, ...])
@@ -255,7 +276,7 @@ export const EditStrategySchema = z.union([
255276
RemoveToolResultStrategySchema,
256277
RemoveToolCallParamsStrategySchema,
257278
TokenLimitStrategySchema,
279+
MiddleOutStrategySchema,
258280
]);
259281

260282
export type EditStrategy = z.infer<typeof EditStrategySchema>;
261-

src/server/api/go/internal/pkg/editor/editor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ func CreateStrategy(config StrategyConfig) (EditStrategy, error) {
2828
return createRemoveToolCallParamsStrategy(config.Params)
2929
case "token_limit":
3030
return createTokenLimitStrategy(config.Params)
31+
case "middle_out":
32+
return createMiddleOutStrategy(config.Params)
3133
default:
3234
return nil, fmt.Errorf("unknown strategy type: %s", config.Type)
3335
}

0 commit comments

Comments
 (0)