backloop-sdk 0.1.5__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.
Files changed (33) hide show
  1. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/PKG-INFO +33 -1
  2. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/README.md +30 -0
  3. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/pyproject.toml +2 -1
  4. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/__init__.py +8 -1
  5. backloop_sdk-0.2.0/src/backloop/_events.py +219 -0
  6. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/_generated.py +1 -1
  7. backloop_sdk-0.2.0/src/backloop/mcp.py +567 -0
  8. backloop_sdk-0.2.0/src/backloop/requests_recorder.py +553 -0
  9. backloop_sdk-0.2.0/tests/test_mcp.py +481 -0
  10. backloop_sdk-0.2.0/tests/test_requests.py +369 -0
  11. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/.gitignore +0 -0
  12. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/LICENSE +0 -0
  13. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/_http.py +0 -0
  14. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/client.py +0 -0
  15. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/forward.py +0 -0
  16. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/ids.py +0 -0
  17. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/integrations.py +0 -0
  18. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/py.typed +0 -0
  19. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/redact.py +0 -0
  20. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/server.py +0 -0
  21. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/tool.py +0 -0
  22. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/types.py +0 -0
  23. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/src/backloop/validate.py +0 -0
  24. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/__init__.py +0 -0
  25. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/helpers.py +0 -0
  26. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/test_client.py +0 -0
  27. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/test_forward.py +0 -0
  28. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/test_ids.py +0 -0
  29. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/test_integrations.py +0 -0
  30. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/test_redact.py +0 -0
  31. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/test_server.py +0 -0
  32. {backloop_sdk-0.1.5 → backloop_sdk-0.2.0}/tests/test_tool.py +0 -0
  33. {backloop_sdk-0.1.5 → 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.1.5
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.1.5"
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,7 +11,9 @@ 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,
@@ -45,7 +47,7 @@ from .validate import (
45
47
  validate_submission,
46
48
  )
47
49
 
48
- __version__ = "0.1.5"
50
+ __version__ = "0.2.0"
49
51
 
50
52
  __all__ = [
51
53
  "ACK_SCHEMA",
@@ -60,6 +62,7 @@ __all__ = [
60
62
  "RECORD_SCHEMA",
61
63
  "REDACTED",
62
64
  "SPEC_VERSION",
65
+ "SessionAnalytics",
63
66
  "WELL_KNOWN_PATH",
64
67
  "AgentInfo",
65
68
  "DiscoveryDocument",
@@ -79,6 +82,8 @@ __all__ = [
79
82
  "ValidationIssue",
80
83
  "ValidationResult",
81
84
  "agent_from_user_agent",
85
+ "collapse_path",
86
+ "detect_agent",
82
87
  "error_response",
83
88
  "fan_out",
84
89
  "fastapi_router",
@@ -90,8 +95,10 @@ __all__ = [
90
95
  "format_issues",
91
96
  "forward_to",
92
97
  "hash_account",
98
+ "instrument_server",
93
99
  "memory_sink",
94
100
  "new_id",
101
+ "record_requests",
95
102
  "redact",
96
103
  "redact_string",
97
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)
@@ -5,7 +5,7 @@ FEEDBACK_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-
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
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