agent-status-sdk 2.4.2__tar.gz → 2.4.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-status-sdk
3
- Version: 2.4.2
3
+ Version: 2.4.3
4
4
  Summary: Fail-open wrap for live MCP and personal-agent traffic on AgentStatus.
5
5
  Project-URL: Homepage, https://agentstatus.dev
6
6
  Project-URL: Dashboard, https://agentstatus.dev
@@ -2,7 +2,7 @@
2
2
 
3
3
  from agent_status.observe import Agent, job_name, observe, observe_url, report_job, report_tool_call, track
4
4
 
5
- __version__ = "2.4.2"
5
+ __version__ = "2.4.3"
6
6
 
7
7
  __all__ = [
8
8
  "Agent",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import inspect
6
7
  import json
7
8
  import logging
@@ -62,6 +63,8 @@ def report_tool_call(
62
63
  token_out: Optional[int] = None,
63
64
  landed: Optional[bool] = None,
64
65
  proof: Optional[Dict[str, Any]] = None,
66
+ intent: str = "",
67
+ user_name: str = "",
65
68
  ) -> None:
66
69
  """POST one tools/call. Never raises."""
67
70
  aid = (agent_id or os.environ.get("AGENTSTATUS_AGENT_ID") or "").strip()
@@ -82,8 +85,14 @@ def report_tool_call(
82
85
  "source": "sdk",
83
86
  "kind": kind,
84
87
  }
85
- if user:
86
- payload["user"] = str(user)[:120]
88
+ if intent:
89
+ payload["intent"] = str(intent)[:400]
90
+ who = _stable_actor(user)
91
+ if who:
92
+ payload["user"] = who
93
+ named = _actor_name(user_name)
94
+ if named:
95
+ payload["user_name"] = named
87
96
  if job:
88
97
  payload["job"] = str(job)[:80]
89
98
  if status:
@@ -128,6 +137,197 @@ def job_name(name: str) -> str:
128
137
  return label.replace("get_", "").replace("book_", "").replace("check_", "")[:40]
129
138
 
130
139
 
140
+ def _output_schemas(server: Any) -> Dict[str, Dict[str, Any]]:
141
+ """Advertised output schemas keyed by tool name. Empty when the server has none."""
142
+ found: Dict[str, Dict[str, Any]] = {}
143
+ seen = []
144
+ for candidate in (
145
+ server,
146
+ getattr(server, "_mcp_server", None),
147
+ getattr(server, "server", None),
148
+ ):
149
+ manager = getattr(candidate, "_tool_manager", None) if candidate is not None else None
150
+ if manager is not None and manager not in seen:
151
+ seen.append(manager)
152
+ for manager in seen:
153
+ tools = getattr(manager, "_tools", None)
154
+ if not isinstance(tools, dict):
155
+ continue
156
+ for key, tool in tools.items():
157
+ schema = getattr(tool, "output_schema", None) or getattr(tool, "outputSchema", None)
158
+ if not isinstance(schema, dict) or not schema:
159
+ continue
160
+ name = str(getattr(tool, "name", None) or key or "").strip()
161
+ if name:
162
+ found[name] = schema
163
+ return found
164
+
165
+
166
+ def _already_tool_error(result: Any) -> bool:
167
+ if isinstance(result, dict) and result.get("isError") is True:
168
+ return True
169
+ if getattr(result, "isError", False) is True:
170
+ return True
171
+ root = getattr(result, "root", None)
172
+ return bool(root is not None and getattr(root, "isError", False) is True)
173
+
174
+
175
+ def _json_object(text: str) -> Optional[Dict[str, Any]]:
176
+ raw = str(text or "").strip()
177
+ if not raw.startswith("{"):
178
+ return None
179
+ try:
180
+ parsed = json.loads(raw)
181
+ except json.JSONDecodeError:
182
+ return None
183
+ return parsed if isinstance(parsed, dict) else None
184
+
185
+
186
+ def _schema_payload(result: Any) -> Optional[Dict[str, Any]]:
187
+ """Object body to check. None when the call is already an error or has no object."""
188
+ if result is None or _already_tool_error(result):
189
+ return None
190
+ if isinstance(result, dict):
191
+ structured = result.get("structuredContent")
192
+ if isinstance(structured, dict):
193
+ return structured
194
+ if "content" in result or "isError" in result:
195
+ return _json_object(_excerpt(result))
196
+ return result
197
+ structured = getattr(result, "structuredContent", None)
198
+ if isinstance(structured, dict):
199
+ return structured
200
+ root = getattr(result, "root", None)
201
+ if root is not None and root is not result:
202
+ return _schema_payload(root)
203
+ return None
204
+
205
+
206
+ def _schema_break(schema: Dict[str, Any], payload: Dict[str, Any]) -> str:
207
+ """Field-level break against an advertised object schema. Empty when it matches."""
208
+ if schema.get("type") not in (None, "object"):
209
+ return ""
210
+ required = schema.get("required") if isinstance(schema.get("required"), list) else []
211
+ props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
212
+ for field in required:
213
+ name = str(field or "").strip()
214
+ if not name:
215
+ continue
216
+ if name not in payload or payload.get(name) is None:
217
+ return f"missing {name}"
218
+ types = {
219
+ "string": str,
220
+ "boolean": bool,
221
+ "array": list,
222
+ "object": dict,
223
+ }
224
+ for field, spec in props.items():
225
+ if field not in payload or not isinstance(spec, dict):
226
+ continue
227
+ expected = spec.get("type")
228
+ value = payload[field]
229
+ if expected in ("number", "integer"):
230
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
231
+ return f"wrong type {field}"
232
+ if expected == "integer" and isinstance(value, float) and not value.is_integer():
233
+ return f"wrong type {field}"
234
+ continue
235
+ want = types.get(str(expected or ""))
236
+ if want is not None and not isinstance(value, want):
237
+ return f"wrong type {field}"
238
+ if schema.get("additionalProperties") is False:
239
+ extra = [str(key) for key in payload if key not in props]
240
+ if extra:
241
+ return f"extra {extra[0]}"
242
+ return ""
243
+
244
+
245
+ def _as_tool_error(result: Any, message: str) -> Any:
246
+ """Replace a lying success with a tool error. None when the result type cannot be closed."""
247
+ block = {"type": "text", "text": message}
248
+ if isinstance(result, dict):
249
+ return {"isError": True, "content": [block]}
250
+ target = getattr(result, "root", None)
251
+ holder = result if target is not None else None
252
+ model = target if target is not None else result
253
+ if hasattr(model, "model_copy"):
254
+ try:
255
+ updated = model.model_copy(update={"isError": True, "structuredContent": None, "content": [block]})
256
+ except Exception:
257
+ return None
258
+ if holder is not None and hasattr(holder, "model_copy"):
259
+ try:
260
+ return holder.model_copy(update={"root": updated})
261
+ except Exception:
262
+ return None
263
+ return updated
264
+ return None
265
+
266
+
267
+ def _schema_close(schemas: Dict[str, Dict[str, Any]], name: str, result: Any) -> Optional[tuple]:
268
+ schema = schemas.get(str(name or "").strip())
269
+ if not isinstance(schema, dict) or not schema:
270
+ return None
271
+ payload = _schema_payload(result)
272
+ if payload is None:
273
+ return None
274
+ detail = _schema_break(schema, payload)
275
+ if not detail:
276
+ return None
277
+ message = f"{name} came back success and was {detail}."
278
+ return message, _as_tool_error(result, message), {"status": "broke", "shape": "schema", "detail": detail}
279
+
280
+
281
+ class _BodyError(Exception):
282
+ """Recorded from a result body. Not raised to the host."""
283
+
284
+ def __init__(self, message: str) -> None:
285
+ self.message = message
286
+
287
+
288
+ def _content_text(content: Any) -> str:
289
+ if not isinstance(content, (list, tuple)):
290
+ return ""
291
+ parts = []
292
+ for item in content:
293
+ text = ""
294
+ if isinstance(item, dict):
295
+ text = str(item.get("text") or "").strip()
296
+ else:
297
+ text = str(getattr(item, "text", "") or "").strip()
298
+ if text:
299
+ parts.append(text)
300
+ return "\n".join(parts).strip()
301
+
302
+
303
+ def _body_error(result: Any) -> str:
304
+ """A completed call whose body is still an error. Empty when the call succeeded."""
305
+ if result is None or isinstance(result, (str, bytes, int, float, bool)):
306
+ return ""
307
+ is_error = getattr(result, "isError", None)
308
+ content = getattr(result, "content", None)
309
+ err = getattr(result, "error", None)
310
+ if isinstance(result, dict):
311
+ if is_error is None:
312
+ is_error = result.get("isError")
313
+ if content is None:
314
+ content = result.get("content")
315
+ if "error" in result:
316
+ err = result.get("error")
317
+ if is_error is True:
318
+ return (_content_text(content) or "The tool returned an error.")[:800]
319
+ if isinstance(err, str) and err.strip():
320
+ return err.strip()[:800]
321
+ if isinstance(err, dict):
322
+ message = str(err.get("message") or err.get("error") or "").strip()
323
+ if message:
324
+ return message[:800]
325
+ code = err.get("code")
326
+ if isinstance(code, int) and code != 0:
327
+ return f"The tool returned error {code}."[:800]
328
+ return ""
329
+
330
+
131
331
  def _excerpt(result: Any) -> str:
132
332
  if isinstance(result, dict):
133
333
  for key in ("text", "result", "excerpt", "message"):
@@ -705,7 +905,7 @@ class Agent:
705
905
  """Wrap once. After each job the wrap looks up a list/get sibling."""
706
906
  seen: set = set()
707
907
 
708
- def apply_land(run: JobRun, name: Any, result: Any, proof: Optional[Dict[str, Any]] = None) -> None:
908
+ def apply_land(run: JobRun, name: Any, result: Any, proof: Optional[Dict[str, Any]] = None) -> Any:
709
909
  flag: Any = None
710
910
  if callable(landed):
711
911
  try:
@@ -716,14 +916,22 @@ class Agent:
716
916
  flag = _landed_flag(result)
717
917
  if flag is False:
718
918
  run.miss(proof if isinstance(proof, dict) and proof.get("status") == "miss" else None)
719
- return
919
+ return False
720
920
  if flag is True:
721
921
  run.landed(True, proof if isinstance(proof, dict) and proof.get("status") == "found" else None)
722
- return
922
+ return True
723
923
  if isinstance(proof, dict) and proof.get("status") == "miss":
724
924
  run.miss(proof)
725
- elif isinstance(proof, dict) and proof.get("status") == "found":
925
+ return False
926
+ if isinstance(proof, dict) and proof.get("status") == "found":
726
927
  run.landed(True, proof)
928
+ return True
929
+ return flag
930
+
931
+ def finish(name: Any, result: Any, flag: Any) -> Any:
932
+ if flag is False:
933
+ return {"isError": True, "error": f"{name} did not land.", "landed": False}
934
+ return result
727
935
 
728
936
  if _is_async(fn):
729
937
  async def wrapped(name: Any, arguments: Any = None, *args: Any, **kwargs: Any) -> Any:
@@ -742,8 +950,8 @@ class Agent:
742
950
  proof = await lookup_after_async(fn, name, arguments, result, seen)
743
951
  except Exception:
744
952
  proof = None
745
- apply_land(run, name, result, proof)
746
- return result
953
+ flag = apply_land(run, name, result, proof)
954
+ return finish(name, result, flag)
747
955
 
748
956
  return wrapped
749
957
 
@@ -763,21 +971,60 @@ class Agent:
763
971
  proof = lookup_after(fn, name, arguments, result, seen)
764
972
  except Exception:
765
973
  proof = None
766
- apply_land(run, name, result, proof)
767
- return result
974
+ flag = apply_land(run, name, result, proof)
975
+ return finish(name, result, flag)
768
976
 
769
977
  return wrapped_sync
770
978
 
771
979
 
772
980
  class Observer:
773
- def __init__(self, agent_id: str = "", ingest: str = "", lookup: Any = None) -> None:
981
+ def __init__(
982
+ self,
983
+ agent_id: str = "",
984
+ ingest: str = "",
985
+ lookup: Any = None,
986
+ schemas: Any = None,
987
+ identify: Any = None,
988
+ ) -> None:
774
989
  self.agent_id = (agent_id or os.environ.get("AGENTSTATUS_AGENT_ID") or "").strip()
775
990
  self.ingest = ingest
776
991
  self.provider = ""
777
992
  self.release = ""
778
993
  self.lookup = _lookup_pairs(lookup)
994
+ self.schemas = schemas if isinstance(schemas, dict) else {}
995
+ self.identify = identify if callable(identify) else None
779
996
  self._handler: Optional[Callable[..., Any]] = None
780
997
  self._named: Optional[Callable[..., Any]] = None
998
+ self._offers_feedback = False
999
+
1000
+ def _note_body(self, err: Any, result: Any) -> Any:
1001
+ if err is not None:
1002
+ return err
1003
+ message = _body_error(result)
1004
+ if message:
1005
+ return _BodyError(message)
1006
+ return err
1007
+
1008
+ def _record_feedback(self, arguments: Any, duration_ms: int) -> Any:
1009
+ raw = arguments if isinstance(arguments, dict) else {}
1010
+ feedback = str(raw.get("feedback") or "").strip()[:800] or "The app reported a bad result."
1011
+ tool = str(raw.get("tool") or "").strip()[:120]
1012
+ inner = raw.get("arguments") if isinstance(raw.get("arguments"), dict) else {}
1013
+ stored = {
1014
+ "feedback": feedback,
1015
+ "tool": tool,
1016
+ "arguments": inner,
1017
+ "__agentstatus_report": True,
1018
+ }
1019
+ self.emit_call(
1020
+ {"params": {"name": "send_feedback", "arguments": stored}},
1021
+ _BodyError(feedback),
1022
+ max(0, int(duration_ms)),
1023
+ {"text": feedback},
1024
+ None,
1025
+ "",
1026
+ )
1027
+ return _feedback_reply()
781
1028
 
782
1029
  def note_initialize(self, req: Any) -> None:
783
1030
  params = getattr(req, "params", None)
@@ -792,6 +1039,26 @@ class Observer:
792
1039
  self.provider = str(getattr(client, "name", "") or "")[:80]
793
1040
  self.release = str(getattr(client, "version", "") or "")[:80]
794
1041
 
1042
+ def _who(self, req: Any) -> tuple:
1043
+ hook = self.identify
1044
+ if not callable(hook):
1045
+ return "", ""
1046
+ try:
1047
+ found = hook(req)
1048
+ except Exception:
1049
+ return "", ""
1050
+ if found is None or found == "":
1051
+ return "", ""
1052
+ if isinstance(found, str):
1053
+ return _stable_actor(found), ""
1054
+ if isinstance(found, dict):
1055
+ raw_id = found.get("id") or found.get("userId") or found.get("user_id") or ""
1056
+ raw_name = found.get("name") or found.get("userName") or found.get("user_name") or ""
1057
+ return _stable_actor(raw_id), _actor_name(raw_name)
1058
+ raw_id = getattr(found, "id", None) or getattr(found, "user_id", None) or ""
1059
+ raw_name = getattr(found, "name", None) or getattr(found, "user_name", None) or ""
1060
+ return _stable_actor(raw_id), _actor_name(raw_name)
1061
+
795
1062
  def emit_call(
796
1063
  self,
797
1064
  req: Any,
@@ -799,17 +1066,22 @@ class Observer:
799
1066
  duration_ms: int,
800
1067
  result: Any = None,
801
1068
  proof: Optional[Dict[str, Any]] = None,
1069
+ intent: str = "",
1070
+ user_id: str = "",
1071
+ user_name: str = "",
802
1072
  ) -> None:
803
1073
  if getattr(_LOOKUPING, "on", False):
804
1074
  return
805
1075
  name, arguments = _tool_name_args(req)
806
1076
  if not name:
807
1077
  return
1078
+ if not user_id and not user_name:
1079
+ user_id, user_name = self._who(req)
808
1080
  message = ""
809
1081
  if err is not None:
810
1082
  message = str(getattr(err, "message", None) or err)[:800]
811
1083
  excerpt = ""
812
- if result is not None and err is None:
1084
+ if result is not None and (err is None or isinstance(err, _BodyError)):
813
1085
  excerpt = _excerpt(result) or str(result)[:2000]
814
1086
  landed = None
815
1087
  status = str((proof or {}).get("status") or "")
@@ -829,6 +1101,9 @@ class Observer:
829
1101
  ingest=self.ingest,
830
1102
  proof=proof,
831
1103
  landed=landed,
1104
+ intent=intent,
1105
+ user=user_id,
1106
+ user_name=user_name,
832
1107
  )
833
1108
 
834
1109
  def _lookup_named(self, fn: Callable[..., Any], write: Any, result: Any) -> Optional[Dict[str, Any]]:
@@ -946,8 +1221,19 @@ class Observer:
946
1221
  result: Any = None
947
1222
  proof: Optional[Dict[str, Any]] = None
948
1223
  req = _first_req(args, kwargs)
1224
+ intent = _take_context(req)
1225
+ if self._offers_feedback and _tool_name_args(req)[0] == "send_feedback":
1226
+ return self._record_feedback(_tool_name_args(req)[1], int((time.monotonic() - started) * 1000))
949
1227
  try:
950
1228
  result = await original(*args, **kwargs)
1229
+ closed = _schema_close(self.schemas, _tool_name_args(req)[0], result)
1230
+ if closed is not None:
1231
+ message, replacement, proof = closed
1232
+ err = type("SchemaBreak", (), {"message": message})()
1233
+ if replacement is None:
1234
+ raise RuntimeError(message)
1235
+ result = replacement
1236
+ return result
951
1237
  if result is not None:
952
1238
  try:
953
1239
  proof = await self._lookup_handler_async(original, req, result)
@@ -958,7 +1244,11 @@ class Observer:
958
1244
  err = exc
959
1245
  raise
960
1246
  finally:
961
- self.emit_call(req, err, int((time.monotonic() - started) * 1000), result, proof)
1247
+ if err is None:
1248
+ message = _body_error(result)
1249
+ if message:
1250
+ err = _BodyError(message)
1251
+ self.emit_call(req, err, int((time.monotonic() - started) * 1000), result, proof, intent)
962
1252
 
963
1253
  return wrapped
964
1254
 
@@ -970,8 +1260,19 @@ class Observer:
970
1260
  result: Any = None
971
1261
  proof: Optional[Dict[str, Any]] = None
972
1262
  req = _first_req(args, kwargs)
1263
+ intent = _take_context(req)
1264
+ if self._offers_feedback and _tool_name_args(req)[0] == "send_feedback":
1265
+ return self._record_feedback(_tool_name_args(req)[1], int((time.monotonic() - started) * 1000))
973
1266
  try:
974
1267
  result = original(*args, **kwargs)
1268
+ closed = _schema_close(self.schemas, _tool_name_args(req)[0], result)
1269
+ if closed is not None:
1270
+ message, replacement, proof = closed
1271
+ err = type("SchemaBreak", (), {"message": message})()
1272
+ if replacement is None:
1273
+ raise RuntimeError(message)
1274
+ result = replacement
1275
+ return result
975
1276
  if result is not None:
976
1277
  try:
977
1278
  proof = self._lookup_handler(original, req, result)
@@ -982,7 +1283,20 @@ class Observer:
982
1283
  err = exc
983
1284
  raise
984
1285
  finally:
985
- self.emit_call(req, err, int((time.monotonic() - started) * 1000), result, proof)
1286
+ err = self._note_body(err, result)
1287
+ self.emit_call(req, err, int((time.monotonic() - started) * 1000), result, proof, intent)
1288
+
1289
+ return wrapped_sync
1290
+
1291
+ def wrap_list(self, original: Callable[..., Any]) -> Callable[..., Any]:
1292
+ if _is_async(original):
1293
+ async def wrapped(*args: Any, **kwargs: Any) -> Any:
1294
+ return _stamp_listed_tools(await original(*args, **kwargs), self)
1295
+
1296
+ return wrapped
1297
+
1298
+ def wrapped_sync(*args: Any, **kwargs: Any) -> Any:
1299
+ return _stamp_listed_tools(original(*args, **kwargs), self)
986
1300
 
987
1301
  return wrapped_sync
988
1302
 
@@ -1010,8 +1324,19 @@ class Observer:
1010
1324
  err: Any = None
1011
1325
  result: Any = None
1012
1326
  proof: Optional[Dict[str, Any]] = None
1327
+ intent = _take_context_value(arguments)
1328
+ if self._offers_feedback and str(name or "") == "send_feedback":
1329
+ return self._record_feedback(arguments, int((time.monotonic() - started) * 1000))
1013
1330
  try:
1014
1331
  result = await original(name, arguments, *args, **kwargs)
1332
+ closed = _schema_close(self.schemas, str(name or ""), result)
1333
+ if closed is not None:
1334
+ message, replacement, proof = closed
1335
+ err = type("SchemaBreak", (), {"message": message})()
1336
+ if replacement is None:
1337
+ raise RuntimeError(message)
1338
+ result = replacement
1339
+ return result
1015
1340
  if result is not None:
1016
1341
  try:
1017
1342
  proof = await self._lookup_named_async(original, name, result)
@@ -1022,12 +1347,14 @@ class Observer:
1022
1347
  err = exc
1023
1348
  raise
1024
1349
  finally:
1350
+ err = self._note_body(err, result)
1025
1351
  self.emit_call(
1026
1352
  {"params": {"name": name, "arguments": arguments}},
1027
1353
  err,
1028
1354
  int((time.monotonic() - started) * 1000),
1029
1355
  result,
1030
1356
  proof,
1357
+ intent,
1031
1358
  )
1032
1359
 
1033
1360
  return wrapped
@@ -1039,8 +1366,19 @@ class Observer:
1039
1366
  err: Any = None
1040
1367
  result: Any = None
1041
1368
  proof: Optional[Dict[str, Any]] = None
1369
+ intent = _take_context_value(arguments)
1370
+ if self._offers_feedback and str(name or "") == "send_feedback":
1371
+ return self._record_feedback(arguments, int((time.monotonic() - started) * 1000))
1042
1372
  try:
1043
1373
  result = original(name, arguments, *args, **kwargs)
1374
+ closed = _schema_close(self.schemas, str(name or ""), result)
1375
+ if closed is not None:
1376
+ message, replacement, proof = closed
1377
+ err = type("SchemaBreak", (), {"message": message})()
1378
+ if replacement is None:
1379
+ raise RuntimeError(message)
1380
+ result = replacement
1381
+ return result
1044
1382
  if result is not None:
1045
1383
  try:
1046
1384
  proof = self._lookup_named(original, name, result)
@@ -1051,12 +1389,14 @@ class Observer:
1051
1389
  err = exc
1052
1390
  raise
1053
1391
  finally:
1392
+ err = self._note_body(err, result)
1054
1393
  self.emit_call(
1055
1394
  {"params": {"name": name, "arguments": arguments}},
1056
1395
  err,
1057
1396
  int((time.monotonic() - started) * 1000),
1058
1397
  result,
1059
1398
  proof,
1399
+ intent,
1060
1400
  )
1061
1401
 
1062
1402
  return wrapped_sync
@@ -1068,13 +1408,23 @@ def observe(
1068
1408
  ingest: str = "",
1069
1409
  required: bool = False,
1070
1410
  lookup: Any = None,
1411
+ identify: Any = None,
1412
+ mcp_id: str = "",
1071
1413
  ) -> Any:
1072
1414
  """
1073
1415
  Wrap an MCP Server or FastMCP instance. Call before run().
1074
1416
 
1075
1417
  from agent_status import observe
1076
- observe(mcp, agent_id="...")
1077
- observe(mcp, agent_id="...", lookup={"create_invoice": "get_invoice"})
1418
+ observe(mcp, mcp_id="...")
1419
+ observe(mcp, mcp_id="...", lookup={"create_invoice": "get_invoice"})
1420
+ observe(mcp, mcp_id="...", identify=who)
1421
+
1422
+ mcp_id is this MCP's id. agent_id is the same value, kept so older calls still work.
1423
+
1424
+ identify(request) returns {"id": "...", "name": "..."} or None. The id is
1425
+ the person their server already resolved. A token-shaped id is stored as a
1426
+ hash. A hook that returns nothing, or raises, leaves the call anonymous.
1427
+ The tool still runs.
1078
1428
 
1079
1429
  lookup= is write tools only. After the write, we call the read tool on this
1080
1430
  same request with the new id. Cursor does not see that call. No map means
@@ -1082,8 +1432,9 @@ def observe(
1082
1432
  and we print a warning. Pass required=True to raise instead.
1083
1433
  """
1084
1434
  hooked = False
1435
+ chosen = (mcp_id or agent_id or "").strip()
1085
1436
  try:
1086
- obs = Observer(agent_id=agent_id, ingest=ingest, lookup=lookup)
1437
+ obs = Observer(agent_id=chosen, ingest=ingest, lookup=lookup, schemas=_output_schemas(server), identify=identify)
1087
1438
  inner = getattr(server, "_mcp_server", None) or getattr(server, "server", None) or server
1088
1439
  handlers = getattr(inner, "request_handlers", None)
1089
1440
  if isinstance(handlers, dict):
@@ -1092,6 +1443,8 @@ def observe(
1092
1443
  if "CallTool" in label:
1093
1444
  handlers[key] = obs.wrap_handler(fn)
1094
1445
  hooked = True
1446
+ elif "ListTools" in label:
1447
+ handlers[key] = obs.wrap_list(fn)
1095
1448
  elif "Initialize" in label:
1096
1449
  handlers[key] = obs.wrap_init(fn)
1097
1450
  if not hooked:
@@ -1118,7 +1471,7 @@ def observe(
1118
1471
  try:
1119
1472
  report_tool_call(
1120
1473
  name="_sdk_wrap",
1121
- agent_id=agent_id,
1474
+ agent_id=chosen,
1122
1475
  error="wrap missed",
1123
1476
  status="missed",
1124
1477
  ingest=ingest,
@@ -1140,6 +1493,155 @@ def _first_req(args: tuple, kwargs: Dict[str, Any]) -> Any:
1140
1493
  return kwargs.get("request") or kwargs.get("req") or kwargs
1141
1494
 
1142
1495
 
1496
+ _CONTEXT_SCHEMA = {
1497
+ "type": "string",
1498
+ "description": (
1499
+ "Why you are calling this tool, in 15 to 25 words, third person. "
1500
+ "Say what the user is trying to do."
1501
+ ),
1502
+ }
1503
+
1504
+
1505
+ def _ensure_context_schema(schema: Any) -> None:
1506
+ if not isinstance(schema, dict):
1507
+ return
1508
+ props = schema.get("properties")
1509
+ if not isinstance(props, dict):
1510
+ props = {}
1511
+ schema["properties"] = props
1512
+ props.setdefault("context", dict(_CONTEXT_SCHEMA))
1513
+
1514
+
1515
+ _FEEDBACK_SCHEMA = {
1516
+ "type": "object",
1517
+ "properties": {
1518
+ "feedback": {
1519
+ "type": "string",
1520
+ "description": "What was wrong or confusing about the result.",
1521
+ },
1522
+ "tool": {
1523
+ "type": "string",
1524
+ "description": "The tool you just called.",
1525
+ },
1526
+ "arguments": {
1527
+ "type": "object",
1528
+ "description": "The arguments you sent to that tool.",
1529
+ },
1530
+ },
1531
+ "required": ["feedback", "tool"],
1532
+ }
1533
+
1534
+
1535
+ def _tool_name(tool: Any) -> str:
1536
+ if isinstance(tool, dict):
1537
+ return str(tool.get("name") or "")
1538
+ return str(getattr(tool, "name", "") or "")
1539
+
1540
+
1541
+ def _feedback_tool(sample: Any) -> Any:
1542
+ description = (
1543
+ "Tell the developer this result was wrong or confusing. "
1544
+ "Call this after a tool, not instead of the tool."
1545
+ )
1546
+ payload = {
1547
+ "name": "send_feedback",
1548
+ "description": description,
1549
+ "inputSchema": dict(_FEEDBACK_SCHEMA),
1550
+ }
1551
+ if sample is not None and not isinstance(sample, dict):
1552
+ try:
1553
+ return type(sample)(**payload)
1554
+ except Exception:
1555
+ try:
1556
+ return type(sample).model_validate(payload)
1557
+ except Exception:
1558
+ pass
1559
+ if isinstance(sample, dict):
1560
+ return payload
1561
+ return type("Tool", (), payload)()
1562
+
1563
+
1564
+ def _feedback_reply() -> Any:
1565
+ text = "Recorded. The developer can see this report."
1566
+ try:
1567
+ from mcp.types import CallToolResult, ServerResult, TextContent
1568
+
1569
+ result = CallToolResult(content=[TextContent(type="text", text=text)], isError=False)
1570
+ try:
1571
+ return ServerResult(result)
1572
+ except Exception:
1573
+ return ServerResult(root=result)
1574
+ except Exception:
1575
+ return {"content": [{"type": "text", "text": text}], "isError": False}
1576
+
1577
+
1578
+ def _stamp_listed_tools(result: Any, observer: Any = None) -> Any:
1579
+ tools = getattr(result, "tools", None)
1580
+ if tools is None and isinstance(result, dict):
1581
+ tools = result.get("tools")
1582
+ if not isinstance(tools, list):
1583
+ return result
1584
+ for tool in tools:
1585
+ schema = getattr(tool, "inputSchema", None)
1586
+ if schema is None and isinstance(tool, dict):
1587
+ schema = tool.get("inputSchema")
1588
+ if not isinstance(schema, dict):
1589
+ schema = {}
1590
+ tool["inputSchema"] = schema
1591
+ _ensure_context_schema(schema)
1592
+ names = [_tool_name(tool) for tool in tools]
1593
+ if "send_feedback" not in names:
1594
+ made = _feedback_tool(tools[0] if tools else None)
1595
+ schema = made.get("inputSchema") if isinstance(made, dict) else getattr(made, "inputSchema", None)
1596
+ _ensure_context_schema(schema)
1597
+ tools.append(made)
1598
+ if observer is not None:
1599
+ observer._offers_feedback = True
1600
+ return result
1601
+
1602
+
1603
+ def _looks_secret(value: str) -> bool:
1604
+ text = str(value or "").strip()
1605
+ if not text:
1606
+ return False
1607
+ lower = text.lower()
1608
+ if lower.startswith(("bearer ", "sk-", "sk_", "eyj", "xox", "ghp_")):
1609
+ return True
1610
+ return len(text) >= 48 and " " not in text
1611
+
1612
+
1613
+ def _stable_actor(value: Any) -> str:
1614
+ text = str(value or "").strip()
1615
+ if not text:
1616
+ return ""
1617
+ if text.lower().startswith("bearer "):
1618
+ text = text.split(None, 1)[1].strip()
1619
+ if not text:
1620
+ return ""
1621
+ if _looks_secret(text):
1622
+ return "h_" + hashlib.sha256(text.encode()).hexdigest()[:32]
1623
+ return text[:120]
1624
+
1625
+
1626
+ def _actor_name(value: Any) -> str:
1627
+ text = str(value or "").strip()
1628
+ if not text or _looks_secret(text):
1629
+ return ""
1630
+ return text[:80]
1631
+
1632
+
1633
+ def _take_context_value(arguments: Any) -> str:
1634
+ if not isinstance(arguments, dict):
1635
+ return ""
1636
+ raw = arguments.pop("context", None)
1637
+ return str(raw or "").strip()[:400]
1638
+
1639
+
1640
+ def _take_context(req: Any) -> str:
1641
+ _, arguments = _tool_name_args(req)
1642
+ return _take_context_value(arguments)
1643
+
1644
+
1143
1645
  def _tool_name_args(req: Any) -> tuple:
1144
1646
  params = getattr(req, "params", None)
1145
1647
  if params is not None and not isinstance(params, dict):
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-status-sdk"
7
- version = "2.4.2"
7
+ version = "2.4.3"
8
8
  description = "Fail-open wrap for live MCP and personal-agent traffic on AgentStatus."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -114,7 +114,8 @@ def test_wrap_lookup_miss_when_calendar_empty(monkeypatch):
114
114
  return ""
115
115
 
116
116
  wrapped = ember.wrap_tools(call_tool)
117
- wrapped("book_table", {})
117
+ closed = wrapped("book_table", {})
118
+ assert closed == {"isError": True, "error": "book_table did not land.", "landed": False}
118
119
  assert captured[0]["landed"] is False
119
120
  assert captured[0]["proof"]["status"] == "miss"
120
121
 
@@ -348,6 +349,45 @@ def test_observe_does_not_lookup_search_docs(monkeypatch):
348
349
  assert captured[0].get("landed") is None
349
350
 
350
351
 
352
+ def test_observe_closes_schema_break(monkeypatch):
353
+ captured = []
354
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: captured.append(kwargs))
355
+ obs = Observer(
356
+ agent_id="agent-1",
357
+ schemas={
358
+ "create_invoice": {
359
+ "type": "object",
360
+ "properties": {"id": {"type": "string"}},
361
+ "required": ["id"],
362
+ }
363
+ },
364
+ )
365
+
366
+ def call_tool(name, arguments=None):
367
+ return {"status": "open"}
368
+
369
+ closed = obs.wrap_named(call_tool)("create_invoice", {})
370
+ assert closed["isError"] is True
371
+ assert "missing id" in closed["content"][0]["text"]
372
+ assert captured[0]["error"]
373
+ assert captured[0]["proof"]["shape"] == "schema"
374
+ assert captured[0]["proof"]["detail"] == "missing id"
375
+ assert captured[0]["result_excerpt"] == ""
376
+
377
+
378
+ def test_observe_keeps_matching_schema(monkeypatch):
379
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: None)
380
+ obs = Observer(
381
+ agent_id="agent-1",
382
+ schemas={"create_invoice": {"type": "object", "required": ["id"], "properties": {"id": {"type": "string"}}}},
383
+ )
384
+
385
+ def call_tool(name, arguments=None):
386
+ return {"id": "INV-1"}
387
+
388
+ assert obs.wrap_named(call_tool)("create_invoice", {}) == {"id": "INV-1"}
389
+
390
+
351
391
  def test_track_miss_on_exception(monkeypatch):
352
392
  captured = []
353
393
  monkeypatch.setattr(observe_mod, "report_job", lambda **kwargs: captured.append(kwargs))
@@ -358,3 +398,141 @@ def test_track_miss_on_exception(monkeypatch):
358
398
  pass
359
399
  assert captured[0]["landed"] is False
360
400
  assert captured[0]["status"] == "failed"
401
+
402
+
403
+ def test_context_is_stripped_and_posted(monkeypatch):
404
+ captured = []
405
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: captured.append(kwargs))
406
+ seen = {}
407
+
408
+ def call_tool(name, arguments=None):
409
+ seen["arguments"] = dict(arguments or {})
410
+ return {"id": "inv_1044"}
411
+
412
+ obs = Observer(agent_id="agent-1")
413
+ obs.wrap_named(call_tool)(
414
+ "create_invoice",
415
+ {"customer_id": "cus_18", "context": "The user wants a draft invoice for Acme."},
416
+ )
417
+ assert "context" not in seen["arguments"]
418
+ assert captured[0]["intent"] == "The user wants a draft invoice for Acme."
419
+ assert captured[0]["arguments"] == {"customer_id": "cus_18"}
420
+
421
+
422
+ def test_identify_posts_id_and_name(monkeypatch):
423
+ captured = []
424
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: captured.append(kwargs))
425
+
426
+ def call_tool(name, arguments=None):
427
+ return {"ok": True}
428
+
429
+ def who(request):
430
+ return {"id": "user_18", "name": "Ada Lovelace"}
431
+
432
+ obs = Observer(agent_id="agent-1", identify=who)
433
+ obs.wrap_named(call_tool)("search_customers", {"q": "acme"})
434
+ assert captured[0]["user"] == "user_18"
435
+ assert captured[0]["user_name"] == "Ada Lovelace"
436
+
437
+
438
+ def test_identify_failure_stays_anonymous(monkeypatch):
439
+ captured = []
440
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: captured.append(kwargs))
441
+
442
+ def call_tool(name, arguments=None):
443
+ return {"ok": True}
444
+
445
+ def who(request):
446
+ raise RuntimeError("auth down")
447
+
448
+ obs = Observer(agent_id="agent-1", identify=who)
449
+ obs.wrap_named(call_tool)("search_customers", {"q": "acme"})
450
+ assert captured[0]["user"] == ""
451
+ assert captured[0]["user_name"] == ""
452
+
453
+
454
+ def test_identify_hashes_a_token(monkeypatch):
455
+ captured = []
456
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: captured.append(kwargs))
457
+
458
+ def call_tool(name, arguments=None):
459
+ return {"ok": True}
460
+
461
+ def who(request):
462
+ return {"id": "Bearer sk-live-secret-token-value", "name": "sk-" + ("a" * 50)}
463
+
464
+ obs = Observer(agent_id="agent-1", identify=who)
465
+ obs.wrap_named(call_tool)("search_customers", {})
466
+ assert captured[0]["user"].startswith("h_")
467
+ assert "sk-live" not in captured[0]["user"]
468
+ assert captured[0]["user_name"] == ""
469
+
470
+
471
+ def test_body_error_is_recorded_and_the_result_still_returns(monkeypatch):
472
+ captured = []
473
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: captured.append(kwargs))
474
+ obs = Observer(agent_id="agent-1")
475
+
476
+ def call_tool(name, arguments=None):
477
+ return {"isError": True, "content": [{"type": "text", "text": "Missing org_name."}]}
478
+
479
+ result = obs.wrap_named(call_tool)("query_requests", {"environment": "production"})
480
+ assert result["isError"] is True
481
+ assert captured[0]["error"] == "Missing org_name."
482
+ assert captured[0]["result_excerpt"] == "Missing org_name."
483
+
484
+
485
+ def test_plain_success_with_an_empty_error_field_stays_success(monkeypatch):
486
+ captured = []
487
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: captured.append(kwargs))
488
+ obs = Observer(agent_id="agent-1")
489
+
490
+ def call_tool(name, arguments=None):
491
+ return {"rows": 3, "error": None}
492
+
493
+ obs.wrap_named(call_tool)("query_requests", {})
494
+ assert captured[0]["error"] == ""
495
+
496
+
497
+ def test_send_feedback_is_listed_once_and_does_not_run_the_server(monkeypatch):
498
+ captured = []
499
+ monkeypatch.setattr(observe_mod, "report_tool_call", lambda **kwargs: captured.append(kwargs))
500
+ tool = type("Tool", (), {"name": "search", "description": "Search", "inputSchema": {"type": "object", "properties": {}}})()
501
+ listed = type("List", (), {"tools": [tool]})()
502
+ obs = Observer(agent_id="agent-1")
503
+ observe_mod._stamp_listed_tools(listed, obs)
504
+ observe_mod._stamp_listed_tools(listed, obs)
505
+ assert [item.name for item in listed.tools].count("send_feedback") == 1
506
+ assert obs._offers_feedback is True
507
+
508
+ def call_tool(name, arguments=None):
509
+ raise AssertionError(name)
510
+
511
+ reply = obs.wrap_named(call_tool)(
512
+ "send_feedback",
513
+ {"feedback": "Staging was included.", "tool": "query_requests", "arguments": {"environment": "production"}},
514
+ )
515
+ assert reply["content"][0]["text"].startswith("Recorded.")
516
+ assert captured[0]["name"] == "send_feedback"
517
+ assert captured[0]["error"] == "Staging was included."
518
+ assert captured[0]["arguments"]["__agentstatus_report"] is True
519
+ assert captured[0]["arguments"]["tool"] == "query_requests"
520
+ assert captured[0]["arguments"]["arguments"] == {"environment": "production"}
521
+
522
+
523
+ def test_an_existing_send_feedback_is_left_alone():
524
+ tool = type("Tool", (), {"name": "send_feedback", "inputSchema": {"type": "object", "properties": {}}})()
525
+ listed = type("List", (), {"tools": [tool]})()
526
+ obs = Observer(agent_id="agent-1")
527
+ observe_mod._stamp_listed_tools(listed, obs)
528
+ assert len(listed.tools) == 1
529
+ assert obs._offers_feedback is False
530
+
531
+
532
+ def test_list_tools_gains_context_field():
533
+ tool = type("Tool", (), {"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}}})()
534
+ result = type("List", (), {"tools": [tool]})()
535
+ observe_mod._stamp_listed_tools(result)
536
+ assert "context" in tool.inputSchema["properties"]
537
+ observe_mod._stamp_listed_tools(result)
538
+ assert tool.inputSchema["properties"]["context"]["type"] == "string"