Appearance
build-helper-step-in-tempo
build-helper-step-in-tempo
buildwriteseither
Use this when: you're adding a Task Helper enricher or refiner
Problem it solves — The Task Helper pipeline grows by adding grounding (enrichers) or draft transforms (refiners). This scaffolds a new step already wired into the pipeline, so you extend the helper without re-plumbing it.
Build a helper step in Tempo
A helper step plugs into the two pipelines that wrap TaskHelperService::prepare():
- Enricher — runs before drafting, appends grounding to the prompt.
- Refiner — runs after drafting, transforms the
HelperResult.
Steps are selected per skill in the Helpers editor and resolved through HelperStepRegistry. The skill is one move: mirror the existing exemplars. Build the step complete in one pass (see be-complete): class + contract + registry entry + tests, not a class with TODO methods.
| Building… | Read & mirror | Contract |
|---|---|---|
| Enricher | app/Services/GmailEnricher.php | app/Contracts/Enricher.php |
| Refiner | tests/Fakes/FakeRefiner.php (no production one yet) | app/Contracts/Refiner.php |
Doesn't fit? If the thing has no per-skill toggle, or must run for every task regardless of label, it isn't a step — put it directly in prepare() or a job, and skip this skill.
Workflow checklist
- Class in
app/ServicesimplementingEnricherorRefiner. Implementkey()(stable, stored on the skill — never change it once shipped) andlabel()(shown in the Helpers editor). Container-resolved, so type-hint dependencies in the constructor (a token service, a sanitiser,PushService…). handle($x, Closure $next)— it's a pipeline stage, so always return$next($x), even on the degraded path. An enricher appends via$context->add('label', $content); a refiner returns a (possibly new)HelperResult.- Register the class-string in
config/helper-steps.phpundersteps. That's the only wiring —HelperStepRegistry+HelperStepPickersurface it in the editor automatically. Optionally seed it onto a skill inHelperSkillSeeder(setenrichers/refinersvia a model save, not theupsert— casts don't apply to bulk upsert). - Fencing is automatic — don't wrap content in
UntrustedContentyourself. Every block an enricher adds is fenced by thetask-helperBlade template via@untrusted, so all step context is spotlighted uniformly. Just add raw(label, content). - Degrade, never hard-fail (enrichers): no input, no auth, no result, or a flaky upstream call must fall through to a generic draft — wrap I/O in
try/catch, log, and still$next($context). Auth-missing → a reauthPushServicenudge (seeGmailEnricher::validAccessToken). - Refiners stay pure — value in, value out, no I/O or side effects. Their only egress is the draft text; that's what keeps "always draft, never auto-send" structural.
- External read seam (enrichers that fetch): put it behind a narrow read-only contract like
GmailReader— expose only read methods so the read-only guarantee is structural, and add an architecture test asserting no write/send method exists (seetests/Feature/GmailReadOnlyTest.php).
Trust-boundary check: an enricher that pulls from a new external source adds an attacker-controllable input — a new trust boundary. When it does, review
docs/security/threat-model.md: confirm the@untrustedfencing + read-only seam cover it, add the boundary if new, and bump itslast_reviewed.
Test conventions
- Mirror
tests/Feature/GmailEnricherTest.php.covers(YourStep::class). - Register the step for the test:
config()->set('helper-steps.steps', [YourStep::class]), then select it on a skill (HelperSkill::factory()->create(['enrichers' => ['your-key']])). - Enrichers: assert the contributed block reaches the prompt end-to-end —
TaskHelperAgent::fake([...]), runTaskHelperService::prepare(), thenTaskHelperAgent::assertPrompted(fn ($p) => str_contains($p->prompt, '<<UNTRUSTED:label>>') && str_contains($p->prompt, $expected)). Or drivehandle()directly with afn ($c) => $cterminator and inspect$context->blocks(). - Refiners: assert
prepare()returns the transformedHelperResult. - Cover the degraded paths (ZOMBIES): zero input, upstream error, no match.
Http::fake()every external call — no test touches a real API. - Idempotency/ledger (if it persists): re-running the same task must not duplicate rows (
updateOrCreateon the natural key).
Gates
composer ci:check = Pint check, npm lint/format/types, PHPStan L7, coverage ≥ 80, mutation ≥ 90. Thresholds are fixed — close gaps with tests, never by lowering them.