diff --git a/authbridge/sparc-service/sparc_service/__main__.py b/authbridge/sparc-service/sparc_service/__main__.py index a0f387a4..65d7ba23 100644 --- a/authbridge/sparc-service/sparc_service/__main__.py +++ b/authbridge/sparc-service/sparc_service/__main__.py @@ -8,6 +8,8 @@ def main() -> None: + import logging + logging.basicConfig(level=logging.INFO) settings = Settings.from_env() uvicorn.run("sparc_service.api:app", host=settings.host, port=settings.port, log_level="info") diff --git a/authbridge/sparc-service/sparc_service/api.py b/authbridge/sparc-service/sparc_service/api.py index 67df2c24..c120b1a1 100644 --- a/authbridge/sparc-service/sparc_service/api.py +++ b/authbridge/sparc-service/sparc_service/api.py @@ -8,7 +8,9 @@ from __future__ import annotations +import json import logging +import os from fastapi import FastAPI, HTTPException from fastapi.concurrency import run_in_threadpool @@ -19,6 +21,37 @@ log = logging.getLogger(__name__) +# When SPARC_LOG_REQUESTS=true, log the full incoming ReflectRequest JSON so +# you can inspect exactly what the caller sends (useful for diagnosing +# unexpected tool argument keys). Disabled by default — payloads can be large. +_LOG_REQUESTS = os.getenv("SPARC_LOG_REQUESTS", "").strip().lower() in {"1", "true", "yes"} + +# When SPARC_STRIP_TOOL_ARG_KEYS is set (comma-separated key names), those keys +# are removed from every tool_calls[].function.arguments JSON object before the +# request reaches SPARC. Use to drop agent-injected keys that are not in the +# tool spec and would cause SPARC to reject the call. +# Example: SPARC_STRIP_TOOL_ARG_KEYS=session_id,request_id +_STRIP_KEYS: frozenset[str] = frozenset( + k.strip() for k in os.getenv("SPARC_STRIP_TOOL_ARG_KEYS", "").split(",") if k.strip() +) + + +def _strip_tool_arg_keys(tool_calls: list[dict], keys: frozenset[str]) -> list[dict]: + """Return a copy of tool_calls with the named argument keys removed.""" + result = [] + for tc in tool_calls: + fn = tc.get("function", {}) + raw_args = fn.get("arguments", "") + try: + args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + if isinstance(args, dict): + args = {k: v for k, v in args.items() if k not in keys} + new_args = json.dumps(args) if isinstance(args, dict) else raw_args + except (json.JSONDecodeError, TypeError): + new_args = raw_args + result.append({**tc, "function": {**fn, "arguments": new_args}}) + return result + def create_app(engine: ReflectionEngine | None = None) -> FastAPI: """Build the FastAPI app. Inject ``engine`` in tests; defaults to env config.""" @@ -51,6 +84,16 @@ def readyz() -> dict[str, object]: @app.post("/reflect", response_model=ReflectResponse) async def reflect(request: ReflectRequest) -> ReflectResponse: + if _LOG_REQUESTS: + log.info("incoming reflect request: %s", request.model_dump_json()) + + if _STRIP_KEYS and request.tool_calls: + request = request.model_copy( + update={"tool_calls": _strip_tool_arg_keys(request.tool_calls, _STRIP_KEYS)} + ) + if _LOG_REQUESTS: + log.info("after strip (%s): tool_calls=%s", sorted(_STRIP_KEYS), request.tool_calls) + # SPARCReflectionComponent.process is synchronous (and CPU/IO bound on the # LLM call); run it off the event loop so the service stays responsive. try: