@miller-tech/uap 1.76.5 → 1.77.0

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.
@@ -189,6 +189,23 @@ PROXY_TOOL_STATE_FINALIZE_THRESHOLD = int(
189
189
  PROXY_TOOL_STATE_REVIEW_CYCLE_LIMIT = int(
190
190
  os.environ.get("PROXY_TOOL_STATE_REVIEW_CYCLE_LIMIT", "3")
191
191
  )
192
+ # #4 Coordination no-op suppression. Pure-bookkeeping tools (task/board status
193
+ # updates) are never productive to repeat, so they are banned after fewer cycle
194
+ # detections than the generic per-tool threshold. This targets the 35B-A3B
195
+ # "update the task instead of doing the actual work" loop (observed live:
196
+ # cycling_tools=['TaskUpdate']). Tunable via env; set the threshold to 0 to
197
+ # disable the faster ban and fall back to the generic threshold.
198
+ PROXY_COORDINATION_TOOLS = {
199
+ t.strip()
200
+ for t in os.environ.get(
201
+ "PROXY_COORDINATION_TOOLS",
202
+ "TaskUpdate,TaskCreate,TaskList,TaskGet,TaskOutput",
203
+ ).split(",")
204
+ if t.strip()
205
+ }
206
+ PROXY_COORDINATION_BAN_THRESHOLD = int(
207
+ os.environ.get("PROXY_COORDINATION_BAN_THRESHOLD", "2")
208
+ )
192
209
  # Force finalize after N consecutive forced_budget_exhausted events where
193
210
  # neither cycling nor stagnation was detected — catches "distinct but
194
211
  # unproductive" tool spam that defeats per-tool cycle detection.
@@ -498,6 +515,20 @@ PROXY_FORCE_NON_STREAM = os.environ.get(
498
515
  "off",
499
516
  "no",
500
517
  }
518
+ # Streaming keep-alive heartbeat (seconds) for the guarded-non-stream path.
519
+ # That path buffers the ENTIRE upstream generation before emitting any SSE
520
+ # bytes, so a long generation (e.g. a 28k-token runaway taking ~14 min at
521
+ # depth-slowed decode) sends the client nothing for the whole wait and the
522
+ # client's streaming idle-timeout fires -> "API Error". When > 0, the proxy
523
+ # emits an immediate `message_start` then periodic `ping` events to the client
524
+ # while it awaits+guards the buffered upstream response, keeping the connection
525
+ # alive; the buffered content is streamed once ready. 0 disables (old behavior).
526
+ try:
527
+ PROXY_STREAM_HEARTBEAT_SECS = float(
528
+ os.environ.get("PROXY_STREAM_HEARTBEAT_SECS", "0")
529
+ )
530
+ except ValueError:
531
+ PROXY_STREAM_HEARTBEAT_SECS = 0.0
501
532
  PROXY_FORCED_TOOL_DAMPENER = os.environ.get(
502
533
  "PROXY_FORCED_TOOL_DAMPENER", "on"
503
534
  ).lower() not in {
@@ -3648,15 +3679,26 @@ def _resolve_state_machine_tool_choice(
3648
3679
  for part in fp.split("|"):
3649
3680
  raw_names.append(part.split(":")[0])
3650
3681
  monitor.cycling_tool_names = list(dict.fromkeys(raw_names))
3651
- # Cycle 18 Option 2: track per-tool cycle counts and ban after 3 cycles
3682
+ # Cycle 18 Option 2: track per-tool cycle counts and ban after N cycles.
3683
+ # #4: coordination/bookkeeping tools (TaskUpdate etc.) are banned
3684
+ # faster (PROXY_COORDINATION_BAN_THRESHOLD) since repeating them is
3685
+ # never productive; other tools keep the generic threshold of 3.
3652
3686
  for name in monitor.cycling_tool_names:
3653
3687
  monitor.tool_cycle_counts[name] = monitor.tool_cycle_counts.get(name, 0) + 1
3654
- if monitor.tool_cycle_counts[name] >= 3 and name not in monitor.session_banned_tools:
3688
+ is_coord = (
3689
+ name in PROXY_COORDINATION_TOOLS
3690
+ and PROXY_COORDINATION_BAN_THRESHOLD > 0
3691
+ )
3692
+ ban_at = PROXY_COORDINATION_BAN_THRESHOLD if is_coord else 3
3693
+ if monitor.tool_cycle_counts[name] >= ban_at and name not in monitor.session_banned_tools:
3655
3694
  monitor.session_banned_tools.add(name)
3656
3695
  logger.warning(
3657
- "TOOL BAN: '%s' banned for session after %d cycle detections",
3696
+ "TOOL BAN: '%s' banned for session after %d cycle detections "
3697
+ "(threshold=%d%s)",
3658
3698
  name,
3659
3699
  monitor.tool_cycle_counts[name],
3700
+ ban_at,
3701
+ ", coordination" if is_coord else "",
3660
3702
  )
3661
3703
  logger.warning(
3662
3704
  "TOOL STATE MACHINE: entering review (cycle=%s repeat=%d stagnation=%d cycles=%d cycling_tools=%s)",
@@ -7838,19 +7880,84 @@ def openai_to_anthropic_response(
7838
7880
  }
7839
7881
 
7840
7882
 
7841
- async def stream_anthropic_message(anthropic_resp: dict):
7842
- """Stream a finalized Anthropic message as SSE events."""
7843
- message = {
7844
- "id": anthropic_resp.get("id", f"msg_{uuid.uuid4().hex[:24]}"),
7845
- "type": "message",
7846
- "role": "assistant",
7847
- "content": [],
7848
- "model": anthropic_resp.get("model", "unknown"),
7849
- "stop_reason": None,
7850
- "stop_sequence": None,
7851
- "usage": {"input_tokens": 0, "output_tokens": 0},
7852
- }
7853
- yield f"event: message_start\ndata: {json.dumps({'type': 'message_start', 'message': message})}\n\n"
7883
+ async def _heartbeat_then_buffered(produce_coro, model: str):
7884
+ """SSE generator: keep-alive heartbeat wrapper for the guarded-non-stream path.
7885
+
7886
+ Emits an immediate ``message_start`` so the client registers an active
7887
+ stream, then ``ping`` events every PROXY_STREAM_HEARTBEAT_SECS while
7888
+ ``produce_coro`` (which awaits + guards the buffered upstream response) runs,
7889
+ then streams the buffered content. Keeps the connection alive through long
7890
+ buffered generations so the client's streaming idle-timeout does not fire.
7891
+
7892
+ ``produce_coro`` resolves to EITHER the finalized Anthropic response dict OR
7893
+ a Starlette ``Response`` (the guarded path's error returns). Since the stream
7894
+ has already committed to HTTP 200, an error Response is re-emitted as an SSE
7895
+ ``error`` event rather than an HTTP status.
7896
+ """
7897
+ msg_id = f"msg_{uuid.uuid4().hex[:24]}"
7898
+ yield (
7899
+ f"event: message_start\n"
7900
+ f"data: {json.dumps({'type': 'message_start', 'message': {'id': msg_id, 'type': 'message', 'role': 'assistant', 'content': [], 'model': model, 'stop_reason': None, 'stop_sequence': None, 'usage': {'input_tokens': 0, 'output_tokens': 0}}})}\n\n"
7901
+ )
7902
+ interval = PROXY_STREAM_HEARTBEAT_SECS if PROXY_STREAM_HEARTBEAT_SECS > 0 else 15.0
7903
+ task = asyncio.ensure_future(produce_coro)
7904
+ try:
7905
+ while True:
7906
+ try:
7907
+ # shield keeps the produce task alive across ping timeouts;
7908
+ # only the wait_for wrapper is cancelled on each TimeoutError.
7909
+ produced = await asyncio.wait_for(asyncio.shield(task), timeout=interval)
7910
+ break
7911
+ except asyncio.TimeoutError:
7912
+ yield 'event: ping\ndata: {"type": "ping"}\n\n'
7913
+ except asyncio.CancelledError:
7914
+ # Client disconnected — cancel the in-flight produce and propagate.
7915
+ task.cancel()
7916
+ raise
7917
+ except Exception as exc:
7918
+ logger.error("heartbeat produce failed: %s", exc)
7919
+ yield (
7920
+ "event: error\n"
7921
+ f"data: {json.dumps({'type': 'error', 'error': {'type': 'api_error', 'message': str(exc)[:500]}})}\n\n"
7922
+ )
7923
+ return
7924
+
7925
+ if isinstance(produced, Response):
7926
+ # Guarded path returned an error Response; re-emit as an SSE error event.
7927
+ try:
7928
+ payload = json.loads(bytes(produced.body).decode("utf-8"))
7929
+ except Exception:
7930
+ payload = {
7931
+ "type": "error",
7932
+ "error": {"type": "overloaded_error", "message": "Upstream error"},
7933
+ }
7934
+ yield f"event: error\ndata: {json.dumps(payload)}\n\n"
7935
+ return
7936
+
7937
+ # produced is the finalized Anthropic response dict — stream its content
7938
+ # without a second message_start (already sent above).
7939
+ async for chunk in stream_anthropic_message(produced, emit_message_start=False):
7940
+ yield chunk
7941
+
7942
+
7943
+ async def stream_anthropic_message(anthropic_resp: dict, emit_message_start: bool = True):
7944
+ """Stream a finalized Anthropic message as SSE events.
7945
+
7946
+ emit_message_start=False skips the leading message_start event for callers
7947
+ (the heartbeat wrapper) that have already emitted one to start the stream.
7948
+ """
7949
+ if emit_message_start:
7950
+ message = {
7951
+ "id": anthropic_resp.get("id", f"msg_{uuid.uuid4().hex[:24]}"),
7952
+ "type": "message",
7953
+ "role": "assistant",
7954
+ "content": [],
7955
+ "model": anthropic_resp.get("model", "unknown"),
7956
+ "stop_reason": None,
7957
+ "stop_sequence": None,
7958
+ "usage": {"input_tokens": 0, "output_tokens": 0},
7959
+ }
7960
+ yield f"event: message_start\ndata: {json.dumps({'type': 'message_start', 'message': message})}\n\n"
7854
7961
 
7855
7962
  content_blocks = anthropic_resp.get("content", []) or [{"type": "text", "text": ""}]
7856
7963
  block_index = 0
@@ -8528,88 +8635,108 @@ async def messages(request: Request):
8528
8635
  openai_body,
8529
8636
  )
8530
8637
  if use_guarded_non_stream:
8531
- strict_body = dict(openai_body)
8532
- strict_body["stream"] = False
8638
+ async def _produce_guarded():
8639
+ strict_body = dict(openai_body)
8640
+ strict_body["stream"] = False
8533
8641
 
8534
- try:
8535
- strict_resp = await _post_with_generation_timeout(
8536
- client,
8537
- f"{LLAMA_CPP_BASE}/chat/completions",
8538
- strict_body,
8539
- {"Content-Type": "application/json"},
8540
- )
8541
- except Exception as exc:
8542
- # Check if upstream is hung before returning error
8543
- await _check_slot_hang(LLAMA_CPP_BASE.replace("/v1", "/slots"))
8544
- return Response(
8545
- content=json.dumps(
8546
- {
8547
- "type": "error",
8548
- "error": {
8549
- "type": "overloaded_error",
8550
- "message": f"Upstream server unavailable after {PROXY_UPSTREAM_RETRY_MAX} retries: {exc}",
8551
- },
8552
- }
8553
- ),
8554
- status_code=529,
8555
- media_type="application/json",
8556
- )
8642
+ try:
8643
+ strict_resp = await _post_with_generation_timeout(
8644
+ client,
8645
+ f"{LLAMA_CPP_BASE}/chat/completions",
8646
+ strict_body,
8647
+ {"Content-Type": "application/json"},
8648
+ )
8649
+ except Exception as exc:
8650
+ # Check if upstream is hung before returning error
8651
+ await _check_slot_hang(LLAMA_CPP_BASE.replace("/v1", "/slots"))
8652
+ return Response(
8653
+ content=json.dumps(
8654
+ {
8655
+ "type": "error",
8656
+ "error": {
8657
+ "type": "overloaded_error",
8658
+ "message": f"Upstream server unavailable after {PROXY_UPSTREAM_RETRY_MAX} retries: {exc}",
8659
+ },
8660
+ }
8661
+ ),
8662
+ status_code=529,
8663
+ media_type="application/json",
8664
+ )
8557
8665
 
8558
- if strict_resp.status_code != 200:
8559
- error_text = strict_resp.text[:1000]
8560
- # Try the Gemma 4 PEG parse-failure recovery first — relax
8561
- # tool_choice='required' so the retry isn't constrained by the
8562
- # strict-grammar that triggered the parse failure.
8563
- relaxed = _is_gemma4_peg_parse_failure(strict_resp.status_code, error_text) and \
8564
- _relax_tool_choice_for_gemma4_peg_retry(strict_body, "strict-stream")
8565
- if relaxed:
8566
- try:
8567
- strict_resp = await _post_with_generation_timeout(
8568
- client,
8569
- f"{LLAMA_CPP_BASE}/chat/completions",
8570
- strict_body,
8571
- {"Content-Type": "application/json"},
8572
- )
8573
- except Exception:
8574
- pass # fall through to next handler
8575
- if strict_resp.status_code != 200:
8576
- error_text = strict_resp.text[:1000]
8577
- if _maybe_disable_grammar_for_tools_error(
8578
- strict_body,
8579
- strict_resp.status_code,
8580
- error_text,
8581
- "strict-stream",
8582
- ):
8583
- try:
8584
- strict_resp = await _post_with_generation_timeout(
8585
- client,
8586
- f"{LLAMA_CPP_BASE}/chat/completions",
8587
- strict_body,
8588
- {"Content-Type": "application/json"},
8666
+ if strict_resp.status_code != 200:
8667
+ error_text = strict_resp.text[:1000]
8668
+ # Try the Gemma 4 PEG parse-failure recovery first — relax
8669
+ # tool_choice='required' so the retry isn't constrained by the
8670
+ # strict-grammar that triggered the parse failure.
8671
+ relaxed = _is_gemma4_peg_parse_failure(strict_resp.status_code, error_text) and \
8672
+ _relax_tool_choice_for_gemma4_peg_retry(strict_body, "strict-stream")
8673
+ if relaxed:
8674
+ try:
8675
+ strict_resp = await _post_with_generation_timeout(
8676
+ client,
8677
+ f"{LLAMA_CPP_BASE}/chat/completions",
8678
+ strict_body,
8679
+ {"Content-Type": "application/json"},
8680
+ )
8681
+ except Exception:
8682
+ pass # fall through to next handler
8683
+ if strict_resp.status_code != 200:
8684
+ error_text = strict_resp.text[:1000]
8685
+ if _maybe_disable_grammar_for_tools_error(
8686
+ strict_body,
8687
+ strict_resp.status_code,
8688
+ error_text,
8689
+ "strict-stream",
8690
+ ):
8691
+ try:
8692
+ strict_resp = await _post_with_generation_timeout(
8693
+ client,
8694
+ f"{LLAMA_CPP_BASE}/chat/completions",
8695
+ strict_body,
8696
+ {"Content-Type": "application/json"},
8697
+ )
8698
+ except Exception as exc:
8699
+ return Response(
8700
+ content=json.dumps(
8701
+ {
8702
+ "type": "error",
8703
+ "error": {
8704
+ "type": "overloaded_error",
8705
+ "message": f"Upstream server unavailable after {PROXY_UPSTREAM_RETRY_MAX} retries: {exc}",
8706
+ },
8707
+ }
8708
+ ),
8709
+ status_code=529,
8710
+ media_type="application/json",
8711
+ )
8712
+
8713
+ if strict_resp.status_code != 200:
8714
+ error_text = strict_resp.text[:1000]
8715
+ # Cycle 19 Option 2: For 503 "Loading model", don't advance state
8716
+ # machine — return retriable 503 with Retry-After header so the
8717
+ # client can retry without wasting state machine budget.
8718
+ if _is_loading_model_503(strict_resp):
8719
+ logger.warning(
8720
+ "Upstream 503 Loading model (strict-stream) — returning retriable 503 without advancing state",
8589
8721
  )
8590
- except Exception as exc:
8591
8722
  return Response(
8592
8723
  content=json.dumps(
8593
8724
  {
8594
8725
  "type": "error",
8595
8726
  "error": {
8596
8727
  "type": "overloaded_error",
8597
- "message": f"Upstream server unavailable after {PROXY_UPSTREAM_RETRY_MAX} retries: {exc}",
8728
+ "message": "Upstream model is loading. Retry in 10 seconds.",
8598
8729
  },
8599
8730
  }
8600
8731
  ),
8601
- status_code=529,
8732
+ status_code=503,
8733
+ headers={"Retry-After": "10"},
8602
8734
  media_type="application/json",
8603
8735
  )
8604
-
8605
- if strict_resp.status_code != 200:
8606
- error_text = strict_resp.text[:1000]
8607
- # Cycle 19 Option 2: For 503 "Loading model", don't advance state
8608
- # machine — return retriable 503 with Retry-After header so the
8609
- # client can retry without wasting state machine budget.
8610
- if _is_loading_model_503(strict_resp):
8611
- logger.warning(
8612
- "Upstream 503 Loading model (strict-stream) — returning retriable 503 without advancing state",
8736
+ logger.error(
8737
+ "Upstream HTTP %d (strict-stream): %s",
8738
+ strict_resp.status_code,
8739
+ error_text,
8613
8740
  )
8614
8741
  return Response(
8615
8742
  content=json.dumps(
@@ -8617,126 +8744,122 @@ async def messages(request: Request):
8617
8744
  "type": "error",
8618
8745
  "error": {
8619
8746
  "type": "overloaded_error",
8620
- "message": "Upstream model is loading. Retry in 10 seconds.",
8747
+ "message": f"Upstream error (HTTP {strict_resp.status_code}): {error_text[:500]}",
8621
8748
  },
8622
8749
  }
8623
8750
  ),
8624
- status_code=503,
8625
- headers={"Retry-After": "10"},
8751
+ status_code=529,
8626
8752
  media_type="application/json",
8627
8753
  )
8628
- logger.error(
8629
- "Upstream HTTP %d (strict-stream): %s",
8630
- strict_resp.status_code,
8631
- error_text,
8754
+
8755
+ openai_resp = strict_resp.json()
8756
+ # Recover tool calls from <tool_call> XML before guardrails run
8757
+ _maybe_extract_text_tool_calls(
8758
+ openai_resp,
8759
+ anthropic_tools=body.get("tools"),
8760
+ suppress=monitor.suppress_text_tool_extraction,
8632
8761
  )
8633
- return Response(
8634
- content=json.dumps(
8635
- {
8636
- "type": "error",
8637
- "error": {
8638
- "type": "overloaded_error",
8639
- "message": f"Upstream error (HTTP {strict_resp.status_code}): {error_text[:500]}",
8640
- },
8641
- }
8642
- ),
8643
- status_code=529,
8644
- media_type="application/json",
8762
+ openai_resp = await _apply_unexpected_end_turn_guardrail(
8763
+ client,
8764
+ openai_resp,
8765
+ strict_body,
8766
+ body,
8767
+ monitor,
8768
+ session_id,
8769
+ )
8770
+ openai_resp = await _apply_malformed_tool_guardrail(
8771
+ client,
8772
+ openai_resp,
8773
+ strict_body,
8774
+ body,
8775
+ monitor,
8776
+ session_id,
8645
8777
  )
8646
8778
 
8647
- openai_resp = strict_resp.json()
8648
- # Recover tool calls from <tool_call> XML before guardrails run
8649
- _maybe_extract_text_tool_calls(
8650
- openai_resp,
8651
- anthropic_tools=body.get("tools"),
8652
- suppress=monitor.suppress_text_tool_extraction,
8653
- )
8654
- openai_resp = await _apply_unexpected_end_turn_guardrail(
8655
- client,
8656
- openai_resp,
8657
- strict_body,
8658
- body,
8659
- monitor,
8660
- session_id,
8661
- )
8662
- openai_resp = await _apply_malformed_tool_guardrail(
8663
- client,
8664
- openai_resp,
8665
- strict_body,
8666
- body,
8667
- monitor,
8668
- session_id,
8669
- )
8670
-
8671
- openai_resp, was_degenerate = _detect_and_truncate_degenerate_repetition(openai_resp)
8672
- if was_degenerate:
8673
- # Retry with constrained parameters to avoid degenerate output.
8674
- # With tools: force tool_choice=required for a useful tool call.
8675
- # Without tools (finalize): retry with capped max_tokens for clean text.
8676
- has_tools = bool(strict_body.get("tools"))
8677
- retry_body = dict(strict_body)
8678
- retry_body["max_tokens"] = 2048
8679
- retry_body["temperature"] = 0.1
8680
- retry_body["stream"] = False
8681
- if has_tools:
8682
- retry_body["tool_choice"] = "required"
8683
- logger.warning("DEGENERATE RETRY: retrying with tool_choice=required max_tokens=2048")
8779
+ openai_resp, was_degenerate = _detect_and_truncate_degenerate_repetition(openai_resp)
8780
+ if was_degenerate:
8781
+ # Retry with constrained parameters to avoid degenerate output.
8782
+ # With tools: force tool_choice=required for a useful tool call.
8783
+ # Without tools (finalize): retry with capped max_tokens for clean text.
8784
+ has_tools = bool(strict_body.get("tools"))
8785
+ retry_body = dict(strict_body)
8786
+ retry_body["max_tokens"] = 2048
8787
+ retry_body["temperature"] = 0.1
8788
+ retry_body["stream"] = False
8789
+ if has_tools:
8790
+ retry_body["tool_choice"] = "required"
8791
+ logger.warning("DEGENERATE RETRY: retrying with tool_choice=required max_tokens=2048")
8792
+ else:
8793
+ logger.warning("DEGENERATE RETRY: retrying text-only with max_tokens=2048 temp=0.1")
8794
+ try:
8795
+ retry_resp = await _post_with_generation_timeout(
8796
+ client, f"{LLAMA_CPP_BASE}/chat/completions", retry_body,
8797
+ {"Content-Type": "application/json"},
8798
+ )
8799
+ if retry_resp.status_code == 200:
8800
+ retry_data = retry_resp.json()
8801
+ retry_text = _openai_message_text(retry_data)
8802
+ _, retry_degenerate = _detect_and_truncate_degenerate_repetition(retry_data)
8803
+ if retry_degenerate:
8804
+ logger.info("DEGENERATE RETRY: retry also degenerate, using truncated original")
8805
+ elif has_tools and (retry_data.get("choices", [{}])[0]
8806
+ .get("message", {}).get("tool_calls")):
8807
+ logger.info("DEGENERATE RETRY: success, got tool call")
8808
+ openai_resp = retry_data
8809
+ elif not has_tools and retry_text and len(retry_text) > 50:
8810
+ logger.info("DEGENERATE RETRY: success, got clean text (%d chars)", len(retry_text))
8811
+ openai_resp = retry_data
8812
+ else:
8813
+ logger.info("DEGENERATE RETRY: retry insufficient, using truncated original")
8814
+ except Exception as exc:
8815
+ logger.warning("DEGENERATE RETRY: failed: %s", exc)
8816
+ anthropic_resp = openai_to_anthropic_response(
8817
+ openai_resp, model,
8818
+ expose_thinking=isinstance(body.get("thinking"), dict)
8819
+ and (body["thinking"].get("type") or "").lower() == "enabled",
8820
+ suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
8821
+ )
8822
+ _maybe_normalize_toolcall_paths(anthropic_resp, body)
8823
+ # FINALIZE CONTINUATION: inject synthetic tool_use to keep client loop alive
8824
+ if (
8825
+ monitor.finalize_turn_active
8826
+ and monitor.finalize_continuation_count < PROXY_FINALIZE_CONTINUATION_MAX
8827
+ and anthropic_resp.get("stop_reason") == "end_turn"
8828
+ ):
8829
+ anthropic_resp = _inject_synthetic_continuation(anthropic_resp, monitor, body)
8830
+ monitor.record_response(anthropic_resp.get("usage", {}).get("output_tokens", 0))
8831
+ # Update last_input_tokens from upstream's actual prompt_tokens
8832
+ upstream_input = anthropic_resp.get("usage", {}).get("input_tokens", 0)
8833
+ if upstream_input > 0:
8834
+ monitor.last_input_tokens = upstream_input
8835
+ if PROXY_FORCE_NON_STREAM:
8836
+ logger.info(
8837
+ "FORCED NON-STREAM: served stream response via guarded non-stream path"
8838
+ )
8839
+ elif PROXY_MALFORMED_TOOL_STREAM_STRICT and _has_tool_definitions(body):
8840
+ logger.info(
8841
+ "STRICT STREAM GUARDRAIL: served stream response via guarded non-stream path"
8842
+ )
8684
8843
  else:
8685
- logger.warning("DEGENERATE RETRY: retrying text-only with max_tokens=2048 temp=0.1")
8686
- try:
8687
- retry_resp = await _post_with_generation_timeout(
8688
- client, f"{LLAMA_CPP_BASE}/chat/completions", retry_body,
8689
- {"Content-Type": "application/json"},
8844
+ logger.info(
8845
+ "REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path"
8690
8846
  )
8691
- if retry_resp.status_code == 200:
8692
- retry_data = retry_resp.json()
8693
- retry_text = _openai_message_text(retry_data)
8694
- _, retry_degenerate = _detect_and_truncate_degenerate_repetition(retry_data)
8695
- if retry_degenerate:
8696
- logger.info("DEGENERATE RETRY: retry also degenerate, using truncated original")
8697
- elif has_tools and (retry_data.get("choices", [{}])[0]
8698
- .get("message", {}).get("tool_calls")):
8699
- logger.info("DEGENERATE RETRY: success, got tool call")
8700
- openai_resp = retry_data
8701
- elif not has_tools and retry_text and len(retry_text) > 50:
8702
- logger.info("DEGENERATE RETRY: success, got clean text (%d chars)", len(retry_text))
8703
- openai_resp = retry_data
8704
- else:
8705
- logger.info("DEGENERATE RETRY: retry insufficient, using truncated original")
8706
- except Exception as exc:
8707
- logger.warning("DEGENERATE RETRY: failed: %s", exc)
8708
- anthropic_resp = openai_to_anthropic_response(
8709
- openai_resp, model,
8710
- expose_thinking=isinstance(body.get("thinking"), dict)
8711
- and (body["thinking"].get("type") or "").lower() == "enabled",
8712
- suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
8713
- )
8714
- _maybe_normalize_toolcall_paths(anthropic_resp, body)
8715
- # FINALIZE CONTINUATION: inject synthetic tool_use to keep client loop alive
8716
- if (
8717
- monitor.finalize_turn_active
8718
- and monitor.finalize_continuation_count < PROXY_FINALIZE_CONTINUATION_MAX
8719
- and anthropic_resp.get("stop_reason") == "end_turn"
8720
- ):
8721
- anthropic_resp = _inject_synthetic_continuation(anthropic_resp, monitor, body)
8722
- monitor.record_response(anthropic_resp.get("usage", {}).get("output_tokens", 0))
8723
- # Update last_input_tokens from upstream's actual prompt_tokens
8724
- upstream_input = anthropic_resp.get("usage", {}).get("input_tokens", 0)
8725
- if upstream_input > 0:
8726
- monitor.last_input_tokens = upstream_input
8727
- if PROXY_FORCE_NON_STREAM:
8728
- logger.info(
8729
- "FORCED NON-STREAM: served stream response via guarded non-stream path"
8730
- )
8731
- elif PROXY_MALFORMED_TOOL_STREAM_STRICT and _has_tool_definitions(body):
8732
- logger.info(
8733
- "STRICT STREAM GUARDRAIL: served stream response via guarded non-stream path"
8734
- )
8735
- else:
8736
- logger.info(
8737
- "REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path"
8738
- )
8739
8847
 
8848
+ return anthropic_resp
8849
+
8850
+ if PROXY_STREAM_HEARTBEAT_SECS > 0:
8851
+ return StreamingResponse(
8852
+ _heartbeat_then_buffered(_produce_guarded(), model),
8853
+ media_type="text/event-stream",
8854
+ headers={
8855
+ "Cache-Control": "no-cache",
8856
+ "Connection": "keep-alive",
8857
+ },
8858
+ )
8859
+ _produced = await _produce_guarded()
8860
+ if isinstance(_produced, Response):
8861
+ return _produced
8862
+ anthropic_resp = _produced
8740
8863
  return StreamingResponse(
8741
8864
  stream_anthropic_message(anthropic_resp),
8742
8865
  media_type="text/event-stream",
@@ -0,0 +1,68 @@
1
+ """Tests for #4: coordination-tool no-op loop suppression (faster ban).
2
+
3
+ A pure-bookkeeping tool that cycles (e.g. TaskUpdate) is banned after
4
+ PROXY_COORDINATION_BAN_THRESHOLD (2) cycle detections, while a generic tool
5
+ keeps the original threshold of 3. Banning removes the tool from the offered
6
+ set, physically breaking the "update the task instead of doing the work" loop.
7
+ """
8
+ import importlib.util
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
13
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
14
+ ap = importlib.util.module_from_spec(spec)
15
+ spec.loader.exec_module(ap)
16
+
17
+
18
+ def _cycling_body(tool="TaskUpdate"):
19
+ # >= PROXY_TOOL_STATE_MIN_MESSAGES (6) messages, last user message carries a
20
+ # tool_result -> active agentic loop.
21
+ msgs = [{"role": "user", "content": "do the task"}]
22
+ for i in range(3):
23
+ msgs.append({"role": "assistant", "content": [
24
+ {"type": "tool_use", "id": f"t{i}", "name": tool, "input": {}}]})
25
+ msgs.append({"role": "user", "content": [
26
+ {"type": "tool_result", "tool_use_id": f"t{i}", "content": "ok"}]})
27
+ return {"messages": msgs}
28
+
29
+
30
+ def _drive_one_cycle(monitor, tool):
31
+ """Put the monitor in act-phase with a detected cycle of `tool` and run the
32
+ state-machine resolver once (which runs the ban loop)."""
33
+ fp = f"{tool}:deadbeef"
34
+ monitor.tool_turn_phase = "act"
35
+ monitor.tool_state_forced_budget_remaining = 5
36
+ monitor.tool_state_review_cycles = 0
37
+ win = max(2, ap.PROXY_TOOL_STATE_CYCLE_WINDOW)
38
+ monitor.tool_call_history = [fp] * (win + 2)
39
+ monitor.last_tool_fingerprint = fp
40
+ monitor.tool_state_stagnation_streak = max(1, ap.PROXY_TOOL_STATE_STAGNATION_THRESHOLD)
41
+ ap._resolve_state_machine_tool_choice(
42
+ _cycling_body(tool), monitor, has_tool_results=True, last_user_has_tool_result=True
43
+ )
44
+
45
+
46
+ class CoordinationBanTest(unittest.TestCase):
47
+ def test_config_defaults(self):
48
+ self.assertIn("TaskUpdate", ap.PROXY_COORDINATION_TOOLS)
49
+ self.assertEqual(ap.PROXY_COORDINATION_BAN_THRESHOLD, 2)
50
+
51
+ def test_coordination_tool_banned_after_two_cycles(self):
52
+ m = ap.SessionMonitor(context_window=132096)
53
+ _drive_one_cycle(m, "TaskUpdate")
54
+ self.assertNotIn("TaskUpdate", m.session_banned_tools, "should not ban after 1 cycle")
55
+ _drive_one_cycle(m, "TaskUpdate")
56
+ self.assertIn("TaskUpdate", m.session_banned_tools, "should ban after 2 cycles (coordination)")
57
+
58
+ def test_generic_tool_needs_three_cycles(self):
59
+ m = ap.SessionMonitor(context_window=132096)
60
+ for _ in range(2):
61
+ _drive_one_cycle(m, "glob")
62
+ self.assertNotIn("glob", m.session_banned_tools, "generic tool must NOT ban at 2")
63
+ _drive_one_cycle(m, "glob")
64
+ self.assertIn("glob", m.session_banned_tools, "generic tool bans at 3")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()