rote-cli 0.11.0__tar.gz → 0.12.0__tar.gz

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.
Files changed (73) hide show
  1. {rote_cli-0.11.0 → rote_cli-0.12.0}/PKG-INFO +1 -1
  2. {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/ir-schema.md +13 -0
  3. {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/llm-judge-extraction.md +48 -1
  4. {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/node-kinds.md +7 -0
  5. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/__init__.py +1 -1
  6. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/_common.py +45 -1
  7. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/_py_common.py +210 -119
  8. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/_ts_common.py +1009 -50
  9. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/cloudflare.py +211 -33
  10. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/dbos.py +43 -34
  11. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/dbos_ts.py +44 -9
  12. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/inngest.py +40 -6
  13. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/python.py +55 -46
  14. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/temporal.py +78 -67
  15. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/cli.py +126 -0
  16. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/claude.py +8 -23
  17. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/config.py +16 -1
  18. rote_cli-0.12.0/src/rote/contracts.py +330 -0
  19. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/baseline.py +2 -4
  20. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/empirical.py +2 -4
  21. rote_cli-0.12.0/src/rote/inference/__init__.py +35 -0
  22. rote_cli-0.12.0/src/rote/inference/_runtime_helper.py +914 -0
  23. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/ir.py +63 -3
  24. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/probe.py +58 -9
  25. {rote_cli-0.11.0 → rote_cli-0.12.0}/.gitignore +0 -0
  26. {rote_cli-0.11.0 → rote_cli-0.12.0}/LICENSE +0 -0
  27. {rote_cli-0.11.0 → rote_cli-0.12.0}/README.md +0 -0
  28. {rote_cli-0.11.0 → rote_cli-0.12.0}/pyproject.toml +0 -0
  29. {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/SKILL.md +0 -0
  30. {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/crystallization-heuristics.md +0 -0
  31. {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/eval-estimates.md +0 -0
  32. {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/implementation.md +0 -0
  33. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/__main__.py +0 -0
  34. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/_dbos.py +0 -0
  35. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/__init__.py +0 -0
  36. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/app_registry.py +0 -0
  37. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/cloud_auth.py +0 -0
  38. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/cloud_compile.py +0 -0
  39. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/__init__.py +0 -0
  40. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/__init__.py +0 -0
  41. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/_fs_tools.py +0 -0
  42. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/anthropic_api.py +0 -0
  43. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/codex.py +0 -0
  44. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/openai_api.py +0 -0
  45. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/events.py +0 -0
  46. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/update.py +0 -0
  47. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/deploy.py +0 -0
  48. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/deploy_rote_cloud.py +0 -0
  49. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/__init__.py +0 -0
  50. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/estimate.py +0 -0
  51. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/pricing.py +0 -0
  52. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/priors.py +0 -0
  53. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/scorecard.py +0 -0
  54. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/sidecar.py +0 -0
  55. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/tokens.py +0 -0
  56. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/init_wizard.py +0 -0
  57. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/__init__.py +0 -0
  58. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/_runtime_helper.py +0 -0
  59. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/auth.py +0 -0
  60. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/registry.py +0 -0
  61. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/release.py +0 -0
  62. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/tokens.py +0 -0
  63. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/__init__.py +0 -0
  64. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/_node.py +0 -0
  65. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/cloudflare.py +0 -0
  66. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/dbos_ts.py +0 -0
  67. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/inngest.py +0 -0
  68. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/temporal.py +0 -0
  69. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/serve/__init__.py +0 -0
  70. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/serve/backends.py +0 -0
  71. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/serve/registry.py +0 -0
  72. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/serve/server.py +0 -0
  73. {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/skill_source.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rote-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Compile fuzzy AI skills into deterministic, reliable workflows
5
5
  Project-URL: Homepage, https://github.com/trevhud/rote
6
6
  Project-URL: Repository, https://github.com/trevhud/rote
@@ -336,6 +336,9 @@ the loop.
336
336
  tools:
337
337
  - zoominfo_search_contacts
338
338
  - zoominfo_search_companies
339
+ tool_servers:
340
+ zoominfo_search_contacts: zoominfo
341
+ zoominfo_search_companies: zoominfo
339
342
  loop_body:
340
343
  - enrich_contact_batch
341
344
  - vet_contact
@@ -345,6 +348,16 @@ the loop.
345
348
  timeout: 15m
346
349
  ```
347
350
 
351
+ **Note on `tool_servers`:** map every tool you can to the MCP server
352
+ that provides it. `tools` names tools without a server, and that gap is
353
+ not cosmetic — the pipeline's MCP requirements are derived from server
354
+ names, so a pipeline whose only MCP usage is an agent loop reports *no
355
+ required servers at all* and the user is never told to authenticate.
356
+ Fill in the entries you're confident about and omit the rest; a partial
357
+ map is valid, and an unmapped tool is resolved at run time (more
358
+ fragile, and impossible on Cloudflare, which has no local registry).
359
+ Every key must also appear in `tools`.
360
+
348
361
  **Note on `loop_body`:** sub-nodes listed here must also exist as
349
362
  top-level nodes in the `nodes:` list. They're referenced from the
350
363
  loop_body but also emitted as standalone activities (so they can be
@@ -253,7 +253,7 @@ signature_spec:
253
253
  # 'openai' this reaches any OpenAI-compatible
254
254
  # server (Ollama, vLLM, a gateway). Only set
255
255
  # it when the source skill names an endpoint.
256
- temperature: 0.0 # optional
256
+ temperature: 0.0 # DO NOT SET — see below
257
257
  ```
258
258
 
259
259
  Emitted code layers runtime overrides on top of these defaults —
@@ -261,6 +261,31 @@ Emitted code layers runtime overrides on top of these defaults —
261
261
  variables — so pick sensible defaults and let operators retarget a
262
262
  judge without re-compiling.
263
263
 
264
+ ### Never set `temperature`
265
+
266
+ Leave `temperature` out of `signature_spec`. It reads like the
267
+ obviously-right knob for a deterministic judge, and it is a trap:
268
+ **current Anthropic models reject the parameter outright** with
269
+
270
+ ```
271
+ 400 invalid_request_error: `temperature` is not supported on this
272
+ model. Remove it from your request.
273
+ ```
274
+
275
+ A judge carrying `temperature: 0.0` is therefore dead on arrival
276
+ against a current model — which is exactly where an operator's
277
+ `ROTE_MODEL_<NODE_ID>` override points it. Found live: a graduated
278
+ pipeline whose every judge call 400'd on the first real run.
279
+
280
+ Determinism does not come from temperature anyway. It comes from the
281
+ schema-locked decoding the adapters already emit — a forced tool call
282
+ (Anthropic), `response_format: json_schema` (OpenAI / Workers AI). The
283
+ output shape is pinned whatever the sampler does.
284
+
285
+ If a source skill *explicitly* names a temperature, set it and know
286
+ that emitted code turns it into a `ROTE_TEMPERATURE_<NODE_ID>`
287
+ override whose empty value drops the parameter again.
288
+
264
289
  ### Deriving the JSON Schemas
265
290
 
266
291
  The Pydantic models you wrote in step 3 (input model) and step 4
@@ -294,6 +319,28 @@ entry and use `{"$ref": "#/$defs/InnerModelName"}` at the use site.
294
319
  This matches Pydantic's own emission shape and lets adapters resolve
295
320
  references with a single helper.
296
321
 
322
+ #### A key that can be null is not a required string
323
+
324
+ `required` in an *input* schema is a claim about the data a real
325
+ upstream step will hand you, and it is the single easiest way to make
326
+ a pipeline die on live data. A field that a tool returns as `null` —
327
+ on drafts, on unpublished rows, on anything half-filled — must be
328
+ either omitted from `required` or typed `{"anyOf": [{"type":
329
+ "string"}, {"type": "null"}]}`. Emitted models make a non-required
330
+ field `X | None = None`, so dropping it from `required` is enough.
331
+
332
+ Presence is not non-nullness. An observed payload that always carries
333
+ a `summary` key still tells you nothing about whether its value is
334
+ ever `null`, and schema inference from observations records the key,
335
+ not the nullability.
336
+
337
+ Found live: a weekly-report pipeline judged nine posts one at a time;
338
+ one of them had `summary: null`, the judge's input model required a
339
+ string, and the whole workflow failed on that single element. When you
340
+ have observed payloads (the baseline run's tool results), scan the
341
+ values you actually saw — any `null` in a sample is proof the field
342
+ belongs in the nullable set.
343
+
297
344
  ### Designing the prompt template
298
345
 
299
346
  The prompt is a Jinja-style template. Variables are addressed by the
@@ -170,6 +170,13 @@ Both the number of iterations and the specific queries vary per campaign.
170
170
  tools the agent may call). `loop_body` is optional and lists the IDs of
171
171
  sub-nodes the loop invokes each iteration.
172
172
 
173
+ **Also set `tool_servers`** — tool name → the MCP server providing it —
174
+ for every tool whose server you can identify from the skill (the same
175
+ evidence that gives an `external_call` its `mcp.server`). Without it the
176
+ tool is a bare name: the pipeline under-reports its MCP requirements, the
177
+ user is never prompted to authenticate, and the emitted loop has to guess
178
+ which server to search. A partial map is fine; guessing is not.
179
+
173
180
  **Common mistake:** leaving things as `agent_loop` when they could be
174
181
  crystallized. Most skills over-use this kind on their first pass because
175
182
  "the LLM was doing it" is the easiest classification. Fight the urge —
@@ -1,3 +1,3 @@
1
1
  """rote — compile fuzzy AI skills into deterministic workflows."""
2
2
 
3
- __version__ = "0.11.0"
3
+ __version__ = "0.12.0"
@@ -16,7 +16,7 @@ import json
16
16
  import re
17
17
  from pathlib import Path
18
18
 
19
- from rote.ir import Node, Pipeline, parse_input_ref
19
+ from rote.ir import Node, NodeKind, Pipeline, parse_input_ref
20
20
 
21
21
  # ───────── Default LLM models ─────────
22
22
  #
@@ -420,3 +420,47 @@ def check_input_refs_available(node: Node, available: set[str]) -> None:
420
420
  f"workflow (it runs in a later wave, or is a loop-body sub-node "
421
421
  f"with no top-level result)."
422
422
  )
423
+
424
+
425
+ # ───────── MCP-capability refusal ─────────
426
+
427
+
428
+ def refuse_mcp_only_nodes(pipeline: Pipeline, adapter: str) -> None:
429
+ """Refuse pipelines an MCP-incapable adapter cannot emit, loudly and early.
430
+
431
+ ``ir.Node`` accepts ``impl`` *or* ``mcp`` on an ``external_call`` — a
432
+ node carrying only an ``mcp`` binding is perfectly valid IR. Adapters
433
+ with no MCP backend have no module to import for such a node, so they
434
+ must refuse it rather than emit a call to nothing.
435
+
436
+ Reported for the whole pipeline at once, before any file is written,
437
+ so the user fixes every offending node in one pass instead of
438
+ rediscovering them one re-emit at a time.
439
+ """
440
+ orphans = [
441
+ n.id
442
+ for n in pipeline.nodes
443
+ if n.kind is NodeKind.EXTERNAL_CALL and n.impl is None and n.mcp is not None
444
+ ]
445
+ if not orphans:
446
+ return
447
+ raise ValueError(
448
+ f"{adapter} adapter: pipeline {pipeline.name!r} contains external_call "
449
+ f"node(s) [{', '.join(orphans)}] bound to an MCP server with no `impl:` — "
450
+ f"the {adapter} adapter has no MCP backend, so there is nothing to call. "
451
+ f"Emit onto a runtime that speaks MCP "
452
+ f"(`--runtime dbos`, `--runtime dbos-ts`, `--runtime cloudflare`, "
453
+ f"`--runtime inngest`), or give each node an `impl:` pointing at an "
454
+ f"extracted module."
455
+ )
456
+
457
+
458
+ #: Fallback iteration bound when an ``agent_loop`` declares no
459
+ #: ``termination``. An agent loop must always be bounded — an unbounded
460
+ #: one in a durable workflow burns budget until a human notices.
461
+ #:
462
+ #: Lives here rather than beside either language's emitter because it is
463
+ #: an IR policy, not a language detail: a loop compiled to Python and the
464
+ #: same loop compiled to TypeScript must agree on how many turns "no
465
+ #: declared bound" means.
466
+ DEFAULT_AGENT_MAX_ITERATIONS = 10
@@ -26,7 +26,12 @@ import textwrap
26
26
  from pathlib import Path
27
27
  from typing import Any
28
28
 
29
- from rote.adapters._common import _to_pascal_case, safe_docstring_line
29
+ from rote.adapters._common import (
30
+ DEFAULT_AGENT_MAX_ITERATIONS,
31
+ EmitWriter,
32
+ _to_pascal_case,
33
+ safe_docstring_line,
34
+ )
30
35
  from rote.ir import LLMSignature, Node, NodeKind, Pipeline, parse_input_ref
31
36
 
32
37
  # ───────── impl / signature path references ─────────
@@ -545,6 +550,12 @@ def emit_signature_module(
545
550
  f'MODEL = os.environ.get("ROTE_MODEL_{env_suffix}", {json.dumps(model)})\n'
546
551
  f'BASE_URL = os.environ.get("ROTE_BASE_URL_{env_suffix}"{base_url_default})'
547
552
  )
553
+ if spec.temperature is not None:
554
+ overrides_block += (
555
+ f"\nTEMPERATURE = os.environ.get(\n"
556
+ f' "ROTE_TEMPERATURE_{env_suffix}", {json.dumps(str(spec.temperature))}\n'
557
+ f")"
558
+ )
548
559
 
549
560
  interpolate_block = textwrap.dedent(
550
561
  '''\
@@ -575,14 +586,10 @@ def emit_signature_module(
575
586
  '''
576
587
  )
577
588
 
578
- if spec.client == "anthropic":
579
- call_block = _emit_forward_anthropic(node, pascal, spec)
580
- elif spec.client == "openai":
581
- call_block = _emit_forward_openai(node, pascal, spec)
582
- else: # pragma: no cover — LLMSignature validates the client field
589
+ if spec.client not in ("anthropic", "openai"): # pragma: no cover — IR-validated
583
590
  raise ValueError(f"Unsupported LLM client: {spec.client!r}")
584
591
 
585
- usage_block = _emit_usage_logger(node, spec)
592
+ sampling_block = _emit_sampling_helper(spec)
586
593
 
587
594
  return (
588
595
  header
@@ -598,49 +605,33 @@ def emit_signature_module(
598
605
  + "\n\n\n"
599
606
  + interpolate_block
600
607
  + "\n\n"
601
- + usage_block
602
- + "\n\n"
603
- + call_block
608
+ + sampling_block
609
+ + _emit_forward(node, pascal, spec)
604
610
  )
605
611
 
606
612
 
607
- def _emit_usage_logger(node: Node, spec: LLMSignature) -> str:
608
- """Emit the opt-in usage log hook.
613
+ def _emit_sampling_helper(spec: LLMSignature) -> str:
614
+ """Emit ``temperature`` as a runtime knob rather than a baked constant.
609
615
 
610
- When ``ROTE_USAGE_LOG`` names a file, every judge call appends one
611
- JSONL record with its real token usage. This is what lets
612
- ``rote eval --run`` measure the compiled pipeline's actual LLM
613
- footprint instead of estimating it — and it doubles as a zero-setup
614
- observability tap in production. No env var → no-op, and a logging
615
- failure never breaks the judge call itself.
616
+ Current Anthropic models **reject** ``temperature`` outright with a
617
+ 400 ("`temperature` is not supported on this model"), so a constant
618
+ compiled into the judge makes it unrunnable the moment an operator
619
+ points ``ROTE_MODEL_<ID>`` at a newer model — which is the whole
620
+ point of that knob. An empty ``ROTE_TEMPERATURE_<ID>`` omits the
621
+ parameter entirely; unset keeps the IR's value.
616
622
 
617
- Records the module-level ``MODEL`` constant, not the emit-time
618
- default — operators can swap models at runtime via
619
- ``ROTE_MODEL_<NODE>``, and measured usage must be priced against
620
- the model that actually served the call.
623
+ No IR temperature → no knob and no keyword argument, which is the
624
+ right default: the forced tool call already pins the output shape.
621
625
  """
622
- if spec.client == "openai":
623
- in_attr, out_attr = "prompt_tokens", "completion_tokens"
624
- else:
625
- in_attr, out_attr = "input_tokens", "output_tokens"
626
+ if spec.temperature is None:
627
+ return ""
626
628
  return (
627
- f"def _log_usage(response: Any) -> None:\n"
628
- f' """Append token usage as JSONL to $ROTE_USAGE_LOG, if set."""\n'
629
- f' path = os.environ.get("ROTE_USAGE_LOG")\n'
630
- f" if not path:\n"
631
- f" return\n"
632
- f" try:\n"
633
- f' usage = getattr(response, "usage", None)\n'
634
- f" record = {{\n"
635
- f' "node": {json.dumps(node.id)},\n'
636
- f' "model": MODEL,\n'
637
- f' "input_tokens": getattr(usage, {json.dumps(in_attr)}, None),\n'
638
- f' "output_tokens": getattr(usage, {json.dumps(out_attr)}, None),\n'
639
- f" }}\n"
640
- f' with open(path, "a", encoding="utf-8") as f:\n'
641
- f' f.write(json.dumps(record) + "\\n")\n'
642
- f" except OSError:\n"
643
- f" pass\n"
629
+ "def _sampling_kwargs() -> dict[str, Any]:\n"
630
+ ' """``temperature`` if configured; empty env value omits it."""\n'
631
+ " if not TEMPERATURE.strip():\n"
632
+ " return {}\n"
633
+ ' return {"temperature": float(TEMPERATURE)}\n'
634
+ "\n\n"
644
635
  )
645
636
 
646
637
 
@@ -652,89 +643,188 @@ def _default_model_for(
652
643
  return anthropic_default_model
653
644
 
654
645
 
655
- def _temperature_line(spec: LLMSignature) -> str:
656
- if spec.temperature is None:
657
- return ""
658
- return f" temperature={spec.temperature},\n"
646
+ def _emit_forward(node: Node, pascal: str, spec: LLMSignature) -> str:
647
+ """Emit the judge class: interpolate, delegate, validate.
659
648
 
649
+ The vendor call itself is deliberately *not* emitted per node. It
650
+ lives in ``signatures/_rote_inference.py`` — the verbatim copy of
651
+ :mod:`rote.inference._runtime_helper` — because the same call has to
652
+ choose between three billing lanes (the user's Claude subscription
653
+ via ``claude -p``, their API key, or their rote cloud tenant) and
654
+ that decision is a fact about the machine, not about the pipeline.
655
+ Emitting it once per judge would mean N copies of the resolver
656
+ drifting apart inside a single generated app.
660
657
 
661
- def _emit_forward_anthropic(node: Node, pascal: str, spec: LLMSignature) -> str:
662
- temp = _temperature_line(spec)
658
+ What stays here is what genuinely differs per node: the typed
659
+ models, the prompt, the schema, and the operator knobs.
660
+ """
661
+ kind = "Anthropic" if spec.client == "anthropic" else "OpenAI"
662
+ description_arg = (
663
+ " tool_description=TOOL_DESCRIPTION,\n" if spec.client == "anthropic" else ""
664
+ )
665
+ sampling_arg = (
666
+ " sampling=_sampling_kwargs(),\n" if spec.temperature is not None else ""
667
+ )
663
668
  return (
664
669
  f"class {pascal}:\n"
665
- f' """Typed LLM judge for {node.id} (Anthropic structured output)."""\n'
670
+ f' """Typed LLM judge for {node.id} ({kind} structured output)."""\n'
666
671
  f"\n"
667
672
  f" def forward(self, inputs: {pascal}Input) -> {pascal}Output:\n"
668
- f" # Lazy import: the SDK is only needed at call time, and the\n"
669
- f" # module stays importable in environments without it.\n"
670
- f" import anthropic\n"
673
+ f" # Which provider serves this call — and who is billed for it —\n"
674
+ f" # is resolved at runtime; see signatures/_rote_inference.py.\n"
675
+ f" from signatures._rote_inference import call_judge\n"
671
676
  f"\n"
672
- f" client = anthropic.Anthropic(base_url=BASE_URL)\n"
673
- f" response = client.messages.create(\n"
677
+ f" payload = call_judge(\n"
678
+ f" node_id={json.dumps(node.id)},\n"
679
+ f" client={json.dumps(spec.client)},\n"
674
680
  f" model=MODEL,\n"
675
- f" max_tokens=4096,\n"
676
- f"{temp}"
677
- f" tools=[\n"
678
- f" {{\n"
679
- f' "name": {json.dumps(node.id)},\n'
680
- f' "description": TOOL_DESCRIPTION,\n'
681
- f' "input_schema": OUTPUT_JSON_SCHEMA,\n'
682
- f" }}\n"
683
- f" ],\n"
684
- f' tool_choice={{"type": "tool", "name": {json.dumps(node.id)}}},\n'
685
- f" messages=[\n"
686
- f" {{\n"
687
- f' "role": "user",\n'
688
- f' "content": _interpolate(PROMPT, inputs.model_dump(by_alias=True)),\n'
689
- f" }}\n"
690
- f" ],\n"
691
- f" )\n"
692
- f" _log_usage(response)\n"
693
- f" for block in response.content:\n"
694
- f' if block.type == "tool_use":\n'
695
- f" return {pascal}Output.model_validate(block.input)\n"
696
- f" raise RuntimeError(\n"
697
- f' "LLM did not return a tool_use block for {node.id}"\n'
681
+ f" base_url=BASE_URL,\n"
682
+ f" prompt=_interpolate(PROMPT, inputs.model_dump(by_alias=True)),\n"
683
+ f" output_schema=OUTPUT_JSON_SCHEMA,\n"
684
+ f"{description_arg}"
685
+ f"{sampling_arg}"
698
686
  f" )\n"
687
+ f" return {pascal}Output.model_validate(payload)\n"
699
688
  )
700
689
 
701
690
 
702
- def _emit_forward_openai(node: Node, pascal: str, spec: LLMSignature) -> str:
703
- temp = _temperature_line(spec)
704
- return (
705
- f"class {pascal}:\n"
706
- f' """Typed LLM judge for {node.id} (OpenAI structured output)."""\n'
707
- f"\n"
708
- f" def forward(self, inputs: {pascal}Input) -> {pascal}Output:\n"
709
- f" # Lazy import: the SDK is only needed at call time, and the\n"
710
- f" # module stays importable in environments without it.\n"
711
- f" import openai\n"
712
- f"\n"
713
- f" client = openai.OpenAI(base_url=BASE_URL)\n"
714
- f" response = client.chat.completions.create(\n"
715
- f" model=MODEL,\n"
716
- f"{temp}"
717
- f" response_format={{\n"
718
- f' "type": "json_schema",\n'
719
- f' "json_schema": {{\n'
720
- f' "name": {json.dumps(node.id)},\n'
721
- f' "schema": OUTPUT_JSON_SCHEMA,\n'
722
- f' "strict": True,\n'
723
- f" }},\n"
724
- f" }},\n"
725
- f" messages=[\n"
726
- f" {{\n"
727
- f' "role": "user",\n'
728
- f' "content": _interpolate(PROMPT, inputs.model_dump(by_alias=True)),\n'
729
- f" }}\n"
730
- f" ],\n"
731
- f" )\n"
732
- f" _log_usage(response)\n"
733
- f" content = response.choices[0].message.content\n"
734
- f" if not content:\n"
735
- f' raise RuntimeError("OpenAI returned no content for {node.id}")\n'
736
- f" return {pascal}Output.model_validate(json.loads(content))\n"
691
+ def agent_loop_call(
692
+ node: Node, *, default_model: str, indent: str = " ", include_local_tools: bool = True
693
+ ) -> str:
694
+ """Emit the ``run_agent_loop(...)`` call for an ``agent_loop`` node.
695
+
696
+ This is what replaced the ``NotImplementedError`` stub. A pipeline
697
+ that hands its one genuinely agentic step back to the user has not
698
+ graduated that step, so the emitted code runs a real bounded loop:
699
+ the subscription CLI when one is available and the tools are MCP,
700
+ otherwise the vendor SDK's own tool runner.
701
+
702
+ ``loop_body`` sub-nodes are passed as ``local_tools`` — they are
703
+ already-emitted, already-working steps in the same module, so the
704
+ agent drives them per iteration exactly as the IR describes rather
705
+ than the adapter guessing an order for them.
706
+ """
707
+ max_iterations = (
708
+ node.termination.max_iterations
709
+ if node.termination is not None
710
+ else DEFAULT_AGENT_MAX_ITERATIONS
737
711
  )
712
+ inner = indent + " "
713
+ deep = inner + " "
714
+ model_expr = f'os.environ.get("ROTE_MODEL_{node.id.upper()}", {json.dumps(default_model)})'
715
+ lines = [
716
+ f"{indent}from signatures._rote_inference import run_agent_loop",
717
+ "",
718
+ f"{indent}return run_agent_loop(",
719
+ f"{inner}node_id={json.dumps(node.id)},",
720
+ f"{inner}description={_chunked_str_literal(node.description, deep)},",
721
+ f"{inner}task=json.dumps(payload, default=str),",
722
+ f"{inner}model={model_expr},",
723
+ ]
724
+ if node.tools:
725
+ one_line = f"{inner}tools={_py_literal(list(node.tools))},"
726
+ if len(one_line) <= 98:
727
+ lines.append(one_line)
728
+ else:
729
+ # Emitted output is committed and ruff-linted at line-length
730
+ # 100, so a long tool list wraps rather than overflowing.
731
+ lines.append(f"{inner}tools=[")
732
+ lines.extend(f"{deep}{_py_literal(t)}," for t in node.tools)
733
+ lines.append(f"{inner}],")
734
+ if node.tool_servers:
735
+ # Resolved tool → server pins narrow the allowlist to real pairs.
736
+ lines.append(f"{inner}tool_servers={_py_literal(dict(sorted(node.tool_servers.items())))},")
737
+ if node.loop_body and include_local_tools:
738
+ lines.append(f"{inner}local_tools={{")
739
+ for sub in node.loop_body:
740
+ lines.append(f"{inner} {json.dumps(sub)}: {sub},")
741
+ lines.append(f"{inner}}},")
742
+ lines.append(f"{inner}max_iterations={max_iterations},")
743
+ if node.termination is not None:
744
+ condition = _chunked_str_literal(node.termination.condition, deep)
745
+ lines.append(f"{inner}termination={condition},")
746
+ lines.append(f"{indent})")
747
+ return "\n".join(lines) + "\n"
748
+
749
+
750
+ def inference_helper_source() -> str:
751
+ """The source text emitted as ``signatures/_rote_inference.py``.
752
+
753
+ Verbatim, byte for byte — the same contract
754
+ ``extracted/_rote_mcp.py`` has with :mod:`rote.mcp._runtime_helper`.
755
+ **Never hand-edit the emitted copy; fix the module and re-emit.**
756
+ """
757
+ from rote.inference import _runtime_helper
758
+
759
+ return Path(_runtime_helper.__file__).read_text(encoding="utf-8")
760
+
761
+
762
+ def write_signature_package(
763
+ writer: EmitWriter,
764
+ pipeline: Pipeline,
765
+ *,
766
+ anthropic_default_model: str,
767
+ openai_default_model: str,
768
+ generated_by: str,
769
+ regen_command: str,
770
+ context_note: str,
771
+ ) -> dict[str, Path]:
772
+ """Write the whole ``signatures/`` package for a pipeline's judges.
773
+
774
+ Every Python-emitting adapter needs the identical tree — the package
775
+ marker, one typed module per judge, and the verbatim inference
776
+ helper they all import — differing only in the identity strings
777
+ stamped into each module's docstring. Those are parameters; the
778
+ layout is not, because a runtime that emitted a *different* judge
779
+ package would be a second implementation of the judge.
780
+
781
+ Returns the manifest entries to merge into the adapter's ``written``
782
+ map. Empty only when nothing in the pipeline needs inference — an
783
+ ``agent_loop`` pulls the helper in even with no judges, since the
784
+ loop runs through the same provider resolver.
785
+ """
786
+ judges = spec_judges(pipeline)
787
+ agent_loops = pipeline.nodes_by_kind(NodeKind.AGENT_LOOP)
788
+ if not judges and not agent_loops:
789
+ return {}
790
+ pipeline_name = pipeline.name
791
+ written: dict[str, Path] = {
792
+ "signatures/__init__": writer.write(
793
+ "signatures",
794
+ "__init__.py",
795
+ content=f'"""Generated LLM signatures for {pipeline_name}."""\n',
796
+ ),
797
+ # The provider resolver is the *source text* of
798
+ # rote.inference._runtime_helper — one tested implementation,
799
+ # emitted verbatim so the app stays standalone (no rote import
800
+ # at runtime) while every judge shares one billing decision.
801
+ "signatures/_rote_inference": writer.write(
802
+ "signatures",
803
+ "_rote_inference.py",
804
+ content=inference_helper_source(),
805
+ ),
806
+ }
807
+ for node in judges:
808
+ written[f"signatures/{node.id}"] = writer.write(
809
+ "signatures",
810
+ f"{node.id}.py",
811
+ content=emit_signature_module(
812
+ node,
813
+ anthropic_default_model=anthropic_default_model,
814
+ openai_default_model=openai_default_model,
815
+ generated_by=generated_by,
816
+ regen_command=regen_command,
817
+ context_note=context_note,
818
+ ),
819
+ )
820
+ return written
821
+
822
+
823
+ def spec_judges(pipeline: Pipeline) -> list[Node]:
824
+ """The llm_judge nodes carrying a structured ``signature_spec``."""
825
+ return [
826
+ n for n in pipeline.nodes if n.kind is NodeKind.LLM_JUDGE and n.signature_spec is not None
827
+ ]
738
828
 
739
829
 
740
830
  # ───────── extracted/<module>.py emission ─────────
@@ -743,8 +833,8 @@ def _emit_forward_openai(node: Node, pascal: str, spec: LLMSignature) -> str:
743
833
  def resolve_extracted_source(source_dir: Path | None, module_name: str) -> str | None:
744
834
  """The agent-written ``extracted/<module>.py`` beside the pipeline, if any.
745
835
 
746
- A compilation writes its real (test-verified) implementations into
747
- ``<compiled>/extracted/`` next to ``pipeline.yaml``. Emission
836
+ A graduation writes its real (test-verified) implementations into
837
+ ``<graduated>/extracted/`` next to ``pipeline.yaml``. Emission
748
838
  prefers those files verbatim over IR-derived stubs so the runtime
749
839
  dir runs without anyone hand-copying modules across — the gap that
750
840
  made the first real-server pipeline raise ``NotImplementedError``
@@ -780,8 +870,9 @@ def _extracted_layout(pipeline: Pipeline) -> dict[str, list[Node]]:
780
870
  continue
781
871
  module_name, _ = _impl_path_parts(node.impl)
782
872
  modules.setdefault(module_name, []).append(node)
783
- elif node.kind is NodeKind.AGENT_LOOP:
784
- modules.setdefault(node.id, []).append(node)
873
+ # agent_loop nodes deliberately get no extracted/ module: since
874
+ # the inference helper runs them as real bounded loops, a stub
875
+ # would be dead code that only invites someone to fill it in.
785
876
  return modules
786
877
 
787
878
 
@@ -804,7 +895,7 @@ def emit_extracted_module(
804
895
 
805
896
  Same scaffolding convention as the Temporal example package: the
806
897
  functions raise ``NotImplementedError`` until the user fills them in
807
- with direct vendor API calls. The compilation history (MCP origin) is
898
+ with direct vendor API calls. The graduation history (MCP origin) is
808
899
  documented in docstrings only — never in executable code.
809
900
 
810
901
  ``caller_note`` finishes the "Keep the signatures: …" sentence with
@@ -816,7 +907,7 @@ def emit_extracted_module(
816
907
 
817
908
  Auto-generated stubs by {generated_by}. Replace each body
818
909
  with the real implementation (direct vendor API calls — the MCP
819
- tool calls from the source skill were compiled away at emit
910
+ tool calls from the source skill were graduated away at emit
820
911
  time). Keep the signatures: {caller_note}
821
912
  """
822
913