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.
- agentcompile-0.3.0/.github/CODEOWNERS +1 -0
- agentcompile-0.3.0/.github/ISSUE_TEMPLATE/bug_report.yml +23 -0
- agentcompile-0.3.0/.github/ISSUE_TEMPLATE/config.yml +5 -0
- agentcompile-0.3.0/.github/ISSUE_TEMPLATE/feature_request.yml +9 -0
- agentcompile-0.3.0/.github/dependabot.yml +8 -0
- agentcompile-0.3.0/.github/pull_request_template.md +6 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/.github/workflows/ci.yml +2 -2
- {agentcompile-0.2.0 → agentcompile-0.3.0}/.github/workflows/release.yml +3 -3
- agentcompile-0.3.0/.release-please-manifest.json +1 -0
- agentcompile-0.3.0/CHANGELOG.md +22 -0
- agentcompile-0.3.0/CODE_OF_CONDUCT.md +7 -0
- agentcompile-0.3.0/CONTRIBUTING.md +22 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/PKG-INFO +3 -1
- {agentcompile-0.2.0 → agentcompile-0.3.0}/README.md +2 -0
- agentcompile-0.3.0/SECURITY.md +18 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/__init__.py +26 -1
- agentcompile-0.3.0/src/agentcompile/_capture.py +117 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_core.py +15 -1
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_decide.py +8 -2
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_version.py +2 -2
- agentcompile-0.3.0/tests/test_capture.py +150 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/tests/test_wrap.py +21 -0
- agentcompile-0.2.0/.release-please-manifest.json +0 -1
- agentcompile-0.2.0/CHANGELOG.md +0 -8
- {agentcompile-0.2.0 → agentcompile-0.3.0}/.gitignore +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/LICENSE +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/pyproject.toml +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/release-please-config.json +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_anthropic.py +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_conversation.py +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_openai.py +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_payload.py +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_streams.py +0 -0
- {agentcompile-0.2.0 → agentcompile-0.3.0}/src/agentcompile/_trail.py +0 -0
- {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
|
|
@@ -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@
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
16
|
with:
|
|
17
17
|
fetch-depth: 0 # the version comes from git tags
|
|
18
|
-
- uses: actions/setup-python@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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
|
+
[](https://pypi.org/project/agentcompile/) [](https://pypi.org/project/agentcompile/) [](https://github.com/kbhatnagar1506/agentcompile-python/actions/workflows/ci.yml) [](LICENSE)
|
|
29
|
+
|
|
28
30
|
```
|
|
29
31
|
pip install agentcompile
|
|
30
32
|
```
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# agentcompile
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/agentcompile/) [](https://pypi.org/project/agentcompile/) [](https://github.com/kbhatnagar1506/agentcompile-python/actions/workflows/ci.yml) [](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__(
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (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"}
|
agentcompile-0.2.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|