agentcompile 0.2.1__tar.gz → 0.3.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.
Files changed (34) hide show
  1. {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/workflows/ci.yml +2 -2
  2. {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/workflows/release.yml +3 -3
  3. agentcompile-0.3.0/.release-please-manifest.json +1 -0
  4. {agentcompile-0.2.1 → agentcompile-0.3.0}/CHANGELOG.md +7 -0
  5. {agentcompile-0.2.1 → agentcompile-0.3.0}/PKG-INFO +1 -1
  6. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/__init__.py +26 -1
  7. agentcompile-0.3.0/src/agentcompile/_capture.py +117 -0
  8. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_core.py +14 -1
  9. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_version.py +2 -2
  10. agentcompile-0.3.0/tests/test_capture.py +150 -0
  11. agentcompile-0.2.1/.release-please-manifest.json +0 -1
  12. {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/CODEOWNERS +0 -0
  13. {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  14. {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  15. {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  16. {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/dependabot.yml +0 -0
  17. {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/pull_request_template.md +0 -0
  18. {agentcompile-0.2.1 → agentcompile-0.3.0}/.gitignore +0 -0
  19. {agentcompile-0.2.1 → agentcompile-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  20. {agentcompile-0.2.1 → agentcompile-0.3.0}/CONTRIBUTING.md +0 -0
  21. {agentcompile-0.2.1 → agentcompile-0.3.0}/LICENSE +0 -0
  22. {agentcompile-0.2.1 → agentcompile-0.3.0}/README.md +0 -0
  23. {agentcompile-0.2.1 → agentcompile-0.3.0}/SECURITY.md +0 -0
  24. {agentcompile-0.2.1 → agentcompile-0.3.0}/pyproject.toml +0 -0
  25. {agentcompile-0.2.1 → agentcompile-0.3.0}/release-please-config.json +0 -0
  26. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_anthropic.py +0 -0
  27. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_conversation.py +0 -0
  28. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_decide.py +0 -0
  29. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_openai.py +0 -0
  30. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_payload.py +0 -0
  31. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_streams.py +0 -0
  32. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_trail.py +0 -0
  33. {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/cli.py +0 -0
  34. {agentcompile-0.2.1 → agentcompile-0.3.0}/tests/test_wrap.py +0 -0
@@ -12,10 +12,10 @@ jobs:
12
12
  matrix:
13
13
  python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
14
14
  steps:
15
- - uses: actions/checkout@v4
15
+ - uses: actions/checkout@v7
16
16
  with:
17
17
  fetch-depth: 0 # the version comes from git tags
18
- - uses: actions/setup-python@v5
18
+ - uses: actions/setup-python@v7
19
19
  with:
20
20
  python-version: ${{ matrix.python }}
21
21
  - run: pip install -e ".[dev]" ruff
@@ -23,7 +23,7 @@ jobs:
23
23
  released: ${{ steps.rp.outputs.release_created }}
24
24
  tag: ${{ steps.rp.outputs.tag_name }}
25
25
  steps:
26
- - uses: googleapis/release-please-action@v4
26
+ - uses: googleapis/release-please-action@v5
27
27
  id: rp
28
28
  with:
29
29
  config-file: release-please-config.json
@@ -39,11 +39,11 @@ jobs:
39
39
  id-token: write
40
40
  contents: read
41
41
  steps:
42
- - uses: actions/checkout@v4
42
+ - uses: actions/checkout@v7
43
43
  with:
44
44
  ref: ${{ needs.release-please.outputs.tag || github.ref }}
45
45
  fetch-depth: 0
46
- - uses: actions/setup-python@v5
46
+ - uses: actions/setup-python@v7
47
47
  with:
48
48
  python-version: "3.12"
49
49
  - run: pip install build && python -m build
@@ -0,0 +1 @@
1
+ {".":"0.3.0"}
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/kbhatnagar1506/agentcompile-python/compare/v0.2.1...v0.3.0) (2026-10-01)
4
+
5
+
6
+ ### Features
7
+
8
+ * capture (opt-in) sends each call's request and answer in the background ([#7](https://github.com/kbhatnagar1506/agentcompile-python/issues/7)) ([58f564d](https://github.com/kbhatnagar1506/agentcompile-python/commit/58f564da708c31dbf6d4c11c377dab2e315994de))
9
+
3
10
  ## [0.2.1](https://github.com/kbhatnagar1506/agentcompile-python/compare/v0.2.0...v0.2.1) (2026-10-01)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentcompile
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Wrap your agent's model client: repeated jobs run compiled, everything else goes to your model unchanged.
5
5
  Project-URL: Homepage, https://tryagentcompile.com
6
6
  Author-email: AgentCompile <founders@tryagentcompile.com>
@@ -15,12 +15,13 @@ import inspect
15
15
  import os
16
16
  from typing import Any
17
17
 
18
+ from ._capture import Capturer, flush_all
18
19
  from ._conversation import conversation
19
20
  from ._core import LIVE, SHADOW, Proxy, Router, async_create, sync_create
20
21
  from ._decide import AsyncDecider, Decider, Settings
21
22
  from ._trail import OnEvent, Trail
22
23
 
23
- __all__ = ["LIVE", "SHADOW", "conversation", "wrap"]
24
+ __all__ = ["LIVE", "SHADOW", "Settings", "conversation", "flush", "wrap"]
24
25
  try:
25
26
  from ._version import __version__
26
27
  except ImportError: # running from a source checkout that was never built
@@ -40,6 +41,7 @@ def wrap(
40
41
  on_event: OnEvent | None = None,
41
42
  company: str | None = None,
42
43
  http_client: Any = None,
44
+ capture: bool | None = None,
43
45
  ) -> Any:
44
46
  """Wrap an OpenAI- or Anthropic-style client; returns an object you use exactly like it.
45
47
 
@@ -47,6 +49,8 @@ def wrap(
47
49
  mode: "live" answers known jobs; "shadow" decides but always calls your model, so you can
48
50
  see what it would have done. timeout: seconds to wait for a decision before failing open.
49
51
  trail: a path, True for ~/.agentcompile/trail.jsonl, or False. on_event: called per call.
52
+ capture: send each call's request and answer to AgentCompile in the background, so it can
53
+ find the jobs your agent repeats (opt-in; or AGENTCOMPILE_CAPTURE=1). Never slows a call.
50
54
  """
51
55
  settings = Settings(
52
56
  base_url=base_url or os.environ.get("AGENTCOMPILE_URL", DEFAULT_URL),
@@ -55,6 +59,14 @@ def wrap(
55
59
  timeout=timeout,
56
60
  )
57
61
  the_trail = Trail(trail, on_event)
62
+ if capture is None:
63
+ capture = os.environ.get("AGENTCOMPILE_CAPTURE", "") in ("1", "true", "yes")
64
+ # One sender per wrapped client; it always sends from its own thread (sync client).
65
+ capturer = (
66
+ Capturer(settings, http_client if _is_sync_http(http_client) else None)
67
+ if capture
68
+ else None
69
+ )
58
70
 
59
71
  chat = getattr(client, "chat", None)
60
72
  completions = getattr(chat, "completions", None)
@@ -62,6 +74,7 @@ def wrap(
62
74
  from . import _openai
63
75
 
64
76
  router = _router("openai", completions.create, settings, the_trail, mode, http_client)
77
+ router.capturer = capturer
65
78
  create = _create(router, completions.create, _openai.build)
66
79
  return Proxy(
67
80
  client,
@@ -73,12 +86,18 @@ def wrap(
73
86
  from . import _anthropic
74
87
 
75
88
  router = _router("anthropic", messages.create, settings, the_trail, mode, http_client)
89
+ router.capturer = capturer
76
90
  create = _create(router, messages.create, _anthropic.build)
77
91
  return Proxy(client, {"messages": Proxy(messages, {"create": create})})
78
92
 
79
93
  raise TypeError("agentcompile.wrap expects an OpenAI- or Anthropic-style client")
80
94
 
81
95
 
96
+ def flush(timeout: float = 5.0) -> None:
97
+ """Send every captured call still queued (short scripts and tests; also runs at exit)."""
98
+ flush_all(timeout)
99
+
100
+
82
101
  def _router(
83
102
  provider: str, create: Any, settings: Settings, trail: Trail, mode: str, http_client: Any
84
103
  ) -> Router:
@@ -98,6 +117,12 @@ def _create(router: Router, original: Any, build: Any) -> Any:
98
117
  )
99
118
 
100
119
 
120
+ def _is_sync_http(http_client: Any) -> bool:
121
+ """Any client with a plain (not async) post(): httpx, httpx2, a test client."""
122
+ post = getattr(http_client, "post", None)
123
+ return post is not None and not inspect.iscoroutinefunction(inspect.unwrap(post))
124
+
125
+
101
126
  def _is_async(fn: Any) -> bool:
102
127
  """The SDKs wrap create() in decorators; look through them."""
103
128
  return inspect.iscoroutinefunction(inspect.unwrap(fn))
@@ -0,0 +1,117 @@
1
+ """Capture: each model call's request and answer, sent to AgentCompile in the background so it
2
+ can find the jobs your agent repeats (opt-in: `wrap(..., capture=True)`).
3
+
4
+ Never in the way: calls are queued and sent by one background thread in batches; a full queue
5
+ drops the oldest, a failed send is dropped and counted, and nothing here ever raises into your
6
+ agent. Each record is one line in the exchange-log shape AgentCompile's importer reads:
7
+ {"provider", "conversation_id", "timestamp", "request", "response"}.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import atexit
13
+ import collections
14
+ import threading
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from ._decide import CONVERSATION_HEADER, Settings, _request
22
+ from ._payload import jsonable, payload
23
+
24
+ MAX_QUEUE = 2000
25
+ BATCH = 50
26
+ INTERVAL = 1.0
27
+
28
+
29
+ _ALL: list[Capturer] = []
30
+
31
+
32
+ def flush_all(timeout: float = 5.0) -> None:
33
+ for capturer in list(_ALL):
34
+ capturer.flush(timeout)
35
+
36
+
37
+ class Capturer:
38
+ def __init__(self, settings: Settings, http: httpx.Client | None = None) -> None:
39
+ self.settings = settings
40
+ self._http = http
41
+ self._queue: collections.deque[dict[str, Any]] = collections.deque(maxlen=MAX_QUEUE)
42
+ self._lock = threading.Lock()
43
+ self._wake = threading.Event()
44
+ self._thread: threading.Thread | None = None
45
+ self.sent = 0
46
+ self.dropped = 0
47
+ self.failed = 0
48
+ atexit.register(self.flush, 2.0)
49
+ _ALL.append(self)
50
+
51
+ def add(
52
+ self,
53
+ provider: str,
54
+ conversation_id: str | None,
55
+ kwargs: dict[str, Any],
56
+ response: Any,
57
+ stream: bool = False,
58
+ ) -> None:
59
+ """Queue one call. Never raises. A streamed answer is not captured yet: the record
60
+ keeps the request and says so."""
61
+ try:
62
+ record = {
63
+ "provider": provider,
64
+ "conversation_id": conversation_id,
65
+ "timestamp": datetime.now(timezone.utc).isoformat(),
66
+ "request": payload(kwargs),
67
+ "response": None if stream else jsonable(response),
68
+ }
69
+ if stream:
70
+ record["stream"] = True
71
+ with self._lock:
72
+ if len(self._queue) == self._queue.maxlen:
73
+ self.dropped += 1 # the deque drops the oldest
74
+ self._queue.append(record)
75
+ self._start()
76
+ if len(self._queue) >= BATCH:
77
+ self._wake.set()
78
+ except Exception:
79
+ self.dropped += 1
80
+
81
+ def flush(self, timeout: float = 5.0) -> None:
82
+ """Send everything queued now (tests, and at exit)."""
83
+ deadline = time.monotonic() + timeout
84
+ while self._queue and time.monotonic() < deadline:
85
+ self._send_batch()
86
+
87
+ def _start(self) -> None:
88
+ if self._thread is None or not self._thread.is_alive():
89
+ self._thread = threading.Thread(target=self._run, name="agentcompile-capture")
90
+ self._thread.daemon = True
91
+ self._thread.start()
92
+
93
+ def _run(self) -> None:
94
+ while True:
95
+ self._wake.wait(INTERVAL)
96
+ self._wake.clear()
97
+ while self._queue:
98
+ self._send_batch()
99
+
100
+ def _send_batch(self) -> None:
101
+ with self._lock:
102
+ batch = [self._queue.popleft() for _ in range(min(BATCH, len(self._queue)))]
103
+ if not batch:
104
+ return
105
+ url, headers, _ = _request(self.settings, batch[0]["provider"], "capture", {})
106
+ headers.pop(CONVERSATION_HEADER, None)
107
+ url = url.rsplit("/v1/decide", 1)[0] + "/v1/capture"
108
+ try:
109
+ if self._http is None:
110
+ self._http = httpx.Client(timeout=self.settings.timeout)
111
+ response = self._http.post(url, headers=headers, json={"exchanges": batch})
112
+ if response.status_code == 200:
113
+ self.sent += len(batch)
114
+ else:
115
+ self.failed += len(batch)
116
+ except Exception:
117
+ self.failed += len(batch)
@@ -16,9 +16,12 @@ LIVE, SHADOW = "live", "shadow"
16
16
  class Router:
17
17
  """Holds the decision client, the trail and the mode for one wrapped client."""
18
18
 
19
- def __init__(self, provider: str, decider: Any, trail: Trail, mode: str) -> None:
19
+ def __init__(
20
+ self, provider: str, decider: Any, trail: Trail, mode: str, capturer: Any = None
21
+ ) -> None:
20
22
  if mode not in (LIVE, SHADOW):
21
23
  raise ValueError("mode must be 'live' or 'shadow'")
24
+ self.capturer = capturer
22
25
  self.provider = provider
23
26
  self.decider = decider
24
27
  self.trail = trail
@@ -44,6 +47,12 @@ class Router:
44
47
  return "shadow", None
45
48
  return "compiled", decision
46
49
 
50
+ def capture(
51
+ self, conversation_id: str | None, kwargs: dict[str, Any], result: Any, stream: bool
52
+ ) -> None:
53
+ if self.capturer is not None:
54
+ self.capturer.add(self.provider, conversation_id, kwargs, result, stream)
55
+
47
56
  def record(
48
57
  self,
49
58
  *,
@@ -103,8 +112,10 @@ def sync_create(
103
112
  started=started,
104
113
  stream=stream,
105
114
  )
115
+ router.capture(conversation_id, real_kwargs, result, stream)
106
116
  return result
107
117
  result = original(*args, **real_kwargs)
118
+ router.capture(conversation_id, real_kwargs, result, stream)
108
119
  router.record(
109
120
  route=route,
110
121
  conversation_id=conversation_id,
@@ -149,8 +160,10 @@ def async_create(
149
160
  started=started,
150
161
  stream=stream,
151
162
  )
163
+ router.capture(conversation_id, real_kwargs, result, stream)
152
164
  return result
153
165
  result = await original(*args, **real_kwargs)
166
+ router.capture(conversation_id, real_kwargs, result, stream)
154
167
  router.record(
155
168
  route=route,
156
169
  conversation_id=conversation_id,
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.1'
22
- __version_tuple__ = version_tuple = (0, 2, 1)
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,150 @@
1
+ """Capture (wrap(..., capture=True)): each call's request and answer reach AgentCompile in the
2
+ background, in the importer's exchange shape, and never slow or break the call."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ import httpx
10
+ import openai
11
+ import pytest
12
+
13
+ import agentcompile
14
+ from agentcompile import _capture
15
+
16
+ MESSAGES = [{"role": "user", "content": "cancel order #W1"}]
17
+ FORWARD = {"action": "forward", "reason": "no job matched"}
18
+ COMPLETION = {
19
+ "id": "c1",
20
+ "object": "chat.completion",
21
+ "created": 0,
22
+ "model": "gpt-x",
23
+ "choices": [
24
+ {
25
+ "index": 0,
26
+ "message": {"role": "assistant", "content": "from the model"},
27
+ "finish_reason": "stop",
28
+ }
29
+ ],
30
+ }
31
+
32
+
33
+ class Service:
34
+ """AgentCompile: answers decide with forward, records capture batches."""
35
+
36
+ def __init__(self, capture_status: int = 200) -> None:
37
+ self.batches: list[dict[str, Any]] = []
38
+ self.capture_status = capture_status
39
+
40
+ def __call__(self, request: httpx.Request) -> httpx.Response:
41
+ if request.url.path == "/v1/capture":
42
+ self.batches.append(json.loads(request.content))
43
+ assert request.headers["x-agentcompiler-key"] == "ack_acme.k"
44
+ assert "x-agentcompiler-conversation" not in request.headers
45
+ return httpx.Response(self.capture_status, json={"kept": 1})
46
+ return httpx.Response(200, json=FORWARD)
47
+
48
+
49
+ def _client(service: Service, **kw: Any) -> Any:
50
+ provider = openai.OpenAI(
51
+ api_key="sk-test",
52
+ base_url="http://provider.test/v1",
53
+ http_client=httpx.Client(
54
+ transport=httpx.MockTransport(lambda r: httpx.Response(200, json=COMPLETION))
55
+ ),
56
+ )
57
+ return agentcompile.wrap(
58
+ provider,
59
+ key="ack_acme.k",
60
+ base_url="http://ac.test",
61
+ trail=False,
62
+ http_client=httpx.Client(transport=httpx.MockTransport(service)),
63
+ **kw,
64
+ )
65
+
66
+
67
+ @pytest.fixture(autouse=True)
68
+ def _no_leftover_senders() -> Any:
69
+ yield
70
+ _capture._ALL.clear()
71
+
72
+
73
+ def test_capture_is_off_unless_asked(monkeypatch: pytest.MonkeyPatch) -> None:
74
+ monkeypatch.delenv("AGENTCOMPILE_CAPTURE", raising=False)
75
+ service = Service()
76
+ _client(service).chat.completions.create(
77
+ model="gpt-x", messages=MESSAGES, conversation_id="c1"
78
+ )
79
+ agentcompile.flush(2)
80
+ assert service.batches == []
81
+
82
+
83
+ def test_each_call_is_sent_in_the_importers_exchange_shape() -> None:
84
+ service = Service()
85
+ client = _client(service, capture=True)
86
+ for _ in range(3):
87
+ client.chat.completions.create(model="gpt-x", messages=MESSAGES, conversation_id="c1")
88
+ agentcompile.flush(5)
89
+ sent = [x for batch in service.batches for x in batch["exchanges"]]
90
+ assert len(sent) == 3
91
+ first = sent[0]
92
+ assert first["provider"] == "openai" and first["conversation_id"] == "c1"
93
+ assert first["request"]["messages"] == MESSAGES and first["request"]["model"] == "gpt-x"
94
+ assert first["response"]["choices"][0]["message"]["content"] == "from the model"
95
+ assert first["timestamp"]
96
+ assert "conversation_id" not in first["request"] # our keyword never reaches the model
97
+
98
+
99
+ def test_the_environment_turns_capture_on(monkeypatch: pytest.MonkeyPatch) -> None:
100
+ monkeypatch.setenv("AGENTCOMPILE_CAPTURE", "1")
101
+ service = Service()
102
+ _client(service).chat.completions.create(
103
+ model="gpt-x", messages=MESSAGES, conversation_id="c1"
104
+ )
105
+ agentcompile.flush(5)
106
+ assert len(service.batches) == 1
107
+
108
+
109
+ def test_a_failing_service_never_breaks_the_call() -> None:
110
+ service = Service(capture_status=500)
111
+ client = _client(service, capture=True)
112
+ resp = client.chat.completions.create(
113
+ model="gpt-x", messages=MESSAGES, conversation_id="c1"
114
+ )
115
+ assert resp.choices[0].message.content == "from the model"
116
+ agentcompile.flush(5)
117
+ assert _capture._ALL[-1].failed == 1 and _capture._ALL[-1].sent == 0
118
+
119
+
120
+ def test_a_full_queue_drops_the_oldest(monkeypatch: pytest.MonkeyPatch) -> None:
121
+ monkeypatch.setattr(_capture, "MAX_QUEUE", 2)
122
+ settings = agentcompile.Settings("http://ac.test", "ack_acme.k", None, 1.0)
123
+ capturer = _capture.Capturer(
124
+ settings, httpx.Client(transport=httpx.MockTransport(Service()))
125
+ )
126
+ capturer._start = lambda: None # type: ignore[method-assign]
127
+ for n in range(3):
128
+ capturer.add("openai", f"c{n}", {"messages": []}, {"n": n})
129
+ assert capturer.dropped == 1
130
+ assert [r["conversation_id"] for r in capturer._queue] == ["c1", "c2"]
131
+
132
+
133
+ def test_a_streamed_answer_is_marked_not_captured() -> None:
134
+ settings = agentcompile.Settings("http://ac.test", "ack_acme.k", None, 1.0)
135
+ capturer = _capture.Capturer(
136
+ settings, httpx.Client(transport=httpx.MockTransport(Service()))
137
+ )
138
+ capturer._start = lambda: None # type: ignore[method-assign]
139
+ capturer.add("openai", "c1", {"messages": MESSAGES, "stream": True}, object(), stream=True)
140
+ [record] = list(capturer._queue)
141
+ assert record["response"] is None and record["stream"] is True
142
+
143
+
144
+ def test_a_record_that_cannot_be_built_is_dropped_quietly() -> None:
145
+ settings = agentcompile.Settings("http://ac.test", "ack_acme.k", None, 1.0)
146
+ capturer = _capture.Capturer(
147
+ settings, httpx.Client(transport=httpx.MockTransport(Service()))
148
+ )
149
+ capturer.add("openai", "c1", None, None) # type: ignore[arg-type]
150
+ assert capturer.dropped == 1
@@ -1 +0,0 @@
1
- {".":"0.2.1"}
File without changes
File without changes
File without changes
File without changes