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,260 @@
|
|
|
1
|
+
# Crystallization Heuristics
|
|
2
|
+
|
|
3
|
+
Phase 3 of the graduator is where the highest leverage lives: finding
|
|
4
|
+
every place the source skill's prose is hiding a deterministic procedure
|
|
5
|
+
that *should be code*. This file is the pattern library.
|
|
6
|
+
|
|
7
|
+
The guiding rule is simple: **if the skill's author already wrote down
|
|
8
|
+
the exact procedure, don't make the LLM re-derive it at runtime.** Every
|
|
9
|
+
token spent re-deriving a known-fixed procedure is waste, and every
|
|
10
|
+
MANDATORY check enforced only by prose is a bug waiting to happen.
|
|
11
|
+
|
|
12
|
+
## Why crystallization is the biggest win
|
|
13
|
+
|
|
14
|
+
Skills that graduate well typically see:
|
|
15
|
+
- **50–70% token reduction per run** from moving formatting, batching,
|
|
16
|
+
and rule enforcement into code
|
|
17
|
+
- **Reliability wins** from making MANDATORY checks impossible to skip
|
|
18
|
+
- **Testability wins** from having the deterministic parts in regression
|
|
19
|
+
suites instead of trusting the agent to follow instructions
|
|
20
|
+
|
|
21
|
+
Crystallization is mostly a pattern-matching exercise. This file lists
|
|
22
|
+
the patterns you should actively hunt for.
|
|
23
|
+
|
|
24
|
+
## The patterns
|
|
25
|
+
|
|
26
|
+
### Pattern 1 — Literal Python or pseudocode in the prompt
|
|
27
|
+
|
|
28
|
+
**What it looks like:** the skill's markdown includes fenced code blocks
|
|
29
|
+
(```python` or ``` `text` ) that describe exactly how to do something.
|
|
30
|
+
The LLM is being asked to *read* this code and *re-execute it in its
|
|
31
|
+
head*.
|
|
32
|
+
|
|
33
|
+
**BDR example:**
|
|
34
|
+
`references/conference-enrichment.md` includes a full Python function
|
|
35
|
+
definition for `is_pharma(company_name)` — a keyword classifier with
|
|
36
|
+
hardcoded include/exclude lists. The LLM reads this file, mentally
|
|
37
|
+
"runs" the function on each contact, and returns a list. Absurd.
|
|
38
|
+
|
|
39
|
+
**What to do:**
|
|
40
|
+
- Extract the code verbatim into an `extracted/*.py` module.
|
|
41
|
+
- Create a `pure_function` node that calls it.
|
|
42
|
+
- Note the extraction in the graduation report so the human reviewer
|
|
43
|
+
can verify the extraction matches the original.
|
|
44
|
+
|
|
45
|
+
**How to detect:** grep for ` ```python`, ` ```py`, and `def `. Also
|
|
46
|
+
look for `for contact in`-style pseudocode in plain prose — it's the
|
|
47
|
+
same pattern without the code fence.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
### Pattern 2 — Fixed constants embedded in prose
|
|
52
|
+
|
|
53
|
+
**What it looks like:** the skill says "batch of 10", "wait 30 days",
|
|
54
|
+
"accuracy below 85", "up to 250 per call". These numbers never change
|
|
55
|
+
run-over-run; they're API limits or policy thresholds.
|
|
56
|
+
|
|
57
|
+
**BDR examples:**
|
|
58
|
+
- `enrich_contact_batch`: `batch_size: 10` (ZoomInfo API limit)
|
|
59
|
+
- `hubspot_upsert`: `batch_size: 100` (HubSpot API limit)
|
|
60
|
+
- `hubspot_create_list` → `add_contacts_to_list`: `batch_size: 250`
|
|
61
|
+
- `exclusion_check_recent`: `days_back: 30`
|
|
62
|
+
- `vet_contact`: `min_accuracy_score: 85`
|
|
63
|
+
|
|
64
|
+
**What to do:**
|
|
65
|
+
- Lift the constant into the node's `constants:` field in the IR.
|
|
66
|
+
- In the extracted Python module, define it as a module-level constant
|
|
67
|
+
(so it shows up once and is trivially overridable if policy changes).
|
|
68
|
+
- Enforce it in code — the extracted function should *reject* inputs
|
|
69
|
+
that violate the constraint, not just document it.
|
|
70
|
+
|
|
71
|
+
**Why this matters:** constants that live only in prose drift when
|
|
72
|
+
prompts get edited. Once a limit lives in code, it stops drifting. The
|
|
73
|
+
BDR skill's exclusion checks say "30 days" in the prose — in the
|
|
74
|
+
graduated pipeline, `RECENT_EMAIL_DAYS = 30` cannot be silently
|
|
75
|
+
changed to 7 without a commit that shows up in review.
|
|
76
|
+
|
|
77
|
+
**How to detect:** grep for numeric literals in the prose. Most of them
|
|
78
|
+
are constants begging to be lifted. Questions to ask: "does this number
|
|
79
|
+
ever change across runs?" — if no, it's a constant.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### Pattern 3 — MANDATORY checks enforced only by prose
|
|
84
|
+
|
|
85
|
+
**What it looks like:** the skill uses the word "MANDATORY", "required",
|
|
86
|
+
"always", "must", or "do not skip" to describe a check that happens
|
|
87
|
+
before some irreversible action.
|
|
88
|
+
|
|
89
|
+
**BDR example:** Phase 5 exclusion checks. The skill's
|
|
90
|
+
`hubspot-operations.md` literally says "MANDATORY" in ALL CAPS for the
|
|
91
|
+
three exclusion checks (do-not-contact, recently emailed, active
|
|
92
|
+
sequence). In the agent loop, nothing actually prevents the LLM from
|
|
93
|
+
skipping these if prompt drift is bad.
|
|
94
|
+
|
|
95
|
+
**What to do:**
|
|
96
|
+
- Classify the check as a `pure_function` or `external_call` node.
|
|
97
|
+
- Set `mandatory: true` on the node in the IR. The IR schema enforces
|
|
98
|
+
that mandatory nodes cannot be made conditional.
|
|
99
|
+
- The adapter will emit the mandatory node as an unconditional activity
|
|
100
|
+
the workflow always calls in order. The prose enforcement disappears
|
|
101
|
+
because the code-level enforcement replaces it.
|
|
102
|
+
|
|
103
|
+
**Why this matters:** this is the single highest-leverage pattern in
|
|
104
|
+
graduation. A MANDATORY prose check is a *reliability bug waiting to
|
|
105
|
+
happen* — every prompt edit, every model upgrade, every long agent
|
|
106
|
+
trajectory is a chance for the check to get forgotten. Moving it to
|
|
107
|
+
code makes skipping impossible.
|
|
108
|
+
|
|
109
|
+
**How to detect:** grep (case-insensitive) for "mandatory", "required",
|
|
110
|
+
"always", "must", "never skip", "do not skip", "be sure to". Audit
|
|
111
|
+
every hit.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
### Pattern 4 — Fixed string templates for reports and outputs
|
|
116
|
+
|
|
117
|
+
**What it looks like:** the skill shows an exact expected output
|
|
118
|
+
format — usually in a fenced code block or a bulleted template —
|
|
119
|
+
and the agent's job is to fill in the blanks.
|
|
120
|
+
|
|
121
|
+
**BDR example:** `hubspot-operations.md` shows the exact
|
|
122
|
+
pre-enrollment report format, counts-by-reason and all. The LLM was
|
|
123
|
+
generating this markdown by hand every run. Wasteful.
|
|
124
|
+
|
|
125
|
+
**What to do:**
|
|
126
|
+
- Create a `pure_function` node that takes typed inputs (the counts,
|
|
127
|
+
the contact lists) and produces the markdown string.
|
|
128
|
+
- The function is pure string formatting — no LLM.
|
|
129
|
+
|
|
130
|
+
**How to detect:** look for ````text` or ````markdown` blocks with
|
|
131
|
+
placeholder syntax (`[name]`, `{field}`, `...`). Also look for any
|
|
132
|
+
output spec with a fixed structure.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### Pattern 5 — Batching and rate-limiting loops with fixed semantics
|
|
137
|
+
|
|
138
|
+
**What it looks like:** the skill says "process in batches of N",
|
|
139
|
+
"chunk by Y per call", "wait between requests". The loop structure and
|
|
140
|
+
batch size are fixed.
|
|
141
|
+
|
|
142
|
+
**BDR examples:**
|
|
143
|
+
- Enrich contacts in batches of 10
|
|
144
|
+
- Upsert contacts in batches of 100
|
|
145
|
+
- Add to list in batches of 250
|
|
146
|
+
|
|
147
|
+
**What to do:**
|
|
148
|
+
- Extract the batch size as a constant (see Pattern 2).
|
|
149
|
+
- The batching loop lives inside the extracted function, not the
|
|
150
|
+
workflow — the workflow calls the function once with a list of any
|
|
151
|
+
size, and the function handles chunking internally.
|
|
152
|
+
- The adapter can set appropriate activity timeouts based on the
|
|
153
|
+
expected batch count.
|
|
154
|
+
|
|
155
|
+
**How to detect:** grep for "batch of", "chunk", "max ... per".
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### Pattern 6 — Taxonomy / enum lookups that never change
|
|
160
|
+
|
|
161
|
+
**What it looks like:** the skill does setup lookups (ID resolution,
|
|
162
|
+
category mapping, static reference data) at the start of every run.
|
|
163
|
+
The underlying data is stable — these IDs don't change month-over-month.
|
|
164
|
+
|
|
165
|
+
**BDR example:** `taxonomy_lookup` — the ZoomInfo management level IDs
|
|
166
|
+
(VP, Director), industry IDs (pharma, biotech), department ID (Medical
|
|
167
|
+
& Health). These IDs have been stable for years.
|
|
168
|
+
|
|
169
|
+
**What to do:**
|
|
170
|
+
- Extract as a `pure_function` node with a `cache:` config for
|
|
171
|
+
aggressive caching (e.g., `persistent`, `30d`).
|
|
172
|
+
- On first run, the function hits the API; subsequent runs read from
|
|
173
|
+
cache until TTL expires.
|
|
174
|
+
|
|
175
|
+
**How to detect:** look for "look up", "resolve", "get the ID for",
|
|
176
|
+
"find the category", especially as setup steps at the start of a
|
|
177
|
+
phase.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
### Pattern 7 — Numeric or enum thresholds disguised as LLM rules
|
|
182
|
+
|
|
183
|
+
**What it looks like:** the skill's rubric includes a rule like
|
|
184
|
+
"discard contacts with accuracy below 85" or "contacts older than 90
|
|
185
|
+
days are stale". These look like they belong in the `llm_judge` rubric,
|
|
186
|
+
but they're actually hard thresholds.
|
|
187
|
+
|
|
188
|
+
**BDR example:** `vet_contact` — the rubric says "flag contacts below
|
|
189
|
+
85 accuracy". This is a numeric comparison, not a judgment call.
|
|
190
|
+
|
|
191
|
+
**What to do:**
|
|
192
|
+
- Keep the step as `llm_judge` (the rest of the rubric is fuzzy).
|
|
193
|
+
- But add a **pre-filter** in the signature that short-circuits on the
|
|
194
|
+
hard threshold before calling the LLM. The BDR
|
|
195
|
+
`signatures/vet_contact.py` does this — low-accuracy contacts never
|
|
196
|
+
reach the model.
|
|
197
|
+
- This saves tokens on the obvious cases and guarantees consistency on
|
|
198
|
+
the hard rules.
|
|
199
|
+
|
|
200
|
+
**How to detect:** look for numeric comparisons in rubrics ("below X",
|
|
201
|
+
"above Y", "at least Z"). Every one is a potential pre-filter.
|
|
202
|
+
|
|
203
|
+
## When NOT to crystallize
|
|
204
|
+
|
|
205
|
+
The hunt for crystallization should be aggressive, but not blind. Leave
|
|
206
|
+
things agentic when:
|
|
207
|
+
|
|
208
|
+
1. **The procedure genuinely varies run-over-run.** Target company
|
|
209
|
+
research (BDR Phase 1.5) calls different tools for different
|
|
210
|
+
indications, draws on different sources, produces different briefs.
|
|
211
|
+
That's `agent_loop`, not `pure_function`.
|
|
212
|
+
|
|
213
|
+
2. **The inputs are unbounded prose.** Summarizing someone's employment
|
|
214
|
+
history to check franchise alignment isn't code-able — the input
|
|
215
|
+
shape is unbounded and the judgment is fuzzy. That's `llm_judge`.
|
|
216
|
+
|
|
217
|
+
3. **The skill explicitly says "this is a judgment call".** Trust the
|
|
218
|
+
source. If the skill author marked something as needing human or
|
|
219
|
+
LLM judgment, there's usually a reason.
|
|
220
|
+
|
|
221
|
+
4. **Crystallizing would require reimplementing an existing fuzzy
|
|
222
|
+
service.** Don't try to replace a vendor's search ranking with
|
|
223
|
+
your own code. Use the service; codify the *calling convention*,
|
|
224
|
+
not the service's internal logic.
|
|
225
|
+
|
|
226
|
+
## The estimation heuristic
|
|
227
|
+
|
|
228
|
+
When producing the Phase 7 graduation report, estimate the
|
|
229
|
+
"% codifiable" as:
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
codifiable_nodes = count(pure_function) + count(external_call)
|
|
233
|
+
total_nodes = len(all nodes except hitl_gate)
|
|
234
|
+
pct = codifiable_nodes / total_nodes * 100
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A well-graduated BDR-scale skill lands around **60–70% codifiable**.
|
|
238
|
+
If you're below 40%, you're probably leaving crystallization on the
|
|
239
|
+
table. If you're above 80%, either you have a very structured skill
|
|
240
|
+
or you're over-crystallizing — double-check that the remaining
|
|
241
|
+
`llm_judge` / `agent_loop` nodes genuinely need the LLM.
|
|
242
|
+
|
|
243
|
+
## Scanning order
|
|
244
|
+
|
|
245
|
+
When doing Phase 3, walk through the source skill in this order:
|
|
246
|
+
|
|
247
|
+
1. **Every `references/*.md` file first.** The reference files usually
|
|
248
|
+
contain the highest density of crystallizable patterns (literal code,
|
|
249
|
+
constants, rubrics with thresholds).
|
|
250
|
+
2. **Then the main `SKILL.md`.** It's orchestration-level and usually
|
|
251
|
+
has fewer literal patterns, but contains the MANDATORY flags and
|
|
252
|
+
phase ordering.
|
|
253
|
+
3. **For each file, grep for the detection terms listed in each pattern
|
|
254
|
+
above.** Note every hit. You'll usually find more patterns than
|
|
255
|
+
nodes — several patterns often apply to the same node.
|
|
256
|
+
|
|
257
|
+
Record every extraction candidate in the graduation report with
|
|
258
|
+
`file:line`, the current prose, and the proposed codified form. This
|
|
259
|
+
is the primary audit trail for the graduation and the thing the human
|
|
260
|
+
reviewer will check first.
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# Pipeline IR Schema (`pipeline.yaml`)
|
|
2
|
+
|
|
3
|
+
Phase 5 of the graduator produces a `pipeline.yaml` file that describes
|
|
4
|
+
the entire graduated pipeline in a runtime-agnostic form. This file is
|
|
5
|
+
the reference for that YAML schema. It matches the Pydantic models in
|
|
6
|
+
`rote/ir.py`; when in doubt, that module is the authoritative source.
|
|
7
|
+
|
|
8
|
+
## Top-level structure
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
name: bdr-campaign # required, kebab-case, unique
|
|
12
|
+
version: "0.1.0" # required, semver string
|
|
13
|
+
source_skill: ../../skill # optional, path to source skill bundle
|
|
14
|
+
description: | # optional, multi-line prose
|
|
15
|
+
End-to-end BDR outreach campaign workflow...
|
|
16
|
+
|
|
17
|
+
config: # optional, defaults apply if omitted
|
|
18
|
+
schedule: null # cron expression or null
|
|
19
|
+
on_failure: notify_owner # free-form adapter hook
|
|
20
|
+
observability:
|
|
21
|
+
traces: true
|
|
22
|
+
eval_set_dir: ./evals/
|
|
23
|
+
hitl:
|
|
24
|
+
default_timeout: 7d
|
|
25
|
+
|
|
26
|
+
input: # required, pipeline input contract
|
|
27
|
+
type: CampaignBrief
|
|
28
|
+
required:
|
|
29
|
+
- drug_brand
|
|
30
|
+
- drug_generic
|
|
31
|
+
optional:
|
|
32
|
+
- job_focus
|
|
33
|
+
input_schema: # strongly preferred: full JSON Schema
|
|
34
|
+
type: object # for the input payload (see below)
|
|
35
|
+
title: CampaignBrief
|
|
36
|
+
properties: { ... }
|
|
37
|
+
required: [drug_brand, drug_generic, ...]
|
|
38
|
+
|
|
39
|
+
nodes: [ ... ] # required, list of Node objects
|
|
40
|
+
edges: [ ... ] # required, list of Edge objects
|
|
41
|
+
entry_nodes: [target_research, taxonomy_lookup]
|
|
42
|
+
exit_nodes: [manual_enrollment_handoff]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `input.input_schema` — promote the entry payload schema
|
|
46
|
+
|
|
47
|
+
When you design the entry nodes' `signature_spec.input_schema`, you
|
|
48
|
+
already produce a full JSON Schema for the pipeline's input type (the
|
|
49
|
+
BDR example has `CampaignBrief` with its `$defs` for `CampaignType`
|
|
50
|
+
and `JobFocus`). **Promote that same schema to `input.input_schema`**
|
|
51
|
+
so adapters can validate the pipeline input before the workflow
|
|
52
|
+
starts:
|
|
53
|
+
|
|
54
|
+
- Take the input type's object schema (with any enums/nested types it
|
|
55
|
+
needs inlined under its own `$defs`) — the same dictionaries
|
|
56
|
+
Pydantic emits via `model_json_schema()`.
|
|
57
|
+
- The schema's `required` list must match `input.required`, and its
|
|
58
|
+
`properties` must cover every name in `input.required` +
|
|
59
|
+
`input.optional`.
|
|
60
|
+
- `input_schema` is optional in the validator (old pipelines stay
|
|
61
|
+
valid), but always emit it for new graduations — it is the typed
|
|
62
|
+
contract everything downstream keys off.
|
|
63
|
+
|
|
64
|
+
## Node object
|
|
65
|
+
|
|
66
|
+
Every node has a `kind` field, and the required fields depend on the
|
|
67
|
+
kind. The validator enforces kind-specific requirements.
|
|
68
|
+
|
|
69
|
+
### Common fields (all kinds)
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
- id: taxonomy_lookup # required, unique, snake_case
|
|
73
|
+
kind: pure_function # required, one of 5 kinds
|
|
74
|
+
phase: "2" # optional, source skill phase (string)
|
|
75
|
+
description: | # required, short prose
|
|
76
|
+
Resolve ZoomInfo IDs for management levels...
|
|
77
|
+
input: # optional, field→type mapping
|
|
78
|
+
brief: CampaignBrief
|
|
79
|
+
inputs: # data-flow bindings: param → source ref
|
|
80
|
+
brief: pipeline.input # (see "Data-flow bindings" below)
|
|
81
|
+
output: TaxonomyIds # optional, type name or field mapping
|
|
82
|
+
timeout: 5m # optional, duration string
|
|
83
|
+
retry: # optional
|
|
84
|
+
max: 3
|
|
85
|
+
backoff: exponential # linear | exponential | constant
|
|
86
|
+
retry_on: [rate_limit, network] # optional, note: NOT 'on' — YAML parses 'on' as boolean
|
|
87
|
+
mandatory: false # optional, default false
|
|
88
|
+
constants: # optional, arbitrary key/value dict
|
|
89
|
+
batch_size: 10
|
|
90
|
+
cache: # optional
|
|
91
|
+
strategy: persistent
|
|
92
|
+
ttl: 30d
|
|
93
|
+
fan_out: false # optional, if true node invoked per input element
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Data-flow bindings (`inputs:`)
|
|
97
|
+
|
|
98
|
+
`input:` documents *types*; `inputs:` binds *where the values come
|
|
99
|
+
from at runtime*. Adapters render `inputs:` into real payloads — a
|
|
100
|
+
node without `inputs:` receives an empty payload, so **emit `inputs:`
|
|
101
|
+
for every top-level node whose upstream sources you can name.**
|
|
102
|
+
|
|
103
|
+
The grammar is exactly four forms. There is no expression language —
|
|
104
|
+
no arithmetic, no aggregation, no deep paths:
|
|
105
|
+
|
|
106
|
+
| Reference form | Meaning |
|
|
107
|
+
|------------------------------|---------------------------------------------|
|
|
108
|
+
| `pipeline.input` | the whole pipeline input payload |
|
|
109
|
+
| `pipeline.input.<field>` | one top-level field of the pipeline input |
|
|
110
|
+
| `<node_id>.output` | the whole output of an upstream node |
|
|
111
|
+
| `<node_id>.output.<field>` | one top-level field of an upstream node's output |
|
|
112
|
+
|
|
113
|
+
BDR examples:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
- id: lead_generation_loop
|
|
117
|
+
inputs:
|
|
118
|
+
brief: pipeline.input # whole input payload
|
|
119
|
+
intel: target_research.output # whole upstream output
|
|
120
|
+
taxonomy: taxonomy_lookup.output
|
|
121
|
+
target_quota: pipeline.input.target_quota # one input field
|
|
122
|
+
|
|
123
|
+
- id: hubspot_upsert
|
|
124
|
+
inputs:
|
|
125
|
+
# HITL gate outputs work too — this is the reviewer's signal payload:
|
|
126
|
+
contacts: contact_review_gate.output.approved_contacts
|
|
127
|
+
|
|
128
|
+
- id: exclusion_check_recent
|
|
129
|
+
inputs:
|
|
130
|
+
contacts: exclusion_check_dnc.output.passed # chain node → node
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Rules and edge cases:
|
|
134
|
+
|
|
135
|
+
1. **Reference only upstream nodes.** A reference to a node that runs
|
|
136
|
+
in a later wave — or to a loop-body sub-node, which has no
|
|
137
|
+
top-level result — fails at emission time.
|
|
138
|
+
2. **Pipeline input fields must be declared.** `pipeline.input.<field>`
|
|
139
|
+
is validated against `input.required` + `input.optional` +
|
|
140
|
+
`input_schema.properties`.
|
|
141
|
+
3. **Leave a parameter unbound when the grammar can't express it** and
|
|
142
|
+
say why in a comment. BDR has two deliberate examples:
|
|
143
|
+
`dnc_list_id` (deployment configuration, not pipeline data) and
|
|
144
|
+
`vetted_count` (an aggregate — `len()` of a list — which belongs in
|
|
145
|
+
the extracted function, not the reference syntax).
|
|
146
|
+
4. **Loop-body sub-nodes:** their `inputs:` describe what the parent
|
|
147
|
+
loop passes per iteration. Adapters don't resolve them at the top
|
|
148
|
+
level, so treat them as documentation for the loop harness.
|
|
149
|
+
5. **`fan_out` nodes:** bind the element parameter to the upstream
|
|
150
|
+
*list* (e.g. `contact: exclusion_check_sequence.output.passed`).
|
|
151
|
+
The runtime is responsible for per-element dispatch; v0 adapters
|
|
152
|
+
pass the whole list in a single invocation.
|
|
153
|
+
6. **HITL gates need no `inputs:`** — a gate's "output" is the signal
|
|
154
|
+
payload the human sends, and downstream nodes reference it as
|
|
155
|
+
`<gate_id>.output[...]`.
|
|
156
|
+
|
|
157
|
+
### `pure_function` and `external_call`
|
|
158
|
+
|
|
159
|
+
**Required:** `impl` — path to the extracted Python function in
|
|
160
|
+
`"extracted/foo.py:bar_func"` format.
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
- id: pre_enrollment_report
|
|
164
|
+
kind: pure_function
|
|
165
|
+
description: Render the pre-enrollment report as Markdown.
|
|
166
|
+
impl: extracted/report.py:generate_pre_enrollment_report
|
|
167
|
+
input:
|
|
168
|
+
campaign_name: str
|
|
169
|
+
vetted_count: int
|
|
170
|
+
passed_contacts: list[HubSpotContact]
|
|
171
|
+
exclusions: list[ExclusionRecord]
|
|
172
|
+
template_ids: list[str]
|
|
173
|
+
output: report_markdown str
|
|
174
|
+
|
|
175
|
+
- id: hubspot_upsert
|
|
176
|
+
kind: external_call
|
|
177
|
+
description: Batch upsert contacts to HubSpot (100 per call).
|
|
178
|
+
impl: extracted/hubspot.py:batch_upsert_contacts
|
|
179
|
+
input:
|
|
180
|
+
contacts: list[VettedContact]
|
|
181
|
+
output:
|
|
182
|
+
upserted: list[HubSpotContact]
|
|
183
|
+
constants:
|
|
184
|
+
batch_size: 100
|
|
185
|
+
retry:
|
|
186
|
+
max: 5
|
|
187
|
+
backoff: exponential
|
|
188
|
+
timeout: 60s
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### `llm_judge`
|
|
192
|
+
|
|
193
|
+
**Required:** at least one of `signature` (legacy) or `signature_spec`
|
|
194
|
+
(structured). **Strongly preferred: emit both.** The Temporal adapter
|
|
195
|
+
prefers `signature_spec` when present and falls back to the legacy
|
|
196
|
+
path; the Cloudflare adapter and any future non-Python target *require*
|
|
197
|
+
`signature_spec` because there's no shared Python module to import.
|
|
198
|
+
|
|
199
|
+
- **`signature`** — path to a typed Python signature class in
|
|
200
|
+
`"signatures/foo.py:FooClass"` format. Used by the Temporal adapter
|
|
201
|
+
and by humans iterating on the signature with DSPy / BAML. Always
|
|
202
|
+
emit this for runtimes that share Python with the extracted modules.
|
|
203
|
+
|
|
204
|
+
- **`signature_spec`** — runtime-agnostic structured form: JSON Schema
|
|
205
|
+
for input + output (the same schemas Pydantic emits via
|
|
206
|
+
`model_json_schema()`), the prompt template, and the LLM client
|
|
207
|
+
config. This is the cross-language source of truth — adapters
|
|
208
|
+
derive Pydantic / Zod / Go types from the schemas as needed. See
|
|
209
|
+
[`llm-judge-extraction.md`](llm-judge-extraction.md) for the full
|
|
210
|
+
derivation procedure.
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
- id: vet_contact
|
|
214
|
+
kind: llm_judge
|
|
215
|
+
description: Apply the BDR red-flags rubric to a single contact.
|
|
216
|
+
signature: signatures/vet_contact.py:VetContact # legacy path (Temporal)
|
|
217
|
+
signature_spec: # structured form (all runtimes)
|
|
218
|
+
input_schema:
|
|
219
|
+
type: object
|
|
220
|
+
required: [contact, brief, intel]
|
|
221
|
+
properties:
|
|
222
|
+
contact: {$ref: "#/$defs/EnrichedContact"}
|
|
223
|
+
brief: {$ref: "#/$defs/CampaignBrief"}
|
|
224
|
+
intel: {$ref: "#/$defs/IntelBrief"}
|
|
225
|
+
$defs:
|
|
226
|
+
# ... full Pydantic model_json_schema() output
|
|
227
|
+
output_schema:
|
|
228
|
+
type: object
|
|
229
|
+
required: [decision, relevance_evidence]
|
|
230
|
+
properties:
|
|
231
|
+
decision: {enum: [keep, discard]}
|
|
232
|
+
tier:
|
|
233
|
+
anyOf: [{enum: [ideal, strong, good]}, {type: "null"}]
|
|
234
|
+
discard_reason:
|
|
235
|
+
anyOf: [{enum: [indication_mismatch, msl_role, ...]}, {type: "null"}]
|
|
236
|
+
relevance_evidence: {type: string}
|
|
237
|
+
prompt: |
|
|
238
|
+
Apply the BDR vetting rubric to this contact.
|
|
239
|
+
Contact: {{ contact }}
|
|
240
|
+
Brief: {{ brief }}
|
|
241
|
+
Intel: {{ intel }}
|
|
242
|
+
Return your decision via the structured output tool.
|
|
243
|
+
client: anthropic # 'anthropic' | 'openai'
|
|
244
|
+
model: claude-sonnet-4-6 # optional; adapter chooses default if omitted
|
|
245
|
+
temperature: 0.0 # optional
|
|
246
|
+
input:
|
|
247
|
+
contact: EnrichedContact
|
|
248
|
+
brief: CampaignBrief
|
|
249
|
+
intel: IntelBrief
|
|
250
|
+
output:
|
|
251
|
+
decision: VetDecision
|
|
252
|
+
tier: ContactTier
|
|
253
|
+
discard_reason: DiscardReason
|
|
254
|
+
relevance_evidence: str
|
|
255
|
+
constants:
|
|
256
|
+
min_accuracy_score: 85
|
|
257
|
+
eval_set: evals/vet_contact.jsonl
|
|
258
|
+
fan_out: true # one invocation per contact
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### `agent_loop`
|
|
262
|
+
|
|
263
|
+
**Required:** `tools` — list of tool names the agent may call inside
|
|
264
|
+
the loop.
|
|
265
|
+
**Optional:** `loop_body` — list of sub-node IDs invoked each iteration.
|
|
266
|
+
**Optional:** `termination` — condition + max iterations.
|
|
267
|
+
|
|
268
|
+
```yaml
|
|
269
|
+
- id: lead_generation_loop
|
|
270
|
+
kind: agent_loop
|
|
271
|
+
description: Iterative search-enrich-vet loop.
|
|
272
|
+
input:
|
|
273
|
+
brief: CampaignBrief
|
|
274
|
+
intel: IntelBrief
|
|
275
|
+
target_quota: int
|
|
276
|
+
output:
|
|
277
|
+
vetted_contacts: list[VettedContact]
|
|
278
|
+
tools:
|
|
279
|
+
- zoominfo_search_contacts
|
|
280
|
+
- zoominfo_search_companies
|
|
281
|
+
loop_body:
|
|
282
|
+
- enrich_contact_batch
|
|
283
|
+
- vet_contact
|
|
284
|
+
termination:
|
|
285
|
+
condition: vetted_count >= target_quota
|
|
286
|
+
max_iterations: 10
|
|
287
|
+
timeout: 15m
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Note on `loop_body`:** sub-nodes listed here must also exist as
|
|
291
|
+
top-level nodes in the `nodes:` list. They're referenced from the
|
|
292
|
+
loop_body but also emitted as standalone activities (so they can be
|
|
293
|
+
tested in isolation and reused elsewhere). The adapter excludes them
|
|
294
|
+
from top-level execution waves to prevent double-dispatch.
|
|
295
|
+
|
|
296
|
+
### `hitl_gate`
|
|
297
|
+
|
|
298
|
+
**Required:** `signal` — name of the signal the workflow waits for.
|
|
299
|
+
**Optional:** `notify` — how to alert the human reviewer.
|
|
300
|
+
|
|
301
|
+
```yaml
|
|
302
|
+
- id: contact_review_gate
|
|
303
|
+
kind: hitl_gate
|
|
304
|
+
description: Present the vetted contact table to the user for approval.
|
|
305
|
+
input:
|
|
306
|
+
vetted_contacts: list[VettedContact]
|
|
307
|
+
output:
|
|
308
|
+
approved_contacts: list[VettedContact]
|
|
309
|
+
signal: contact_review_approved
|
|
310
|
+
timeout: 7d
|
|
311
|
+
notify:
|
|
312
|
+
channel: slack
|
|
313
|
+
target: "#bdr-reviews"
|
|
314
|
+
message_template: |
|
|
315
|
+
BDR campaign awaiting review: {drug_brand} for {condition_acronym}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**Constraint:** `mandatory: true` is not allowed on `agent_loop` nodes
|
|
319
|
+
(meaningless) but is allowed on every other kind. For HITL gates,
|
|
320
|
+
`mandatory` is implicit — you can't skip a gate the workflow is
|
|
321
|
+
waiting on.
|
|
322
|
+
|
|
323
|
+
## Edge object
|
|
324
|
+
|
|
325
|
+
```yaml
|
|
326
|
+
edges:
|
|
327
|
+
- { from: target_research, to: lead_generation_loop }
|
|
328
|
+
- { from: lead_generation_loop, to: contact_review_gate }
|
|
329
|
+
- { from: contact_review_gate, to: hubspot_upsert, on_signal: approved }
|
|
330
|
+
- { from: exclusion_check_sequence, to: personalize_email, fan_out: true }
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
- **`from` / `to`:** node IDs (must exist in the `nodes:` list).
|
|
334
|
+
- **`on_signal`:** optional. If set, the edge only activates when the
|
|
335
|
+
named signal fires. Used for edges exiting a `hitl_gate`.
|
|
336
|
+
- **`fan_out`:** optional. If true, the destination is invoked once per
|
|
337
|
+
element of the source's output.
|
|
338
|
+
|
|
339
|
+
**Note:** `from` is a reserved word in Python, so the IR's Pydantic
|
|
340
|
+
model uses the alias `from_`. In YAML, always write `from:`.
|
|
341
|
+
|
|
342
|
+
## Validation rules the graduator must respect
|
|
343
|
+
|
|
344
|
+
The IR validator will reject any of these, so produce the YAML with
|
|
345
|
+
these constraints in mind:
|
|
346
|
+
|
|
347
|
+
1. **Node IDs are unique** within a pipeline.
|
|
348
|
+
2. **All `from`/`to` in `edges` reference real node IDs.**
|
|
349
|
+
3. **All `loop_body` entries reference real node IDs.**
|
|
350
|
+
4. **All `entry_nodes` and `exit_nodes` reference real node IDs.**
|
|
351
|
+
5. **Kind-specific required fields are present:**
|
|
352
|
+
- `pure_function` / `external_call` → `impl`
|
|
353
|
+
- `llm_judge` → `signature` (legacy path) or `signature_spec`
|
|
354
|
+
(structured) — at least one. Strongly preferred: emit both.
|
|
355
|
+
- `agent_loop` → `tools`
|
|
356
|
+
- `hitl_gate` → `signal`. Cloudflare additionally requires the
|
|
357
|
+
signal name to match `[A-Za-z0-9_-]+` (no dots, spaces, etc.) —
|
|
358
|
+
this is enforced at adapter emission time.
|
|
359
|
+
6. **`mandatory: true` is not allowed on `agent_loop` nodes.**
|
|
360
|
+
7. **No YAML key named `on:`** — YAML 1.1 parses it as boolean
|
|
361
|
+
`True`. The IR uses `retry_on:` instead.
|
|
362
|
+
8. **All `inputs:` references parse and resolve.** Each value must
|
|
363
|
+
match one of the four reference forms (see "Data-flow bindings"),
|
|
364
|
+
node references must name real nodes (and never the node itself),
|
|
365
|
+
and `pipeline.input.<field>` must name a declared input field.
|
|
366
|
+
|
|
367
|
+
## Duration strings
|
|
368
|
+
|
|
369
|
+
Used in `timeout:`, `default_timeout:`, `cache.ttl:`, etc.
|
|
370
|
+
|
|
371
|
+
| Suffix | Meaning |
|
|
372
|
+
|---|---|
|
|
373
|
+
| `ms` | milliseconds |
|
|
374
|
+
| `s` | seconds |
|
|
375
|
+
| `m` | minutes |
|
|
376
|
+
| `h` | hours |
|
|
377
|
+
| `d` | days |
|
|
378
|
+
|
|
379
|
+
No suffix → seconds. Examples: `5m`, `30s`, `7d`, `250ms`.
|
|
380
|
+
|
|
381
|
+
## Minimal skeleton for a new pipeline
|
|
382
|
+
|
|
383
|
+
When generating a `pipeline.yaml` from scratch, start with this skeleton
|
|
384
|
+
and fill it in:
|
|
385
|
+
|
|
386
|
+
```yaml
|
|
387
|
+
name: <skill-name>
|
|
388
|
+
version: "0.1.0"
|
|
389
|
+
source_skill: <relative path to skill>
|
|
390
|
+
description: |
|
|
391
|
+
<one-paragraph summary of what the skill does>
|
|
392
|
+
|
|
393
|
+
config:
|
|
394
|
+
on_failure: notify_owner
|
|
395
|
+
|
|
396
|
+
input:
|
|
397
|
+
type: <BriefTypeName>
|
|
398
|
+
required: []
|
|
399
|
+
optional: []
|
|
400
|
+
|
|
401
|
+
nodes: []
|
|
402
|
+
edges: []
|
|
403
|
+
entry_nodes: []
|
|
404
|
+
exit_nodes: []
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Then add nodes from the source skill's entry point outward, adding
|
|
408
|
+
edges as you go. Use `entry_nodes` for nodes with no inbound edges
|
|
409
|
+
(except from the pipeline input itself) and `exit_nodes` for terminal
|
|
410
|
+
nodes whose completion means the workflow is done.
|
|
411
|
+
|
|
412
|
+
## Cross-referencing the rubric
|
|
413
|
+
|
|
414
|
+
- Every node's `kind` decision is guided by `node-kinds.md`.
|
|
415
|
+
- Every `pure_function` / `external_call` node's `impl` and `constants`
|
|
416
|
+
come from applying the patterns in `crystallization-heuristics.md`.
|
|
417
|
+
- Every `llm_judge` node's `signature` design is guided by
|
|
418
|
+
`llm-judge-extraction.md`.
|
|
419
|
+
- Every `mandatory: true` flag comes from a Pattern 3 hit in the
|
|
420
|
+
crystallization scan.
|