Agent Rework — Architecture
One spine: a jobs table. Subsystems do domain work and enqueue a job when they need the PM. A general conductor reads the queue, paces the chat, gets the answer, and writes the resolution back. The owning subsystem finishes. The chat agent stops doing everyone's work.
7 minjobs table — the only thing the conductor and the subsystems share. Build it additively alongside the live loop; cut over one flow at a time.
The loop
Green = the answer flowing back. A subsystem enqueues a job, the conductor asks the PM and writes the structured resolution, and the owning subsystem reads it and finishes the work (dispatch the vendor, write the policy). The conductor never dispatches; the subsystems never touch chat.
The jobs table build first
Supabase, durable, multi-process. The seam between "who needs the PM" and "when/how the PM is asked." One pending job per dedupe_key (idempotent enqueues).
| Column | Purpose |
|---|---|
| id, workspace_id | uuid pk · tenant partition |
| source | pms_intake | memory | clock — who owns the resolution |
| kind | confirm_dispatch | ask_promotion | clarify_wo | stale_wo_nudge | open_question |
| status | pending → asking → awaiting_reply → resolved (or cancelled / failed) |
| priority | urgent / normal / low — drives pacing order |
| issue_id, chat_guid | correlation + where to ask |
| ask | jsonb: { question, draft } — what the conductor says (or a hint it rewrites) |
| resume | jsonb: everything the subsystem needs to finish (vendor_id, proposal spec, …) |
| resolution | jsonb: the PM's structured answer, written by the conductor |
| not_before | work-hours hold / snooze — earliest surface time |
| dedupe_key | idempotency: one pending job per (kind, issue/scope) |
| attempts, last_error, *_at | retry + lifecycle timestamps |
This is the drafts.json staging queue generalized: hold_until→not_before, approved_at→status, plus the two things drafts lack — source (route the resolution home) and resume (reconstruct the work).
The conductor
One general agent. It owns the conversation and the pacing — nothing domain-specific.
asking job per chat at a time (batches related asks), respects not_before + priority. This is the "don't dump every job into chat at once" rule.job.ask (warm, in-voice), sends via the existing draft/send pipeline, sets awaiting_reply.resolution ({confirmed, vendor?, freeform?}). The conductor's only LLM job.resolution, sets resolved. A per-source handler consumes it. The conductor is done.Subsystems own the work
PMS intake (the "24/7 electric" fix)
// new-work-order-message.mjs — replaces decideMessage's LLM vendor guess const trade = await classifyTrade(issue) // description → trade (the keystone) const r = await findPolicy({ trigger:{ kind:'new_work_order', trade, property_id } }) // tier 1 → "Sending Osalpa for the electrical?" tier 2 → derived suggest + ask // tier 3 → "Who should handle this?" (never a guessed vendor) enqueueJob({ source:'pms_intake', kind:'confirm_dispatch', issue_id, ask:{ question, draft }, resume:{ vendor_id: r.policy?.args.vendor_id, trade } }) // on resolution {confirmed, vendor}: PMS calls dispatch_vendor + log_event(the decision)
Memory
find_policy already emits a proposal at N=2. When intake (or a sweep) sees one, it enqueues an ask_promotion job with the proposal in resume. On a yes, the memory handler calls log_event(create_policy) — authority gets a name. This is the F half, now with a delivery channel.
The tool split
| Owner | Tools |
|---|---|
| conductor | send_text, wait, done — conversational + flow only |
| PMS | dispatch_vendor, text_tenant, update_issue — invoked by the intake/resolution handler, not the chat agent |
| memory | find_policy, log_event — invoked by intake + the promotion handler |
Today all 10 live on the chat agent (and find_policy/log_event aren't even registered). After the split the conductor can't accidentally dispatch a vendor — it can only talk.
The clock
A heartbeat that turns time into jobs. Cheapest host: a tick() at the end of the issue-poller's existing 5s sweep. Each tick scans for and enqueues (idempotently): stale WO (awaiting PM too long), quiet vendor (dispatched, no reply in TTL), unanswered question (clarification sent, PM silent), follow-up due. The conductor handles them like any other job — so escalations pace through the same channel, never a separate firehose.
Build plan
core/jobs.mjsSchema + enqueue/claim/resolve/list, idempotent, with a smoke test. The spine; nothing else lands without it.classifyTrade(issue) — description → trade. The keystone; find_policy is blind without it. Eval against the seeded WOs.confirm_dispatch. Old decideMessage stays as fallback.Memory subsystem is done (E+F shipped, committed). Steps 1–2 are safe pure-infra wins; 3–6 are the cutover, gated for your review.