From 8e4332e556fd73485c0f684f0acbc694d27c1155 Mon Sep 17 00:00:00 2001 From: Vitaly Zabershinsky Date: Sun, 19 Jul 2026 16:46:30 +0300 Subject: [PATCH 1/3] feat(sparc-service): add SPARC_LOG_REQUESTS and SPARC_STRIP_TOOL_ARG_KEYS env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPARC_LOG_REQUESTS=true logs the full incoming ReflectRequest JSON at INFO level so unexpected tool argument keys can be diagnosed without rebuilding. SPARC_STRIP_TOOL_ARG_KEYS= removes the named keys from every tool_calls[].function.arguments before the request reaches SPARC. Needed as a configurable hotfix for Exgentic sending session_id in tool arguments — a key not declared in the tool spec that causes SPARC to reject the call. Both vars are no-ops when unset. No image rebuild required to toggle them; set via kubectl set env or the sparc-service ConfigMap. Signed-off-by: Vitaly Zabershinsky --- authbridge/sparc-service/sparc_service/api.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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: From 651bb04428245c5e6087e377f05aa9d47fe06b86 Mon Sep 17 00:00:00 2001 From: Vitaly Zabershinsky Date: Sun, 19 Jul 2026 17:08:35 +0300 Subject: [PATCH 2/3] fix(sparc-service): configure root logger so application log.info() output is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uvicorn.run() with log_level="info" only configures the uvicorn logger, not the Python root logger — application loggers (sparc_service.api) had no handler and were silently dropped. Adding basicConfig before uvicorn.run() ensures all INFO+ log lines reach stdout. Signed-off-by: Vitaly Zabershinsky --- authbridge/sparc-service/sparc_service/__main__.py | 2 ++ 1 file changed, 2 insertions(+) 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") From ec9076037f973aea33bda796bbc1a7697afd30f0 Mon Sep 17 00:00:00 2001 From: Vitaly Zabershinsky Date: Sun, 9 Aug 2026 14:51:42 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(sparc-service):=20address=20PR=20#738?= =?UTF-8?q?=20review=20findings=20=E2=80=94=20Settings=20integration,=20nu?= =?UTF-8?q?ll=20function=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.py: add log_requests (bool) and strip_tool_arg_keys (frozenset) fields to Settings dataclass; parse them from SPARC_LOG_REQUESTS and SPARC_STRIP_TOOL_ARG_KEYS in Settings.from_env() using _truthy() - api.py: remove module-level os.getenv for _LOG_REQUESTS/_STRIP_KEYS; thread settings into the reflect endpoint so it reads settings.log_requests and settings.strip_tool_arg_keys instead of module globals; drop unused os import - api.py: guard _strip_tool_arg_keys against {"function": null} — use `fn = tc.get("function") or {}` and skip the entry (not crash) when fn is not a dict, so a null function key returns 400 not 500 - __main__.py: hoist `import logging` to module top (was inside main()) Signed-off-by: Vitaly Zabershinsky --- .../sparc-service/sparc_service/__main__.py | 3 +- authbridge/sparc-service/sparc_service/api.py | 31 +++++++------------ .../sparc-service/sparc_service/settings.py | 12 +++++++ 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/authbridge/sparc-service/sparc_service/__main__.py b/authbridge/sparc-service/sparc_service/__main__.py index 65d7ba23..f545a7f9 100644 --- a/authbridge/sparc-service/sparc_service/__main__.py +++ b/authbridge/sparc-service/sparc_service/__main__.py @@ -2,13 +2,14 @@ from __future__ import annotations +import logging + import uvicorn from .settings import Settings 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 c120b1a1..fd0487ca 100644 --- a/authbridge/sparc-service/sparc_service/api.py +++ b/authbridge/sparc-service/sparc_service/api.py @@ -10,7 +10,6 @@ import json import logging -import os from fastapi import FastAPI, HTTPException from fastapi.concurrency import run_in_threadpool @@ -21,26 +20,18 @@ 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() -) +# _LOG_REQUESTS and _STRIP_KEYS are now read from Settings (via Settings.from_env) +# so all config comes from a single place. See settings.py. 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", {}) + fn = tc.get("function") or {} + if not isinstance(fn, dict): + result.append(tc) + continue raw_args = fn.get("arguments", "") try: args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args @@ -84,15 +75,15 @@ def readyz() -> dict[str, object]: @app.post("/reflect", response_model=ReflectResponse) async def reflect(request: ReflectRequest) -> ReflectResponse: - if _LOG_REQUESTS: + if settings.log_requests: log.info("incoming reflect request: %s", request.model_dump_json()) - if _STRIP_KEYS and request.tool_calls: + if settings.strip_tool_arg_keys and request.tool_calls: request = request.model_copy( - update={"tool_calls": _strip_tool_arg_keys(request.tool_calls, _STRIP_KEYS)} + update={"tool_calls": _strip_tool_arg_keys(request.tool_calls, settings.strip_tool_arg_keys)} ) - if _LOG_REQUESTS: - log.info("after strip (%s): tool_calls=%s", sorted(_STRIP_KEYS), request.tool_calls) + if settings.log_requests: + log.info("after strip (%s): tool_calls=%s", sorted(settings.strip_tool_arg_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. diff --git a/authbridge/sparc-service/sparc_service/settings.py b/authbridge/sparc-service/sparc_service/settings.py index 74e76e99..a894ca74 100644 --- a/authbridge/sparc-service/sparc_service/settings.py +++ b/authbridge/sparc-service/sparc_service/settings.py @@ -98,6 +98,10 @@ class Settings: host: str = "0.0.0.0" port: int = 8090 + # Request logging / arg-stripping (see api.py). + log_requests: bool = False + strip_tool_arg_keys: frozenset = field(default_factory=frozenset) + # Validation errors collected at load time (provider creds missing, etc.). errors: tuple[str, ...] = field(default_factory=tuple) @@ -152,6 +156,12 @@ def from_env(cls) -> "Settings": f"provider={provider} requires SPARC_MODEL (e.g. azure/ or anthropic/claude-3-5-sonnet)" ) + strip_tool_arg_keys: frozenset[str] = frozenset( + k.strip() + for k in os.getenv("SPARC_STRIP_TOOL_ARG_KEYS", "").split(",") + if k.strip() + ) + return cls( provider=provider, model=model, @@ -171,5 +181,7 @@ def from_env(cls) -> "Settings": llm_registry_id=os.getenv("SPARC_LLM_REGISTRY_ID", "").strip(), host=os.getenv("HOST", "0.0.0.0"), port=_int_env("PORT", 8090), + log_requests=_truthy(os.getenv("SPARC_LOG_REQUESTS", "")), + strip_tool_arg_keys=strip_tool_arg_keys, errors=tuple(errors), )