Commit Graph

80 Commits

Author SHA1 Message Date
Nikketryhard
39381a4dfe fix: multi-round tool history rewrite and finishReason handling
- Add ToolRound struct to pair function calls with results per-round
- Replace single-match history rewrite (broke after first round) with
  multi-round loop that rewrites ALL placeholder model turns
- Fix tool result name fallback: use positional index instead of always
  picking the first call
- Set is_complete for any finishReason (FUNCTION_CALL, MAX_TOKENS, etc.)
  not just STOP — prevents response_complete flag from never being set
- Legacy fallback: responses.rs path (single-round via last_calls +
  pending_results) still works when tool_rounds is empty
- Add tests: multi-round rewrite, single-round legacy, no-op, and
  FUNCTION_CALL/MAX_TOKENS finishReason handling
2026-02-16 19:05:37 -06:00
Nikketryhard
6bda2ecafa fix: tool call race conditions and missing completions tool result extraction
- store.rs: record_function_call now falls back to active_cascade_id
  (matching record_usage behavior) instead of blind _latest fallback
- store.rs: add cascade-aware take_function_calls(cascade_id) method
  with priority: exact match → active cascade → _latest → any key
- completions.rs: extract tool_calls from assistant messages and tool
  results from tool messages, storing them for MITM injection. This was
  the ROOT CAUSE — the completions handler stored tool definitions but
  never extracted tool results, so modify_request couldn't rewrite the
  LS conversation history with proper functionCall/functionResponse
- responses.rs: use cascade-aware take_function_calls for consistency
2026-02-16 18:43:16 -06:00
Nikketryhard
38b4130c55 feat: Implement request generation counter and state management to prevent stale data and unblock Language Server for follow-up requests. 2026-02-16 16:21:52 -06:00
Nikketryhard
e6a339d92e fix: clear request_in_flight when stream ends
Without this, request_in_flight stayed true after tool call streaming,
blocking all subsequent turns until the next completions handler
happened to clear it first.
2026-02-16 01:02:09 -06:00
Nikketryhard
3fdd0368a0 fix: block ALL LS follow-up requests across connections
Move the in-flight blocking check to the top of the LLM request flow,
BEFORE request modification. This catches follow-ups on ALL connections
(the LS opens multiple parallel TLS connections). Only the very first
modified request reaches Google — all others get fake STOP responses.

Previously, each new connection independently allowed one request
through before blocking, letting 4-5 requests leak per turn.
2026-02-16 00:57:33 -06:00
Nikketryhard
a8f3c8915f fix: block ALL LS follow-up requests, deduplicate function calls
- Add request_in_flight flag to MitmStore, set immediately when first
  LLM request is forwarded with custom tools active
- Block ALL subsequent LS requests (agentic loop + internal flash-lite)
  with fake SSE responses instead of waiting for response_complete
- Fix function call deduplication: drain() accumulator after storing
  to prevent 3x duplicate tool calls across SSE chunks
- Clear all stale state (response, thinking, function calls, errors)
  at the start of each streaming request
- Handle response_complete with no content (thoughtSignature-only)
  gracefully with timeout instead of infinite hang
2026-02-16 00:51:56 -06:00
Nikketryhard
5f40385c8d feat: sudoless MITM via LD_PRELOAD DNS redirect
Hook getaddrinfo() via LD_PRELOAD to redirect Google API domain
resolution to 127.0.0.1, combined with a port-modified endpoint URL.
This makes the LS connect directly to the local MITM proxy for ALL
API calls - even the CodeAssistClient which has Proxy:nil hardcoded.

Architecture:
  LS → DNS: googleapis.com → 127.0.0.1 (hooked via getaddrinfo)
     → Connect: 127.0.0.1:MITM_PORT (from -cloud_code_endpoint)
     → MITM proxy intercepts transparent TLS via SNI
     → Forward to real Google API

Key findings from investigation:
- Go uses raw syscalls for connect() (NOT hookable via LD_PRELOAD)
- Go uses libc getaddrinfo() for DNS (hookable via CGO path)
- dns_redirect.so is compiled from embedded C source on first run
- No iptables, no sudo, no CAP_NET_BIND_SERVICE needed
2026-02-15 23:24:43 -06:00
Nikketryhard
6a07786c4e feat: implement headless LS authentication via state sync
Reverse-engineered the UnifiedStateSyncUpdate protocol:
- initial_state field is bytes (not string), contains serialized Topic proto
- Map key for OAuth is 'oauthTokenInfoSentinelKey'
- Row.value is base64-encoded OAuthTokenInfo protobuf
- OAuthTokenInfo includes access_token, token_type, expiry (Timestamp)
- Set far-future expiry (2099) to prevent token expiry errors

Also fixed:
- PushUnifiedStateSyncUpdate returns proper empty proto response
- Stream keep-alive avoids sending empty envelopes (LS rejects nil updates)
- uss-enterprisePreferences topic handled (empty initial state)
2026-02-15 21:40:35 -06:00
Nikketryhard
4e4d8e9474 chore: code cleanup and documentation overhaul
- Remove debug header dump from MITM proxy (was temp debugging code)
- Suppress dead_code warnings for intentional OpenAI compat fields
- Rewrite README with styled mermaid architecture diagrams, full
  feature listing, usage examples, and CLI reference
- Update endpoint-gap-analysis: images implemented, audio only stretch
- Update mitm-interception-status: add request modification and error
  capture components
- Update standalone-ls-todo: add new endpoints to test results
- Zero compiler warnings
2026-02-15 18:27:53 -06:00
Nikketryhard
2882f7cce2 feat: propagate Google upstream errors to client
When Google returns an error (400, 429, 500, etc.), the MITM proxy now
captures it and the API handlers return it immediately instead of
hanging until timeout.

- UpstreamError struct stored in MitmStore
- MITM proxy parses Google error JSON (message + status)
- Polling handler checks for upstream errors each cycle
- Streaming handlers emit response.failed / SSE error events
- Error status mapped to OpenAI-style types (invalid_request_error,
  rate_limit_error, authentication_error, server_error, etc.)
- All handlers clear stale errors at request start
2026-02-15 18:19:38 -06:00
Nikketryhard
371c57bab0 fix: parse flat content arrays in Responses API input
When input is [{type: 'input_image', ...}, {type: 'input_text', text: '...'}],
the code was looking for items with role: 'user' which don't exist in flat
content arrays. Now extracts text from input_text items directly first,
falling back to role-based messages only if no flat text found.

Also adds debug header dump for MITM request forwarding.
2026-02-15 18:10:03 -06:00
Nikketryhard
1a6bfa5b53 fix: update Content-Length header when MITM modifies request body
The MITM modifier kept original HTTP headers (including Content-Length)
when replacing the body. When injecting a ~200KB image into a ~66KB
request, Google would only read Content-Length bytes, then hang waiting
for a new request that never comes.

Now we regex-replace the Content-Length header value to match the actual
rechunked body size after modification.
2026-02-15 18:02:13 -06:00
Nikketryhard
89bea030cc feat: inject images via MITM layer instead of relying on LS
The LS silently ignores the 'images' field from our
SendUserCascadeMessageRequest proto — it never forwards image data
to Google's API.

New approach: store the image in MitmStore, then the MITM request
modifier injects it as 'inlineData' directly into the last user
message's parts array in the Google API JSON request.

Flow:
  Client → Proxy (decode base64) → MitmStore.set_pending_image()
  LS → Google API → MITM intercepts → inject inlineData part
  → Google receives image + text together

This works for all three API endpoints (responses, completions,
gemini).
2026-02-15 17:57:32 -06:00
Nikketryhard
0a33c1b706 fix: send images as top-level ImageData field, not ChatMessage blob
SendUserCascadeMessageRequest proto field layout (from JS bundle analysis):
- Field 6 is 'images' (repeated ImageData) at the REQUEST level
- NOT a Blob sub-message inside ChatMessage (field 2)

ImageData proto uses base64_data (field 1) + mime_type (field 2),
not raw bytes. The LS was silently ignoring our ChatMessage blob
because the field structure didn't match.

Also protect MITM modifier from stripping messages containing
inlineData (image parts in Google API JSON).
2026-02-15 17:46:41 -06:00
Nikketryhard
2ac2016ed4 fix: resolve symlink in proxyctl before deriving PROJECT_DIR 2026-02-15 17:36:45 -06:00
Nikketryhard
976c44fdd4 feat: add image support across all endpoints (responses, completions, gemini) 2026-02-15 17:25:33 -06:00
Nikketryhard
ca9f808ee3 feat: completions API improvements, gemini endpoint, response types 2026-02-15 17:08:53 -06:00
Nikketryhard
afa96b88a5 chore: remove broken googleSearch grounding and /v1/search endpoint 2026-02-15 17:08:46 -06:00
Nikketryhard
cc5f48967a fix: LS cleanup uses sudo -u for same-UID kill, prevent double kill 2026-02-15 17:08:43 -06:00
Nikketryhard
b1bd57ab5e feat: forward generation params via MITM + add usageMetadata to Gemini
- Add GenerationParams struct to MitmStore for temperature, top_p,
  top_k, max_output_tokens, stop_sequences, frequency/presence_penalty
- MITM modify_request injects params into request.generationConfig
- All 3 endpoints (Completions, Responses, Gemini) store client params
- Add usageMetadata to Gemini sync responses (promptTokenCount,
  candidatesTokenCount, totalTokenCount, thoughtsTokenCount)
- Add generation param fields to GeminiRequest (temperature, topP, etc.)
- Completions stream_options.include_usage emits final usage chunk
- Completions reasoning_tokens in completion_tokens_details
- Update endpoint gap analysis doc (all high-priority gaps resolved)
2026-02-15 14:23:05 -06:00
Nikketryhard
735c3e357d chore: clean up dead code, fix broken test
- Remove unused methods: append_response_text, clear_response,
  has_pending_function_calls, take_function_calls
- Add #[allow(dead_code)] for intentionally kept future-use methods
  and response modification helpers
- Remove unused now_unix import from gemini.rs
- Fix test_modify_strips_all_tools: tools key is removed entirely
  when no custom tools provided, not left as empty array
- Zero warnings, 32 tests passing
2026-02-15 01:14:51 -06:00
Nikketryhard
981fb3b18d fix: resolve cascade correlation, update KNOWN_ISSUES
- MitmStore: added active_cascade_id field with set/get/clear methods
- record_usage() now falls back to active_cascade_id when the heuristic
  cascade hint is absent (fixes usage always going to _latest)
- All three API handlers set active cascade before send_message
- KNOWN_ISSUES: moved 3 issues to resolved:
  - Request modification (already true, was stale entry)
  - Cascade correlation (fixed via active_cascade_id)
  - Progressive thinking streaming (fixed via MITM bypass)
2026-02-15 01:10:34 -06:00
Nikketryhard
b3af73cebd feat: sync all endpoints with MITM LS bypass + real-time thinking streaming
- Responses API (streaming): MITM bypass path polls MitmStore directly
  when custom tools are active, skipping LS step polling entirely.
  Streams thinking text deltas in real-time as they arrive from the MITM.
  Handles function calls, text response, and thinking/reasoning events.

- Responses API (sync): Same MITM bypass for non-streaming responses.
  Polls MitmStore for function calls or completed text before falling
  back to LS path.

- Gemini endpoint: MITM bypass polls MitmStore directly for tool call
  responses, eliminating LS overhead.

- MitmStore: Added captured_thinking_text field with set/peek/take methods
  for real-time thinking text capture from MITM SSE.

- MITM proxy: Now captures both thinking_text and response_text from
  StreamingAccumulator into MitmStore when bypass mode is active.
2026-02-15 01:03:39 -06:00
Nikketryhard
50b53097bc fix: bypass LS entirely when custom tools are active
When custom tools are set, don't forward ANY response from Google
to the LS. Instead, capture text and function calls directly into
MitmStore. The completions handler reads from MitmStore.

This eliminates the LS multi-turn loop (5 requests, 30+ seconds)
that occurred because the LS kept processing responses internally.
Tool calls now return in ~1.3s instead of timing out.
2026-02-15 00:54:40 -06:00
Nikketryhard
ec1c0c700d fix: decouple function call detection from LS step polling
Move MitmStore function call check outside get_steps() block so tool
calls are detected immediately when captured by MITM, regardless of
LS processing state. Also reduce poll interval to 300ms.

The LS can take 20-30s for its internal multi-turn loop. Previously,
function call checks were nested inside the steps block and required
LS to have produced steps. Now the MITM capture is picked up within
300ms of detection.
2026-02-15 00:48:14 -06:00
Nikketryhard
4f08b994c7 fix: include tool results in conversation context
When OpenCode sends follow-up messages with tool results, include
the full conversation (user message, assistant tool calls, and tool
results) in the text sent to the model. Previously only the user
message was extracted, causing the model to never see tool results
and call the same tool repeatedly in an infinite loop.

Also add tool_calls and tool_call_id fields to CompletionMessage.
2026-02-15 00:42:43 -06:00
Nikketryhard
5d4125fa0d fix: suppress dummy text from tool call responses
Check for MITM-captured function calls BEFORE emitting text in the
streaming handler. This prevents the dummy 'Tool call completed'
placeholder (sent to the LS) from leaking to OpenCode, which was
confusing it into infinite loops.

Also removes duplicate function call storage at end of response loop
since they're now stored immediately when detected.
2026-02-15 00:37:39 -06:00
Nikketryhard
502318acec fix: store function calls in MitmStore immediately on detection
Previously, captured function calls were only stored in MitmStore
after the response loop ended. The completions handler polls
take_any_function_calls() during streaming, creating a race condition
where the MitmStore was empty.

Now function calls are stored immediately when parse_streaming_chunk
detects them, in both the initial body and body chunk paths.
2026-02-15 00:28:40 -06:00
Nikketryhard
40c6379ca1 fix: strip $schema and unsupported JSON Schema fields from tool params
Google's Gemini API rejects $schema, additionalProperties, $ref,
$defs, default, examples, and title in tool parameter schemas.
OpenCode/MCP tools include these standard JSON Schema fields.
Now recursively stripped during OpenAI→Gemini tool conversion.
2026-02-15 00:18:32 -06:00
Nikketryhard
7c44729ace fix: forge dummy STOP response to LS on functionCall capture
When the MITM detects a functionCall in Google's response AND custom
tools are active, send a forged clean text response to the LS instead
of the real one. This prevents the LS from seeing function calls for
tools it doesn't manage, eliminating the retry loop entirely.

The real function call data is captured in MitmStore and returned to
the client (OpenCode) through the completions handler.

Also removes the complex chunked-encoding response rewriting approach
in favor of this simpler forge-and-break strategy.
2026-02-15 00:15:00 -06:00
Nikketryhard
19ff784cae fix: always strip old functionCall/functionResponse from LS history
The function call stripping was only happening when no custom tools
were present. But even with custom tools injected, the LS history
contains functionCall/functionResponse parts for LS-internal tools
that we stripped, causing MALFORMED_FUNCTION_CALL. Now always strip
regardless of custom tools presence.
2026-02-14 23:59:13 -06:00
Nikketryhard
3303ce38de feat: add tool call support to chat completions endpoint
- Accept tools and tool_choice fields in CompletionRequest
- Convert OpenAI tools to Gemini format and store in MitmStore
- Detect MITM-captured function calls in streaming poll loop
- Emit tool_calls delta chunks in OpenAI streaming format
- Finish with 'tool_calls' reason instead of 'stop' when tools used
- Only clear tools when request has none (prevents stale state leak)
2026-02-14 23:47:23 -06:00
Nikketryhard
19090b79f0 fix: prevent MALFORMED_FUNCTION_CALL infinite retry loop
Root cause: after stripping LS tool definitions, two things remained:
1. toolConfig with mode=VALIDATED (forces function calling even with
   empty tools array)
2. Model's training/identity context causing it to attempt function
   calls in text

Fix:
- Remove empty tools array and toolConfig when no custom tools injected
- Strip functionCall/functionResponse parts from conversation history
- Append explicit 'no tools available' instruction to system prompt
- Remove debug dump code
2026-02-14 23:31:26 -06:00
Nikketryhard
a52d1bf475 fix: strip functionCall/functionResponse from history when no tools
When LS tools are stripped from the request but the conversation history
still contains functionCall/functionResponse parts referencing those
tools, Google returns MALFORMED_FUNCTION_CALL and the LS retries in an
infinite loop, causing the request to hang forever.

Now after stripping LS tools and confirming no custom tools are injected,
we also strip all functionCall/functionResponse parts from the history
and remove any messages that become empty as a result.
2026-02-14 23:19:28 -06:00
Nikketryhard
7e16a7b892 fix: clear stale tool state in completions handler to prevent hang
Tool definitions stored in MitmStore from /v1/responses requests were
persisting and getting injected into /v1/chat/completions requests.
This caused Gemini to return functionCalls instead of text, and since
the completions handler has no function call handling logic, it would
poll forever waiting for text that never came.

Fix: clear active_tools, active_tool_config, and has_active_function_call
at the start of handle_completions. Also add clear_active_function_call()
method to MitmStore.
2026-02-14 23:10:45 -06:00
Nikketryhard
786987116b feat: full tool call support (OpenAI + Gemini endpoints)
- store.rs: Add tool context storage (active tools, tool config, pending
  tool results, call_id mapping, last function calls for history rewrite)
- types.rs: Add tools/tool_choice fields to ResponsesRequest, add
  build_function_call_output helper for OpenAI function_call output items
- modify.rs: Replace hardcoded get_weather with dynamic ToolContext
  injection. Add openai_tools_to_gemini and openai_tool_choice_to_gemini
  converters. Add conversation history rewriting for tool result turns
  (replaces fake 'Tool call completed' model turn with real functionCall,
  injects functionResponse before last user turn)
- proxy.rs: Build ToolContext from MitmStore before calling modify_request.
  Save last_function_calls for history rewriting on subsequent turns
- responses.rs: Store client tools in MitmStore before LS call. Detect
  function_call_output in input array for tool result submission. Return
  captured functionCalls as OpenAI function_call output items with
  generated call_ids and stringified arguments
- gemini.rs: New Gemini-native endpoint (POST /v1/gemini) with zero
  format translation. Accepts functionDeclarations directly, returns
  functionCall in Gemini format directly
- mod.rs: Wire /v1/gemini route, bump version to 3.3.0
2026-02-14 22:56:44 -06:00
Nikketryhard
8455aa674f feat: capture function calls from Google + block follow-up quota waste
When MITM strips LS tools and injects custom tools:
- Google returns functionCall → captured in MitmStore
- Follow-up LS requests are blocked with fake SSE response
- Proxy consumes captured calls and clears the flag
- Result: 1 real Google API call instead of 5+ per tool call

Flow: Client → Proxy → LS → MITM(inject tool) → Google
      Google returns functionCall → MITM captures it
      LS tries follow-up → MITM blocks (fake response)
      Proxy reads captured functionCall → returns to client
2026-02-14 22:37:28 -06:00
Nikketryhard
146be139a2 fix: re-enable tool stripping after testing
With tools present, LS enters full agentic mode doing multi-turn
tool calls (file searches, terminal commands, etc.). A simple
weather question caused 40+ Google API calls in 120s before timeout.
Tool stripping is required to maintain single-turn behavior.
2026-02-14 22:18:02 -06:00
Nikketryhard
3e3af85798 feat: add proxyctl daemon manager, fix standalone LS cleanup
- Add proxyctl CLI script for systemd service management
- Add systemd user service file for background operation
- Fix standalone LS kill: properly track real LS PID via pgrep
  and use sudo kill for cross-user cleanup on shutdown
- Remove deprecated scripts (dns-redirect, iptables-redirect,
  mitm-wrapper, standalone-ls, parse-snapshot)
- Disable tool stripping in MITM for tool call investigation
- Update GEMINI.md with CLI tools documentation
2026-02-14 22:14:00 -06:00
Nikketryhard
f64f007421 fix: reduce GetCascadeTrajectory log spam from debug to trace 2026-02-14 21:43:36 -06:00
Nikketryhard
940786c57f docs: update standalone LS, MITM, and panel stream investigation
- Add panel-stream-investigation.md documenting dead end
- Update KNOWN_ISSUES: move polling and panel stream to resolved
- Update GEMINI.md with standalone LS section and new MITM setup
- Fix standalone-ls-todo to reflect default mode
2026-02-14 21:40:35 -06:00
Nikketryhard
b965be3f60 feat: add reactive streaming and remove dead panel stream code
- Subscribe to StreamCascadeReactiveUpdates for real-time cascade state diffs
- Fall back to timer-based polling if streaming RPC unavailable
- Remove StreamCascadePanelReactiveUpdates code (dead end, only has plan_status/user_settings)
- Remove debug diff file-saving code
- Add stream_reactive_rpc() helper to backend
2026-02-14 21:39:04 -06:00
Nikketryhard
3d7a7f492b fix: reduce poll intervals for smoother streaming
Streaming poll: 800-1200ms → 150-250ms (5x faster)
Sync poll: 1000-1800ms → 200-400ms (4x faster)

Verified via STEP_DUMP instrumentation that the LS updates
plannerResponse.response incrementally during GENERATING status,
so faster polling yields smoother progressive text delivery.

Also restructured streaming to emit reasoning events first
when thinking content is detected in LS steps before response text.
2026-02-14 20:34:37 -06:00
Nikketryhard
b1a089d21d feat: emit streaming reasoning events per OpenAI spec
Adds proper streaming SSE events for reasoning content:
- response.output_item.added (reasoning)
- response.reasoning_summary_part.added
- response.reasoning_summary_text.delta
- response.reasoning_summary_text.done
- response.reasoning_summary_part.done
- response.output_item.done (reasoning)

These are emitted before the message events, matching the format
that OpenAI-compatible clients expect for displaying thinking content.
2026-02-14 19:57:52 -06:00
Nikketryhard
5c1f4c77d9 fix: add retry logic for MITM thinking text merge race condition
The LS makes two Google API calls for thinking models. Call 2 (thinking
summary) may not have arrived by the time usage_from_poll runs after
Call 1 (response). Now we peek first, and if thinking tokens exist but
text is missing, wait up to 1s for the merge to happen.

Also adds peek_usage method to MitmStore for non-consuming reads.
2026-02-14 19:54:37 -06:00
Nikketryhard
34b9553484 feat: capture thinking text via MITM dual-call merge
The LS makes TWO separate Google API calls for thinking models:
  Call 1: response + thinking token count (no thinking text)
  Call 2: thinking summary text (no thinking tokens)

Each hits a different StreamingAccumulator, so we:
1. Capture response_text in StreamingAccumulator (non-thinking parts)
2. In MitmStore::record_usage, detect when Call 2 arrives for a
   cascade that already has thinking tokens from Call 1
3. Merge Call 2's response_text as thinking_text on Call 1's usage

Also injects includeThoughts into Google API requests via MITM
modify to ensure thinking text is available in SSE responses.
2026-02-14 19:49:15 -06:00
Nikketryhard
905d55beb5 feat: capture thinking text from MITM-intercepted API responses
The LS strips thinking/reasoning text from plannerResponse steps —
only the thinkingSignature (opaque verification blob) is preserved.
The actual thinking text flows through the MITM proxy in the raw
Google SSE response (parts with thought: true) and Anthropic SSE
(thinking_delta content blocks).

Changes:
- StreamingAccumulator now accumulates thinking text from SSE events
- ApiUsage gains thinking_text: Option<String>
- usage_from_poll returns (Usage, Option<thinking_text>)
- Thinking text priority: MITM-captured > LS-extracted (fallback)
- Reasoning output item now populated from real API data
- Removed debug dump code
2026-02-14 19:30:09 -06:00
Nikketryhard
19dc920872 fix: return thinking as reasoning output item per OpenAI spec
Thinking content was previously returned as non-standard top-level
fields (thinking, thinking_duration). Now follows the official OpenAI
Responses API format:

- Reasoning appears as a 'type: reasoning' item in the output array
  with summary[].text containing the thinking content
- Message item follows after the reasoning item
- thinking_signature kept as proxy extension (internal multi-turn data)
- Removed ResponseOutput/OutputContent structs in favor of
  serde_json::Value for polymorphic output items
2026-02-14 19:16:12 -06:00
Nikketryhard
7c4e781900 feat: aggressive request stripping — keep only identity + conversation
Strip everything from intercepted LLM requests except:
- <identity> section in system instruction
- Actual conversation turns (user messages + model responses)

Removed: tool_calling, web_app_dev, knowledge_discovery,
persistent_context, skills, ephemeral_message, communication_style,
user_information, user_rules, MEMORY, workflows, mcp_servers,
conversation_summaries, ADDITIONAL_METADATA, Step Id prefixes.

Expected reduction: ~92% (63KB → ~5KB for simple requests).
2026-02-14 19:05:49 -06:00
Nikketryhard
1a7c81e5f9 feat: strip ALL tools from intercepted requests by default
Tools are only needed by the Antigravity webview for tool-call UI.
Our proxy doesn't need them — the model generates text responses fine
without tool definitions. Stripping all 20 tools saves ~15KB per request.
2026-02-14 18:53:38 -06:00