User1342 commited on
Commit
7a2798b
·
1 Parent(s): 9f85661

Hold the sampler to the action schema, so a small model calls the tool instead of describing it

Browse files
distinct_agent/cli.py CHANGED
@@ -1422,6 +1422,16 @@ def main(
1422
  args.llama_server or _resolve_llama_server(args),
1423
  energy_meter=energy_meter,
1424
  )
 
 
 
 
 
 
 
 
 
 
1425
  if not runner.available:
1426
  print(
1427
  "llama-server was not found. Supply --llama-server, or --llama-cli "
 
1422
  args.llama_server or _resolve_llama_server(args),
1423
  energy_meter=energy_meter,
1424
  )
1425
+ # SAY WHICH DEVICE WILL DO THE WORK, AND SAY THAT IT WAS MEASURED.
1426
+ #
1427
+ # A volunteer with a graphics card who sees it sitting idle assumes
1428
+ # something is broken. On the laptop this was developed on the card
1429
+ # really is the wrong device -- 0.50 tokens per second against 2.93 on
1430
+ # the CPU, because a partly offloaded model pays a round trip per
1431
+ # token -- and the first time a model is loaded the worker spends a
1432
+ # few minutes finding that out. Both are worth saying out loud.
1433
+ runner._notify = lambda message: print(f" {message}", file=sys.stderr, flush=True)
1434
+ print(f"Runtime: {runner.executable}", file=sys.stderr, flush=True)
1435
  if not runner.available:
1436
  print(
1437
  "llama-server was not found. Supply --llama-server, or --llama-cli "
distinct_agent/harness.py CHANGED
@@ -147,6 +147,7 @@ class StructuredToolHarness:
147
  # number chosen when nobody was measuring. See :func:`prompt_budget`.
148
  window = int(getattr(model.manifest, "context_length", 4096))
149
  limits = fit_output_tokens(job.limits, window)
 
150
  budget = prompt_budget(window, int(limits["max_output_tokens"]))
151
  per_result = min(self.max_result_characters, max(400, budget // 3))
152
  exchanges: list[str] = []
@@ -178,11 +179,15 @@ class StructuredToolHarness:
178
  )
179
  model_usages.append(dict(inference.usage))
180
  action = _parse_action(inference.text, self.max_action_characters)
181
- if action is None and makers and not events and not nudged:
182
- # Prose on the first move, when a tool here makes files. The
183
- # parser accepts prose and always will, but accepting it here
184
- # is how eleven of fourteen benchmark failures ended: a good
185
- # paragraph *about* the document, and no document.
 
 
 
 
186
  nudged = True
187
  current_prompt = _nudge_prompt(head, makers)
188
  continue
@@ -790,6 +795,43 @@ def _example_arguments(schema: Any) -> dict[str, Any]:
790
  return example
791
 
792
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
  def file_making_tools(manifest: tuple[Mapping[str, Any], ...]) -> tuple[str, ...]:
794
  """Which of these tools produce a file, read off their own schemas.
795
 
 
147
  # number chosen when nobody was measuring. See :func:`prompt_budget`.
148
  window = int(getattr(model.manifest, "context_length", 4096))
149
  limits = fit_output_tokens(job.limits, window)
150
+ limits["response_schema"] = action_schema(manifest)
151
  budget = prompt_budget(window, int(limits["max_output_tokens"]))
152
  per_result = min(self.max_result_characters, max(400, budget // 3))
153
  exchanges: list[str] = []
 
179
  )
180
  model_usages.append(dict(inference.usage))
181
  action = _parse_action(inference.text, self.max_action_characters)
182
+ answered_without_acting = action is None or action["type"] == "final"
183
+ if answered_without_acting and makers and not events and not nudged:
184
+ # Finished on the first move, when a tool here makes files.
185
+ # Two shapes of the same failure: prose, which the parser
186
+ # accepts and always will, and a well-formed ``final`` that a
187
+ # constrained sampler will happily produce. Either way the
188
+ # answer is a good paragraph *about* the document and no
189
+ # document, which is how eleven of fourteen benchmark
190
+ # workloads failed.
191
  nudged = True
192
  current_prompt = _nudge_prompt(head, makers)
193
  continue
 
795
  return example
796
 
797
 
798
+ def action_schema(manifest: tuple[Mapping[str, Any], ...]) -> dict[str, Any]:
799
+ """The shape every turn of the structured loop must take.
800
+
801
+ Handed to the runner, which hands it to llama.cpp, which holds the sampler
802
+ to it. That is the difference between an instruction the model may ignore
803
+ and a shape it cannot leave, and it is the fix for the failure that cost
804
+ eleven of fourteen benchmark workloads: a model that wrote a paragraph
805
+ about the document instead of calling the tool that makes one.
806
+
807
+ Two alternatives rather than one loose object with optional fields: a
808
+ ``tool`` action without a tool, or a ``final`` without an answer, is
809
+ exactly the malformed action the parser has to refuse, and a schema that
810
+ permits it has not constrained anything worth constraining.
811
+ """
812
+
813
+ refs = [
814
+ str(item.get("ref") or f"{item.get('id')}@{item.get('version')}") for item in manifest
815
+ ]
816
+ call: dict[str, Any] = {
817
+ "type": "object",
818
+ "properties": {
819
+ "type": {"const": "tool"},
820
+ "tool": {"enum": refs} if refs else {"type": "string"},
821
+ "arguments": {"type": "object"},
822
+ },
823
+ "required": ["type", "tool", "arguments"],
824
+ "additionalProperties": False,
825
+ }
826
+ answer: dict[str, Any] = {
827
+ "type": "object",
828
+ "properties": {"type": {"const": "final"}, "answer": {"type": "string"}},
829
+ "required": ["type", "answer"],
830
+ "additionalProperties": False,
831
+ }
832
+ return {"anyOf": [call, answer]} if refs else answer
833
+
834
+
835
  def file_making_tools(manifest: tuple[Mapping[str, Any], ...]) -> tuple[str, ...]:
836
  """Which of these tools produce a file, read off their own schemas.
837
 
distinct_agent/server_runner.py CHANGED
@@ -265,6 +265,10 @@ class LlamaServerRunner:
265
  #: ``None`` until something is loaded; ``0`` is a real answer meaning
266
  #: everything is on the CPU, and is not the same as "not measured".
267
  self.gpu_layers_used: int | None = None
 
 
 
 
268
  #: What calibration found, when it ran in this process. Empty when the
269
  #: answer came from the cache, which is the usual case.
270
  self.offload_measurements: tuple[Any, ...] = ()
@@ -336,6 +340,7 @@ class LlamaServerRunner:
336
  max_tokens: int,
337
  temperature: float,
338
  timeout: float,
 
339
  ) -> tuple[Mapping[str, Any], str]:
340
  """One request lost its connection. Get the server back and ask again.
341
 
@@ -364,7 +369,11 @@ class LlamaServerRunner:
364
  self._start_locked(model)
365
  try:
366
  return self._generate(
367
- prompt, max_tokens=max_tokens, temperature=temperature, timeout=timeout
 
 
 
 
368
  )
369
  except _TransportLost as again:
370
  raise RunnerError(f"{again} (it had already been recovered once)") from again
@@ -641,6 +650,7 @@ class LlamaServerRunner:
641
  max_tokens: int,
642
  temperature: float,
643
  timeout: float,
 
644
  ) -> tuple[Mapping[str, Any], str]:
645
  """Ask the model, through its own chat template wherever possible.
646
 
@@ -676,6 +686,22 @@ class LlamaServerRunner:
676
  omission.
677
  """
678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  chat_payload = {
680
  "messages": [{"role": "user", "content": prompt}],
681
  "max_tokens": max_tokens,
@@ -688,12 +714,33 @@ class LlamaServerRunner:
688
  # block that arrives anyway, because not every build honours this.
689
  "chat_template_kwargs": {"enable_thinking": False},
690
  }
 
 
 
 
 
691
  try:
692
- return self._post(self.CHAT_PATH, chat_payload, timeout), self.PROMPT_MODE_CHAT
 
 
 
693
  except RunnerTimedOut:
694
  raise
695
  except _NoSuchEndpoint:
696
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  raw_payload = {
698
  "prompt": prompt,
699
  "n_predict": max_tokens,
@@ -701,6 +748,8 @@ class LlamaServerRunner:
701
  "stream": False,
702
  "cache_prompt": True,
703
  }
 
 
704
  return self._post(self.COMPLETION_PATH, raw_payload, timeout), self.PROMPT_MODE_RAW
705
 
706
  def _post(
@@ -819,12 +868,17 @@ class LlamaServerRunner:
819
 
820
  started = time.monotonic()
821
  temperature = float(limits.get("temperature", 0.2))
 
 
 
 
822
  try:
823
  body, prompt_mode = self._generate(
824
  prompt,
825
  max_tokens=max_tokens,
826
  temperature=temperature,
827
  timeout=timeout,
 
828
  )
829
  except _TransportLost as lost:
830
  body, prompt_mode = self._retry_after_transport_loss(
@@ -834,6 +888,7 @@ class LlamaServerRunner:
834
  max_tokens=max_tokens,
835
  temperature=temperature,
836
  timeout=timeout,
 
837
  )
838
  except RunnerTimedOut:
839
  # LET IT FINISH TIDYING UP BEFORE THE NEXT RUN ARRIVES.
@@ -869,6 +924,10 @@ class LlamaServerRunner:
869
  # different thing from a templated chat turn, and a run log has
870
  # to be able to say which one it got.
871
  "prompt_mode": prompt_mode,
 
 
 
 
872
  "max_output_tokens": max_tokens,
873
  "elapsed_seconds": round(time.monotonic() - started, 3),
874
  "isolation": self.isolation.to_dict() if self.isolation else None,
 
265
  #: ``None`` until something is loaded; ``0`` is a real answer meaning
266
  #: everything is on the CPU, and is not the same as "not measured".
267
  self.gpu_layers_used: int | None = None
268
+ #: Whether this build honours a JSON-schema constraint. ``None`` until
269
+ #: one has been tried; ``False`` after a build refuses one, which stops
270
+ #: every later request paying for the same refusal.
271
+ self.supports_schema: bool | None = None
272
  #: What calibration found, when it ran in this process. Empty when the
273
  #: answer came from the cache, which is the usual case.
274
  self.offload_measurements: tuple[Any, ...] = ()
 
340
  max_tokens: int,
341
  temperature: float,
342
  timeout: float,
343
+ schema: Mapping[str, Any] | None = None,
344
  ) -> tuple[Mapping[str, Any], str]:
345
  """One request lost its connection. Get the server back and ask again.
346
 
 
369
  self._start_locked(model)
370
  try:
371
  return self._generate(
372
+ prompt,
373
+ max_tokens=max_tokens,
374
+ temperature=temperature,
375
+ timeout=timeout,
376
+ schema=schema,
377
  )
378
  except _TransportLost as again:
379
  raise RunnerError(f"{again} (it had already been recovered once)") from again
 
650
  max_tokens: int,
651
  temperature: float,
652
  timeout: float,
653
+ schema: Mapping[str, Any] | None = None,
654
  ) -> tuple[Mapping[str, Any], str]:
655
  """Ask the model, through its own chat template wherever possible.
656
 
 
686
  omission.
687
  """
688
 
689
+ # ASKING NICELY FOR JSON DOES NOT WORK ON A SEVEN BILLION PARAMETER
690
+ # MODEL, AND IT DOES NOT HAVE TO.
691
+ #
692
+ # The harness needs one JSON object per turn. The prompt said so in
693
+ # capitals, gave a worked example, and re-asked once when prose came
694
+ # back; the model still wrote a perfectly good paragraph *about* the
695
+ # document it had been asked to create, called nothing, and produced
696
+ # no file. Eleven of fourteen benchmark failures were that.
697
+ #
698
+ # llama.cpp can constrain the sampler to a JSON schema, which turns
699
+ # "please reply in this shape" from an instruction the model may
700
+ # ignore into a shape it cannot leave. A build that does not support
701
+ # it says so with a 400, and the flag below stops it being asked
702
+ # again, so this degrades to the behaviour it replaces rather than
703
+ # failing.
704
+ constrain = schema is not None and self.supports_schema is not False
705
  chat_payload = {
706
  "messages": [{"role": "user", "content": prompt}],
707
  "max_tokens": max_tokens,
 
714
  # block that arrives anyway, because not every build honours this.
715
  "chat_template_kwargs": {"enable_thinking": False},
716
  }
717
+ if constrain:
718
+ chat_payload["response_format"] = {
719
+ "type": "json_schema",
720
+ "json_schema": {"name": "action", "strict": True, "schema": dict(schema or {})},
721
+ }
722
  try:
723
+ body = self._post(self.CHAT_PATH, chat_payload, timeout)
724
+ if constrain:
725
+ self.supports_schema = True
726
+ return body, self.PROMPT_MODE_CHAT
727
  except RunnerTimedOut:
728
  raise
729
  except _NoSuchEndpoint:
730
  pass
731
+ except RunnerError:
732
+ if not constrain:
733
+ raise
734
+ # Refused with a schema attached. Assume the schema is why, say so
735
+ # once, and carry on without it rather than losing the run.
736
+ self.supports_schema = False
737
+ return self._generate(
738
+ prompt,
739
+ max_tokens=max_tokens,
740
+ temperature=temperature,
741
+ timeout=timeout,
742
+ schema=None,
743
+ )
744
  raw_payload = {
745
  "prompt": prompt,
746
  "n_predict": max_tokens,
 
748
  "stream": False,
749
  "cache_prompt": True,
750
  }
751
+ if constrain:
752
+ raw_payload["json_schema"] = dict(schema or {})
753
  return self._post(self.COMPLETION_PATH, raw_payload, timeout), self.PROMPT_MODE_RAW
754
 
755
  def _post(
 
868
 
869
  started = time.monotonic()
870
  temperature = float(limits.get("temperature", 0.2))
871
+ # Carried in limits rather than in the signature, so a runner that
872
+ # cannot constrain its output ignores it instead of refusing the call.
873
+ schema = limits.get("response_schema")
874
+ schema = schema if isinstance(schema, Mapping) else None
875
  try:
876
  body, prompt_mode = self._generate(
877
  prompt,
878
  max_tokens=max_tokens,
879
  temperature=temperature,
880
  timeout=timeout,
881
+ schema=schema,
882
  )
883
  except _TransportLost as lost:
884
  body, prompt_mode = self._retry_after_transport_loss(
 
888
  max_tokens=max_tokens,
889
  temperature=temperature,
890
  timeout=timeout,
891
+ schema=schema,
892
  )
893
  except RunnerTimedOut:
894
  # LET IT FINISH TIDYING UP BEFORE THE NEXT RUN ARRIVES.
 
924
  # different thing from a templated chat turn, and a run log has
925
  # to be able to say which one it got.
926
  "prompt_mode": prompt_mode,
927
+ # Whether the sampler was held to the action schema. An answer
928
+ # produced under a constraint is a different artefact from one
929
+ # produced freely, in the same way a templated turn is.
930
+ "schema_constrained": bool(schema) and self.supports_schema is not False,
931
  "max_output_tokens": max_tokens,
932
  "elapsed_seconds": round(time.monotonic() - started, 3),
933
  "isolation": self.isolation.to_dict() if self.isolation else None,
tests/test_harness_makes_files.py CHANGED
@@ -207,7 +207,9 @@ def test_reaching_the_budget_keeps_the_work_rather_than_discarding_it(tmp_path)
207
 
208
 
209
  def test_a_job_asking_for_more_than_the_worker_allows_is_clamped_not_refused(tmp_path) -> None:
210
- broker = _Broker((DECK,), max_calls=2)
 
 
211
  result = _run(
212
  StructuredToolHarness(),
213
  _job(max_tool_calls=StructuredToolHarness.hard_max_tool_calls),
@@ -227,3 +229,75 @@ def test_a_job_asking_beyond_the_hard_ceiling_is_still_refused(tmp_path) -> None
227
  _Broker((DECK,)),
228
  tmp_path,
229
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
 
209
  def test_a_job_asking_for_more_than_the_worker_allows_is_clamped_not_refused(tmp_path) -> None:
210
+ # No file-making tool here on purpose: this is about the budget, and with
211
+ # one present a first-turn final answer is correctly corrected instead.
212
+ broker = _Broker((CALCULATOR,), max_calls=2)
213
  result = _run(
214
  StructuredToolHarness(),
215
  _job(max_tool_calls=StructuredToolHarness.hard_max_tool_calls),
 
229
  _Broker((DECK,)),
230
  tmp_path,
231
  )
232
+
233
+
234
+ # -- the shape the sampler is held to ----------------------------------------
235
+
236
+
237
+ def test_the_action_schema_offers_a_call_and_an_answer_and_nothing_else() -> None:
238
+ from distinct_agent.harness import action_schema
239
+
240
+ schema = action_schema((DECK, CALCULATOR))
241
+ shapes = schema["anyOf"]
242
+ assert [shape["properties"]["type"]["const"] for shape in shapes] == ["tool", "final"]
243
+ assert all(shape["additionalProperties"] is False for shape in shapes)
244
+
245
+
246
+ def test_the_schema_only_permits_tools_that_exist() -> None:
247
+ """A constrained sampler cannot then invent a tool name."""
248
+
249
+ from distinct_agent.harness import action_schema
250
+
251
+ assert action_schema((DECK,))["anyOf"][0]["properties"]["tool"]["enum"] == ["create-deck@1"]
252
+
253
+
254
+ def test_a_call_without_a_tool_is_not_a_permitted_shape() -> None:
255
+ """Constraining to something the parser would refuse constrains nothing."""
256
+
257
+ from distinct_agent.harness import action_schema
258
+
259
+ call = action_schema((DECK,))["anyOf"][0]
260
+ assert set(call["required"]) == {"type", "tool", "arguments"}
261
+
262
+
263
+ def test_a_job_with_no_tools_can_only_answer() -> None:
264
+ from distinct_agent.harness import action_schema
265
+
266
+ schema = action_schema(())
267
+ assert "anyOf" not in schema
268
+ assert schema["properties"]["type"]["const"] == "final"
269
+
270
+
271
+ def test_the_schema_travels_to_the_runner_with_the_run(tmp_path) -> None:
272
+ """It is carried in limits so a runner that cannot use it can ignore it."""
273
+
274
+ seen = {}
275
+
276
+ class _Watching(_Model):
277
+ def run(self, model, prompt, *, cancel_event=None, progress=None, limits=None):
278
+ seen.update(limits or {})
279
+ return super().run(model, prompt, cancel_event=cancel_event,
280
+ progress=progress, limits=limits)
281
+
282
+ _run(
283
+ StructuredToolHarness(),
284
+ _job(),
285
+ _Watching(json.dumps({"type": "final", "answer": "done"})),
286
+ _Broker((CALCULATOR,)),
287
+ tmp_path,
288
+ )
289
+ assert "anyOf" in seen["response_schema"] or "properties" in seen["response_schema"]
290
+
291
+
292
+ def test_a_well_formed_answer_that_skipped_the_file_is_corrected_too(tmp_path) -> None:
293
+ """A constrained sampler emits valid JSON. It can still answer without acting."""
294
+
295
+ runner = _Model(
296
+ json.dumps({"type": "final", "answer": "Here is an outline of the deck..."}),
297
+ json.dumps({"type": "tool", "tool": "create-deck@1", "arguments": {"slides": "A :: b"}}),
298
+ json.dumps({"type": "final", "answer": "deck built"}),
299
+ )
300
+ broker = _Broker((DECK,))
301
+ result = _run(StructuredToolHarness(), _job(), runner, broker, tmp_path)
302
+ assert broker.calls and broker.calls[0][0] == "create-deck@1"
303
+ assert result.text == "deck built"
tests/test_server_runner_recovery.py CHANGED
@@ -293,3 +293,98 @@ def test_an_unreadable_error_body_is_simply_absent(tmp_path) -> None:
293
 
294
  error.read = explode
295
  assert server_runner._http_detail(error) == ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
  error.read = explode
295
  assert server_runner._http_detail(error) == ""
296
+
297
+
298
+ # -- holding the sampler to a shape ------------------------------------------
299
+
300
+
301
+ SCHEMA = {
302
+ "anyOf": [
303
+ {
304
+ "type": "object",
305
+ "properties": {"type": {"const": "tool"}, "tool": {"enum": ["a@1"]}},
306
+ "required": ["type", "tool"],
307
+ },
308
+ {
309
+ "type": "object",
310
+ "properties": {"type": {"const": "final"}, "answer": {"type": "string"}},
311
+ "required": ["type", "answer"],
312
+ },
313
+ ]
314
+ }
315
+
316
+
317
+ def test_the_schema_reaches_llama_server_with_the_request(tmp_path, monkeypatch) -> None:
318
+ sent = {}
319
+
320
+ def post(self, path, payload, timeout):
321
+ sent["path"] = path
322
+ sent["payload"] = dict(payload)
323
+ return {"choices": [{"message": {"content": '{"type":"final","answer":"x"}'}}]}
324
+
325
+ monkeypatch.setattr(server_runner.LlamaServerRunner, "_post", post)
326
+ runner = _runner(tmp_path)
327
+ runner._generate("hi", max_tokens=10, temperature=0.0, timeout=5, schema=SCHEMA)
328
+ assert sent["payload"]["response_format"]["json_schema"]["schema"] == SCHEMA
329
+
330
+
331
+ def test_a_build_that_refuses_the_schema_is_asked_again_without_it(tmp_path, monkeypatch) -> None:
332
+ """A constraint is an optimisation. Losing the run over it is not."""
333
+
334
+ attempts = []
335
+
336
+ def post(self, path, payload, timeout):
337
+ attempts.append("response_format" in payload)
338
+ if attempts[-1]:
339
+ raise RunnerError("llama-server rejected the request: HTTP 400")
340
+ return {"choices": [{"message": {"content": "plain text"}}]}
341
+
342
+ monkeypatch.setattr(server_runner.LlamaServerRunner, "_post", post)
343
+ runner = _runner(tmp_path)
344
+ body, _mode = runner._generate("hi", max_tokens=10, temperature=0.0, timeout=5, schema=SCHEMA)
345
+ assert attempts == [True, False]
346
+ assert runner.supports_schema is False
347
+
348
+
349
+ def test_a_build_that_refused_once_is_not_asked_again(tmp_path, monkeypatch) -> None:
350
+ """Every later request would otherwise pay for the same refusal."""
351
+
352
+ attempts = []
353
+
354
+ def post(self, path, payload, timeout):
355
+ attempts.append("response_format" in payload)
356
+ return {"choices": [{"message": {"content": "text"}}]}
357
+
358
+ monkeypatch.setattr(server_runner.LlamaServerRunner, "_post", post)
359
+ runner = _runner(tmp_path)
360
+ runner.supports_schema = False
361
+ runner._generate("hi", max_tokens=10, temperature=0.0, timeout=5, schema=SCHEMA)
362
+ assert attempts == [False]
363
+
364
+
365
+ def test_no_schema_means_no_constraint_in_the_payload(tmp_path, monkeypatch) -> None:
366
+ sent = {}
367
+
368
+ def post(self, path, payload, timeout):
369
+ sent.update(payload)
370
+ return {"choices": [{"message": {"content": "text"}}]}
371
+
372
+ monkeypatch.setattr(server_runner.LlamaServerRunner, "_post", post)
373
+ _runner(tmp_path)._generate("hi", max_tokens=10, temperature=0.0, timeout=5)
374
+ assert "response_format" not in sent
375
+
376
+
377
+ def test_the_run_says_whether_its_answer_was_constrained(tmp_path, monkeypatch) -> None:
378
+ monkeypatch.setattr(
379
+ server_runner.LlamaServerRunner,
380
+ "_post",
381
+ lambda self, path, payload, timeout: {
382
+ "choices": [{"message": {"content": '{"type":"final","answer":"x"}'}}]
383
+ },
384
+ )
385
+ monkeypatch.setattr(server_runner.LlamaServerRunner, "ensure_started", lambda self, m: None)
386
+ runner = _runner(tmp_path)
387
+ result = runner.run(_model(tmp_path), "hi", limits={"response_schema": SCHEMA})
388
+ assert result.usage["schema_constrained"] is True
389
+ plain = runner.run(_model(tmp_path), "hi")
390
+ assert plain.usage["schema_constrained"] is False