rote-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- rote/adapters/__init__.py +70 -0
- rote/adapters/_common.py +149 -0
- rote/adapters/cloudflare.py +978 -0
- rote/adapters/dbos.py +1235 -0
- rote/adapters/temporal.py +568 -0
- rote/cli.py +520 -0
- rote/graduator/__init__.py +259 -0
- rote/graduator/drivers/__init__.py +226 -0
- rote/graduator/drivers/anthropic_api.py +423 -0
- rote/graduator/drivers/claude.py +312 -0
- rote/graduator/drivers/codex.py +83 -0
- rote/ir.py +495 -0
- rote/serve/__init__.py +14 -0
- rote/serve/backends.py +162 -0
- rote/serve/registry.py +201 -0
- rote/serve/server.py +249 -0
- rote/skills/rote-graduate/SKILL.md +230 -0
- rote/skills/rote-graduate/references/crystallization-heuristics.md +260 -0
- rote/skills/rote-graduate/references/ir-schema.md +420 -0
- rote/skills/rote-graduate/references/llm-judge-extraction.md +341 -0
- rote/skills/rote-graduate/references/node-kinds.md +223 -0
- rote_cli-0.1.0.dist-info/METADATA +546 -0
- rote_cli-0.1.0.dist-info/RECORD +27 -0
- rote_cli-0.1.0.dist-info/WHEEL +4 -0
- rote_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rote_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# LLM Judge Extraction
|
|
2
|
+
|
|
3
|
+
Phase 4 of the graduator turns every `llm_judge` node's fuzzy prose
|
|
4
|
+
rubric into a typed signature with bounded inputs and outputs. This
|
|
5
|
+
file is the reference for that extraction.
|
|
6
|
+
|
|
7
|
+
The premise is simple: if the source skill's rubric defines a
|
|
8
|
+
classification (red flags, tiers, decision categories), that
|
|
9
|
+
classification has a natural schema — and *running it as an unbounded
|
|
10
|
+
LLM prompt throws away all the structure*. A typed signature recovers
|
|
11
|
+
the structure, makes the step regression-testable, and lets downstream
|
|
12
|
+
nodes consume a predictable shape instead of parsing free text.
|
|
13
|
+
|
|
14
|
+
## What a typed signature looks like
|
|
15
|
+
|
|
16
|
+
A typed signature is a Python class (or BAML function, or DSPy
|
|
17
|
+
Signature) with three parts:
|
|
18
|
+
|
|
19
|
+
1. **Input model** — a Pydantic BaseModel with every field the LLM
|
|
20
|
+
needs, sourced from upstream nodes.
|
|
21
|
+
2. **Output model** — a Pydantic BaseModel with enum-bounded fields
|
|
22
|
+
wherever the rubric implies a discrete choice, plus any structured
|
|
23
|
+
rationale.
|
|
24
|
+
3. **Forward method** — the call site that dispatches to the LLM. For
|
|
25
|
+
v0 this is a stub; downstream work fills it in with DSPy or BAML.
|
|
26
|
+
|
|
27
|
+
The BDR example `signatures/vet_contact.py` is the canonical reference
|
|
28
|
+
implementation.
|
|
29
|
+
|
|
30
|
+
## The extraction procedure
|
|
31
|
+
|
|
32
|
+
### Step 1 — Read the source rubric
|
|
33
|
+
|
|
34
|
+
Find every piece of prose in the source skill that describes how to
|
|
35
|
+
classify the input. For BDR's `vet_contact`, this is
|
|
36
|
+
`references/quality-and-vetting.md`:
|
|
37
|
+
|
|
38
|
+
- High-signal indicators (boosts)
|
|
39
|
+
- Red flags (discard)
|
|
40
|
+
- The core test ("would this person commission an RWE study?")
|
|
41
|
+
- Tier definitions (ideal / strong / good)
|
|
42
|
+
- Numeric thresholds (accuracy ≥ 85)
|
|
43
|
+
|
|
44
|
+
### Step 2 — Enumerate the decision space
|
|
45
|
+
|
|
46
|
+
Ask: what are the possible outputs? For `vet_contact`:
|
|
47
|
+
|
|
48
|
+
| Dimension | Values |
|
|
49
|
+
|---|---|
|
|
50
|
+
| Decision | `keep`, `discard` |
|
|
51
|
+
| Tier (if keep) | `ideal`, `strong`, `good` |
|
|
52
|
+
| Reason (if discard) | `indication_mismatch`, `msl_role`, `biomarker_discovery`, `translational`, `sales_commercial`, `ops_strategy`, `program_management`, `low_accuracy`, `no_valid_email`, `other` |
|
|
53
|
+
| Evidence | free-form string (1-2 sentences) |
|
|
54
|
+
|
|
55
|
+
Anything with a small enumerable value space becomes an **enum**. Free
|
|
56
|
+
text is only used for the *evidence* or *explanation* field, never for
|
|
57
|
+
the core decision.
|
|
58
|
+
|
|
59
|
+
**Rule of thumb:** if you can write the values in a table like the one
|
|
60
|
+
above, it's an enum. If the values would span a paragraph, it's free
|
|
61
|
+
text.
|
|
62
|
+
|
|
63
|
+
### Step 3 — Design the input model
|
|
64
|
+
|
|
65
|
+
Walk the IR backward from the node. The input model must contain every
|
|
66
|
+
field the LLM needs to make the decision. For `vet_contact`:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
class VetContactInput(BaseModel):
|
|
70
|
+
model_config = ConfigDict(extra="forbid")
|
|
71
|
+
|
|
72
|
+
contact: EnrichedContact # from enrich_contact_batch upstream
|
|
73
|
+
brief: CampaignBrief # from pipeline input
|
|
74
|
+
intel: IntelBrief # from target_research upstream
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**`extra="forbid"`** prevents accidental field additions that would
|
|
78
|
+
silently break downstream reads. Strict-by-default.
|
|
79
|
+
|
|
80
|
+
### Step 4 — Design the output model
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
class VetDecision(str, Enum):
|
|
84
|
+
KEEP = "keep"
|
|
85
|
+
DISCARD = "discard"
|
|
86
|
+
|
|
87
|
+
class ContactTier(str, Enum):
|
|
88
|
+
IDEAL = "ideal"
|
|
89
|
+
STRONG = "strong"
|
|
90
|
+
GOOD = "good"
|
|
91
|
+
|
|
92
|
+
class DiscardReason(str, Enum):
|
|
93
|
+
INDICATION_MISMATCH = "indication_mismatch"
|
|
94
|
+
MSL_ROLE = "msl_role"
|
|
95
|
+
BIOMARKER_DISCOVERY = "biomarker_discovery"
|
|
96
|
+
# ... one per rubric category
|
|
97
|
+
|
|
98
|
+
class VetContactOutput(BaseModel):
|
|
99
|
+
model_config = ConfigDict(extra="forbid")
|
|
100
|
+
|
|
101
|
+
decision: VetDecision
|
|
102
|
+
tier: ContactTier | None = None # only when decision == keep
|
|
103
|
+
discard_reason: DiscardReason | None = None # only when decision == discard
|
|
104
|
+
relevance_evidence: str
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Optional fields with `None` defaults** are how you express "this
|
|
108
|
+
field only applies in some decision branches" without making the
|
|
109
|
+
schema conditional. Downstream consumers handle the None case.
|
|
110
|
+
|
|
111
|
+
### Step 5 — Lift hard thresholds into a pre-filter
|
|
112
|
+
|
|
113
|
+
If the rubric contains any numeric threshold or enum check that can be
|
|
114
|
+
evaluated without the LLM, put it in the `forward()` method as a
|
|
115
|
+
pre-filter that short-circuits before calling the model.
|
|
116
|
+
|
|
117
|
+
From `signatures/vet_contact.py`:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
MIN_ACCURACY_SCORE: int = 85
|
|
121
|
+
|
|
122
|
+
class VetContact:
|
|
123
|
+
async def forward(self, inputs: VetContactInput) -> VetContactOutput:
|
|
124
|
+
if inputs.contact.accuracy_score < MIN_ACCURACY_SCORE:
|
|
125
|
+
return VetContactOutput(
|
|
126
|
+
decision=VetDecision.DISCARD,
|
|
127
|
+
discard_reason=DiscardReason.LOW_ACCURACY,
|
|
128
|
+
relevance_evidence=(
|
|
129
|
+
f"Accuracy score {inputs.contact.accuracy_score} "
|
|
130
|
+
f"below threshold {MIN_ACCURACY_SCORE}."
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
# ... dispatch to LLM for fuzzy cases
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Why this matters:** the pre-filter saves tokens on the obvious cases
|
|
137
|
+
(probably 20-40% of real contacts) and guarantees the hard rule cannot
|
|
138
|
+
drift. The LLM can never "forget" the accuracy threshold because it
|
|
139
|
+
never sees those contacts.
|
|
140
|
+
|
|
141
|
+
### Step 6 — Scaffold a seed eval set
|
|
142
|
+
|
|
143
|
+
For every `llm_judge` node, create a seed evals file at the path
|
|
144
|
+
referenced by the node's `eval_set:` field. Each line is one test case.
|
|
145
|
+
|
|
146
|
+
Harvest examples directly from the source rubric. For every
|
|
147
|
+
discard_reason enum value, construct at least one input that should
|
|
148
|
+
produce that reason.
|
|
149
|
+
|
|
150
|
+
For BDR `evals/vet_contact.jsonl`:
|
|
151
|
+
|
|
152
|
+
```jsonl
|
|
153
|
+
{"name": "msl_role_discard", "input": {"contact": {"job_title": "Medical Science Liaison, Respiratory", "employment_history": [{"title": "MSL, Oncology"}], "accuracy_score": 95}, "brief": {"therapeutic_area": "respiratory"}}, "expected": {"decision": "discard", "discard_reason": "msl_role"}}
|
|
154
|
+
{"name": "biomarker_discard", "input": {"contact": {"job_title": "Director, Biomarker Sciences", "accuracy_score": 92}}, "expected": {"decision": "discard", "discard_reason": "biomarker_discovery"}}
|
|
155
|
+
{"name": "low_accuracy_discard", "input": {"contact": {"job_title": "Sr. Director RWE", "accuracy_score": 70}}, "expected": {"decision": "discard", "discard_reason": "low_accuracy"}}
|
|
156
|
+
{"name": "ideal_coe", "input": {"contact": {"job_title": "Sr. Director, Real World Evidence CoE", "employment_history": [...]}}, "expected": {"decision": "keep", "tier": "ideal"}}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
These seed examples are not a full eval suite — they're the starting
|
|
160
|
+
point a human can expand as they encounter real cases. But having them
|
|
161
|
+
in the repo means the downstream DSPy/BAML compile step has a regression
|
|
162
|
+
baseline to optimize against.
|
|
163
|
+
|
|
164
|
+
## Common patterns
|
|
165
|
+
|
|
166
|
+
### Pattern: "discard categories" rubric
|
|
167
|
+
|
|
168
|
+
The source skill has an explicit list of categories to reject. Each
|
|
169
|
+
becomes an enum member of a `Reason` enum, and the eval set has one
|
|
170
|
+
example per category.
|
|
171
|
+
|
|
172
|
+
### Pattern: "tier the keepers" rubric
|
|
173
|
+
|
|
174
|
+
The source skill defines tiers (ideal / strong / good) with criteria
|
|
175
|
+
for each. Each tier becomes an enum member of a `Tier` field, which is
|
|
176
|
+
optional (only set when the decision is to keep).
|
|
177
|
+
|
|
178
|
+
### Pattern: "one judge, fan out"
|
|
179
|
+
|
|
180
|
+
If the source skill applies the same judgment to a list of inputs
|
|
181
|
+
(vet 50 contacts, personalize 10 emails), set `fan_out: true` on the
|
|
182
|
+
node in the IR. The adapter will invoke the signature once per input
|
|
183
|
+
element in parallel. The signature itself handles one input at a time.
|
|
184
|
+
|
|
185
|
+
### Pattern: "cheap pre-filter + expensive LLM"
|
|
186
|
+
|
|
187
|
+
Almost every `llm_judge` has at least one numeric or enum constraint
|
|
188
|
+
hiding in the rubric. Always check.
|
|
189
|
+
|
|
190
|
+
## What does NOT belong in a signature
|
|
191
|
+
|
|
192
|
+
- **Exploratory work.** If the "classification" requires the agent to
|
|
193
|
+
decide which external sources to consult, it's an `agent_loop`, not
|
|
194
|
+
an `llm_judge`.
|
|
195
|
+
- **Unbounded generation.** If the output is a paragraph of free prose
|
|
196
|
+
with no schema, reconsider whether it's the right kind. Most
|
|
197
|
+
"generate a paragraph" tasks can be narrowed to a few structured
|
|
198
|
+
fields (opening line + TA callout + CTA, for example).
|
|
199
|
+
- **Multi-step reasoning across many inputs.** Signatures take one
|
|
200
|
+
bounded input and return one bounded output. If the step needs to
|
|
201
|
+
reason across a batch, use `fan_out: true` so each input is its
|
|
202
|
+
own invocation.
|
|
203
|
+
|
|
204
|
+
## The output of Phase 4
|
|
205
|
+
|
|
206
|
+
For each `llm_judge` node:
|
|
207
|
+
|
|
208
|
+
1. A file at `signatures/<node_name>.py` with:
|
|
209
|
+
- The input model
|
|
210
|
+
- The output model
|
|
211
|
+
- The enum definitions
|
|
212
|
+
- The signature class with a stub `forward()` method (pre-filter
|
|
213
|
+
logic included; LLM dispatch raises `NotImplementedError`)
|
|
214
|
+
2. A file at `evals/<node_name>.jsonl` with 3–10 seed examples, one
|
|
215
|
+
per distinct decision path.
|
|
216
|
+
3. A `signature_spec:` block embedded directly in the
|
|
217
|
+
`pipeline.yaml` node — see "Cross-runtime signature_spec" below.
|
|
218
|
+
|
|
219
|
+
Record every signature extracted in the Phase 7 graduation report with
|
|
220
|
+
the source rubric location and a one-line summary of the output schema.
|
|
221
|
+
This is the audit trail for the human reviewer.
|
|
222
|
+
|
|
223
|
+
## Cross-runtime signature_spec
|
|
224
|
+
|
|
225
|
+
Python signature files (item 1 above) work for the Temporal adapter,
|
|
226
|
+
which emits Python and can `import` them directly. They do **not**
|
|
227
|
+
work for runtimes that emit a different language — the Cloudflare
|
|
228
|
+
adapter emits TypeScript, can't read Python, and has no way to call
|
|
229
|
+
into a Python BAML/DSPy client (those have native Rust binaries that
|
|
230
|
+
don't run on Cloudflare Workers' V8 isolate).
|
|
231
|
+
|
|
232
|
+
The IR carries a runtime-agnostic structured form alongside the path:
|
|
233
|
+
`signature_spec`. Every `llm_judge` node should populate it. The
|
|
234
|
+
adapter that consumes the IR converts the schemas to whatever native
|
|
235
|
+
shape its target language expects — Pydantic for Python, Zod for
|
|
236
|
+
TypeScript, etc.
|
|
237
|
+
|
|
238
|
+
### Field shape
|
|
239
|
+
|
|
240
|
+
```yaml
|
|
241
|
+
signature_spec:
|
|
242
|
+
input_schema: { ... } # JSON Schema for the input model
|
|
243
|
+
output_schema: { ... } # JSON Schema for the output model
|
|
244
|
+
prompt: | # Jinja-style {{ var }} interpolation
|
|
245
|
+
<multi-line prompt template>
|
|
246
|
+
client: anthropic # 'anthropic' | 'openai'
|
|
247
|
+
model: claude-sonnet-4-6 # optional; adapter chooses default
|
|
248
|
+
temperature: 0.0 # optional
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Deriving the JSON Schemas
|
|
252
|
+
|
|
253
|
+
The Pydantic models you wrote in step 3 (input model) and step 4
|
|
254
|
+
(output model) already know how to emit JSON Schema:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
VetContactInput.model_json_schema()
|
|
258
|
+
VetContactOutput.model_json_schema()
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Embed those dictionaries verbatim under `input_schema` and
|
|
262
|
+
`output_schema`. The `$defs` block stays inline — adapters resolve
|
|
263
|
+
references at emit time.
|
|
264
|
+
|
|
265
|
+
If you can't run Python, derive the JSON Schema by hand from the
|
|
266
|
+
Pydantic source:
|
|
267
|
+
|
|
268
|
+
| Pydantic | JSON Schema |
|
|
269
|
+
| --- | --- |
|
|
270
|
+
| `field: str` (required) | `{"type": "string"}` in `properties`, name in `required` |
|
|
271
|
+
| `field: int` | `{"type": "integer"}` |
|
|
272
|
+
| `field: float` | `{"type": "number"}` |
|
|
273
|
+
| `field: bool` | `{"type": "boolean"}` |
|
|
274
|
+
| `field: list[X]` | `{"type": "array", "items": <X>}` |
|
|
275
|
+
| `field: SomeEnum` | `{"enum": [<member values>]}` (or `$ref` to a `$defs` entry) |
|
|
276
|
+
| `field: X \| None = None` | `{"anyOf": [<X>, {"type": "null"}], "default": null}`, optional in `required` |
|
|
277
|
+
| `model_config = ConfigDict(extra="forbid")` | `"additionalProperties": false` on the object |
|
|
278
|
+
|
|
279
|
+
For nested Pydantic models, hoist the inner model into a `$defs`
|
|
280
|
+
entry and use `{"$ref": "#/$defs/InnerModelName"}` at the use site.
|
|
281
|
+
This matches Pydantic's own emission shape and lets adapters resolve
|
|
282
|
+
references with a single helper.
|
|
283
|
+
|
|
284
|
+
### Designing the prompt template
|
|
285
|
+
|
|
286
|
+
The prompt is a Jinja-style template. Variables are addressed by the
|
|
287
|
+
input model's field names (top-level only — adapters use simple
|
|
288
|
+
`{{ contact }}` substitution that JSON-stringifies non-string
|
|
289
|
+
values). Three rules:
|
|
290
|
+
|
|
291
|
+
1. **Always end with a directive that names the structured-output
|
|
292
|
+
tool** — the adapter wraps the call in tool-use mode and the LLM
|
|
293
|
+
needs the cue to invoke it. Example: *"Return your decision via
|
|
294
|
+
the structured output tool."*
|
|
295
|
+
2. **Reproduce the discard-categories table inline.** The schema
|
|
296
|
+
already constrains the output enum, but the prompt should still
|
|
297
|
+
describe each category in prose so the LLM has the rubric.
|
|
298
|
+
3. **Don't paste the source skill's entire reference file** — just
|
|
299
|
+
the rubric. The skill bundle has plenty of context that's
|
|
300
|
+
irrelevant at decision time and inflates token cost per call.
|
|
301
|
+
|
|
302
|
+
### Worked example: vet_contact prompt
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
prompt: |
|
|
306
|
+
You are vetting a contact for a BDR outreach campaign.
|
|
307
|
+
|
|
308
|
+
Apply this rubric:
|
|
309
|
+
- Discard if job title indicates MSL, Biomarker/Discovery,
|
|
310
|
+
Translational Research, Sales/Commercial, Operations/Strategy,
|
|
311
|
+
or Program Management.
|
|
312
|
+
- Discard on indication mismatch with the campaign therapeutic
|
|
313
|
+
area.
|
|
314
|
+
- Tier surviving contacts: ideal / strong / good based on RWE/HEOR
|
|
315
|
+
signal density.
|
|
316
|
+
|
|
317
|
+
Core test: would this person commission, design, or approve a
|
|
318
|
+
real-world evidence study?
|
|
319
|
+
|
|
320
|
+
Contact: {{ contact }}
|
|
321
|
+
Campaign brief: {{ brief }}
|
|
322
|
+
Intel brief: {{ intel }}
|
|
323
|
+
|
|
324
|
+
Return your decision via the structured output tool.
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Pre-filter logic and signature_spec
|
|
328
|
+
|
|
329
|
+
Hard thresholds (the Step 5 pre-filter) live in **Python**, not in the
|
|
330
|
+
prompt. Cross-language emission is the responsibility of the runtime
|
|
331
|
+
adapter. The Temporal adapter calls the Python signature class, which
|
|
332
|
+
runs the pre-filter then dispatches to the LLM. The Cloudflare adapter
|
|
333
|
+
emits a TS function that calls the LLM directly — a future iteration
|
|
334
|
+
will model the pre-filter as a separate `pure_function` node so it
|
|
335
|
+
runs cross-runtime, but for v0.2 the signature_spec is "schema +
|
|
336
|
+
prompt only" and the pre-filter only short-circuits in the Python
|
|
337
|
+
runtime.
|
|
338
|
+
|
|
339
|
+
If you want a hard rule to apply on every runtime today, model it as
|
|
340
|
+
a separate `pure_function` node *before* the `llm_judge` and route
|
|
341
|
+
short-circuited inputs around the LLM via an explicit edge.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# Node Kinds — Classification Rubric
|
|
2
|
+
|
|
3
|
+
Every step in a graduated pipeline is exactly **one** of five kinds. Phase 2
|
|
4
|
+
of the graduator's job is assigning the right kind to every step in the
|
|
5
|
+
source skill. This file is the reference for that classification.
|
|
6
|
+
|
|
7
|
+
## The five kinds
|
|
8
|
+
|
|
9
|
+
### `pure_function`
|
|
10
|
+
|
|
11
|
+
A step whose logic is fully deterministic: same input, same output, no LLM
|
|
12
|
+
reasoning required, no external API calls.
|
|
13
|
+
|
|
14
|
+
**Signals to recognize one:**
|
|
15
|
+
- The skill's prose describes a fixed transformation (e.g., "format as
|
|
16
|
+
markdown with these sections", "group contacts by company").
|
|
17
|
+
- The skill includes literal Python, pseudocode, or a concrete formula.
|
|
18
|
+
- The step produces a report, a formatted output, or a structured
|
|
19
|
+
summary from already-structured inputs.
|
|
20
|
+
- The step is a loop with a clear termination condition over
|
|
21
|
+
already-gathered data.
|
|
22
|
+
|
|
23
|
+
**Anti-signals (not a `pure_function`):**
|
|
24
|
+
- The step calls an external service → `external_call`.
|
|
25
|
+
- The step makes a fuzzy judgment → `llm_judge`.
|
|
26
|
+
- The logic varies based on context in a way that can't be enumerated
|
|
27
|
+
→ `agent_loop`.
|
|
28
|
+
|
|
29
|
+
**BDR example:** `pre_enrollment_report` — takes counts of vetted, passed,
|
|
30
|
+
and excluded contacts and renders a fixed markdown template. No LLM, no
|
|
31
|
+
APIs, just string formatting.
|
|
32
|
+
|
|
33
|
+
**Common mistake:** don't miss steps where the LLM was being used to
|
|
34
|
+
generate obvious string templates. If the skill's prose *already shows you
|
|
35
|
+
the exact output format*, the LLM was doing pure formatting and the step
|
|
36
|
+
is a `pure_function`.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
### `external_call`
|
|
41
|
+
|
|
42
|
+
A step that makes a deterministic call to an external service (HTTP API,
|
|
43
|
+
database, file system) with retry and timeout semantics.
|
|
44
|
+
|
|
45
|
+
**Signals to recognize one:**
|
|
46
|
+
- The skill uses a tool in a fixed, repeatable way — not exploratory.
|
|
47
|
+
- The call has well-known limits (batch size, rate limits) that appear
|
|
48
|
+
in the skill's prose.
|
|
49
|
+
- The step is "fetch X from service Y given params Z" with no LLM
|
|
50
|
+
reasoning about the response shape.
|
|
51
|
+
|
|
52
|
+
**Anti-signals (not an `external_call`):**
|
|
53
|
+
- The response requires LLM interpretation before being useful →
|
|
54
|
+
that's a pair of nodes: `external_call` followed by `llm_judge`.
|
|
55
|
+
- The tool is being used exploratorily with variable inputs →
|
|
56
|
+
`agent_loop`.
|
|
57
|
+
- The step is a pure in-memory computation → `pure_function`.
|
|
58
|
+
|
|
59
|
+
**BDR example:** `enrich_contact_batch` — calls
|
|
60
|
+
`zoominfo_enrich_contacts` with a fixed output field set and a hard batch
|
|
61
|
+
size of 10. The parameters don't vary run-over-run.
|
|
62
|
+
|
|
63
|
+
**Common mistake:** treating every tool call as an `external_call`. A tool
|
|
64
|
+
call is only an `external_call` if the *semantics* of calling it are
|
|
65
|
+
deterministic. If the agent decides which tool to call and what to pass
|
|
66
|
+
each iteration, it's an `agent_loop`.
|
|
67
|
+
|
|
68
|
+
**MCP → deterministic API:** this is the kind that most often originates
|
|
69
|
+
as an MCP tool call in the source skill. The graduation step is to record
|
|
70
|
+
the underlying vendor API the tool wraps (e.g.,
|
|
71
|
+
`POST /crm/v3/objects/contacts/batch/upsert`) in the `impl`'s docstring so
|
|
72
|
+
the trace from skill → MCP tool → REST endpoint is visible in the code.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
### `llm_judge`
|
|
77
|
+
|
|
78
|
+
A step that asks an LLM to make a classification or generation against a
|
|
79
|
+
rubric, with typed input and typed output. The fuzzy part is bounded —
|
|
80
|
+
the output space is enumerable or naturally small.
|
|
81
|
+
|
|
82
|
+
**Signals to recognize one:**
|
|
83
|
+
- The skill describes "apply these rules to decide X" where X is a
|
|
84
|
+
bounded decision (keep/discard, tier A/B/C, a short piece of
|
|
85
|
+
generated text).
|
|
86
|
+
- There's an explicit rubric in the skill — red flags list, core test,
|
|
87
|
+
tier definitions, discard categories.
|
|
88
|
+
- The output has a natural schema: decision + reason + evidence.
|
|
89
|
+
- The input is bounded (a single contact, a single row of data) rather
|
|
90
|
+
than exploratory.
|
|
91
|
+
|
|
92
|
+
**Anti-signals (not an `llm_judge`):**
|
|
93
|
+
- The output is free-form prose with no schema → `agent_loop`.
|
|
94
|
+
- The input is exploratory (search queries, research topics) → `agent_loop`.
|
|
95
|
+
- The "judgment" has hard numerical thresholds that could be encoded
|
|
96
|
+
in Python — lift those into a pre-filter in the signature, but the
|
|
97
|
+
step itself can still be an `llm_judge`.
|
|
98
|
+
|
|
99
|
+
**BDR example:** `vet_contact` — takes an enriched contact plus the
|
|
100
|
+
campaign brief, applies the BDR red-flags rubric, returns
|
|
101
|
+
`{decision: keep|discard, tier: ideal|strong|good, discard_reason: ...,
|
|
102
|
+
relevance_evidence: str}`. The decision is fuzzy (reading employment
|
|
103
|
+
history) but the output schema is tight.
|
|
104
|
+
|
|
105
|
+
**Common mistake:** running an `llm_judge` when the classification is
|
|
106
|
+
actually deterministic. If every dimension of the decision maps to a
|
|
107
|
+
boolean check on the input, it's a `pure_function`.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
### `agent_loop`
|
|
112
|
+
|
|
113
|
+
A step that requires genuine LLM orchestration — the agent decides what
|
|
114
|
+
to do next, which tools to call, and when to terminate, based on
|
|
115
|
+
intermediate results. Reserved for genuinely exploratory work.
|
|
116
|
+
|
|
117
|
+
**Signals to recognize one:**
|
|
118
|
+
- The skill's prose says things like "iterate until...", "try different
|
|
119
|
+
searches", "backfill when gaps appear".
|
|
120
|
+
- The tool choices vary run-over-run.
|
|
121
|
+
- The termination condition depends on intermediate results, not a
|
|
122
|
+
fixed iteration count.
|
|
123
|
+
- The step produces a summary or brief from external research where
|
|
124
|
+
the sources aren't known in advance.
|
|
125
|
+
|
|
126
|
+
**Anti-signals (not an `agent_loop`):**
|
|
127
|
+
- The step is a fixed sequence of API calls with known inputs → chain
|
|
128
|
+
of `external_call` nodes.
|
|
129
|
+
- The step is an LLM classification with bounded output → `llm_judge`.
|
|
130
|
+
- Every "iteration" does the same thing on different inputs → a
|
|
131
|
+
`pure_function` or `external_call` with `fan_out: true`.
|
|
132
|
+
|
|
133
|
+
**BDR example:** `lead_generation_loop` — starts with three parallel
|
|
134
|
+
ZoomInfo searches, enriches in batches, discards contacts that fail
|
|
135
|
+
vetting, backfills with new targeted searches until the quota is met.
|
|
136
|
+
Both the number of iterations and the specific queries vary per campaign.
|
|
137
|
+
|
|
138
|
+
**Required fields:** `tools` must be set on every `agent_loop` node (the
|
|
139
|
+
tools the agent may call). `loop_body` is optional and lists the IDs of
|
|
140
|
+
sub-nodes the loop invokes each iteration.
|
|
141
|
+
|
|
142
|
+
**Common mistake:** leaving things as `agent_loop` when they could be
|
|
143
|
+
crystallized. Most skills over-use this kind on their first pass because
|
|
144
|
+
"the LLM was doing it" is the easiest classification. Fight the urge —
|
|
145
|
+
prefer any other kind when the data supports it. Every step you keep as
|
|
146
|
+
`agent_loop` is a run-time cost and a reliability risk.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### `hitl_gate`
|
|
151
|
+
|
|
152
|
+
A step where the workflow pauses waiting for a human signal before
|
|
153
|
+
continuing. Survives worker restarts; resumes when the signal arrives.
|
|
154
|
+
|
|
155
|
+
**Signals to recognize one:**
|
|
156
|
+
- The skill explicitly says "present to the user", "wait for approval",
|
|
157
|
+
"the user may add or remove".
|
|
158
|
+
- The next step is conditional on explicit human decision.
|
|
159
|
+
- The skill says something "must be done manually in the UI" — the
|
|
160
|
+
gate is the thing that confirms it happened.
|
|
161
|
+
|
|
162
|
+
**Anti-signals (not a `hitl_gate`):**
|
|
163
|
+
- The step prints output but doesn't wait for a response → that's the
|
|
164
|
+
end of a pipeline or a terminal `pure_function`.
|
|
165
|
+
- The "human" is conceptual — there's no actual signal → still a
|
|
166
|
+
`pure_function` that produces a report.
|
|
167
|
+
|
|
168
|
+
**BDR examples:**
|
|
169
|
+
1. `contact_review_gate` — Phase 3 pause where the BDR reviews the
|
|
170
|
+
vetted contact table before CRM upload. Signal:
|
|
171
|
+
`contact_review_approved`.
|
|
172
|
+
2. `manual_enrollment_handoff` — Phase 7 pause where the BDR manually
|
|
173
|
+
enrolls contacts in HubSpot's UI. Signal: `bdr_enrollment_complete`.
|
|
174
|
+
|
|
175
|
+
**Required field:** every `hitl_gate` MUST have a `signal`. The adapter
|
|
176
|
+
uses this to generate the corresponding signal handler in the workflow.
|
|
177
|
+
|
|
178
|
+
**Optional field:** `notify` tells the adapter how to alert the human
|
|
179
|
+
reviewer when the workflow reaches the gate (Slack channel, email, etc.).
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Decision rules for ambiguous cases
|
|
184
|
+
|
|
185
|
+
**When a step could be two kinds, prefer the more deterministic one.** In
|
|
186
|
+
descending order of determinism:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
pure_function > external_call > llm_judge > agent_loop
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The north star is "keep the LLM at points where the input is unbounded or
|
|
193
|
+
ambiguous, and codify everything else." Every step you move leftward on
|
|
194
|
+
this ladder is tokens saved and reliability gained.
|
|
195
|
+
|
|
196
|
+
**`agent_loop` vs `external_call`:** could the agent do this with a fixed
|
|
197
|
+
sequence of calls? If yes, it's `external_call`s (possibly chained via
|
|
198
|
+
edges or fan-out). If the sequence itself varies based on intermediate
|
|
199
|
+
results, it's `agent_loop`.
|
|
200
|
+
|
|
201
|
+
**`llm_judge` vs `pure_function`:** does the decision depend on reading
|
|
202
|
+
prose (job titles, descriptions, employment histories, free text)? If
|
|
203
|
+
yes, it's `llm_judge`. If it depends only on numeric thresholds or enum
|
|
204
|
+
matching, it's `pure_function`.
|
|
205
|
+
|
|
206
|
+
**`llm_judge` vs `agent_loop`:** does the output have a schema? If yes,
|
|
207
|
+
it's `llm_judge`. Free-form output usually indicates the agent is
|
|
208
|
+
orchestrating something and you haven't found its real boundaries yet.
|
|
209
|
+
|
|
210
|
+
**`hitl_gate` vs report-producing `pure_function`:** is there an explicit
|
|
211
|
+
signal or approval the workflow waits for? If yes, `hitl_gate`. If the
|
|
212
|
+
step just prints a summary and the pipeline ends or continues
|
|
213
|
+
unconditionally, it's a `pure_function`.
|
|
214
|
+
|
|
215
|
+
## Cheat sheet
|
|
216
|
+
|
|
217
|
+
| Question | Kind |
|
|
218
|
+
|---|---|
|
|
219
|
+
| Is there an explicit human signal? | `hitl_gate` |
|
|
220
|
+
| Is it a vendor API call with fixed semantics? | `external_call` |
|
|
221
|
+
| Is it fully deterministic Python? | `pure_function` |
|
|
222
|
+
| Is it a fuzzy classification with typed output? | `llm_judge` |
|
|
223
|
+
| Does the agent genuinely need to decide? | `agent_loop` |
|