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.
- {rote_cli-0.11.0 → rote_cli-0.12.0}/PKG-INFO +1 -1
- {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/ir-schema.md +13 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/llm-judge-extraction.md +48 -1
- {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/node-kinds.md +7 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/__init__.py +1 -1
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/_common.py +45 -1
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/_py_common.py +210 -119
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/_ts_common.py +1009 -50
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/cloudflare.py +211 -33
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/dbos.py +43 -34
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/dbos_ts.py +44 -9
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/inngest.py +40 -6
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/python.py +55 -46
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/temporal.py +78 -67
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/cli.py +126 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/claude.py +8 -23
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/config.py +16 -1
- rote_cli-0.12.0/src/rote/contracts.py +330 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/baseline.py +2 -4
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/empirical.py +2 -4
- rote_cli-0.12.0/src/rote/inference/__init__.py +35 -0
- rote_cli-0.12.0/src/rote/inference/_runtime_helper.py +914 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/ir.py +63 -3
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/probe.py +58 -9
- {rote_cli-0.11.0 → rote_cli-0.12.0}/.gitignore +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/LICENSE +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/README.md +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/pyproject.toml +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/SKILL.md +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/crystallization-heuristics.md +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/eval-estimates.md +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/skills/rote-compile/references/implementation.md +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/__main__.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/_dbos.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/adapters/__init__.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/app_registry.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/cloud_auth.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/cloud_compile.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/__init__.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/__init__.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/_fs_tools.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/anthropic_api.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/codex.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/drivers/openai_api.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/events.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/compiler/update.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/deploy.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/deploy_rote_cloud.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/__init__.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/estimate.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/pricing.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/priors.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/scorecard.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/sidecar.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/eval/tokens.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/init_wizard.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/__init__.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/_runtime_helper.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/auth.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/registry.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/release.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/mcp/tokens.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/__init__.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/_node.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/cloudflare.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/dbos_ts.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/inngest.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/runners/temporal.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/serve/__init__.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/serve/backends.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/serve/registry.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/serve/server.py +0 -0
- {rote_cli-0.11.0 → rote_cli-0.12.0}/src/rote/skill_source.py +0 -0
|
@@ -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 #
|
|
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 —
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
+
|
|
602
|
-
+
|
|
603
|
-
+ call_block
|
|
608
|
+
+ sampling_block
|
|
609
|
+
+ _emit_forward(node, pascal, spec)
|
|
604
610
|
)
|
|
605
611
|
|
|
606
612
|
|
|
607
|
-
def
|
|
608
|
-
"""Emit
|
|
613
|
+
def _emit_sampling_helper(spec: LLMSignature) -> str:
|
|
614
|
+
"""Emit ``temperature`` as a runtime knob rather than a baked constant.
|
|
609
615
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
618
|
-
default
|
|
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.
|
|
623
|
-
|
|
624
|
-
else:
|
|
625
|
-
in_attr, out_attr = "input_tokens", "output_tokens"
|
|
626
|
+
if spec.temperature is None:
|
|
627
|
+
return ""
|
|
626
628
|
return (
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
656
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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} (
|
|
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" #
|
|
669
|
-
f" #
|
|
670
|
-
f" import
|
|
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"
|
|
673
|
-
f"
|
|
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"
|
|
676
|
-
f"
|
|
677
|
-
f"
|
|
678
|
-
f"
|
|
679
|
-
f
|
|
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
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
|
747
|
-
``<
|
|
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
|
-
|
|
784
|
-
|
|
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
|
|
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
|
|
910
|
+
tool calls from the source skill were graduated away at emit
|
|
820
911
|
time). Keep the signatures: {caller_note}
|
|
821
912
|
"""
|
|
822
913
|
|