Skip to content

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 & mirrorContract
Enricherapp/Services/GmailEnricher.phpapp/Contracts/Enricher.php
Refinertests/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

  1. Class in app/Services implementing Enricher or Refiner. Implement key() (stable, stored on the skill — never change it once shipped) and label() (shown in the Helpers editor). Container-resolved, so type-hint dependencies in the constructor (a token service, a sanitiser, PushService…).
  2. 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.
  3. Register the class-string in config/helper-steps.php under steps. That's the only wiring — HelperStepRegistry + HelperStepPicker surface it in the editor automatically. Optionally seed it onto a skill in HelperSkillSeeder (set enrichers/refiners via a model save, not the upsert — casts don't apply to bulk upsert).
  4. Fencing is automatic — don't wrap content in UntrustedContent yourself. Every block an enricher adds is fenced by the task-helper Blade template via @untrusted, so all step context is spotlighted uniformly. Just add raw (label, content).
  5. 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 reauth PushService nudge (see GmailEnricher::validAccessToken).
  6. 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.
  7. 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 (see tests/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 @untrusted fencing + read-only seam cover it, add the boundary if new, and bump its last_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([...]), run TaskHelperService::prepare(), then TaskHelperAgent::assertPrompted(fn ($p) => str_contains($p->prompt, '<<UNTRUSTED:label>>') && str_contains($p->prompt, $expected)). Or drive handle() directly with a fn ($c) => $c terminator and inspect $context->blocks().
  • Refiners: assert prepare() returns the transformed HelperResult.
  • 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 (updateOrCreate on 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.