backloop-sdk 0.1.4__tar.gz → 0.2.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.
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/PKG-INFO +33 -1
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/README.md +30 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/pyproject.toml +2 -1
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/__init__.py +10 -1
- backloop_sdk-0.2.0/src/backloop/_events.py +219 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/_generated.py +4 -4
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/integrations.py +7 -2
- backloop_sdk-0.2.0/src/backloop/mcp.py +567 -0
- backloop_sdk-0.2.0/src/backloop/requests_recorder.py +553 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/server.py +34 -1
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/test_integrations.py +2 -1
- backloop_sdk-0.2.0/tests/test_mcp.py +481 -0
- backloop_sdk-0.2.0/tests/test_requests.py +369 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/test_server.py +10 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/.gitignore +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/LICENSE +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/_http.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/client.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/forward.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/ids.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/py.typed +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/redact.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/tool.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/types.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/src/backloop/validate.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/__init__.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/helpers.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/test_client.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/test_forward.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/test_ids.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/test_redact.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/test_tool.py +0 -0
- {backloop_sdk-0.1.4 → backloop_sdk-0.2.0}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: backloop-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for the Agent Feedback Protocol: validate, send and receive structured feedback from AI agents.
|
|
5
5
|
Project-URL: Homepage, https://trybackloop.com
|
|
6
6
|
Project-URL: Specification, https://trybackloop.com/spec/
|
|
@@ -20,6 +20,8 @@ Provides-Extra: fastapi
|
|
|
20
20
|
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
21
21
|
Provides-Extra: flask
|
|
22
22
|
Requires-Dist: flask>=2.0; extra == 'flask'
|
|
23
|
+
Provides-Extra: mcp
|
|
24
|
+
Requires-Dist: mcp>=1.2; extra == 'mcp'
|
|
23
25
|
Description-Content-Type: text/markdown
|
|
24
26
|
|
|
25
27
|
# backloop-sdk
|
|
@@ -97,6 +99,36 @@ document = handler.discovery("https://api.example.com")
|
|
|
97
99
|
|
|
98
100
|
`on_record(record)` stores or forwards each accepted record. Return `{"known_issue": {...}}` to pass a tracked issue back to the agent. If it raises, the agent gets `503`. Add `feedback_link_header()` as a `Link` header on your API's error responses so agents find the endpoint.
|
|
99
101
|
|
|
102
|
+
## MCP servers: session analytics
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
pip install "backloop-sdk[mcp]"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from mcp.server.fastmcp import FastMCP # or mcp.server.mcpserver.MCPServer (mcp 2.x), or fastmcp.FastMCP
|
|
110
|
+
from backloop import instrument_server
|
|
111
|
+
|
|
112
|
+
mcp = FastMCP("acme")
|
|
113
|
+
analytics = instrument_server(mcp, url=BACKLOOP_URL, ingest_key=INGEST_KEY, service="acme-mcp")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Every tool call becomes a `tool_call` event (session, client, duration, ok or error, redacted previews of the input and result), sent in batches to `<url>/v1/sessions/events` from a background thread. Tools registered before or after the call are covered, sync or async. Each tool gets an optional `telemetry` argument the agent fills in (`user_intent`, `agent_thinking`, `user_frustration`, `agent_model`); it is removed before your function runs. Sending never raises into a tool: a failure logs one warning.
|
|
117
|
+
|
|
118
|
+
Options: `identify=lambda ctx: ...` returns the pseudonymous account (may be async for async tools), `telemetry=False`, `redact=False`, `preview_chars=2000`, `flush_ms=1000`, `on_events=callable` for a sink of your own. Call `analytics.flush()` before a serverless function returns (it also runs at exit).
|
|
119
|
+
|
|
120
|
+
## REST APIs: agent request analytics
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from backloop import record_requests
|
|
124
|
+
|
|
125
|
+
app.add_middleware(record_requests, url=BACKLOOP_URL, ingest_key=INGEST_KEY, service="acme-api") # FastAPI / Starlette
|
|
126
|
+
app.wsgi_app = record_requests(app.wsgi_app, url=BACKLOOP_URL, ingest_key=INGEST_KEY) # Flask
|
|
127
|
+
application = record_requests(application, url=BACKLOOP_URL, ingest_key=INGEST_KEY) # any ASGI or WSGI app
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Each request from an AI agent becomes an `http_request` event: method, route template (FastAPI's route or Flask's rule, else the path with ids collapsed to `:id`), status, duration, the agent, and redacted previews of the query (or body) and response. `X-Agent-Session` or `Mcp-Session-Id` groups requests into a session. Browser and unknown traffic is skipped unless `agents_only=False`; `detect_agent(headers)` tells you what was detected (Claude-User, ChatGPT-User, claude-code, Cursor, the Anthropic and OpenAI SDKs, `X-Agent-Name`, `Signature-Agent`...). `/feedback`, `/health`, `/healthz` and `/.well-known/` are skipped by default (`skip=lambda path: ...`). Other options: `identify` and `route` (both get the ASGI scope or WSGI environ), `preview_chars=1000`, `redact`, `flush_ms`, `on_events`.
|
|
131
|
+
|
|
100
132
|
## Utilities
|
|
101
133
|
|
|
102
134
|
```python
|
|
@@ -73,6 +73,36 @@ document = handler.discovery("https://api.example.com")
|
|
|
73
73
|
|
|
74
74
|
`on_record(record)` stores or forwards each accepted record. Return `{"known_issue": {...}}` to pass a tracked issue back to the agent. If it raises, the agent gets `503`. Add `feedback_link_header()` as a `Link` header on your API's error responses so agents find the endpoint.
|
|
75
75
|
|
|
76
|
+
## MCP servers: session analytics
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
pip install "backloop-sdk[mcp]"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from mcp.server.fastmcp import FastMCP # or mcp.server.mcpserver.MCPServer (mcp 2.x), or fastmcp.FastMCP
|
|
84
|
+
from backloop import instrument_server
|
|
85
|
+
|
|
86
|
+
mcp = FastMCP("acme")
|
|
87
|
+
analytics = instrument_server(mcp, url=BACKLOOP_URL, ingest_key=INGEST_KEY, service="acme-mcp")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Every tool call becomes a `tool_call` event (session, client, duration, ok or error, redacted previews of the input and result), sent in batches to `<url>/v1/sessions/events` from a background thread. Tools registered before or after the call are covered, sync or async. Each tool gets an optional `telemetry` argument the agent fills in (`user_intent`, `agent_thinking`, `user_frustration`, `agent_model`); it is removed before your function runs. Sending never raises into a tool: a failure logs one warning.
|
|
91
|
+
|
|
92
|
+
Options: `identify=lambda ctx: ...` returns the pseudonymous account (may be async for async tools), `telemetry=False`, `redact=False`, `preview_chars=2000`, `flush_ms=1000`, `on_events=callable` for a sink of your own. Call `analytics.flush()` before a serverless function returns (it also runs at exit).
|
|
93
|
+
|
|
94
|
+
## REST APIs: agent request analytics
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from backloop import record_requests
|
|
98
|
+
|
|
99
|
+
app.add_middleware(record_requests, url=BACKLOOP_URL, ingest_key=INGEST_KEY, service="acme-api") # FastAPI / Starlette
|
|
100
|
+
app.wsgi_app = record_requests(app.wsgi_app, url=BACKLOOP_URL, ingest_key=INGEST_KEY) # Flask
|
|
101
|
+
application = record_requests(application, url=BACKLOOP_URL, ingest_key=INGEST_KEY) # any ASGI or WSGI app
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each request from an AI agent becomes an `http_request` event: method, route template (FastAPI's route or Flask's rule, else the path with ids collapsed to `:id`), status, duration, the agent, and redacted previews of the query (or body) and response. `X-Agent-Session` or `Mcp-Session-Id` groups requests into a session. Browser and unknown traffic is skipped unless `agents_only=False`; `detect_agent(headers)` tells you what was detected (Claude-User, ChatGPT-User, claude-code, Cursor, the Anthropic and OpenAI SDKs, `X-Agent-Name`, `Signature-Agent`...). `/feedback`, `/health`, `/healthz` and `/.well-known/` are skipped by default (`skip=lambda path: ...`). Other options: `identify` and `route` (both get the ASGI scope or WSGI environ), `preview_chars=1000`, `redact`, `flush_ms`, `on_events`.
|
|
105
|
+
|
|
76
106
|
## Utilities
|
|
77
107
|
|
|
78
108
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "backloop-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python SDK for the Agent Feedback Protocol: validate, send and receive structured feedback from AI agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -29,6 +29,7 @@ Issues = "https://github.com/backloophq/agent-feedback-protocol/issues"
|
|
|
29
29
|
[project.optional-dependencies]
|
|
30
30
|
fastapi = ["fastapi>=0.100"]
|
|
31
31
|
flask = ["flask>=2.0"]
|
|
32
|
+
mcp = ["mcp>=1.2"]
|
|
32
33
|
|
|
33
34
|
[tool.hatch.build.targets.wheel]
|
|
34
35
|
packages = ["src/backloop"]
|
|
@@ -11,11 +11,14 @@ from .client import FeedbackClient, FeedbackError, SubmitResult, feedback_url_fr
|
|
|
11
11
|
from .forward import ForwardError, MemorySink, fan_out, forward_to, memory_sink
|
|
12
12
|
from .ids import hash_account, new_id
|
|
13
13
|
from .integrations import fastapi_router, flask_blueprint
|
|
14
|
+
from .mcp import SessionAnalytics, instrument_server
|
|
14
15
|
from .redact import REDACTED, redact, redact_string
|
|
16
|
+
from .requests_recorder import collapse_path, detect_agent, record_requests
|
|
15
17
|
from .server import (
|
|
16
18
|
FeedbackHandler,
|
|
17
19
|
HandlerResponse,
|
|
18
20
|
OnRecordResult,
|
|
21
|
+
agent_from_user_agent,
|
|
19
22
|
error_response,
|
|
20
23
|
feedback_link_header,
|
|
21
24
|
)
|
|
@@ -44,7 +47,7 @@ from .validate import (
|
|
|
44
47
|
validate_submission,
|
|
45
48
|
)
|
|
46
49
|
|
|
47
|
-
__version__ = "0.
|
|
50
|
+
__version__ = "0.2.0"
|
|
48
51
|
|
|
49
52
|
__all__ = [
|
|
50
53
|
"ACK_SCHEMA",
|
|
@@ -59,6 +62,7 @@ __all__ = [
|
|
|
59
62
|
"RECORD_SCHEMA",
|
|
60
63
|
"REDACTED",
|
|
61
64
|
"SPEC_VERSION",
|
|
65
|
+
"SessionAnalytics",
|
|
62
66
|
"WELL_KNOWN_PATH",
|
|
63
67
|
"AgentInfo",
|
|
64
68
|
"DiscoveryDocument",
|
|
@@ -77,6 +81,9 @@ __all__ = [
|
|
|
77
81
|
"SubmitResult",
|
|
78
82
|
"ValidationIssue",
|
|
79
83
|
"ValidationResult",
|
|
84
|
+
"agent_from_user_agent",
|
|
85
|
+
"collapse_path",
|
|
86
|
+
"detect_agent",
|
|
80
87
|
"error_response",
|
|
81
88
|
"fan_out",
|
|
82
89
|
"fastapi_router",
|
|
@@ -88,8 +95,10 @@ __all__ = [
|
|
|
88
95
|
"format_issues",
|
|
89
96
|
"forward_to",
|
|
90
97
|
"hash_account",
|
|
98
|
+
"instrument_server",
|
|
91
99
|
"memory_sink",
|
|
92
100
|
"new_id",
|
|
101
|
+
"record_requests",
|
|
93
102
|
"redact",
|
|
94
103
|
"redact_string",
|
|
95
104
|
"validate_ack",
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Session events: the shared queue, previews and limits behind ``instrument_server`` and ``record_requests``.
|
|
2
|
+
|
|
3
|
+
Events go to ``POST <url>/v1/sessions/events`` as ``{"events": [...]}``, in the
|
|
4
|
+
same JSON shapes as the TypeScript SDK's ``sessions.ts``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Union
|
|
16
|
+
|
|
17
|
+
from . import _http
|
|
18
|
+
from .redact import redact as _redact
|
|
19
|
+
from .redact import redact_string
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("backloop")
|
|
22
|
+
|
|
23
|
+
FRUSTRATION_LEVELS = ("low", "medium", "high")
|
|
24
|
+
|
|
25
|
+
#: Same values as ``SESSION_EVENT_LIMITS`` in ``@backloop/sdk``.
|
|
26
|
+
SESSION_EVENT_LIMITS = {"preview": 4000, "error": 1000, "telemetry": 1000, "batch": 500}
|
|
27
|
+
|
|
28
|
+
SessionEvent = Dict[str, Any]
|
|
29
|
+
OnEvents = Callable[[List[SessionEvent]], Any]
|
|
30
|
+
RedactOption = Union[bool, Mapping[str, Any]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clip(value: str, limit: int) -> str:
|
|
34
|
+
return value if len(value) <= limit else value[: limit - 1] + "…"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def iso_now(ms: Optional[float] = None) -> str:
|
|
38
|
+
"""ISO 8601 in UTC with milliseconds, like ``Date.prototype.toISOString``."""
|
|
39
|
+
dt = datetime.fromtimestamp((time.time() * 1000 if ms is None else ms) / 1000, timezone.utc)
|
|
40
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _jsonable(value: Any) -> Any:
|
|
44
|
+
dump = getattr(value, "model_dump", None) # pydantic models
|
|
45
|
+
if callable(dump):
|
|
46
|
+
try:
|
|
47
|
+
return dump(mode="json")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
if isinstance(value, (set, frozenset)):
|
|
51
|
+
return list(value)
|
|
52
|
+
if isinstance(value, bytes):
|
|
53
|
+
return value.decode("utf-8", "replace")
|
|
54
|
+
return str(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Scrubber:
|
|
58
|
+
"""Redaction and size-capped previews, from the ``redact`` and ``preview_chars`` options."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, redact: RedactOption = True, preview_chars: int = 2000) -> None:
|
|
61
|
+
self.enabled = redact is not False
|
|
62
|
+
options = dict(redact) if isinstance(redact, Mapping) else {}
|
|
63
|
+
self.emails = options.get("emails", True)
|
|
64
|
+
self.patterns = list(options.get("patterns") or ())
|
|
65
|
+
self.preview_chars = min(max(int(preview_chars), 0), SESSION_EVENT_LIMITS["preview"])
|
|
66
|
+
|
|
67
|
+
def string(self, value: str) -> str:
|
|
68
|
+
return redact_string(value, self.emails, self.patterns) if self.enabled else value
|
|
69
|
+
|
|
70
|
+
def value(self, value: Any) -> Any:
|
|
71
|
+
return _redact(value, self.emails, self.patterns) if self.enabled else value
|
|
72
|
+
|
|
73
|
+
def preview(self, value: Any) -> Optional[str]:
|
|
74
|
+
if not self.preview_chars or value is None:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
text = value
|
|
79
|
+
else:
|
|
80
|
+
plain = json.loads(json.dumps(value, ensure_ascii=False, default=_jsonable))
|
|
81
|
+
text = json.dumps(self.value(plain), ensure_ascii=False, separators=(",", ":"))
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
return clip(self.string(text), self.preview_chars) if text else None
|
|
85
|
+
|
|
86
|
+
def error(self, message: str) -> str:
|
|
87
|
+
return clip(self.string(message), SESSION_EVENT_LIMITS["error"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clean_telemetry(raw: Any, scrubber: Scrubber) -> Optional[Dict[str, str]]:
|
|
91
|
+
"""Keep the known telemetry fields, trimmed, redacted and capped; None when nothing is left."""
|
|
92
|
+
if raw is None:
|
|
93
|
+
return None
|
|
94
|
+
if not isinstance(raw, Mapping):
|
|
95
|
+
dump = getattr(raw, "model_dump", None)
|
|
96
|
+
raw = dump() if callable(dump) else getattr(raw, "__dict__", None)
|
|
97
|
+
if not isinstance(raw, Mapping):
|
|
98
|
+
return None
|
|
99
|
+
out: Dict[str, str] = {}
|
|
100
|
+
for key in ("user_intent", "agent_thinking"):
|
|
101
|
+
value = raw.get(key)
|
|
102
|
+
if isinstance(value, str) and value.strip():
|
|
103
|
+
out[key] = clip(scrubber.string(value.strip()), SESSION_EVENT_LIMITS["telemetry"])
|
|
104
|
+
model = raw.get("agent_model")
|
|
105
|
+
if isinstance(model, str) and model.strip():
|
|
106
|
+
out["agent_model"] = clip(model.strip(), 128)
|
|
107
|
+
frustration = raw.get("user_frustration")
|
|
108
|
+
if isinstance(frustration, str) and frustration in FRUSTRATION_LEVELS:
|
|
109
|
+
out["user_frustration"] = frustration
|
|
110
|
+
# Keep the TS key order: user_intent, agent_thinking, user_frustration, agent_model.
|
|
111
|
+
order = ("user_intent", "agent_thinking", "user_frustration", "agent_model")
|
|
112
|
+
return {k: out[k] for k in order if k in out} or None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class EventQueue:
|
|
116
|
+
"""Queues events and POSTs them in batches from one background daemon thread.
|
|
117
|
+
|
|
118
|
+
A send failure goes to ``on_error`` (default: one logged warning) and is
|
|
119
|
+
never raised to the code that pushed the event.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
url: Optional[str] = None,
|
|
125
|
+
ingest_key: Optional[str] = None,
|
|
126
|
+
on_events: Optional[OnEvents] = None,
|
|
127
|
+
flush_ms: float = 1000,
|
|
128
|
+
on_error: Optional[Callable[[BaseException], None]] = None,
|
|
129
|
+
timeout: float = 5.0,
|
|
130
|
+
) -> None:
|
|
131
|
+
self.endpoint = url.rstrip("/") + "/v1/sessions/events" if url else None
|
|
132
|
+
self.ingest_key = ingest_key
|
|
133
|
+
self.on_events = on_events
|
|
134
|
+
self.flush_s = max(float(flush_ms), 0) / 1000
|
|
135
|
+
self.timeout = timeout
|
|
136
|
+
self.enabled = bool(self.endpoint or on_events)
|
|
137
|
+
self._on_error = on_error
|
|
138
|
+
self._warned = False
|
|
139
|
+
self._cond = threading.Condition()
|
|
140
|
+
self._send_lock = threading.Lock()
|
|
141
|
+
self._queue: List[SessionEvent] = []
|
|
142
|
+
self._thread: Optional[threading.Thread] = None
|
|
143
|
+
if self.enabled:
|
|
144
|
+
atexit.register(self.flush)
|
|
145
|
+
|
|
146
|
+
def push(self, event: SessionEvent) -> None:
|
|
147
|
+
if not self.enabled:
|
|
148
|
+
return
|
|
149
|
+
with self._cond:
|
|
150
|
+
self._queue.append(event)
|
|
151
|
+
# Started lazily, and again in a forked worker (threads don't survive fork).
|
|
152
|
+
if self._thread is None or not self._thread.is_alive():
|
|
153
|
+
self._thread = threading.Thread(target=self._run, name="backloop-events", daemon=True)
|
|
154
|
+
self._thread.start()
|
|
155
|
+
self._cond.notify()
|
|
156
|
+
|
|
157
|
+
def flush(self) -> None:
|
|
158
|
+
"""Send what is queued now, in the calling thread. Never raises."""
|
|
159
|
+
with self._send_lock:
|
|
160
|
+
with self._cond:
|
|
161
|
+
events, self._queue = self._queue, []
|
|
162
|
+
if not events:
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
self._send(events)
|
|
166
|
+
except BaseException as e: # noqa: BLE001 - never raised into the caller
|
|
167
|
+
self._error(e)
|
|
168
|
+
|
|
169
|
+
def _run(self) -> None:
|
|
170
|
+
batch = SESSION_EVENT_LIMITS["batch"]
|
|
171
|
+
while True:
|
|
172
|
+
with self._cond:
|
|
173
|
+
while not self._queue:
|
|
174
|
+
self._cond.wait()
|
|
175
|
+
deadline = time.monotonic() + self.flush_s
|
|
176
|
+
while 0 < len(self._queue) < batch:
|
|
177
|
+
remaining = deadline - time.monotonic()
|
|
178
|
+
if remaining <= 0:
|
|
179
|
+
break
|
|
180
|
+
self._cond.wait(remaining)
|
|
181
|
+
self.flush()
|
|
182
|
+
|
|
183
|
+
def _send(self, events: List[SessionEvent]) -> None:
|
|
184
|
+
if self.on_events is not None:
|
|
185
|
+
self.on_events(events)
|
|
186
|
+
if not self.endpoint:
|
|
187
|
+
return
|
|
188
|
+
headers = {"content-type": "application/json"}
|
|
189
|
+
if self.ingest_key:
|
|
190
|
+
headers["authorization"] = f"Bearer {self.ingest_key}"
|
|
191
|
+
size = SESSION_EVENT_LIMITS["batch"]
|
|
192
|
+
for i in range(0, len(events), size):
|
|
193
|
+
body = _http.dumps({"events": events[i : i + size]})
|
|
194
|
+
last_error: Optional[BaseException] = None
|
|
195
|
+
for _ in range(2):
|
|
196
|
+
try:
|
|
197
|
+
status, _headers, _raw = _http.request(self.endpoint, "POST", body, headers, self.timeout)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
last_error = e
|
|
200
|
+
continue
|
|
201
|
+
if 200 <= status < 300:
|
|
202
|
+
last_error = None
|
|
203
|
+
break
|
|
204
|
+
last_error = RuntimeError(f"Backloop returned HTTP {status}")
|
|
205
|
+
if status < 500 and status != 429:
|
|
206
|
+
break
|
|
207
|
+
if last_error is not None:
|
|
208
|
+
raise last_error
|
|
209
|
+
|
|
210
|
+
def _error(self, error: BaseException) -> None:
|
|
211
|
+
if self._on_error is not None:
|
|
212
|
+
try:
|
|
213
|
+
self._on_error(error)
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
return
|
|
217
|
+
if not self._warned:
|
|
218
|
+
self._warned = True
|
|
219
|
+
logger.warning("[backloop] session events not sent: %s", error)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Generated by scripts/sync-schema.mjs from spec/. Do not edit by hand.
|
|
2
2
|
import json
|
|
3
3
|
|
|
4
|
-
FEEDBACK_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"https://trybackloop.com/schemas/agent-feedback/0.1/feedback.schema.json\",\"title\":\"Agent Feedback Submission\",\"description\":\"Structured feedback an AI agent sends to a service when something prevents or complicates its task. Agent Feedback Protocol v0.1.\",\"type\":\"object\",\"required\":[\"type\",\"goal\",\"message\"],\"additionalProperties\":false,\"properties\":{\"spec_version\":{\"description\":\"Protocol version the agent is speaking. Defaults to \\\"0.1\\\" when omitted.\",\"type\":\"string\",\"const\":\"0.1\"},\"type\":{\"description\":\"Category of the problem.\",\"type\":\"string\",\"enum\":[\"missing_capability\",\"bug\",\"unclear_documentation\",\"unexpected_response\",\"unhelpful_error\",\"performance\",\"other\"]},\"goal\":{\"description\":\"What the agent (or its user) was trying to accomplish, in plain language. Describe the task, not the API call.\",\"type\":\"string\",\"minLength\":1,\"maxLength\":1000},\"message\":{\"description\":\"What prevented or complicated the task.\",\"type\":\"string\",\"minLength\":1,\"maxLength\":4000},\"endpoint\":{\"description\":\"The operation involved: an HTTP path template (\\\"/companies/search\\\") or a tool name (\\\"search_companies\\\").\",\"type\":\"string\",\"minLength\":1,\"maxLength\":512},\"method\":{\"description\":\"HTTP method, when the endpoint is an HTTP path.\",\"type\":\"string\",\"enum\":[\"GET\",\"POST\",\"PUT\",\"PATCH\",\"DELETE\",\"HEAD\",\"OPTIONS\"]},\"outcome\":{\"description\":\"What happened to the task. blocked: the task could not be completed. degraded: completed, but worse or slower than it should have been. completed: completed fine; the feedback is a suggestion.\",\"type\":\"string\",\"enum\":[\"blocked\",\"degraded\",\"completed\"]},\"workaround\":{\"description\":\"Whether the agent found a workaround.\",\"type\":\"boolean\"},\"workaround_description\":{\"description\":\"The workaround the agent used, if any.\",\"type\":\"string\",\"maxLength\":2000},\"expected\":{\"description\":\"What the agent expected to find or happen.\",\"type\":\"string\",\"maxLength\":2000},\"suggestion\":{\"description\":\"A concrete change that would have let the agent succeed.\",\"type\":\"string\",\"maxLength\":2000},\"request_id\":{\"description\":\"The service's request ID for a related call, to join feedback with logs.\",\"type\":\"string\",\"maxLength\":256},\"session_id\":{\"description\":\"Opaque ID shared by all feedback from one agent task run. Used to de-duplicate retries.\",\"type\":\"string\",\"maxLength\":256},\"agent\":{\"description\":\"Self-reported
|
|
4
|
+
FEEDBACK_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"https://trybackloop.com/schemas/agent-feedback/0.1/feedback.schema.json\",\"title\":\"Agent Feedback Submission\",\"description\":\"Structured feedback an AI agent sends to a service when something prevents or complicates its task. Agent Feedback Protocol v0.1.\",\"type\":\"object\",\"required\":[\"type\",\"goal\",\"message\"],\"additionalProperties\":false,\"properties\":{\"spec_version\":{\"description\":\"Protocol version the agent is speaking. Defaults to \\\"0.1\\\" when omitted.\",\"type\":\"string\",\"const\":\"0.1\"},\"type\":{\"description\":\"Category of the problem.\",\"type\":\"string\",\"enum\":[\"missing_capability\",\"bug\",\"unclear_documentation\",\"unexpected_response\",\"unhelpful_error\",\"performance\",\"other\"]},\"goal\":{\"description\":\"What the agent (or its user) was trying to accomplish, in plain language. Describe the task, not the API call.\",\"type\":\"string\",\"minLength\":1,\"maxLength\":1000},\"message\":{\"description\":\"What prevented or complicated the task.\",\"type\":\"string\",\"minLength\":1,\"maxLength\":4000},\"endpoint\":{\"description\":\"The operation involved: an HTTP path template (\\\"/companies/search\\\") or a tool name (\\\"search_companies\\\").\",\"type\":\"string\",\"minLength\":1,\"maxLength\":512},\"method\":{\"description\":\"HTTP method, when the endpoint is an HTTP path.\",\"type\":\"string\",\"enum\":[\"GET\",\"POST\",\"PUT\",\"PATCH\",\"DELETE\",\"HEAD\",\"OPTIONS\"]},\"outcome\":{\"description\":\"What happened to the task. blocked: the task could not be completed. degraded: completed, but worse or slower than it should have been. completed: completed fine; the feedback is a suggestion.\",\"type\":\"string\",\"enum\":[\"blocked\",\"degraded\",\"completed\"]},\"workaround\":{\"description\":\"Whether the agent found a workaround.\",\"type\":\"boolean\"},\"workaround_description\":{\"description\":\"The workaround the agent used, if any.\",\"type\":\"string\",\"maxLength\":2000},\"expected\":{\"description\":\"What the agent expected to find or happen.\",\"type\":\"string\",\"maxLength\":2000},\"suggestion\":{\"description\":\"A concrete change that would have let the agent succeed.\",\"type\":\"string\",\"maxLength\":2000},\"request_id\":{\"description\":\"The service's request ID for a related call, to join feedback with logs.\",\"type\":\"string\",\"maxLength\":256},\"session_id\":{\"description\":\"Opaque ID shared by all feedback from one agent task run. Used to de-duplicate retries.\",\"type\":\"string\",\"maxLength\":256},\"agent\":{\"description\":\"Who is reporting: the agent or product name and its model. Self-reported; always fill `name` and `model`.\",\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"name\":{\"type\":\"string\",\"maxLength\":128},\"version\":{\"type\":\"string\",\"maxLength\":64},\"model\":{\"type\":\"string\",\"maxLength\":128},\"framework\":{\"type\":\"string\",\"maxLength\":128}}},\"evidence\":{\"description\":\"Sanitized details of the failing interaction. Never include credentials or personal data.\",\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"status_code\":{\"type\":\"integer\",\"minimum\":100,\"maximum\":599},\"request\":{\"description\":\"Parameters or body the agent sent, with secrets removed.\",\"type\":\"object\"},\"response_excerpt\":{\"description\":\"The relevant part of the response.\",\"type\":\"string\",\"maxLength\":4000}}},\"metadata\":{\"description\":\"Free-form extra data. Services may ignore it.\",\"type\":\"object\"}}}")
|
|
5
5
|
|
|
6
6
|
RECORD_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"https://trybackloop.com/schemas/agent-feedback/0.1/record.schema.json\",\"title\":\"Agent Feedback Record\",\"description\":\"A feedback submission after the receiving service has accepted it: the agent's submission plus server-side context. This is what services store and forward to collectors.\",\"type\":\"object\",\"required\":[\"id\",\"received_at\",\"feedback\"],\"additionalProperties\":false,\"properties\":{\"id\":{\"description\":\"Unique ID assigned by the receiving service.\",\"type\":\"string\",\"minLength\":1,\"maxLength\":128},\"received_at\":{\"description\":\"RFC 3339 timestamp of when the service accepted the submission.\",\"type\":\"string\",\"format\":\"date-time\"},\"service\":{\"description\":\"Name of the service that received the feedback.\",\"type\":\"string\",\"maxLength\":128},\"account\":{\"description\":\"Opaque, stable identifier of the customer account the agent authenticated as. Services should hash or pseudonymize it.\",\"type\":\"string\",\"maxLength\":256},\"source\":{\"description\":\"Channel the feedback arrived through.\",\"type\":\"string\",\"enum\":[\"http\",\"mcp\",\"sdk\",\"other\"]},\"feedback\":{\"$ref\":\"feedback.schema.json\"}}}")
|
|
7
7
|
|
|
8
|
-
ACK_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"https://trybackloop.com/schemas/agent-feedback/0.1/ack.schema.json\",\"title\":\"Agent Feedback Acknowledgement\",\"description\":\"Body of a 202 response to POST /feedback.\",\"type\":\"object\",\"required\":[\"id\",\"status\",\"received_at\"],\"properties\":{\"id\":{\"type\":\"string\"},\"status\":{\"type\":\"string\",\"const\":\"accepted\"},\"received_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"known_issue\":{\"description\":\"Present when the service already tracks this problem. Agents can use it to stop retrying or to apply a known workaround.\",\"type\":\"object\",\"required\":[\"id\",\"title\",\"status\"],\"properties\":{\"id\":{\"type\":\"string\"},\"title\":{\"type\":\"string\"},\"status\":{\"type\":\"string\",\"enum\":[\"acknowledged\",\"planned\",\"in_progress\",\"fixed\",\"wont_fix\"]},\"url\":{\"type\":\"string\"},\"workaround\":{\"type\":\"string\"}}}}}")
|
|
8
|
+
ACK_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"https://trybackloop.com/schemas/agent-feedback/0.1/ack.schema.json\",\"title\":\"Agent Feedback Acknowledgement\",\"description\":\"Body of a 202 response to POST /feedback.\",\"type\":\"object\",\"required\":[\"id\",\"status\",\"received_at\"],\"properties\":{\"id\":{\"type\":\"string\"},\"status\":{\"type\":\"string\",\"const\":\"accepted\"},\"received_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"known_issue\":{\"description\":\"Present when the service already tracks this problem. Agents can use it to stop retrying or to apply a known workaround.\",\"type\":\"object\",\"required\":[\"id\",\"title\",\"status\"],\"properties\":{\"id\":{\"type\":\"string\"},\"title\":{\"type\":\"string\"},\"status\":{\"type\":\"string\",\"enum\":[\"acknowledged\",\"planned\",\"in_progress\",\"fixed\",\"wont_fix\"]},\"url\":{\"type\":\"string\"},\"workaround\":{\"type\":\"string\"},\"fixed_at\":{\"type\":\"string\",\"format\":\"date-time\",\"description\":\"When status is fixed: when the fix shipped.\"},\"note\":{\"type\":\"string\",\"description\":\"Written by a person on the service's team: what changed and how to use it now.\"}}}}}")
|
|
9
9
|
|
|
10
|
-
AGENT_FEEDBACK_INSTRUCTIONS = "## Reporting feedback to the services you use\n\nThe services you call accept structured feedback through `POST /feedback` (or the `submit_feedback` tool). Your reports go to the engineers who build the service and are used to fix what got in your way. Use it.\n\n**Submit feedback when something prevented or complicated your task, even if you found a workaround:**\n- a capability you needed does not exist (a filter, field, endpoint or bulk operation), so you made extra calls, filtered results yourself, or gave up;\n- the documentation was missing, ambiguous or wrong, and you had to guess;\n- a response was surprising or inconsistent with the docs;\n- an error message did not tell you how to fix the request;\n- the service behaved incorrectly;\n- latency, rate limits or pagination made the task impractical.\n\n**Before your final answer,** check whether any of these happened. If one did and you have not reported it, report it now, then answer.\n\n**Don't submit feedback** for your own mistakes that the service reported clearly, for things you have not tried, or more than once for the same problem in the same task.\n\n**Write it like this:**\n- `goal`: what you or your user were trying to accomplish, in plain language. \"Find companies currently hiring GTM engineers\", not \"call /companies/search\".\n- `message`: what got in the way, specifically. \"No hiring-role filter is available; `q` only matches company names.\"\n- `type`: `missing_capability`, `bug`, `unclear_documentation`, `unexpected_response`, `unhelpful_error`, `performance` or `other`.\n- Fill `endpoint`, `outcome` (`blocked`, `degraded` or `completed`), `workaround`, `expected` and `suggestion` when you know them. A concrete suggestion is the most useful thing you can give.\n- Reuse the same `session_id` for every report in one task.\n\n**Never include** credentials, API keys, tokens, passwords, personal data, or your user's private content. Describe the shape of a request, not its secrets.\n\n**Keep going.** Submitting feedback is best effort. Send it, then continue with the task or a workaround. If the response contains `known_issue`, the problem is already tracked: tell your user if it matters, and use the workaround if one is given."
|
|
10
|
+
AGENT_FEEDBACK_INSTRUCTIONS = "## Reporting feedback to the services you use\n\nThe services you call accept structured feedback through `POST /feedback` (or the `submit_feedback` tool). Your reports go to the engineers who build the service and are used to fix what got in your way. Use it.\n\n**Submit feedback when something prevented or complicated your task, even if you found a workaround:**\n- a capability you needed does not exist (a filter, field, endpoint or bulk operation), so you made extra calls, filtered results yourself, or gave up;\n- the documentation was missing, ambiguous or wrong, and you had to guess;\n- a response was surprising or inconsistent with the docs;\n- an error message did not tell you how to fix the request;\n- the service behaved incorrectly;\n- latency, rate limits or pagination made the task impractical.\n\n**Before your final answer,** check whether any of these happened. If one did and you have not reported it, report it now, then answer.\n\n**Don't submit feedback** for your own mistakes that the service reported clearly, for things you have not tried, or more than once for the same problem in the same task.\n\n**Write it like this:**\n- `goal`: what you or your user were trying to accomplish, in plain language. \"Find companies currently hiring GTM engineers\", not \"call /companies/search\".\n- `message`: what got in the way, specifically. \"No hiring-role filter is available; `q` only matches company names.\"\n- `type`: `missing_capability`, `bug`, `unclear_documentation`, `unexpected_response`, `unhelpful_error`, `performance` or `other`.\n- Fill `endpoint`, `outcome` (`blocked`, `degraded` or `completed`), `workaround`, `expected` and `suggestion` when you know them. A concrete suggestion is the most useful thing you can give.\n- Always fill `agent`: `name` (the agent or product you run as) and `model`. Engineers use it to tell which agent a report came from.\n- Reuse the same `session_id` for every report in one task.\n\n**Never include** credentials, API keys, tokens, passwords, personal data, or your user's private content. Describe the shape of a request, not its secrets.\n\n**Keep going.** Submitting feedback is best effort. Send it, then continue with the task or a workaround. If the response contains `known_issue`, the problem is already tracked: tell your user if it matters, and use the workaround if one is given."
|
|
11
11
|
|
|
12
|
-
FEEDBACK_TOOL_DESCRIPTION = "Report a problem with this service to its engineering team. Call this when something prevented or complicated your task: a missing capability (filter, field, endpoint), unclear or wrong documentation, a surprising response, an unhelpful error, a bug, or impractical performance. Describe your goal in plain language, say specifically what got in the way, and include a concrete suggestion if you have one. Report each distinct problem once per task. Never include credentials, personal data or your user's private content. This is best effort: after calling it, continue with your task."
|
|
12
|
+
FEEDBACK_TOOL_DESCRIPTION = "Report a problem with this service to its engineering team. Call this when something prevented or complicated your task: a missing capability (filter, field, endpoint), unclear or wrong documentation, a surprising response, an unhelpful error, a bug, or impractical performance. Describe your goal in plain language, say specifically what got in the way, and include a concrete suggestion if you have one. Say who you are in `agent` (name and model). Report each distinct problem once per task. Never include credentials, personal data or your user's private content. This is best effort: after calling it, continue with your task."
|
|
@@ -65,7 +65,12 @@ def fastapi_router(handler: FeedbackHandler, identify: Optional[Identify] = None
|
|
|
65
65
|
request.headers.get("x-forwarded-for"), request.client.host if request.client else None
|
|
66
66
|
)
|
|
67
67
|
status, payload, headers = await run_in_threadpool(
|
|
68
|
-
handler.handle_submit,
|
|
68
|
+
handler.handle_submit,
|
|
69
|
+
bytes(body),
|
|
70
|
+
request.headers.get("content-type"),
|
|
71
|
+
account,
|
|
72
|
+
client_ip,
|
|
73
|
+
request.headers.get("user-agent"),
|
|
69
74
|
)
|
|
70
75
|
return JSONResponse(payload, status_code=status, headers=headers)
|
|
71
76
|
|
|
@@ -94,7 +99,7 @@ def flask_blueprint(handler: FeedbackHandler, identify: Optional[Identify] = Non
|
|
|
94
99
|
account = resolve(request) if resolve else None
|
|
95
100
|
client_ip = _client_ip(request.headers.get("X-Forwarded-For"), request.remote_addr)
|
|
96
101
|
status, payload, headers = handler.handle_submit(
|
|
97
|
-
body, request.headers.get("Content-Type"), account, client_ip
|
|
102
|
+
body, request.headers.get("Content-Type"), account, client_ip, request.headers.get("User-Agent")
|
|
98
103
|
)
|
|
99
104
|
return Response(json.dumps(payload, ensure_ascii=False), status=status, headers=headers)
|
|
100
105
|
|