grok-loop-kit 1.0.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.
- grok_loop_kit-1.0.0/PKG-INFO +28 -0
- grok_loop_kit-1.0.0/README.md +14 -0
- grok_loop_kit-1.0.0/grok_loop_kit/__init__.py +25 -0
- grok_loop_kit-1.0.0/grok_loop_kit/async_client.py +88 -0
- grok_loop_kit-1.0.0/grok_loop_kit/client.py +450 -0
- grok_loop_kit-1.0.0/grok_loop_kit/py.typed +0 -0
- grok_loop_kit-1.0.0/grok_loop_kit.egg-info/PKG-INFO +28 -0
- grok_loop_kit-1.0.0/grok_loop_kit.egg-info/SOURCES.txt +11 -0
- grok_loop_kit-1.0.0/grok_loop_kit.egg-info/dependency_links.txt +1 -0
- grok_loop_kit-1.0.0/grok_loop_kit.egg-info/requires.txt +3 -0
- grok_loop_kit-1.0.0/grok_loop_kit.egg-info/top_level.txt +1 -0
- grok_loop_kit-1.0.0/pyproject.toml +28 -0
- grok_loop_kit-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grok-loop-kit
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Auto-apply Grok 4.5 context compaction to xAI Responses API agent loops.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Booyaka101/grok-loop-kit
|
|
7
|
+
Project-URL: Repository, https://github.com/Booyaka101/grok-loop-kit
|
|
8
|
+
Project-URL: Issues, https://github.com/Booyaka101/grok-loop-kit/issues
|
|
9
|
+
Keywords: grok,grok-4.5,xai,context-compaction,agent,responses-api,llm
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: requests
|
|
13
|
+
Requires-Dist: requests>=2.28; extra == "requests"
|
|
14
|
+
|
|
15
|
+
# grok-loop-kit (Python)
|
|
16
|
+
|
|
17
|
+
Automatic Grok 4.5 context compaction for xAI Responses API agent loops.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from grok_loop_kit import GrokLoopClient
|
|
21
|
+
|
|
22
|
+
client = GrokLoopClient(api_key, compact_every=8, compact_at_tokens=8000, model="grok-4.5")
|
|
23
|
+
res = client.send_message("hello")
|
|
24
|
+
print(res["_grokLoopKit"].totalCompactions)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Zero runtime dependencies (stdlib `urllib`). See the repository root README for the
|
|
28
|
+
full API and the Node/LangGraph packages.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# grok-loop-kit (Python)
|
|
2
|
+
|
|
3
|
+
Automatic Grok 4.5 context compaction for xAI Responses API agent loops.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from grok_loop_kit import GrokLoopClient
|
|
7
|
+
|
|
8
|
+
client = GrokLoopClient(api_key, compact_every=8, compact_at_tokens=8000, model="grok-4.5")
|
|
9
|
+
res = client.send_message("hello")
|
|
10
|
+
print(res["_grokLoopKit"].totalCompactions)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Zero runtime dependencies (stdlib `urllib`). See the repository root README for the
|
|
14
|
+
full API and the Node/LangGraph packages.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""grok-loop-kit: automatic Grok 4.5 context compaction for xAI Responses API agent loops."""
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncGrokLoopClient
|
|
4
|
+
from .client import (
|
|
5
|
+
CompactionEvent,
|
|
6
|
+
CompletionWithMeta,
|
|
7
|
+
GrokLoopClient,
|
|
8
|
+
GrokLoopKitMeta,
|
|
9
|
+
build_compacted_transcript,
|
|
10
|
+
extract_assistant_text,
|
|
11
|
+
extract_compaction_text,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"GrokLoopClient",
|
|
16
|
+
"AsyncGrokLoopClient",
|
|
17
|
+
"CompletionWithMeta",
|
|
18
|
+
"CompactionEvent",
|
|
19
|
+
"GrokLoopKitMeta",
|
|
20
|
+
"build_compacted_transcript",
|
|
21
|
+
"extract_assistant_text",
|
|
22
|
+
"extract_compaction_text",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AsyncGrokLoopClient — an asyncio-friendly facade over GrokLoopClient.
|
|
3
|
+
|
|
4
|
+
Zero extra dependencies: each network-bound call runs the synchronous client in
|
|
5
|
+
a worker thread via ``asyncio.to_thread``, so it never blocks the event loop.
|
|
6
|
+
State (messages, turn_count, compaction counters) lives on the wrapped sync
|
|
7
|
+
client and is exposed here as read-through properties.
|
|
8
|
+
|
|
9
|
+
Note: like the sync client, a single instance is not safe to drive with
|
|
10
|
+
concurrent overlapping calls — its transcript is shared mutable state. Use one
|
|
11
|
+
client per conversation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from .client import CompletionWithMeta, GrokLoopClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncGrokLoopClient:
|
|
23
|
+
def __init__(self, api_key: str, **kwargs: Any) -> None:
|
|
24
|
+
self._c = GrokLoopClient(api_key, **kwargs)
|
|
25
|
+
|
|
26
|
+
# --- async API ---
|
|
27
|
+
|
|
28
|
+
async def send_message(
|
|
29
|
+
self, user_content: str, tools: Optional[List[Dict[str, Any]]] = None
|
|
30
|
+
) -> CompletionWithMeta:
|
|
31
|
+
return await asyncio.to_thread(self._c.send_message, user_content, tools)
|
|
32
|
+
|
|
33
|
+
async def send_tool_outputs(
|
|
34
|
+
self, outputs: List[Dict[str, str]], tools: Optional[List[Dict[str, Any]]] = None
|
|
35
|
+
) -> CompletionWithMeta:
|
|
36
|
+
return await asyncio.to_thread(self._c.send_tool_outputs, outputs, tools)
|
|
37
|
+
|
|
38
|
+
async def stream_message(
|
|
39
|
+
self,
|
|
40
|
+
user_content: str,
|
|
41
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
42
|
+
on_token: Optional[Callable[[str], None]] = None,
|
|
43
|
+
) -> CompletionWithMeta:
|
|
44
|
+
# The sync generator yields tokens as they arrive; on_token fires from the
|
|
45
|
+
# worker thread in real time. Returns the assembled final completion.
|
|
46
|
+
return await asyncio.to_thread(self._c.stream_message, user_content, tools, on_token)
|
|
47
|
+
|
|
48
|
+
async def compact(self) -> Dict[str, Any]:
|
|
49
|
+
return await asyncio.to_thread(self._c.compact)
|
|
50
|
+
|
|
51
|
+
# --- passthroughs ---
|
|
52
|
+
|
|
53
|
+
def get_tool_calls(self, completion: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
54
|
+
return self._c.get_tool_calls(completion)
|
|
55
|
+
|
|
56
|
+
def reset(self) -> None:
|
|
57
|
+
self._c.reset()
|
|
58
|
+
|
|
59
|
+
def get_state(self) -> Dict[str, Any]:
|
|
60
|
+
return self._c.get_state()
|
|
61
|
+
|
|
62
|
+
def load_state(self, state: Dict[str, Any]) -> None:
|
|
63
|
+
self._c.load_state(state)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def messages(self) -> List[Dict[str, Any]]:
|
|
67
|
+
return self._c.messages
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def turn_count(self) -> int:
|
|
71
|
+
return self._c.turn_count
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def total_compactions(self) -> int:
|
|
75
|
+
return self._c.total_compactions
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def accumulated_tokens(self) -> int:
|
|
79
|
+
return self._c.accumulated_tokens
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def estimated_tokens_saved(self) -> int:
|
|
83
|
+
return self._c.estimated_tokens_saved
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def sync(self) -> GrokLoopClient:
|
|
87
|
+
"""Access the underlying synchronous client."""
|
|
88
|
+
return self._c
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GrokLoopClient — a thin wrapper over xAI's Responses API that transparently
|
|
3
|
+
applies Grok 4.5 context compaction inside an agent loop.
|
|
4
|
+
|
|
5
|
+
Mirrors the TypeScript implementation. Uses only the standard library
|
|
6
|
+
(urllib) so it has no runtime dependencies.
|
|
7
|
+
|
|
8
|
+
Docs:
|
|
9
|
+
https://docs.x.ai/developers/grok-4-5
|
|
10
|
+
https://docs.x.ai/developers/advanced-api-usage/context-compaction
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
InputItem = Dict[str, Any]
|
|
23
|
+
Tool = Dict[str, Any]
|
|
24
|
+
|
|
25
|
+
DEFAULT_COMPACT_EVERY = 8
|
|
26
|
+
DEFAULT_COMPACT_AT_TOKENS = 8000
|
|
27
|
+
DEFAULT_MODEL = "grok-4.5"
|
|
28
|
+
DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
|
29
|
+
DEFAULT_MAX_RETRIES = 2
|
|
30
|
+
DEFAULT_RETRY_BASE_MS = 500
|
|
31
|
+
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class GrokLoopKitMeta:
|
|
36
|
+
"""Bookkeeping attached to every completion GrokLoopClient returns."""
|
|
37
|
+
|
|
38
|
+
turnsSinceCompact: int
|
|
39
|
+
estimatedTokensSaved: int
|
|
40
|
+
totalCompactions: int
|
|
41
|
+
|
|
42
|
+
def as_dict(self) -> Dict[str, int]:
|
|
43
|
+
return {
|
|
44
|
+
"turnsSinceCompact": self.turnsSinceCompact,
|
|
45
|
+
"estimatedTokensSaved": self.estimatedTokensSaved,
|
|
46
|
+
"totalCompactions": self.totalCompactions,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class CompactionEvent:
|
|
52
|
+
"""Details passed to the ``on_compact`` hook each time a compaction runs."""
|
|
53
|
+
|
|
54
|
+
totalCompactions: int
|
|
55
|
+
estimatedTokensSaved: int
|
|
56
|
+
droppedMessageCount: int
|
|
57
|
+
atTurn: int
|
|
58
|
+
compaction: Dict[str, Any]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class CompletionWithMeta:
|
|
63
|
+
"""A raw Responses API completion plus grok-loop-kit bookkeeping."""
|
|
64
|
+
|
|
65
|
+
raw: Dict[str, Any]
|
|
66
|
+
_grokLoopKit: GrokLoopKitMeta
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def output(self) -> Any:
|
|
70
|
+
return self.raw.get("output")
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def usage(self) -> Dict[str, Any]:
|
|
74
|
+
return self.raw.get("usage", {})
|
|
75
|
+
|
|
76
|
+
def tool_calls(self) -> List[Dict[str, Any]]:
|
|
77
|
+
return [i for i in (self.raw.get("output") or []) if i.get("type") == "function_call"]
|
|
78
|
+
|
|
79
|
+
def __getitem__(self, key: str) -> Any:
|
|
80
|
+
if key == "_grokLoopKit":
|
|
81
|
+
return self._grokLoopKit
|
|
82
|
+
return self.raw[key]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# A pluggable HTTP POST returning (parsed_json). Signature:
|
|
86
|
+
# (base_url, path, body, api_key, headers, timeout) -> dict
|
|
87
|
+
Poster = Callable[..., Dict[str, Any]]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _RetryableHTTP(Exception):
|
|
91
|
+
def __init__(self, status: int, retry_after: Optional[str], detail: str):
|
|
92
|
+
super().__init__(detail)
|
|
93
|
+
self.status = status
|
|
94
|
+
self.retry_after = retry_after
|
|
95
|
+
self.detail = detail
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _default_post(base_url, path, body, api_key, headers=None, timeout=None):
|
|
99
|
+
data = json.dumps(body).encode("utf-8")
|
|
100
|
+
hdrs = {"content-type": "application/json", "authorization": f"Bearer {api_key}"}
|
|
101
|
+
if headers:
|
|
102
|
+
hdrs.update(headers)
|
|
103
|
+
req = urllib.request.Request(f"{base_url}{path}", data=data, method="POST", headers=hdrs)
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
106
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
107
|
+
except urllib.error.HTTPError as exc:
|
|
108
|
+
detail = exc.read().decode("utf-8", "replace")[:500]
|
|
109
|
+
if exc.code in RETRYABLE_STATUS:
|
|
110
|
+
raise _RetryableHTTP(exc.code, exc.headers.get("retry-after"), detail) from exc
|
|
111
|
+
raise RuntimeError(f"grok-loop-kit: POST {path} failed {exc.code}: {detail}") from exc
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _default_stream(base_url, path, body, api_key, headers=None, timeout=None):
|
|
115
|
+
"""POST with stream:true and yield parsed SSE event dicts as they arrive."""
|
|
116
|
+
data = json.dumps({**body, "stream": True}).encode("utf-8")
|
|
117
|
+
hdrs = {
|
|
118
|
+
"content-type": "application/json",
|
|
119
|
+
"accept": "text/event-stream",
|
|
120
|
+
"authorization": f"Bearer {api_key}",
|
|
121
|
+
}
|
|
122
|
+
if headers:
|
|
123
|
+
hdrs.update(headers)
|
|
124
|
+
req = urllib.request.Request(f"{base_url}{path}", data=data, method="POST", headers=hdrs)
|
|
125
|
+
resp = urllib.request.urlopen(req, timeout=timeout) # noqa: S310
|
|
126
|
+
try:
|
|
127
|
+
for raw in resp:
|
|
128
|
+
line = raw.decode("utf-8").strip()
|
|
129
|
+
if not line.startswith("data:"):
|
|
130
|
+
continue
|
|
131
|
+
payload = line[5:].strip()
|
|
132
|
+
if not payload or payload == "[DONE]":
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
yield json.loads(payload)
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
continue
|
|
138
|
+
finally:
|
|
139
|
+
resp.close()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class GrokLoopClient:
|
|
143
|
+
"""Auto-compacting client for xAI Responses API agent loops."""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
api_key: str,
|
|
148
|
+
*,
|
|
149
|
+
compact_every: int = DEFAULT_COMPACT_EVERY,
|
|
150
|
+
compact_at_tokens: int = DEFAULT_COMPACT_AT_TOKENS,
|
|
151
|
+
model: str = DEFAULT_MODEL,
|
|
152
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
153
|
+
instructions: Optional[str] = None,
|
|
154
|
+
headers: Optional[Dict[str, str]] = None,
|
|
155
|
+
timeout: Optional[float] = None,
|
|
156
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
157
|
+
retry_base_ms: int = DEFAULT_RETRY_BASE_MS,
|
|
158
|
+
on_compact: Optional[Callable[[CompactionEvent], None]] = None,
|
|
159
|
+
extra_body: Optional[Dict[str, Any]] = None,
|
|
160
|
+
poster: Optional[Poster] = None,
|
|
161
|
+
stream_poster: Optional[Callable[..., Any]] = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
if not api_key:
|
|
164
|
+
raise ValueError("grok-loop-kit: api_key is required")
|
|
165
|
+
if compact_every < 1:
|
|
166
|
+
raise ValueError("grok-loop-kit: compact_every must be >= 1")
|
|
167
|
+
|
|
168
|
+
self.api_key = api_key
|
|
169
|
+
self.compact_every = compact_every
|
|
170
|
+
self.compact_at_tokens = compact_at_tokens
|
|
171
|
+
self.model = model
|
|
172
|
+
self.base_url = base_url.rstrip("/")
|
|
173
|
+
self.instructions = instructions
|
|
174
|
+
self.headers = headers or {}
|
|
175
|
+
self.timeout = timeout
|
|
176
|
+
self.max_retries = max_retries
|
|
177
|
+
self.retry_base_ms = retry_base_ms
|
|
178
|
+
self.on_compact = on_compact
|
|
179
|
+
self.extra_body = extra_body or {}
|
|
180
|
+
self._post_impl: Poster = poster or _default_post
|
|
181
|
+
self._stream_impl = stream_poster or _default_stream
|
|
182
|
+
|
|
183
|
+
# State.
|
|
184
|
+
self.messages: List[InputItem] = []
|
|
185
|
+
self.turn_count = 0
|
|
186
|
+
self.accumulated_tokens = 0
|
|
187
|
+
self.turns_since_compact = 0
|
|
188
|
+
self.total_compactions = 0
|
|
189
|
+
self.estimated_tokens_saved = 0
|
|
190
|
+
self._last_input_tokens = 0
|
|
191
|
+
|
|
192
|
+
def send_message(
|
|
193
|
+
self, user_content: str, tools: Optional[List[Tool]] = None
|
|
194
|
+
) -> CompletionWithMeta:
|
|
195
|
+
"""Send a user turn through the loop, auto-compacting when due."""
|
|
196
|
+
self.messages.append({"role": "user", "content": user_content})
|
|
197
|
+
return self._run_turn(tools)
|
|
198
|
+
|
|
199
|
+
def send_tool_outputs(
|
|
200
|
+
self, outputs: List[Dict[str, str]], tools: Optional[List[Tool]] = None
|
|
201
|
+
) -> CompletionWithMeta:
|
|
202
|
+
"""Submit tool-call results and continue the loop (counts as a turn)."""
|
|
203
|
+
for o in outputs:
|
|
204
|
+
self.messages.append(
|
|
205
|
+
{"type": "function_call_output", "call_id": o["call_id"], "output": o["output"]}
|
|
206
|
+
)
|
|
207
|
+
return self._run_turn(tools)
|
|
208
|
+
|
|
209
|
+
def stream_message(
|
|
210
|
+
self,
|
|
211
|
+
user_content: str,
|
|
212
|
+
tools: Optional[List[Tool]] = None,
|
|
213
|
+
on_token: Optional[Callable[[str], None]] = None,
|
|
214
|
+
) -> CompletionWithMeta:
|
|
215
|
+
"""Stream a user turn: deltas go to ``on_token``; returns the final completion."""
|
|
216
|
+
self.messages.append({"role": "user", "content": user_content})
|
|
217
|
+
self.turn_count += 1
|
|
218
|
+
self.turns_since_compact += 1
|
|
219
|
+
|
|
220
|
+
body: Dict[str, Any] = {"model": self.model, "input": self.messages}
|
|
221
|
+
if self.instructions:
|
|
222
|
+
body["instructions"] = self.instructions
|
|
223
|
+
if tools:
|
|
224
|
+
body["tools"] = tools
|
|
225
|
+
body.update(self.extra_body)
|
|
226
|
+
|
|
227
|
+
text_parts: List[str] = []
|
|
228
|
+
final: Optional[Dict[str, Any]] = None
|
|
229
|
+
for ev in self._stream_impl(
|
|
230
|
+
self.base_url, "/responses", body, self.api_key, self.headers, self.timeout
|
|
231
|
+
):
|
|
232
|
+
t = ev.get("type", "")
|
|
233
|
+
if t.endswith("output_text.delta") and isinstance(ev.get("delta"), str):
|
|
234
|
+
text_parts.append(ev["delta"])
|
|
235
|
+
if on_token:
|
|
236
|
+
on_token(ev["delta"])
|
|
237
|
+
elif t == "response.completed" and ev.get("response"):
|
|
238
|
+
final = ev["response"]
|
|
239
|
+
|
|
240
|
+
completion = final or {
|
|
241
|
+
"object": "response",
|
|
242
|
+
"status": "completed",
|
|
243
|
+
"model": self.model,
|
|
244
|
+
"output": [
|
|
245
|
+
{"type": "message", "role": "assistant",
|
|
246
|
+
"content": [{"type": "output_text", "text": "".join(text_parts)}]}
|
|
247
|
+
],
|
|
248
|
+
"usage": {},
|
|
249
|
+
}
|
|
250
|
+
return self._finalize_turn(completion)
|
|
251
|
+
|
|
252
|
+
def get_tool_calls(self, completion: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
253
|
+
"""Extract ``function_call`` items from a completion's output."""
|
|
254
|
+
return [i for i in (completion.get("output") or []) if i.get("type") == "function_call"]
|
|
255
|
+
|
|
256
|
+
def reset(self) -> None:
|
|
257
|
+
"""Clear all loop state (transcript and counters), keeping configuration."""
|
|
258
|
+
self.messages = []
|
|
259
|
+
self.turn_count = 0
|
|
260
|
+
self.accumulated_tokens = 0
|
|
261
|
+
self.turns_since_compact = 0
|
|
262
|
+
self.total_compactions = 0
|
|
263
|
+
self.estimated_tokens_saved = 0
|
|
264
|
+
self._last_input_tokens = 0
|
|
265
|
+
|
|
266
|
+
def get_state(self) -> Dict[str, Any]:
|
|
267
|
+
"""Snapshot the loop state for persistence (JSON-serializable)."""
|
|
268
|
+
return {
|
|
269
|
+
"version": 1,
|
|
270
|
+
"messages": self.messages,
|
|
271
|
+
"turn_count": self.turn_count,
|
|
272
|
+
"accumulated_tokens": self.accumulated_tokens,
|
|
273
|
+
"turns_since_compact": self.turns_since_compact,
|
|
274
|
+
"total_compactions": self.total_compactions,
|
|
275
|
+
"estimated_tokens_saved": self.estimated_tokens_saved,
|
|
276
|
+
"last_input_tokens": self._last_input_tokens,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
def load_state(self, state: Dict[str, Any]) -> None:
|
|
280
|
+
"""Restore loop state produced by ``get_state()``."""
|
|
281
|
+
if state.get("version") != 1:
|
|
282
|
+
raise ValueError(f"grok-loop-kit: unsupported state version {state.get('version')}")
|
|
283
|
+
self.messages = state.get("messages", [])
|
|
284
|
+
self.turn_count = state.get("turn_count", 0)
|
|
285
|
+
self.accumulated_tokens = state.get("accumulated_tokens", 0)
|
|
286
|
+
self.turns_since_compact = state.get("turns_since_compact", 0)
|
|
287
|
+
self.total_compactions = state.get("total_compactions", 0)
|
|
288
|
+
self.estimated_tokens_saved = state.get("estimated_tokens_saved", 0)
|
|
289
|
+
self._last_input_tokens = state.get("last_input_tokens", 0)
|
|
290
|
+
|
|
291
|
+
def compact(self) -> Dict[str, Any]:
|
|
292
|
+
"""Fold the current transcript into a compaction item."""
|
|
293
|
+
compaction = self._post_compact(self.messages)
|
|
294
|
+
usage = compaction.get("usage") or {}
|
|
295
|
+
dropped = usage.get("dropped_message_count", len(self.messages))
|
|
296
|
+
|
|
297
|
+
# Per xAI docs: usage.input_tokens = pre-compaction conversation tokens,
|
|
298
|
+
# usage.output_tokens = compacted record size. Removed = input - output.
|
|
299
|
+
pre_tokens = usage.get("input_tokens", self._last_input_tokens)
|
|
300
|
+
compacted_record_tokens = usage.get("output_tokens", 0)
|
|
301
|
+
self.estimated_tokens_saved += max(0, pre_tokens - compacted_record_tokens)
|
|
302
|
+
# Reuse the compaction result the way the API expects: verbatim items.
|
|
303
|
+
self.messages = build_compacted_transcript(compaction)
|
|
304
|
+
self.accumulated_tokens = 0
|
|
305
|
+
self.turns_since_compact = 0
|
|
306
|
+
self.total_compactions += 1
|
|
307
|
+
|
|
308
|
+
if self.on_compact:
|
|
309
|
+
self.on_compact(
|
|
310
|
+
CompactionEvent(
|
|
311
|
+
totalCompactions=self.total_compactions,
|
|
312
|
+
estimatedTokensSaved=self.estimated_tokens_saved,
|
|
313
|
+
droppedMessageCount=dropped,
|
|
314
|
+
atTurn=self.turn_count,
|
|
315
|
+
compaction=compaction,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return compaction
|
|
319
|
+
|
|
320
|
+
# --- internal ---
|
|
321
|
+
|
|
322
|
+
def _run_turn(self, tools: Optional[List[Tool]]) -> CompletionWithMeta:
|
|
323
|
+
self.turn_count += 1
|
|
324
|
+
self.turns_since_compact += 1
|
|
325
|
+
completion = self._post_responses(self.messages, tools)
|
|
326
|
+
return self._finalize_turn(completion)
|
|
327
|
+
|
|
328
|
+
def _finalize_turn(self, completion: Dict[str, Any]) -> CompletionWithMeta:
|
|
329
|
+
"""Shared post-completion bookkeeping: preserve output, tally tokens, compact."""
|
|
330
|
+
# Preserve ALL output items verbatim (messages, reasoning, function_calls).
|
|
331
|
+
output = completion.get("output")
|
|
332
|
+
if isinstance(output, list) and output:
|
|
333
|
+
self.messages.extend(output)
|
|
334
|
+
|
|
335
|
+
# accumulated_tokens tracks the CURRENT rendered context size (what the
|
|
336
|
+
# next request would send), per xAI's "rendered context above a threshold"
|
|
337
|
+
# guidance — not a running sum. Resets to 0 when compaction shrinks context.
|
|
338
|
+
usage = completion.get("usage") or {}
|
|
339
|
+
in_tok = usage.get("input_tokens")
|
|
340
|
+
if isinstance(in_tok, (int, float)):
|
|
341
|
+
self._last_input_tokens = int(in_tok)
|
|
342
|
+
self.accumulated_tokens = int(in_tok) + int(usage.get("output_tokens", 0))
|
|
343
|
+
|
|
344
|
+
if (
|
|
345
|
+
self.turn_count % self.compact_every == 0
|
|
346
|
+
or self.accumulated_tokens > self.compact_at_tokens
|
|
347
|
+
):
|
|
348
|
+
self.compact()
|
|
349
|
+
|
|
350
|
+
return CompletionWithMeta(
|
|
351
|
+
raw=completion,
|
|
352
|
+
_grokLoopKit=GrokLoopKitMeta(
|
|
353
|
+
turnsSinceCompact=self.turns_since_compact,
|
|
354
|
+
estimatedTokensSaved=self.estimated_tokens_saved,
|
|
355
|
+
totalCompactions=self.total_compactions,
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _post_responses(
|
|
360
|
+
self, input_items: List[InputItem], tools: Optional[List[Tool]]
|
|
361
|
+
) -> Dict[str, Any]:
|
|
362
|
+
body: Dict[str, Any] = {"model": self.model, "input": input_items}
|
|
363
|
+
if self.instructions:
|
|
364
|
+
body["instructions"] = self.instructions
|
|
365
|
+
if tools:
|
|
366
|
+
body["tools"] = tools
|
|
367
|
+
body.update(self.extra_body)
|
|
368
|
+
return self._post("/responses", body)
|
|
369
|
+
|
|
370
|
+
def _post_compact(self, input_items: List[InputItem]) -> Dict[str, Any]:
|
|
371
|
+
return self._post("/responses/compact", {"model": self.model, "input": input_items})
|
|
372
|
+
|
|
373
|
+
def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
374
|
+
attempt = 0
|
|
375
|
+
while True:
|
|
376
|
+
try:
|
|
377
|
+
return self._post_impl(
|
|
378
|
+
self.base_url, path, body, self.api_key, self.headers, self.timeout
|
|
379
|
+
)
|
|
380
|
+
except _RetryableHTTP as exc:
|
|
381
|
+
if attempt >= self.max_retries:
|
|
382
|
+
raise RuntimeError(
|
|
383
|
+
f"grok-loop-kit: POST {path} failed {exc.status}: {exc.detail}"
|
|
384
|
+
) from exc
|
|
385
|
+
time.sleep(self._backoff_s(attempt, exc.retry_after))
|
|
386
|
+
attempt += 1
|
|
387
|
+
except (urllib.error.URLError, TimeoutError):
|
|
388
|
+
if attempt >= self.max_retries:
|
|
389
|
+
raise
|
|
390
|
+
time.sleep(self._backoff_s(attempt, None))
|
|
391
|
+
attempt += 1
|
|
392
|
+
|
|
393
|
+
def _backoff_s(self, attempt: int, retry_after: Optional[str]) -> float:
|
|
394
|
+
if retry_after is not None:
|
|
395
|
+
try:
|
|
396
|
+
return max(0.0, float(retry_after))
|
|
397
|
+
except ValueError:
|
|
398
|
+
pass
|
|
399
|
+
return (self.retry_base_ms * (2 ** attempt)) / 1000.0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def extract_assistant_text(completion: Dict[str, Any]) -> str:
|
|
403
|
+
"""Pull the assistant's text out of a Responses API completion."""
|
|
404
|
+
parts: List[str] = []
|
|
405
|
+
for item in completion.get("output") or []:
|
|
406
|
+
if item.get("type") != "message":
|
|
407
|
+
continue
|
|
408
|
+
for c in item.get("content") or []:
|
|
409
|
+
text = c.get("text")
|
|
410
|
+
if isinstance(text, str):
|
|
411
|
+
parts.append(text)
|
|
412
|
+
elif c.get("type") == "refusal" and isinstance(c.get("refusal"), str):
|
|
413
|
+
parts.append(c["refusal"])
|
|
414
|
+
return "".join(parts)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def extract_compaction_text(compaction: Dict[str, Any]) -> str:
|
|
418
|
+
"""
|
|
419
|
+
Pull the compacted text out of a compaction reply. The real API returns an
|
|
420
|
+
array of items with ``encrypted_content``; mocks/gateways may return a str.
|
|
421
|
+
"""
|
|
422
|
+
out = compaction.get("output")
|
|
423
|
+
if isinstance(out, str):
|
|
424
|
+
return out
|
|
425
|
+
if isinstance(out, list):
|
|
426
|
+
parts: List[str] = []
|
|
427
|
+
for item in out:
|
|
428
|
+
enc = item.get("encrypted_content")
|
|
429
|
+
if isinstance(enc, str):
|
|
430
|
+
parts.append(enc)
|
|
431
|
+
for c in item.get("content") or []:
|
|
432
|
+
text = c.get("text")
|
|
433
|
+
if isinstance(text, str):
|
|
434
|
+
parts.append(text)
|
|
435
|
+
if parts:
|
|
436
|
+
return "".join(parts)
|
|
437
|
+
return "[COMPACTED]"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def build_compacted_transcript(compaction: Dict[str, Any]) -> List[InputItem]:
|
|
441
|
+
"""
|
|
442
|
+
Build the post-compaction transcript. Per xAI docs, compaction ``output``
|
|
443
|
+
items are spread back into ``input`` verbatim. Gateways/mocks that return a
|
|
444
|
+
plain string become a single user message.
|
|
445
|
+
"""
|
|
446
|
+
out = compaction.get("output")
|
|
447
|
+
if isinstance(out, list) and out:
|
|
448
|
+
return list(out)
|
|
449
|
+
text = out if isinstance(out, str) else extract_compaction_text(compaction)
|
|
450
|
+
return [{"role": "user", "content": text}]
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grok-loop-kit
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Auto-apply Grok 4.5 context compaction to xAI Responses API agent loops.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Booyaka101/grok-loop-kit
|
|
7
|
+
Project-URL: Repository, https://github.com/Booyaka101/grok-loop-kit
|
|
8
|
+
Project-URL: Issues, https://github.com/Booyaka101/grok-loop-kit/issues
|
|
9
|
+
Keywords: grok,grok-4.5,xai,context-compaction,agent,responses-api,llm
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: requests
|
|
13
|
+
Requires-Dist: requests>=2.28; extra == "requests"
|
|
14
|
+
|
|
15
|
+
# grok-loop-kit (Python)
|
|
16
|
+
|
|
17
|
+
Automatic Grok 4.5 context compaction for xAI Responses API agent loops.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from grok_loop_kit import GrokLoopClient
|
|
21
|
+
|
|
22
|
+
client = GrokLoopClient(api_key, compact_every=8, compact_at_tokens=8000, model="grok-4.5")
|
|
23
|
+
res = client.send_message("hello")
|
|
24
|
+
print(res["_grokLoopKit"].totalCompactions)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Zero runtime dependencies (stdlib `urllib`). See the repository root README for the
|
|
28
|
+
full API and the Node/LangGraph packages.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
grok_loop_kit/__init__.py
|
|
4
|
+
grok_loop_kit/async_client.py
|
|
5
|
+
grok_loop_kit/client.py
|
|
6
|
+
grok_loop_kit/py.typed
|
|
7
|
+
grok_loop_kit.egg-info/PKG-INFO
|
|
8
|
+
grok_loop_kit.egg-info/SOURCES.txt
|
|
9
|
+
grok_loop_kit.egg-info/dependency_links.txt
|
|
10
|
+
grok_loop_kit.egg-info/requires.txt
|
|
11
|
+
grok_loop_kit.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
grok_loop_kit
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "grok-loop-kit"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Auto-apply Grok 4.5 context compaction to xAI Responses API agent loops."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["grok", "grok-4.5", "xai", "context-compaction", "agent", "responses-api", "llm"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/Booyaka101/grok-loop-kit"
|
|
17
|
+
Repository = "https://github.com/Booyaka101/grok-loop-kit"
|
|
18
|
+
Issues = "https://github.com/Booyaka101/grok-loop-kit/issues"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
# Faster HTTP if you prefer; the package works on stdlib urllib without it.
|
|
22
|
+
requests = ["requests>=2.28"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
packages = ["grok_loop_kit"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
grok_loop_kit = ["py.typed"]
|