tomas.helmfridsson commited on
Commit
d68e71f
·
1 Parent(s): 15c0f31

Fix HF output limits and requirements feedback routing

Browse files
src/qa_platform/llm_runtime.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import json
4
  import os
5
  import socket
 
6
  from dataclasses import dataclass
7
  from importlib import import_module
8
  from pathlib import Path
@@ -18,7 +19,7 @@ class LLMRuntimeError(RuntimeError):
18
 
19
  HTTP_TIMEOUT_SECONDS = 55
20
  OLLAMA_DISCOVERY_TIMEOUT_SECONDS = 2
21
- HF_PROVIDER_MAX_ATTEMPTS = 1
22
  HF_MODEL_FAMILY_MAP = {
23
  "Qwen 3 32B": "Qwen/Qwen3-32B",
24
  "Qwen3-30B-A3B": "Qwen/Qwen3-30B-A3B",
@@ -189,124 +190,89 @@ def _call_hf_structured_llm(
189
  schema: dict[str, Any],
190
  temperature: float,
191
  ) -> tuple[dict[str, Any], LLMCallMetadata]:
192
- token = os.environ.get("HF_TOKEN", "").strip()
193
- if not token:
194
- raise LLMRuntimeError("HF_TOKEN is required for Hugging Face live LLM execution.")
195
-
196
  errors: list[str] = []
197
- for model_id in _candidate_model_ids(runtime_config):
198
- for attempt in range(1, HF_PROVIDER_MAX_ATTEMPTS + 1):
199
- try:
200
- content, usage = _post_hf_inference_chat_completion(
201
- api_key=token,
202
- model_id=model_id,
203
- messages=[
204
- {
205
- "role": "system",
206
- "content": (
207
- system_prompt.strip()
208
- + "\nReturn only valid JSON matching the requested schema. "
209
- + "Do not add markdown fences or commentary."
210
- ),
211
- },
212
- {
213
- "role": "user",
214
- "content": (
215
- user_prompt.strip()
216
- + "\nJSON schema to satisfy exactly:\n"
217
- + json.dumps(
218
- {
219
- "name": schema_name,
220
- "schema": schema,
221
- },
222
- ensure_ascii=False,
223
- )
224
- ),
225
- },
226
- ],
227
- temperature=temperature,
228
- )
229
- return _extract_json_object(content), LLMCallMetadata(
230
- endpoint="huggingface_hub.InferenceClient.chat_completion",
231
- model_id=model_id,
232
- provider_strategy=runtime_config.provider_strategy,
233
- response_format_used=False,
234
- usage=usage,
235
- )
236
- except Exception as exc: # noqa: BLE001
237
- errors.append(
238
- f"{model_id} inference-client attempt {attempt}/{HF_PROVIDER_MAX_ATTEMPTS}: {exc}"
239
- )
240
- break
241
 
242
- endpoint, api_key = _resolve_endpoint_and_key(runtime_config)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  response_format = {
244
  "type": "json_schema",
245
- "json_schema": {
246
- "name": schema_name,
247
- "schema": schema,
248
- "strict": True,
249
- },
250
  }
251
- messages = [
252
- {"role": "system", "content": system_prompt.strip()},
253
- {"role": "user", "content": user_prompt.strip()},
254
- ]
255
- for model_id in _candidate_model_ids(runtime_config):
256
- for attempt in range(1, HF_PROVIDER_MAX_ATTEMPTS + 1):
257
- fallback_system_prompt = (
258
- system_prompt.strip()
259
- + "\nReturn only valid JSON matching the requested schema. Do not add markdown fences or commentary."
 
 
 
 
 
 
 
260
  )
261
- fallback_user_prompt = (
262
- user_prompt.strip()
263
- + "\nJSON schema to satisfy exactly:\n"
264
- + json.dumps(schema, ensure_ascii=False)
 
 
 
 
265
  )
266
- try:
267
- payload = _post_chat_completion(
268
- endpoint=endpoint,
269
- api_key=api_key,
270
- model_id=model_id,
271
- messages=messages,
272
- temperature=temperature,
273
- response_format=response_format,
274
- )
275
- content = _extract_message_content(payload)
276
- return json.loads(content), LLMCallMetadata(
277
- endpoint=endpoint,
278
- model_id=model_id,
279
- provider_strategy=runtime_config.provider_strategy,
280
- response_format_used=True,
281
- usage=payload.get("usage") or {},
282
- )
283
- except (LLMRuntimeError, json.JSONDecodeError) as first_error:
284
- errors.append(
285
- f"{model_id} router structured-output attempt {attempt}/{HF_PROVIDER_MAX_ATTEMPTS}: {first_error}"
286
- )
287
- try:
288
- fallback_payload = _post_chat_completion(
289
- endpoint=endpoint,
290
- api_key=api_key,
291
- model_id=model_id,
292
- messages=[
293
- {"role": "system", "content": fallback_system_prompt},
294
- {"role": "user", "content": fallback_user_prompt},
295
- ],
296
- temperature=temperature,
297
- response_format=None,
298
- )
299
- content = _extract_message_content(fallback_payload)
300
- return _extract_json_object(content), LLMCallMetadata(
301
- endpoint=endpoint,
302
- model_id=model_id,
303
- provider_strategy=runtime_config.provider_strategy,
304
- response_format_used=False,
305
- usage=fallback_payload.get("usage") or {},
306
- )
307
- except Exception as second_error: # noqa: BLE001
308
- errors.append(f"{model_id} router fallback attempt: {second_error}")
309
- break
310
 
311
  raise LLMRuntimeError(
312
  f"Live LLM call failed for {runtime_config.agent_name}. Attempts: {' | '.join(errors)}"
@@ -407,6 +373,7 @@ def _post_hf_inference_chat_completion(
407
  model_id: str,
408
  messages: list[dict[str, str]],
409
  temperature: float,
 
410
  ) -> tuple[str, dict[str, Any]]:
411
  try:
412
  huggingface_hub = import_module("huggingface_hub")
@@ -415,21 +382,23 @@ def _post_hf_inference_chat_completion(
415
  "huggingface_hub is not installed. Install it to use Hugging Face live execution."
416
  ) from exc
417
 
418
- client = huggingface_hub.InferenceClient(model=model_id, token=api_key)
419
  try:
420
  response = client.chat_completion(
421
  messages=messages,
422
- max_tokens=2000,
423
  temperature=temperature,
424
  )
425
  except (TimeoutError, socket.timeout) as exc:
426
  raise LLMRuntimeError(
427
- f"Hugging Face InferenceClient timed out after {HTTP_TIMEOUT_SECONDS} seconds."
428
  ) from exc
429
  except Exception as exc: # noqa: BLE001
430
  raise LLMRuntimeError(str(exc)) from exc
431
 
432
  choice = response.choices[0]
 
 
433
  message = getattr(choice, "message", None)
434
  content = None
435
  if message is not None:
@@ -455,6 +424,8 @@ def _post_chat_completion(
455
  messages: list[dict[str, str]],
456
  temperature: float,
457
  response_format: dict[str, Any] | None,
 
 
458
  ) -> dict[str, Any]:
459
  body: dict[str, Any] = {
460
  "model": model_id,
@@ -462,6 +433,8 @@ def _post_chat_completion(
462
  "stream": False,
463
  "temperature": temperature,
464
  }
 
 
465
  if response_format:
466
  body["response_format"] = response_format
467
 
@@ -474,7 +447,7 @@ def _post_chat_completion(
474
  encoded = json.dumps(body).encode("utf-8")
475
  req = request.Request(endpoint, data=encoded, headers=headers, method="POST")
476
  try:
477
- with request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as response:
478
  return json.loads(response.read().decode("utf-8"))
479
  except error.HTTPError as exc:
480
  detail = exc.read().decode("utf-8", errors="replace")
@@ -485,7 +458,7 @@ def _post_chat_completion(
485
  raise LLMRuntimeError(f"Could not reach model endpoint {endpoint}: {exc.reason}") from exc
486
  except (TimeoutError, socket.timeout) as exc:
487
  raise LLMRuntimeError(
488
- f"Model endpoint {endpoint} timed out after {HTTP_TIMEOUT_SECONDS} seconds."
489
  ) from exc
490
 
491
 
 
3
  import json
4
  import os
5
  import socket
6
+ import time
7
  from dataclasses import dataclass
8
  from importlib import import_module
9
  from pathlib import Path
 
19
 
20
  HTTP_TIMEOUT_SECONDS = 55
21
  OLLAMA_DISCOVERY_TIMEOUT_SECONDS = 2
22
+ HF_OUTPUT_TOKEN_BUDGET = 8192
23
  HF_MODEL_FAMILY_MAP = {
24
  "Qwen 3 32B": "Qwen/Qwen3-32B",
25
  "Qwen3-30B-A3B": "Qwen/Qwen3-30B-A3B",
 
190
  schema: dict[str, Any],
191
  temperature: float,
192
  ) -> tuple[dict[str, Any], LLMCallMetadata]:
193
+ endpoint, api_key = _resolve_endpoint_and_key(runtime_config)
194
+ model_id = resolve_live_model_id(runtime_config)
195
+ # Keep the selected provider strategy throughout retries, and share one budget.
196
+ deadline = time.monotonic() + max(0.1, runtime_config.timeout_seconds - 1)
197
  errors: list[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
+ def remaining_timeout() -> float:
200
+ remaining = deadline - time.monotonic()
201
+ if remaining <= 0:
202
+ raise LLMRuntimeError(
203
+ f"HF call budget exhausted for {runtime_config.agent_name}. "
204
+ f"Attempts: {' | '.join(errors)}"
205
+ )
206
+ return remaining
207
+
208
+ json_messages = [
209
+ {
210
+ "role": "system",
211
+ "content": (
212
+ system_prompt.strip()
213
+ + "\nReturn only valid JSON matching the requested schema. "
214
+ + "Do not add markdown fences or commentary."
215
+ ),
216
+ },
217
+ {
218
+ "role": "user",
219
+ "content": (
220
+ user_prompt.strip()
221
+ + "\nJSON schema to satisfy exactly:\n"
222
+ + json.dumps({"name": schema_name, "schema": schema}, ensure_ascii=False)
223
+ ),
224
+ },
225
+ ]
226
+ try:
227
+ content, usage = _post_hf_inference_chat_completion(
228
+ api_key=api_key,
229
+ model_id=model_id,
230
+ messages=json_messages,
231
+ temperature=temperature,
232
+ timeout_seconds=remaining_timeout(),
233
+ )
234
+ return _extract_json_object(content), LLMCallMetadata(
235
+ endpoint="huggingface_hub.InferenceClient.chat_completion",
236
+ model_id=model_id,
237
+ provider_strategy=runtime_config.provider_strategy,
238
+ response_format_used=False,
239
+ usage=usage,
240
+ )
241
+ except Exception as exc: # noqa: BLE001
242
+ errors.append(f"{model_id} inference-client attempt: {exc}")
243
+
244
  response_format = {
245
  "type": "json_schema",
246
+ "json_schema": {"name": schema_name, "schema": schema, "strict": True},
 
 
 
 
247
  }
248
+ for format_value in (response_format, None):
249
+ timeout_seconds = remaining_timeout()
250
+ try:
251
+ payload = _post_chat_completion(
252
+ endpoint=endpoint,
253
+ api_key=api_key,
254
+ model_id=model_id,
255
+ messages=(
256
+ [{"role": "system", "content": system_prompt.strip()},
257
+ {"role": "user", "content": user_prompt.strip()}]
258
+ if format_value else json_messages
259
+ ),
260
+ temperature=temperature,
261
+ response_format=format_value,
262
+ timeout_seconds=timeout_seconds,
263
+ max_tokens=HF_OUTPUT_TOKEN_BUDGET,
264
  )
265
+ if (payload.get("choices") or [{}])[0].get("finish_reason") == "length":
266
+ raise LLMRuntimeError("HF response exhausted its 8192-token output budget.")
267
+ return _extract_json_object(_extract_message_content(payload)), LLMCallMetadata(
268
+ endpoint=endpoint,
269
+ model_id=model_id,
270
+ provider_strategy=runtime_config.provider_strategy,
271
+ response_format_used=bool(format_value),
272
+ usage=payload.get("usage") or {},
273
  )
274
+ except Exception as exc: # noqa: BLE001
275
+ errors.append(f"{model_id} router {'structured' if format_value else 'JSON'} attempt: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
  raise LLMRuntimeError(
278
  f"Live LLM call failed for {runtime_config.agent_name}. Attempts: {' | '.join(errors)}"
 
373
  model_id: str,
374
  messages: list[dict[str, str]],
375
  temperature: float,
376
+ timeout_seconds: float = HTTP_TIMEOUT_SECONDS,
377
  ) -> tuple[str, dict[str, Any]]:
378
  try:
379
  huggingface_hub = import_module("huggingface_hub")
 
382
  "huggingface_hub is not installed. Install it to use Hugging Face live execution."
383
  ) from exc
384
 
385
+ client = huggingface_hub.InferenceClient(model=model_id, token=api_key, timeout=timeout_seconds)
386
  try:
387
  response = client.chat_completion(
388
  messages=messages,
389
+ max_tokens=HF_OUTPUT_TOKEN_BUDGET,
390
  temperature=temperature,
391
  )
392
  except (TimeoutError, socket.timeout) as exc:
393
  raise LLMRuntimeError(
394
+ f"Hugging Face InferenceClient timed out after {timeout_seconds:g} seconds."
395
  ) from exc
396
  except Exception as exc: # noqa: BLE001
397
  raise LLMRuntimeError(str(exc)) from exc
398
 
399
  choice = response.choices[0]
400
+ if getattr(choice, "finish_reason", None) == "length":
401
+ raise LLMRuntimeError("HF response exhausted its 8192-token output budget.")
402
  message = getattr(choice, "message", None)
403
  content = None
404
  if message is not None:
 
424
  messages: list[dict[str, str]],
425
  temperature: float,
426
  response_format: dict[str, Any] | None,
427
+ timeout_seconds: float = HTTP_TIMEOUT_SECONDS,
428
+ max_tokens: int | None = None,
429
  ) -> dict[str, Any]:
430
  body: dict[str, Any] = {
431
  "model": model_id,
 
433
  "stream": False,
434
  "temperature": temperature,
435
  }
436
+ if max_tokens is not None:
437
+ body["max_tokens"] = max_tokens
438
  if response_format:
439
  body["response_format"] = response_format
440
 
 
447
  encoded = json.dumps(body).encode("utf-8")
448
  req = request.Request(endpoint, data=encoded, headers=headers, method="POST")
449
  try:
450
+ with request.urlopen(req, timeout=timeout_seconds) as response:
451
  return json.loads(response.read().decode("utf-8"))
452
  except error.HTTPError as exc:
453
  detail = exc.read().decode("utf-8", errors="replace")
 
458
  raise LLMRuntimeError(f"Could not reach model endpoint {endpoint}: {exc.reason}") from exc
459
  except (TimeoutError, socket.timeout) as exc:
460
  raise LLMRuntimeError(
461
+ f"Model endpoint {endpoint} timed out after {timeout_seconds:g} seconds."
462
  ) from exc
463
 
464
 
src/qa_platform/orchestrator.py CHANGED
@@ -914,12 +914,11 @@ class OrchestratorAgent:
914
  "feedback_messages": review.improvement_actions[:2]
915
  or ["Strengthen oracle quality and expected results in the current test designs."],
916
  }
917
- if "requirements" in routing_focus and "test_design" not in routing_focus:
918
  return {
919
  "from_agent": "Review Agent",
920
  "to_agent": "Requirements Analyst",
921
- "feedback_messages": review.improvement_actions[:2]
922
- or ["Clarify ambiguous requirements and reduce assumptions before redesign."],
923
  }
924
 
925
  weak_oracle_findings = [finding for finding in review.findings if "weak oracle definition" in finding]
@@ -1007,6 +1006,25 @@ class OrchestratorAgent:
1007
 
1008
  return None
1009
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1010
  def _normalize_routing_focus(
1011
  self,
1012
  raw_focus: list[str],
@@ -1021,10 +1039,17 @@ class OrchestratorAgent:
1021
  focus.add("requirements")
1022
  elif normalized in {"test_design", "test design", "designer", "test design agent"}:
1023
  focus.add("test_design")
 
 
 
1024
  if any(
1025
  keyword in combined
1026
  for keyword in (
1027
  "assumption",
 
 
 
 
1028
  "ambiguous requirement",
1029
  "unclear requirement",
1030
  "acceptance criteria",
 
914
  "feedback_messages": review.improvement_actions[:2]
915
  or ["Strengthen oracle quality and expected results in the current test designs."],
916
  }
917
+ if "requirements" in routing_focus:
918
  return {
919
  "from_agent": "Review Agent",
920
  "to_agent": "Requirements Analyst",
921
+ "feedback_messages": self._requirements_review_feedback(review),
 
922
  }
923
 
924
  weak_oracle_findings = [finding for finding in review.findings if "weak oracle definition" in finding]
 
1006
 
1007
  return None
1008
 
1009
+ def _requirements_review_feedback(self, review) -> list[str]:
1010
+ clarification_words = (
1011
+ "clarify", "define", "specify", "document", "resolve",
1012
+ "reduce assumptions", "remove assumptions",
1013
+ )
1014
+ # Prefer clarification requests over test actions that merely mention assumptions.
1015
+ targeted = [
1016
+ action for action in review.improvement_actions
1017
+ if any(word in action.lower() for word in clarification_words)
1018
+ ]
1019
+ if targeted:
1020
+ return targeted[:2]
1021
+ requirement_words = ("requirement", "acceptance criteria", "assumption", "ambigu", "policy", "rules")
1022
+ findings = [
1023
+ finding for finding in review.findings
1024
+ if any(word in finding.lower() for word in requirement_words)
1025
+ ]
1026
+ return findings[:2] or ["Clarify ambiguous requirements and reduce assumptions before redesign."]
1027
+
1028
  def _normalize_routing_focus(
1029
  self,
1030
  raw_focus: list[str],
 
1039
  focus.add("requirements")
1040
  elif normalized in {"test_design", "test design", "designer", "test design agent"}:
1041
  focus.add("test_design")
1042
+ # Explicit agent routing takes precedence over words in the explanation.
1043
+ if focus:
1044
+ return focus
1045
  if any(
1046
  keyword in combined
1047
  for keyword in (
1048
  "assumption",
1049
+ "ambiguity",
1050
+ "ambiguities",
1051
+ "undefined",
1052
+ "unspecified",
1053
  "ambiguous requirement",
1054
  "unclear requirement",
1055
  "acceptance criteria",
tests/test_feedback_routing.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import unittest
3
+ from pathlib import Path
4
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
5
+ from qa_platform.models import ReviewReport
6
+ from qa_platform.orchestrator import OrchestratorAgent
7
+
8
+
9
+ class FeedbackRoutingTests(unittest.TestCase):
10
+ def route(self, focus, finding, action):
11
+ orchestrator = OrchestratorAgent()
12
+ orchestrator.reviewer.last_execution = {'routing_focus': focus}
13
+ return orchestrator._select_review_backtrack_route([], ReviewReport(
14
+ approved=False, coverage_ratio=1.0, findings=[finding], improvement_actions=[action]))
15
+
16
+ def test_explicit_requirements_route_survives_oracle_wording(self):
17
+ route = self.route(['requirements'], 'Unclear requirement causes weak oracle.', 'Clarify requirement before revising test case oracle.')
18
+ self.assertEqual(route['to_agent'], 'Requirements Analyst')
19
+
20
+ def test_inferred_mixed_problems_fix_requirements_first(self):
21
+ route = self.route([], 'Ambiguous requirement causes weak oracle.', 'Clarify requirement before revising test case oracle.')
22
+ self.assertEqual(route['to_agent'], 'Requirements Analyst')
23
+
24
+ def test_explicit_design_route_is_not_overridden_by_explanatory_keywords(self):
25
+ route = self.route(['test_design'], 'Oracle fails to check acceptance criteria.', 'Revise the test case.')
26
+ self.assertEqual(route['to_agent'], 'Test Design Agent')
27
+
28
+ def test_explicit_mixed_route_fixes_requirements_first(self):
29
+ route = self.route(['test_design', 'requirements'], 'Unclear requirement and weak oracle.', 'Clarify requirement.')
30
+ self.assertEqual(route['to_agent'], 'Requirements Analyst')
31
+
32
+ def test_requirements_feedback_filters_out_earlier_test_actions(self):
33
+ orchestrator = OrchestratorAgent()
34
+ orchestrator.reviewer.last_execution = {'routing_focus': ['requirements', 'test_design']}
35
+ route = orchestrator._select_review_backtrack_route([], ReviewReport(
36
+ approved=False, coverage_ratio=1.0, findings=['Unclear requirement and weak oracle.'],
37
+ improvement_actions=['Enhance test oracle with server checks.', 'Clarify acceptance criteria for registration.']))
38
+ self.assertEqual(route['feedback_messages'], ['Clarify acceptance criteria for registration.'])
39
+
40
+ def test_undefined_policy_returns_to_requirements(self):
41
+ route = self.route([], 'Password policy is undefined and the oracle is weak.', 'Document the password rules.')
42
+ self.assertEqual(route['to_agent'], 'Requirements Analyst')
43
+
44
+ def test_test_action_mentioning_assumptions_does_not_take_clarification_slot(self):
45
+ orchestrator = OrchestratorAgent()
46
+ orchestrator.reviewer.last_execution = {'routing_focus': ['requirements', 'test_design']}
47
+ route = orchestrator._select_review_backtrack_route([], ReviewReport(
48
+ approved=False, coverage_ratio=1.0, findings=['Password policy is unclear.'],
49
+ improvement_actions=['Create a test covering security assumptions.', 'Clarify password policy in the requirements.']))
50
+ self.assertEqual(route['feedback_messages'], ['Clarify password policy in the requirements.'])
tests/test_hf_runtime.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import sys
4
+ import unittest
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ from unittest.mock import Mock, patch
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
10
+ from qa_platform import llm_runtime as runtime
11
+ from qa_platform.agent_runtime import build_agent_runtime_configs
12
+
13
+
14
+ class HFRuntimeTests(unittest.TestCase):
15
+ def test_sdk_has_output_space_and_network_timeout(self):
16
+ client = Mock()
17
+ client.chat_completion.return_value = SimpleNamespace(
18
+ choices=[SimpleNamespace(finish_reason='stop', message=SimpleNamespace(content='{"ok":true}'))],
19
+ usage=SimpleNamespace(prompt_tokens=10, completion_tokens=2400, total_tokens=2410),
20
+ )
21
+ hub = SimpleNamespace(InferenceClient=Mock(return_value=client))
22
+ with patch.object(runtime, 'import_module', return_value=hub):
23
+ content, usage = runtime._post_hf_inference_chat_completion('secret', 'model:cheapest', [], .2, timeout_seconds=90)
24
+ self.assertTrue(json.loads(content)['ok'])
25
+ self.assertEqual(usage['completion_tokens'], 2400)
26
+ self.assertEqual(hub.InferenceClient.call_args.kwargs['timeout'], 90)
27
+ self.assertEqual(client.chat_completion.call_args.kwargs['max_tokens'], 8192)
28
+
29
+ def test_truncated_response_is_reported_even_if_json_looks_valid(self):
30
+ client = Mock()
31
+ client.chat_completion.return_value = SimpleNamespace(
32
+ choices=[SimpleNamespace(finish_reason='length', message=SimpleNamespace(content='{"ok":true}'))],
33
+ )
34
+ with patch.object(runtime, 'import_module', return_value=SimpleNamespace(InferenceClient=Mock(return_value=client))):
35
+ with self.assertRaisesRegex(runtime.LLMRuntimeError, 'output budget'):
36
+ runtime._post_hf_inference_chat_completion('secret', 'model', [], .2)
37
+
38
+ def test_fallback_keeps_provider_and_shares_remaining_budget(self):
39
+ config = build_agent_runtime_configs()[1]
40
+ with patch.dict(os.environ, {'HF_TOKEN': 'test-token'}), \
41
+ patch.object(runtime, 'load_local_env'), \
42
+ patch.object(runtime.time, 'monotonic', side_effect=[0, 0, 100]), \
43
+ patch.object(runtime, '_post_hf_inference_chat_completion', side_effect=runtime.LLMRuntimeError('provider unavailable')) as sdk, \
44
+ patch.object(runtime, '_post_chat_completion', return_value={'choices': [{'finish_reason': 'stop', 'message': {'content': '{"ok":true}'}}]}) as router:
45
+ payload, _ = runtime.call_structured_llm(config, 'Return JSON', 'Test', 'schema', {'type': 'object'})
46
+ self.assertTrue(payload['ok'])
47
+ self.assertEqual(sdk.call_args.kwargs['timeout_seconds'], 149)
48
+ self.assertEqual(router.call_args.kwargs['timeout_seconds'], 49)
49
+ self.assertEqual(router.call_args.kwargs['model_id'], config.model_id)
50
+
51
+ def test_no_fallback_starts_after_budget_exhaustion(self):
52
+ config = build_agent_runtime_configs()[1]
53
+ with patch.dict(os.environ, {'HF_TOKEN': 'test-token'}), \
54
+ patch.object(runtime, 'load_local_env'), \
55
+ patch.object(runtime.time, 'monotonic', side_effect=[0, 0, 150]), \
56
+ patch.object(runtime, '_post_hf_inference_chat_completion', side_effect=runtime.LLMRuntimeError('timeout')), \
57
+ patch.object(runtime, '_post_chat_completion') as router:
58
+ with self.assertRaisesRegex(runtime.LLMRuntimeError, 'budget exhausted'):
59
+ runtime.call_structured_llm(config, 'Return JSON', 'Test', 'schema', {'type': 'object'})
60
+ router.assert_not_called()
tests/test_service_feedback.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import unittest
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
6
+ import app
7
+
8
+
9
+ class ServiceFeedbackTests(unittest.TestCase):
10
+ def test_service_review_returns_to_requirements_before_redesign(self):
11
+ calls = []
12
+ requirement_calls = []
13
+ reviews = 0
14
+
15
+ def invoke(**kwargs):
16
+ nonlocal reviews
17
+ key = kwargs['service_agent_key']
18
+ calls.append(key)
19
+ if key == 'requirements_analyst':
20
+ requirement_calls.append(kwargs['agent_input']['feedback_messages'])
21
+ output = {'requirements': [{
22
+ 'requirement_id': 'REQ-001', 'original_text': 'The user can log in.',
23
+ 'normalized_text': 'The user can log in.', 'priority': 'medium',
24
+ 'acceptance_criteria': ['Valid credentials authenticate the user.'], 'assumptions': [],
25
+ }]}
26
+ elif key == 'test_designer':
27
+ output = {'test_designs': [{
28
+ 'test_case_id': 'TC-001', 'requirement_ids': ['REQ-001'],
29
+ 'title': 'Valid login', 'test_type': 'functional',
30
+ 'steps': ['Submit valid credentials.'], 'expected_results': ['Authenticated.'],
31
+ 'oracle': {'description': 'Authenticated user session is established.'},
32
+ }]}
33
+ else:
34
+ reviews += 1
35
+ output = {'review': {
36
+ 'approved': reviews > 1, 'coverage_ratio': 1.0,
37
+ 'findings': [] if reviews > 1 else [{
38
+ 'severity': 'major', 'target_type': 'test_case', 'target_id': 'TC-001',
39
+ 'issue': 'Ambiguous requirement produces a weak oracle.',
40
+ 'recommendation': 'Clarify requirement before revising the test case.',
41
+ }],
42
+ 'improvement_actions': [] if reviews > 1 else ['Clarify requirement before revising the test case oracle.'],
43
+ }}
44
+ return {'status': 'completed', 'output': output, 'memory_updates': {}, 'errors': []}
45
+
46
+ configs = app.build_backend_agent_runtime_configs('QA agent service', app.DEFAULT_QA_AGENT_SERVICE_BASE_URL)
47
+ with patch.object(app, '_invoke_qa_agent_service', side_effect=invoke):
48
+ result = app.build_orchestrator_for_backend('QA agent service', app.DEFAULT_QA_AGENT_SERVICE_BASE_URL).process(
49
+ 'Login', 'The user can log in.', agent_configs=configs,
50
+ run_controls=app.RunControlConfig(max_rounds=10, max_feedback_messages=12, max_feedback_per_agent_pair=4))
51
+ self.assertTrue(result.review.approved)
52
+ self.assertEqual(calls, ['requirements_analyst', 'test_designer', 'reviewer'] * 2)
53
+ self.assertEqual(result.pair_feedback_counts, {'Review Agent -> Requirements Analyst': 1})
54
+ self.assertTrue(requirement_calls[1])