agentcompile 0.2.0__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 (35) hide show
  1. agentcompile-0.3.0/.github/CODEOWNERS +1 -0
  2. agentcompile-0.3.0/.github/ISSUE_TEMPLATE/bug_report.yml +23 -0
  3. agentcompile-0.3.0/.github/ISSUE_TEMPLATE/config.yml +5 -0
  4. agentcompile-0.3.0/.github/ISSUE_TEMPLATE/feature_request.yml +9 -0
  5. agentcompile-0.3.0/.github/dependabot.yml +8 -0
  6. agentcompile-0.3.0/.github/pull_request_template.md +6 -0
  7. {agentcompile-0.2.0 → agentcompile-0.3.0}/.github/workflows/ci.yml +2 -2
  8. {agentcompile-0.2.0 → agentcompile-0.3.0}/.github/workflows/release.yml +3 -3
  9. agentcompile-0.3.0/.release-please-manifest.json +1 -0
  10. agentcompile-0.3.0/CHANGELOG.md +22 -0
  11. agentcompile-0.3.0/CODE_OF_CONDUCT.md +7 -0
  12. agentcompile-0.3.0/CONTRIBUTING.md +22 -0
  13. {agentcompile-0.2.0 → agentcompile-0.3.0}/PKG-INFO +3 -1
  14. {agentcompile-0.2.0 → agentcompile-0.3.0}/README.md +2 -0
  15. agentcompile-0.3.0/SECURITY.md +18 -0
  16. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/__init__.py +26 -1
  17. agentcompile-0.3.0/src/agentcompile/_capture.py +117 -0
  18. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_core.py +15 -1
  19. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_decide.py +8 -2
  20. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_version.py +2 -2
  21. agentcompile-0.3.0/tests/test_capture.py +150 -0
  22. {agentcompile-0.2.0 → agentcompile-0.3.0}/tests/test_wrap.py +21 -0
  23. agentcompile-0.2.0/.release-please-manifest.json +0 -1
  24. agentcompile-0.2.0/CHANGELOG.md +0 -8
  25. {agentcompile-0.2.0 → agentcompile-0.3.0}/.gitignore +0 -0
  26. {agentcompile-0.2.0 → agentcompile-0.3.0}/LICENSE +0 -0
  27. {agentcompile-0.2.0 → agentcompile-0.3.0}/pyproject.toml +0 -0
  28. {agentcompile-0.2.0 → agentcompile-0.3.0}/release-please-config.json +0 -0
  29. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_anthropic.py +0 -0
  30. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_conversation.py +0 -0
  31. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_openai.py +0 -0
  32. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_payload.py +0 -0
  33. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_streams.py +0 -0
  34. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_trail.py +0 -0
  35. {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/cli.py +0 -0
@@ -0,0 +1 @@
1
+ * @kbhatnagar1506
@@ -0,0 +1,23 @@
1
+ name: Bug report
2
+ description: Something in the SDK doesn't work as documented
3
+ labels: [bug]
4
+ body:
5
+ - type: textarea
6
+ id: what
7
+ attributes:
8
+ label: What happened, and what did you expect?
9
+ validations: { required: true }
10
+ - type: textarea
11
+ id: repro
12
+ attributes:
13
+ label: Minimal code to reproduce
14
+ render: python
15
+ - type: input
16
+ id: versions
17
+ attributes:
18
+ label: Versions
19
+ placeholder: agentcompile 0.2.0, openai 2.x, Python 3.12
20
+ - type: textarea
21
+ id: trail
22
+ attributes:
23
+ label: Trail lines (`agentcompile trail --json`), with secrets removed
@@ -0,0 +1,5 @@
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Security problem
4
+ url: https://github.com/kbhatnagar1506/agentcompile-python/security/advisories/new
5
+ about: Report vulnerabilities privately, not as an issue.
@@ -0,0 +1,9 @@
1
+ name: Feature request
2
+ description: Something you'd like the SDK to do
3
+ labels: [enhancement]
4
+ body:
5
+ - type: textarea
6
+ id: need
7
+ attributes:
8
+ label: What do you need, and why?
9
+ validations: { required: true }
@@ -0,0 +1,8 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: "/"
5
+ schedule: { interval: weekly }
6
+ - package-ecosystem: github-actions
7
+ directory: "/"
8
+ schedule: { interval: weekly }
@@ -0,0 +1,6 @@
1
+ ## What and why
2
+
3
+ ## Checklist
4
+ - [ ] Tests added or updated
5
+ - [ ] Commit messages follow Conventional Commits (`fix:`, `feat:`, ...)
6
+ - [ ] The SDK still never raises into the caller and still fails open
@@ -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"}
@@ -0,0 +1,22 @@
1
+ # Changelog
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
+
10
+ ## [0.2.1](https://github.com/kbhatnagar1506/agentcompile-python/compare/v0.2.0...v0.2.1) (2026-10-01)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * keep AgentCompile's decision events in the trail ([#5](https://github.com/kbhatnagar1506/agentcompile-python/issues/5)) ([944c178](https://github.com/kbhatnagar1506/agentcompile-python/commit/944c178e5121f3389041335e36cee06a7f80e441))
16
+
17
+ ## [0.2.0](https://github.com/kbhatnagar1506/agentcompile-python/compare/agentcompile-v0.1.0...agentcompile-v0.2.0) (2026-10-01)
18
+
19
+
20
+ ### Features
21
+
22
+ * agentcompile --version ([c2fea67](https://github.com/kbhatnagar1506/agentcompile-python/commit/c2fea67a3bc9b23457a41545f19f94f8005583ce))
@@ -0,0 +1,7 @@
1
+ # Code of conduct
2
+
3
+ Be respectful and constructive. Harassment, personal attacks and discrimination of any kind are not welcome in issues, pull requests or any other project space.
4
+
5
+ Maintainers may edit or remove comments, and block contributors, who don't follow this.
6
+
7
+ Report problems to founders@tryagentcompile.com.
@@ -0,0 +1,22 @@
1
+ # Contributing
2
+
3
+ Thanks for helping. Issues and pull requests are welcome.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -e ".[dev]" ruff
9
+ pytest
10
+ ruff check src tests && ruff format --check src tests
11
+ ```
12
+
13
+ ## Pull requests
14
+
15
+ - One change per pull request, with a test.
16
+ - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org): `fix: ...`, `feat: ...`, `docs: ...`, `chore: ...`. They decide the next version and the changelog.
17
+ - CI must pass on Python 3.9 and 3.13 before a pull request can merge.
18
+ - The SDK must stay safe in someone's live path: it never raises into the caller's agent, never edits the caller's prompts, and fails open.
19
+
20
+ ## Releases
21
+
22
+ Maintainers merge the release pull request that release-please keeps open; that tags the release and publishes it to PyPI.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentcompile
3
- Version: 0.2.0
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>
@@ -25,6 +25,8 @@ Description-Content-Type: text/markdown
25
25
 
26
26
  # agentcompile
27
27
 
28
+ [![PyPI](https://img.shields.io/pypi/v/agentcompile)](https://pypi.org/project/agentcompile/) [![Python](https://img.shields.io/pypi/pyversions/agentcompile)](https://pypi.org/project/agentcompile/) [![CI](https://github.com/kbhatnagar1506/agentcompile-python/actions/workflows/ci.yml/badge.svg)](https://github.com/kbhatnagar1506/agentcompile-python/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
29
+
28
30
  ```
29
31
  pip install agentcompile
30
32
  ```
@@ -1,5 +1,7 @@
1
1
  # agentcompile
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/agentcompile)](https://pypi.org/project/agentcompile/) [![Python](https://img.shields.io/pypi/pyversions/agentcompile)](https://pypi.org/project/agentcompile/) [![CI](https://github.com/kbhatnagar1506/agentcompile-python/actions/workflows/ci.yml/badge.svg)](https://github.com/kbhatnagar1506/agentcompile-python/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
4
+
3
5
  ```
4
6
  pip install agentcompile
5
7
  ```
@@ -0,0 +1,18 @@
1
+ # Security
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please don't open a public issue for security problems. Report them privately through **GitHub → Security → Report a vulnerability** on this repository, or email founders@tryagentcompile.com.
6
+
7
+ We'll acknowledge your report within 3 business days and keep you updated until it's fixed.
8
+
9
+ ## How the SDK handles your data
10
+
11
+ - Your model provider key never leaves your process: the SDK calls your provider itself.
12
+ - To decide, the SDK sends the conversation's messages, system prompt, tool definitions and model name to AgentCompile, with your AgentCompile key. Nothing else from the request is sent.
13
+ - If AgentCompile fails, is slow or answers something unusable, your call goes straight to your model (fail-open).
14
+ - The trail is written only to a local file (`~/.agentcompile/trail.jsonl` by default) unless you pass `trail=False`.
15
+
16
+ ## Supported versions
17
+
18
+ Security fixes go into the latest release.
@@ -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
  *,
@@ -65,6 +74,7 @@ class Router:
65
74
  tool=decision.tool if decision else None,
66
75
  reason=(decision.reason if decision and decision.action == "forward" else None)
67
76
  or error,
77
+ events=list(decision.events) if decision and decision.events else None,
68
78
  decide_ms=round(decide_ms, 1) if decide_ms is not None else None,
69
79
  total_ms=round((time.perf_counter() - started) * 1000, 1),
70
80
  stream=stream or None,
@@ -102,8 +112,10 @@ def sync_create(
102
112
  started=started,
103
113
  stream=stream,
104
114
  )
115
+ router.capture(conversation_id, real_kwargs, result, stream)
105
116
  return result
106
117
  result = original(*args, **real_kwargs)
118
+ router.capture(conversation_id, real_kwargs, result, stream)
107
119
  router.record(
108
120
  route=route,
109
121
  conversation_id=conversation_id,
@@ -148,8 +160,10 @@ def async_create(
148
160
  started=started,
149
161
  stream=stream,
150
162
  )
163
+ router.capture(conversation_id, real_kwargs, result, stream)
151
164
  return result
152
165
  result = await original(*args, **real_kwargs)
166
+ router.capture(conversation_id, real_kwargs, result, stream)
153
167
  router.record(
154
168
  route=route,
155
169
  conversation_id=conversation_id,
@@ -22,12 +22,17 @@ class Decision:
22
22
  call_id: str | None = None
23
23
  text: str | None = None
24
24
  reason: str | None = None
25
+ # What AgentCompile decided along the way (which job, what it asked, why it handed
26
+ # off), as the server reported it: kept so the trail can show it.
27
+ events: tuple[dict[str, Any], ...] = ()
25
28
 
26
29
  @classmethod
27
30
  def parse(cls, body: Any) -> Decision | None:
28
31
  if not isinstance(body, dict):
29
32
  return None
30
33
  action = body.get("action")
34
+ raw = body.get("events")
35
+ events = tuple(e for e in (raw if isinstance(raw, list) else []) if isinstance(e, dict))
31
36
  if (
32
37
  action == "tool_call"
33
38
  and isinstance(body.get("tool"), str)
@@ -38,11 +43,12 @@ class Decision:
38
43
  tool=body["tool"],
39
44
  args=body["args"],
40
45
  call_id=str(body.get("call_id") or ""),
46
+ events=events,
41
47
  )
42
48
  if action == "say" and isinstance(body.get("text"), str):
43
- return cls(action, text=body["text"])
49
+ return cls(action, text=body["text"], events=events)
44
50
  if action == "forward":
45
- return cls(action, reason=str(body.get("reason") or ""))
51
+ return cls(action, reason=str(body.get("reason") or ""), events=events)
46
52
  return None
47
53
 
48
54
 
@@ -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.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
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
@@ -169,6 +169,27 @@ def test_forward_calls_the_model_with_our_keyword_removed(tmp_path: Path) -> Non
169
169
  assert event["route"] == "forwarded" and event["reason"] == "no job matched"
170
170
 
171
171
 
172
+ def test_the_trail_keeps_what_agentcompile_decided_along_the_way(tmp_path: Path) -> None:
173
+ events = [
174
+ {"decision": "activate_recipe", "recipe": "cancel_order"},
175
+ {"decision": "forward", "reason": "the customer asked a question"},
176
+ ]
177
+ fake = Fake({**FORWARD, "events": events})
178
+ _openai(fake, tmp_path).chat.completions.create(
179
+ model="gpt-x", messages=MESSAGES, conversation_id="c1"
180
+ )
181
+ assert _trail(tmp_path)[0]["events"] == events
182
+
183
+
184
+ def test_malformed_events_are_dropped_not_fatal(tmp_path: Path) -> None:
185
+ fake = Fake({**CALL, "events": ["not a dict", {"decision": "emit_read"}]})
186
+ _openai(fake, tmp_path).chat.completions.create(
187
+ model="gpt-x", messages=MESSAGES, conversation_id="c1"
188
+ )
189
+ event = _trail(tmp_path)[0]
190
+ assert event["route"] == "compiled" and event["events"] == [{"decision": "emit_read"}]
191
+
192
+
172
193
  @pytest.mark.parametrize(
173
194
  "fake",
174
195
  [
@@ -1 +0,0 @@
1
- {".":"0.2.0"}
@@ -1,8 +0,0 @@
1
- # Changelog
2
-
3
- ## [0.2.0](https://github.com/kbhatnagar1506/agentcompile-python/compare/agentcompile-v0.1.0...agentcompile-v0.2.0) (2026-10-01)
4
-
5
-
6
- ### Features
7
-
8
- * agentcompile --version ([c2fea67](https://github.com/kbhatnagar1506/agentcompile-python/commit/c2fea67a3bc9b23457a41545f19f94f8005583ce))
File without changes
File without changes