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.
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/PKG-INFO +1 -1
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/agent_status/__init__.py +1 -1
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/agent_status/observe.py +520 -18
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/pyproject.toml +1 -1
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/tests/test_observe.py +179 -1
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/.github/workflows/publish.yml +0 -0
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/.gitignore +0 -0
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/CONTRIBUTING.md +0 -0
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/LICENSE +0 -0
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/README.md +0 -0
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/examples/README.md +0 -0
- {agent_status_sdk-2.4.2 → agent_status_sdk-2.4.3}/tests/__init__.py +0 -0
|
@@ -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
|
|
86
|
-
payload["
|
|
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) ->
|
|
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
|
-
|
|
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__(
|
|
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
|
-
|
|
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.
|
|
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,
|
|
1077
|
-
observe(mcp,
|
|
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=
|
|
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=
|
|
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):
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|