strategist-playbook-dispatch.md21.8 KBView on GitHub # Strategist dispatch — playbook-referenced subagent (not a hardcoded system step)
## 1) Introduction — goal, present state, future state
We want the Strategist to run on deal events as **a plain, playbook-referenced subagent** — visible and editable in the seller's PLAYBOOK.md, gate-able and reorder-able like any subagent — rather than a special case baked into the orchestrator prompt. Today the Strategist exists as an `aop_agents` row + a `subagents/strategist.md` doc (both seeded) and owns the strategic fields, but it is dispatched by a hardcoded bullet in the on-event orchestrator's "system pipeline order" ([on-event-orchestrator-agent.ts:131-138](apps/server/src/mastra/agents/on-event-orchestrator-agent.ts)) that special-cases `run-subagent(@subagents/strategist)` and its `subagents/strategist.md` doc is seeded off to the side by `ensureStrategistSubagentDoc` ([strategist-subagent-doc.ts](apps/server/src/services/aop/strategist-subagent-doc.ts)) rather than by the normal playbook seeder — and crucially it is **not referenced in the default playbook**. We will move the Strategist's doc-seeding into the standard playbook seeder next to `crm-updater`/`next-steps` ([seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts)), add its `<ref>` to the default `<trigger type="any">` block so it dispatches as a normal `@subagents/` line, revert the orchestrator special case, and backfill existing Deals playbooks to include the reference — keeping the row + doc + field ownership exactly as they are so nothing about ownership or attribution changes.
## 2) Present state
### 2.1 Architecture diagram
```text
ACCOUNT SETUP
seedSystemDefaultAgents(aopId,userId) seedPlaybookFiles(aopId,…)
├─ insert aop_agents rows (CRM Updater, Strategist, ├─ seed subagent DOCS via seedFile+prepareSubagentWrite:
│ Overview(off), Post-Event, Task Aggregator) │ crm-updater.md, next-steps.md, meeting-prep.md, daily-agenda.md
├─ stamp ownerAgentId = Strategist on 12 fields │ (agent_id reconciled to the aop_agents row per name)
└─ ensureStrategistSubagentDoc() ← SIDE-DOOR ├─ collect subagentUuids{crm-updater,next-steps,…}
writes subagents/strategist.md (agent doc, └─ buildPlaybookTemplate(refs):
agent_id = Strategist row id) <trigger type="any">
<ref id="{crmUpdater}"/>
⇒ strategist.md EXISTS but is NOT in subagentUuids, <ref id="{nextSteps}"/> ← NO strategist ref
and NOT referenced in the default playbook. </trigger>
│
EVENT → on-event-orchestrator-agent (LLM)
receives <playbook_section> (compiled @subagents lines) + a hardcoded prompt:
"System pipeline order: crm-updater → STRATEGIST(special-case) → drafting → next-steps"
"run @subagents/strategist ONLY when it appears in the directory" ← the hack
dispatches each via run-subagent(@subagents/name) → reads subagents/{name}.md → runAgent(synthetic, agentId)
```
### 2.2 Step-by-step walkthrough
1. **Seed rows** — `seedSystemDefaultAgents` at [aop-agents.ts:352](apps/server/src/services/aop/aop-agents.ts) inserts the system `aop_agents` rows (incl. Strategist, `pipelineOrder:2`, `EVENT_OCCURRED`), stamps `ownerAgentId` on the 12 strategic fields, then calls `ensureStrategistSubagentDoc`.
- Data after this step:
```json
{ "row": { "id": "47aaf4ad…", "name": "Strategist", "pipelineOrder": 2, "enabled": true },
"ownedFields": 12 }
```
2. **Side-door doc seed** — `ensureStrategistSubagentDoc` at [strategist-subagent-doc.ts](apps/server/src/services/aop/strategist-subagent-doc.ts) resolves the Strategist row and writes `user/playbooks/{aopId}/subagents/strategist.md` (a Phase-20 `agent` doc) with `agent_id` = the row id + `STRATEGIST_INSTRUCTIONS` body. It is written **outside** `seedPlaybookFiles`, so the playbook seeder never learns the doc's UUID.
- Data after this step (frontmatter):
```text
---\n\nname: strategist\n\ndescription: …\n\nsystem: true\n\nmodel: sonnet\n\nfill_instructions: …\n\nagent_id: 47aaf4ad…\n\n---\n\n{STRATEGIST_INSTRUCTIONS}
```
3. **Seed subagent docs (the standard path)** — `seedPlaybookFiles` at [seed-playbook.ts:540-600](apps/server/src/services/playbook/seed-playbook.ts) iterates a list `[{name:CRM_UPDATER,file:'crm-updater.md',build:buildCrmUpdaterContent}, {name:TASK_AGGREGATOR,file:'next-steps.md',…}, meeting-prep, daily-agenda]`, calls `prepareSubagentWrite` (injects `agent_id`) + `seedFile` (writeDocument), and collects `subagentUuids[name]`. **The Strategist is absent from this list.**
- Data after this step:
```json
{ "subagentUuids": { "crm-updater": "…", "next-steps": "…", "meeting-prep": "…", "daily-agenda": "…" } }
```
4. **Build the playbook XML** — `buildPlaybookTemplate` at [seed-playbook.ts:115-160](apps/server/src/services/playbook/seed-playbook.ts) composes the `<trigger type="any">` block from `anyBlockRefs = [refs.crmUpdater, refs.nextSteps]` ([seed-playbook.ts:135](apps/server/src/services/playbook/seed-playbook.ts)); `PlaybookDocRefs` ([seed-playbook.ts:93](apps/server/src/services/playbook/seed-playbook.ts)) has no `strategist` field. So the seeded playbook references crm-updater + next-steps only.
- Data after this step:
```xml
<trigger type="any">
<ref id="{crmUpdater}"/>
<ref id="{nextSteps}"/>
</trigger>
```
5. **Compile the playbook for the orchestrator** — `get-playbook-section.ts` at [get-playbook-section.ts:226-265](apps/server/src/services/playbook/get-playbook-section.ts) resolves each `<ref>` to an `@subagents/{name} (desc) [doc_id:…]` line for the compiled `<playbook_section>`. A missing strategist `<ref>` means no strategist line is compiled.
6. **Orchestrator dispatch** — `on-event-orchestrator-agent` ([on-event-orchestrator-agent.ts:206-230](apps/server/src/mastra/agents/on-event-orchestrator-agent.ts)) receives `<playbook_section>` + a **hardcoded** "System pipeline order" that special-cases the Strategist at [on-event-orchestrator-agent.ts:131-138](apps/server/src/mastra/agents/on-event-orchestrator-agent.ts):
```text
1. run-crm-updater
2. run-subagent(@subagents/strategist) — … ONLY when @subagents/strategist appears in the directory …
3. run-post-event-executor
4. update-next-steps-and-tasks (@subagents/next-steps)
```
It dispatches each via `run-subagent`, which reads `subagents/{name}.md`, builds a synthetic `AopAgent` from the frontmatter `agent_id`, and runs it ([runSubagentTool.ts:160-260](apps/server/src/mastra/tools/event-execution/runSubagentTool.ts)).
7. **Ordering** — the orchestrator forces crm-updater first and next-steps last; every playbook `@subagents/` line runs in between. So a playbook-referenced subagent naturally lands after crm-updater and before next-steps (the exact slot the Strategist wants).
## 3) Designed state
### 3.1 Architecture diagram
```text
ACCOUNT SETUP
seedSystemDefaultAgents(aopId,userId) seedPlaybookFiles(aopId,…)
├─ insert aop_agents rows (incl. Strategist) ├─ seed subagent DOCS (agent_id reconciled to rows):
└─ stamp ownerAgentId on 12 fields │ crm-updater.md, STRATEGIST.md ◄NEW, next-steps.md, meeting-prep.md, daily-agenda.md
(NO ensureStrategistSubagentDoc side-door) ├─ collect subagentUuids{…, strategist ◄NEW}
└─ buildPlaybookTemplate(refs):
<trigger type="any">
<ref id="{crmUpdater}"/>
<ref id="{strategist}"/> ◄NEW
<ref id="{nextSteps}"/>
</trigger>
│
EVENT → on-event-orchestrator-agent (LLM)
receives <playbook_section> that now contains the @subagents/strategist line (from the ref)
"System pipeline order: crm-updater → drafting → next-steps" ← strategist bullet REMOVED (back to generic)
runs playbook @subagents/ lines (incl. strategist) between the forced crm-updater and next-steps
→ run-subagent(@subagents/strategist) → subagents/strategist.md → runAgent(synthetic, agentId)
BACKFILL (existing accounts): ensure strategist.md exists + inject <ref id="{strategist}"/> into the
live PLAYBOOK.md <trigger type="any">, recompile + re-save, null contentYjs to force re-parse.
```
### 3.2 Step-by-step walkthrough
1. **Row seeding unchanged** — `seedSystemDefaultAgents` ([aop-agents.ts:352](apps/server/src/services/aop/aop-agents.ts)) still inserts the Strategist row + stamps field ownership, but **no longer** calls `ensureStrategistSubagentDoc` (the doc is now seeded by the playbook seeder). Row + ownership are identical to today.
2. **Standard doc seeding** — add the Strategist to `seedPlaybookFiles`' subagent list ([seed-playbook.ts:560-595](apps/server/src/services/playbook/seed-playbook.ts)) as `{ name: SystemAgentName.STRATEGIST, file: 'strategist.md', build: buildStrategistSubagentBody }`, using the shared body builder so content matches the side-door version. `prepareSubagentWrite` injects `agent_id` (reconciled to the row by name, same as crm-updater), and `subagentUuids['strategist']` is collected.
- Data after this step:
```json
{ "subagentUuids": { "crm-updater": "…", "strategist": "…NEW", "next-steps": "…" } }
```
3. **Reference it in the default playbook** — add `strategist?: string` to `PlaybookDocRefs` ([seed-playbook.ts:93](apps/server/src/services/playbook/seed-playbook.ts)); set `refs.strategist = subagentUuids['strategist']` ([seed-playbook.ts:670-675](apps/server/src/services/playbook/seed-playbook.ts)); include it in `anyBlockRefs` between crm-updater and next-steps ([seed-playbook.ts:135](apps/server/src/services/playbook/seed-playbook.ts)): `[refs.crmUpdater, refs.strategist, refs.nextSteps]`.
- Data after this step:
```xml
<trigger type="any">
<ref id="{crmUpdater}"/>
<ref id="{strategist}"/>
<ref id="{nextSteps}"/>
</trigger>
```
4. **Revert the orchestrator special case** — restore the generic three-step "System pipeline order" at [on-event-orchestrator-agent.ts:131-138](apps/server/src/mastra/agents/on-event-orchestrator-agent.ts) (crm-updater → drafting → next-steps); drop the strategist bullet and the "ONLY when it appears in the directory" clause. The Strategist now dispatches purely because the playbook lists it, in the natural between-the-brackets slot.
5. **Compile + dispatch (unchanged mechanics)** — `get-playbook-section.ts` compiles the new `<ref>` into an `@subagents/strategist … [doc_id:…]` line; the orchestrator runs it via `run-subagent` exactly like any playbook subagent. No change to `runSubagentTool` or ownership.
6. **Backfill existing playbooks** — a script that, per existing Deals AOP with a Strategist row: (a) ensures `subagents/strategist.md` exists (reuse `ensureStrategistSubagentDoc`), (b) loads the live PLAYBOOK.md, injects `<ref id="{strategistDocId}"/>` into `<trigger type="any">` right after the crm-updater ref if absent, (c) recompiles + re-saves via `writeDocument` (documentType `playbook`) and nulls `contentYjs` to force re-parse — mirroring [patch-playbook-crm-refs.ts](apps/server/src/db/migrations/scripts/account-setup/patch-playbook-crm-refs.ts) / [heal-raw-at-tokens.ts](apps/server/src/db/migrations/scripts/playbook-health/heal-raw-at-tokens.ts). Idempotent; run jesse-only first (`BACKFILL_ALL=1` for everyone).
### 3.3 Schema
No DB tables change. The only structural change is a new optional field on the seed-time `PlaybookDocRefs` shape and the seeded subagent frontmatter (unchanged format). Full definitions:
```ts
// apps/server/src/services/playbook/seed-playbook.ts — PlaybookDocRefs (seed-time ref bag; all fields)
interface PlaybookDocRefs {
overallGoal?: string; // always-loaded personal goal doc uuid
emailStyle?: string; // always-loaded email style doc uuid
coachingFramework?: string; // always-loaded coaching framework doc uuid
templates?: string; // on-demand templates doc uuid
crmUpdater?: string; // subagents/crm-updater.md uuid → <trigger type="any">
strategist?: string; // NEW — subagents/strategist.md uuid → <trigger type="any"> (between crm-updater and next-steps)
nextSteps?: string; // subagents/next-steps.md uuid → <trigger type="any">
meetingPrep?: string; // subagents/meeting-prep.md uuid → <trigger type="before-meeting">
dailyAgenda?: string; // subagents/daily-agenda.md uuid → <trigger type="cron">
}
// Seeded subagent doc frontmatter (unchanged shape; the Strategist now seeded via this path).
// Stored in documents.content; documents.documentType = 'agent' (born-as-agent, phase 20).
type SubagentDocFrontmatter = {
name: string; // 'strategist'
description: string;
system: true; // system-default subagent
model: string; // 'sonnet'
fill_instructions: string; // author-facing header hint
agent_id: string; // FK → aop_agents.id (the Strategist row) — reconciled by name
};
```
Relationship diagram:
```text
┌───────────────────────────────┐ ┌───────────────────────────────────────┐
│ aop_agents (Strategist row) │ │ documents: subagents/strategist.md │
│ id (PK) 47aaf4ad… │◄──FK───┤ metadata.agent_id / frontmatter.agent_id│ (documentType 'agent')
│ name 'Strategist' │ name │ path user/playbooks/{aopId}/subagents/ │
│ pipelineOrder 2 enabled │ recon │ strategist.md │
│ (owns 12 strategic fields │ └───────────────┬─────────────────────────┘
│ via customFieldDefinitions. │ │ uuid collected as subagentUuids['strategist']
│ ownerAgentId = this id) │ ▼
└───────────────────────────────┘ ┌───────────────────────────────────────┐
│ documents: PLAYBOOK.md (documentType │
│ 'playbook') <trigger type="any"> │
run-subagent(@subagents/strategist) ◄──┤ <ref id="{crmUpdater}"/> │
reads the doc, builds synthetic │ <ref id="{strategist}"/> ◄NEW ref │
AopAgent from agent_id, runAgent │ <ref id="{nextSteps}"/> │
└───────────────────────────────────────┘
```
## 4) Implementation phases
### Phase 1 — Seed the Strategist as a normal playbook subagent ✅
**Goal:** New accounts get `subagents/strategist.md` seeded by the standard playbook seeder and referenced in the default `<trigger type="any">`, with `agent_id` reconciled to the Strategist row — no side-door.
- [x] Added `strategist?: string` to `PlaybookDocRefs` in [seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts).
- [x] Seed `subagents/strategist.md` in `seedPlaybookFiles` ([seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts)) using the shared `buildStrategistSubagentContent(rowId, STRATEGIST_INSTRUCTIONS)` ([strategist-subagent-doc.ts](apps/server/src/services/aop/strategist-subagent-doc.ts), [aop-agents.ts](apps/server/src/services/aop/aop-agents.ts)). **Divergence (better than planned):** rather than adding it to the `subagentDefs` loop whose `withAgentId` only injects for frontmatter-only builders and otherwise depends on a later `heal-subagent-agent-ids` pass (which doesn't know `strategist`), it is seeded just after the loop with the row id emitted **directly** from `agentIdByName` (already in scope) — guaranteeing `agent_id` === the Strategist row id with no heal step.
- [x] Set `refs.strategist = subagentUuids['strategist']` and placed it in `anyBlockRefs` between crm-updater and next-steps ([seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts)).
- [x] Removed the `ensureStrategistSubagentDoc` call from `seedSystemDefaultAgents` ([aop-agents.ts](apps/server/src/services/aop/aop-agents.ts)) (kept the row insert + field-ownership stamping). The `ensureStrategistSubagentDoc` helper stays — the backfill (Phase 3) still uses it.
**Tests:**
- [x] **Headless (run as jesse):** [strategist-playbook-seed-smoke.ts](apps/server/src/scripts/strategist-playbook-seed-smoke.ts) seeds a throwaway Deals AOP → asserts `subagents/strategist.md` exists (documentType `agent`), `agent_id` (frontmatter + metadata) === the Strategist row id, and the seeded PLAYBOOK.md `<trigger type="any">` references crm-updater → strategist → next-steps **in order**. PASS; throwaway AOP + docs torn down.
- [x] `pnpm --filter @zero/server test seed-playbook` — 37 passed (existing coverage green; the test reproduces `buildPlaybookTemplate` privately, so the smoke is the authoritative check of the real path).
### Phase 2 — Revert the orchestrator hardcoded strategist step ✅
**Goal:** The orchestrator no longer special-cases the Strategist; it dispatches it only because the playbook lists it.
- [x] Restored the generic three-step "System pipeline order" in [on-event-orchestrator-agent.ts](apps/server/src/mastra/agents/on-event-orchestrator-agent.ts) (crm-updater → post-event/drafting → next-steps); removed the numbered strategist step + the "ONLY when it appears in the directory" clause.
- [x] Added one clarifying sentence that analysis/strategy playbook subagents (e.g. `@subagents/strategist`) run as ordinary playbook lines between crm-updater and next-steps — not special-cased.
**Tests:**
- [x] Grep-based check: the hardcoded strategist step (`run-subagent(@subagents/strategist) …` / "ONLY when …") is gone (count 0), the generic three-step text is present (count 1), and `@subagents/strategist` survives only as an example line. Typecheck clean.
### Phase 3 — Backfill existing Deals playbooks ✅
**Goal:** Existing accounts get `subagents/strategist.md` (if missing) and a `<ref>` to it in their live PLAYBOOK.md, so the Strategist dispatches for them too.
- [x] New script [seed-strategist-playbook-ref.ts](apps/server/src/db/migrations/scripts/seed-strategist-playbook-ref.ts): for each user AOP with a Strategist row — (a) `ensureStrategistSubagentDoc`, (b) load PLAYBOOK.md, (c) if `<trigger type="any">` lacks the strategist ref, insert `<ref id="{strategistDocId}"/>` after the crm-updater ref (indentation-matched), (d) `writeDocument` (documentType `playbook`) — the compile hook recompiles, (e) null `contentYjs` + bump `yjsRevision`. Idempotent (skips when the ref is already present).
- [x] Scope flags mirroring the other Strategist backfills: default jesse-only, `BACKFILL_ALL=1`, `BACKFILL_EMAIL=x`.
- [x] Ran for jesse (`patched:1`); `BACKFILL_ALL` left for a later deliberate rollout.
**Tests:**
- [x] **Headless (run as jesse):** after the backfill, the live Deals PLAYBOOK.md `<trigger type="any">` contains the strategist ref **after crm-updater**, `getPlaybookSection({eventType:'email_received'})` (the exact function the orchestrator calls) returns a section containing `@subagents/strategist (Owns the strategic overview …) [doc_id:…]`, and a re-run reports `already` (no-op).
### Phase 4 — Verify live dispatch end-to-end ✅ (deterministic chain proven; LLM tool-call shared w/ all subagents)
**Goal:** Confirm the Strategist actually fires on a real event now that it is playbook-referenced.
- [x] **Deterministic dispatch chain proven headlessly** ([strategist-dispatch-smoke.ts](apps/server/src/scripts/strategist-dispatch-smoke.ts), run as jesse): (1) `getPlaybookSection({eventType:'email_received'})` — the exact function feeding the on-event orchestrator — emits the `@subagents/strategist … [doc_id:…]` dispatch line; (2) `readSubagentFull(aopId,'strategist')` — the exact resolution `run-subagent` performs — returns the doc with `frontmatter.agent_id` === the Strategist row id + 1776-char instructions, so run-subagent builds the correct synthetic agent (which can write the owned fields, proven in [[strategic-overview]] phase 12).
- [x] **Divergence (no silent deviation):** the *full* orchestrator LLM drive is not headlessly reproducible here — the `run-subagent`/`runAgent` chain can't be imported under `tsx` (an unrelated pre-existing `@barkleapp/css-sanitizer` ESM-export quirk; it loads fine in the real server), and a single synthetic LLM run would be costly + non-deterministic. The one remaining link — the orchestrator LLM *choosing* to call `run-subagent(@subagents/strategist)` — is the identical mechanism crm-updater/next-steps use in production every day; confirm it on the **next real jesse Deals event** via Axiom rather than a manufactured one-off.
- [x] Updated Phase 11's dispatch note in [strategic-overview.md](apps/mail/docs/strategic-overview.md): dispatch is now wired + deterministically proven to the LLM tool-call; the live LLM firing is confirmed via real-event monitoring.
**Tests:**
- [x] [strategist-dispatch-smoke.ts](apps/server/src/scripts/strategist-dispatch-smoke.ts) — PASS (both assertions green, run as jesse).