From 536d1fb6f4943cb1bae5549a307c3a58889e04c3 Mon Sep 17 00:00:00 2001 From: ty Date: Sun, 25 Oct 2026 17:28:20 -0500 Subject: [PATCH 1/9] feat(autopilot): build sourced plans and hosted intake handoff --- autopilot/src/plan-contract.ts | 47 +++++++ autopilot/src/plan-evidence.ts | 62 +++++++++ autopilot/src/plan-tools.ts | 74 +++++++++++ autopilot/src/plan-validation.ts | 38 ++++++ autopilot/src/plan.ts | 199 +++++++++++++++++++++++++++++ autopilot/src/server.ts | 81 +++++++++++- autopilot/test/plan.test.ts | 212 +++++++++++++++++++++++++++++++ autopilot/test/server.test.ts | 163 ++++++++++++++++-------- docs/autopilot-phase1-release.md | 68 ++++++++++ 9 files changed, 884 insertions(+), 60 deletions(-) create mode 100644 autopilot/src/plan-contract.ts create mode 100644 autopilot/src/plan-evidence.ts create mode 100644 autopilot/src/plan-tools.ts create mode 100644 autopilot/src/plan-validation.ts create mode 100644 autopilot/src/plan.ts create mode 100644 autopilot/test/plan.test.ts create mode 100644 docs/autopilot-phase1-release.md diff --git a/autopilot/src/plan-contract.ts b/autopilot/src/plan-contract.ts new file mode 100644 index 0000000..79dcbb6 --- /dev/null +++ b/autopilot/src/plan-contract.ts @@ -0,0 +1,47 @@ +/** Wire contract shared with corpus-agent-kit /api/plan, version 1. */ +export type PlanningMode = "exploring" | "ready" | "formed"; +export type EntityChoice = "llc" | "nonprofit" | null; +export type Campaign = Partial>; +export interface PlanInput { + mode: PlanningMode; + activity: string; + operatingState: string | null; + operatingCity: string; + formationState: string | null; + entityType: EntityChoice; + campaign: Campaign; +} +export interface PlanSource { + id: string; + kind: "law" | "catalog" | "guide"; + label: string; + url: string; + excerpt: string; + nodeId?: string; +} +export interface PlanItem { + id: string; + category: "entity" | "registered_agent" | "ein" | "annual_report" | "state_tax" | "local"; + title: string; + body: string; + sourceIds: string[]; + needsCheck: boolean; +} +export interface PlanResponse { + version: 1; + createdAt: string; + items: PlanItem[]; + sources: PlanSource[]; + pricing: { available: boolean; state: string | null; lines: string[]; checkedAt: string }; + tools: string[]; + warnings: string[]; +} +export type ItemProgress = "todo" | "done" | "not_applicable" | "needs_check"; +export interface PlanHandoff { url: string; missing: string[] } +export interface ServiceOffer { + id: "annual_report" | "renewal_bundle" | "registered_agent"; + title: string; + available: boolean; + description: string; + href: string; +} diff --git a/autopilot/src/plan-evidence.ts b/autopilot/src/plan-evidence.ts new file mode 100644 index 0000000..8317a75 --- /dev/null +++ b/autopilot/src/plan-evidence.ts @@ -0,0 +1,62 @@ +import type { PlanSource } from "./plan-contract.js"; +import { record, STATES } from "./plan-validation.js"; + +export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const clean = (s: string) => s.trim().toLowerCase(); +export function lawSource(nodeId: string, label: string, excerpt: string): PlanSource { + return { id: `law:${nodeId.toLowerCase()}`, kind: "law", nodeId: nodeId.toLowerCase(), label, + // Inspected app/code/[...path]/page.tsx accepts the stable UUID URL. + url: `https://corpuslaw.us/code/${nodeId.toLowerCase()}`, excerpt }; +} + +/** Parse only the actual numbered MCP transcript; never harvest arbitrary UUIDs/URLs. */ +export function parseSearch(text: string, jurisdictionName: string): PlanSource[] { + if (!/^\d+ of \d+ results for /m.test(text)) return []; + const blocks = text.split(/\n\n/); + const result: PlanSource[] = []; + for (const block of blocks) { + const lines = block.split("\n"); + const heading = lines[0]?.match(/^\d+\. (.+)$/)?.[1]; + const id = lines.at(-1)?.match(/^ node_id: (\S+)$/)?.[1]; + const where = lines[1]?.trim().split(" · ")[0]; + if (!heading || heading.length > 400 || !id || !UUID.test(id) || clean(where ?? "") !== clean(jurisdictionName)) continue; + const excerpt = lines.slice(2, -1).map(l => l.trim()).join("\n"); + if (!excerpt || excerpt.length > 3000) continue; + const note = text.startsWith("NOTE:") ? text.split("\n\n")[0] + "\n" : ""; + if (note.length > 2000) continue; + result.push(lawSource(id, heading, note + excerpt)); + if (result.length === 2) break; + } + return result; +} + +/** get_node may prefix its JSON with an effective-date warning. Preserve it. */ +export function parseNode(text: string, expectedId: string, jurisdictionName: string): PlanSource | null { + try { + const start = text.indexOf("{"); + if (start < 0) return null; + const prefix = text.slice(0, start).trim(); + if (prefix.length > 2000 || (prefix && !/^(?:NOTE|WARNING|NOT YET IN EFFECT)\b/i.test(prefix))) return null; + const node: unknown = JSON.parse(text.slice(start)); + if (!record(node) || typeof node.nodeId !== "string" || !UUID.test(node.nodeId) || node.nodeId.toLowerCase() !== expectedId.toLowerCase() || + typeof node.citation !== "string" || !node.citation || node.citation.length > 400 || + typeof node.bodyText !== "string" || !node.bodyText.trim() || !record(node.jurisdiction) || + typeof node.jurisdiction.name !== "string" || clean(node.jurisdiction.name) !== clean(jurisdictionName)) return null; + return lawSource(node.nodeId, node.citation, (prefix ? prefix + "\n" : "") + node.bodyText.slice(0, 3000)); + } catch { return null; } +} + +export function exactLocalCoverage(text: string, state: string, city: string): { code: string; name: string } | null { + const names = [city, `${city}, ${state}`, `${city}, ${STATES[state]}`].map(clean); + const matches: { code: string; name: string }[] = []; + for (const line of text.split("\n")) { + const row = line.match(/^- ([A-Z0-9-]+) — (.+) \((?:municipal|city)\): (.+)$/); + if (!row || !row[1].startsWith(`${state}-`) || !names.includes(clean(row[2]))) continue; + const status = row[3]; + if (/PLACEHOLDER|not yet searchable/i.test(status)) continue; + const count = status.match(/([\d,]+) searchable chunks/); + if (!(count && Number(count[1].replace(/,/g, "")) > 0) && !/^delivered after the last daily count — searchable now;/.test(status)) continue; + matches.push({ code: row[1], name: row[2] }); + } + return matches.length === 1 ? matches[0] : null; +} diff --git a/autopilot/src/plan-tools.ts b/autopilot/src/plan-tools.ts new file mode 100644 index 0000000..fd3f637 --- /dev/null +++ b/autopilot/src/plan-tools.ts @@ -0,0 +1,74 @@ +import { corpusBase } from "./tools/law-search.js"; +import { PlanError, record } from "./plan-validation.js"; + +export type ToolName = "formation.requirements" | "law.search" | "law.get_node" | "law.list_coverage" | "formation.handoff"; +export type ToolCall = (name: ToolName, args: Record, signal: AbortSignal) => Promise; +export const TOOL_TIMEOUT_MS = 4500; +export const PLAN_TIMEOUT_MS = 24000; +export const MAX_TOOL_BYTES = 131072; + +/** Enforces a deadline even when an injected/third-party implementation ignores abort. */ +export async function bounded(work: (signal: AbortSignal) => Promise, parent: AbortSignal, ms: number): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(); + parent.addEventListener("abort", abort, { once: true }); + if (parent.aborted) abort(); + const timer = setTimeout(abort, ms); + let rejectAbort: () => void = () => {}; + const stopped = new Promise((_, reject) => { + rejectAbort = () => reject(new PlanError(504, "Planning request timed out.")); + controller.signal.addEventListener("abort", rejectAbort, { once: true }); + if (controller.signal.aborted) rejectAbort(); + }); + try { + if (controller.signal.aborted) return await stopped; + return await Promise.race([work(controller.signal), stopped]); + } finally { + clearTimeout(timer); + parent.removeEventListener("abort", abort); + controller.signal.removeEventListener("abort", rejectAbort); + } +} + +export async function readBoundedResponse(response: Response, signal: AbortSignal): Promise { + if (!response.ok || !response.body) throw new Error("Tool unavailable"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + const cancel = () => { void reader.cancel().catch(() => {}); }; + signal.addEventListener("abort", cancel, { once: true }); + try { + for (;;) { + signal.throwIfAborted(); + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_TOOL_BYTES) throw new Error("Tool response too large"); + chunks.push(value); + } + signal.throwIfAborted(); + return Buffer.concat(chunks).toString("utf8"); + } finally { + signal.removeEventListener("abort", cancel); + await reader.cancel().catch(() => {}); + } +} + +/** MCP tools return prose in structuredContent.text / content[].text, not result objects. */ +export const hostedTool: ToolCall = async (name, args, signal) => { + const response = await fetch(`${corpusBase()}/api/mcp`, { + method: "POST", signal, + headers: { "Content-Type": "application/json", ...(process.env.CORPUS_API_KEY ? { Authorization: `Bearer ${process.env.CORPUS_API_KEY}` } : {}) }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), + }); + const json: unknown = JSON.parse(await readBoundedResponse(response, signal)); + if (!record(json) || json.error || !record(json.result)) throw new Error("Tool failed"); + const result = json.result; + if (result.isError || (record(result.structuredContent) && result.structuredContent.isError)) throw new Error("Tool failed"); + let text: unknown = record(result.structuredContent) ? result.structuredContent.text : undefined; + if (text === undefined && Array.isArray(result.content)) { + text = result.content.filter(c => record(c) && c.type === "text" && typeof c.text === "string").map(c => c.text).join("\n"); + } + if (typeof text !== "string" || !text.trim() || /^Error:/i.test(text.trim())) throw new Error("Tool text missing"); + return text; +}; diff --git a/autopilot/src/plan-validation.ts b/autopilot/src/plan-validation.ts new file mode 100644 index 0000000..fa42d53 --- /dev/null +++ b/autopilot/src/plan-validation.ts @@ -0,0 +1,38 @@ +import type { PlanInput } from "./plan-contract.js"; + +export class PlanError extends Error { + constructor(public status: number, message: string) { super(message); } +} + +export const STATES: Record = Object.fromEntries([ + "AL:Alabama", "AK:Alaska", "AZ:Arizona", "AR:Arkansas", "CA:California", "CO:Colorado", + "CT:Connecticut", "DE:Delaware", "DC:District of Columbia", "FL:Florida", "GA:Georgia", + "HI:Hawaii", "ID:Idaho", "IL:Illinois", "IN:Indiana", "IA:Iowa", "KS:Kansas", "KY:Kentucky", + "LA:Louisiana", "ME:Maine", "MD:Maryland", "MA:Massachusetts", "MI:Michigan", "MN:Minnesota", + "MS:Mississippi", "MO:Missouri", "MT:Montana", "NE:Nebraska", "NV:Nevada", "NH:New Hampshire", + "NJ:New Jersey", "NM:New Mexico", "NY:New York", "NC:North Carolina", "ND:North Dakota", + "OH:Ohio", "OK:Oklahoma", "OR:Oregon", "PA:Pennsylvania", "RI:Rhode Island", "SC:South Carolina", + "SD:South Dakota", "TN:Tennessee", "TX:Texas", "UT:Utah", "VT:Vermont", "VA:Virginia", + "WA:Washington", "WV:West Virginia", "WI:Wisconsin", "WY:Wyoming", +].map(s => s.split(":"))); + +export const CAMPAIGN_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"] as const; +const FIELDS = ["mode", "activity", "operatingState", "operatingCity", "formationState", "entityType", "campaign"]; +export function record(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +export function validatePlanInput(v: unknown): PlanInput { + const bad = () => { throw new PlanError(400, "Invalid planning input."); }; + if (!record(v) || Object.keys(v).some(k => !FIELDS.includes(k)) || FIELDS.some(k => !Object.hasOwn(v, k))) return bad(); + if (!["exploring", "ready", "formed"].includes(v.mode as string)) return bad(); + if (v.entityType !== null && v.entityType !== "llc" && v.entityType !== "nonprofit") return bad(); + for (const key of ["formationState", "operatingState"]) { + if (v[key] !== null && (typeof v[key] !== "string" || !Object.hasOwn(STATES, v[key] as string))) return bad(); + } + if (typeof v.activity !== "string" || v.activity.length > 500 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(v.activity)) return bad(); + if (typeof v.operatingCity !== "string" || v.operatingCity.length > 100 || /[\x00-\x1f\x7f]/.test(v.operatingCity)) return bad(); + if (!record(v.campaign) || Object.keys(v.campaign).some(k => !CAMPAIGN_KEYS.includes(k as typeof CAMPAIGN_KEYS[number]))) return bad(); + if (Object.values(v.campaign).some(s => typeof s !== "string" || s.length > 100 || !/^[A-Za-z0-9_.~-]*$/.test(s))) return bad(); + return { ...v, activity: v.activity.replace(/\s+/g, " ").trim(), operatingCity: v.operatingCity.trim(), campaign: { ...v.campaign } } as PlanInput; +} diff --git a/autopilot/src/plan.ts b/autopilot/src/plan.ts new file mode 100644 index 0000000..4a02360 --- /dev/null +++ b/autopilot/src/plan.ts @@ -0,0 +1,199 @@ +import type { PlanInput, PlanResponse, PlanHandoff, PlanItem, PlanSource } from "./plan-contract.js"; +import { bounded, hostedTool, MAX_TOOL_BYTES, PLAN_TIMEOUT_MS, TOOL_TIMEOUT_MS, type ToolCall, type ToolName } from "./plan-tools.js"; +import { CAMPAIGN_KEYS, PlanError, STATES, validatePlanInput } from "./plan-validation.js"; +import { exactLocalCoverage, parseNode, parseSearch } from "./plan-evidence.js"; +import { getQwen } from "./qwen.js"; +import { lane } from "./routing.js"; + +type Selector = (ids: string[], signal: AbortSignal, context: { input: PlanInput; items: Pick[] }) => Promise; +export interface PlanDeps { + tool?: ToolCall; + select?: Selector; + toolTimeoutMs?: number; + modelTimeoutMs?: number; + timeoutMs?: number; +} +const GUIDE = "https://corpuslaw.us/guides/after-forming-an-llc"; +const CHECKLIST = "https://corpuslaw.us/checklist"; +// Reviewed wording from funnel-content.ts and the after-forming-an-llc guide (2026-10-19). +const EIN = "A single-member LLC with no employees, no relevant excise-tax obligations, and no corporate tax election generally does not need a separate EIN for federal tax purposes. A multi-member LLC generally does. A bank or state requirement may also make an EIN necessary for your next step."; +const RA = "Most states require an LLC to name a registered agent — it can be the owner in many states, or a paid service. Minnesota, Pennsylvania and West Virginia make the agent optional (Minnesota and Pennsylvania still require a registered office address)."; +const ANNUAL = "Most states also charge a recurring fee to keep an LLC alive (often called an annual report or franchise tax) — the amount and due date vary by state."; +const ENTITY = "An LLC, an EIN, and a registered agent are three different things: the LLC is the legal entity your state creates; the EIN is the federal tax ID the IRS issues; the registered agent is the person or company that receives legal mail for the entity at a street address in the formation state."; + +function requestTools(deps: PlanDeps, signal: AbortSignal, tools: string[], warnings: string[]) { + let calls = 0; + return async (name: ToolName, args: Record): Promise => { + signal.throwIfAborted(); + if (++calls > 6) throw new Error("Tool limit reached"); + try { + const text = await bounded(s => (deps.tool ?? hostedTool)(name, args, s), signal, Math.min(deps.toolTimeoutMs ?? TOOL_TIMEOUT_MS, TOOL_TIMEOUT_MS)); + if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > MAX_TOOL_BYTES || /^Error:/i.test(text.trim())) throw new Error("Invalid tool text"); + if (!tools.includes(name)) tools.push(name); + return text; + } catch { + signal.throwIfAborted(); + warnings.push(`${name} was unavailable or returned an unreadable response; related items need checking.`); + return null; + } + }; +} + +const selectKnown: Selector = async (ids, signal, context) => { + const response = await getQwen().chat.completions.create({ + model: lane("fast"), temperature: 0, max_tokens: 256, + messages: [{ role: "system", content: "Return only a JSON array ordering the supplied item IDs. Do not add IDs, prose or sources." }, { role: "user", content: JSON.stringify({ ids, ...context }) }], + }, { signal, timeout: 2000, maxRetries: 0 }); + const text = response.choices[0]?.message.content; + if (!text || text.length > 4096) throw new Error("Invalid ordering"); + return JSON.parse(text); +}; + +/** No user source records or model legal prose can enter the request-local ledger. */ +export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = new AbortController().signal): Promise { + const input = validatePlanInput(raw); + return bounded(async signal => { + const createdAt = new Date().toISOString(); + const tools: string[] = []; + const warnings = ["Source binding establishes provenance, not applicability or completeness. Search matches and unresolved items need checking."]; + const call = requestTools(deps, signal, tools, warnings); + const ledger = new Map(); + const items: PlanItem[] = []; + function guide(id: string, label: string, url: string, excerpt: string) { + ledger.set(id, { id, kind: "guide", label, url, excerpt }); + return id; + } + function item(id: string, category: PlanItem["category"], title: string, body: string, sourceIds: string[] = []) { + items.push({ id, category, title, body, sourceIds, needsCheck: true }); + } + const checklistEntity = guide("guide:entity", "The founder legal checklist", CHECKLIST, ENTITY); + const state = input.formationState; + let catalog: string | null = null; + if (state && input.entityType) { + const returned = await call("formation.requirements", { state, entityType: input.entityType }); + const header = `Formation requirements — ${input.entityType === "llc" ? "LLC" : "nonprofit corporation"} in ${state} (US-${state}):`; + if (returned?.startsWith(header + "\n") && returned.includes("\nREQUIRED (collect ALL of these from your user, in conversation):\n")) { + catalog = returned; + ledger.set("catalog:formation", { id: "catalog:formation", kind: "catalog", label: header.slice(0, -1), url: "https://corpuslaw.us/formation", excerpt: header }); + item("entity", "entity", input.mode === "formed" ? "Review the existing entity" : "Review the selected entity and formation state", header, ["catalog:formation", checklistEntity]); + if (input.mode !== "formed") { + const sections = returned.split(/\n\n/).filter(section => section.startsWith("REQUIRED (") || section.startsWith(`STATE-SPECIFIC QUESTIONS for ${state} (`)); + for (const [index, section] of sections.entries()) { + const body = section.split("\n").filter(line => line.startsWith("- ")).join("\n"); + if (!body || body.length > 3000) { + warnings.push("Some formation intake fields could not be displayed; check the complete intake with Corpus before proceeding."); + continue; + } + const id = `catalog:intake:${index}`; + ledger.set(id, { id, kind: "catalog", label: `${state} formation intake fields`, url: "https://corpuslaw.us/formation", excerpt: body }); + item(`intake_${index}`, "entity", index === 0 ? "Prepare these details for formation" : `Check the ${state} formation questions`, body, [id]); + } + } + } else if (returned) warnings.push("The formation catalog format or jurisdiction could not be verified; its requirements and prices were withheld."); + } + if (!catalog) item("entity", "entity", "Review entity and formation-state choices", ENTITY, [checklistEntity]); + warnings.push(state && input.entityType + ? `Formation catalog items concern ${state} and ${input.entityType} only; they do not establish operating-state requirements. ${input.mode === "formed" ? "Already-formed mode does not recommend a new formation." : ""}`.trim() + : "Formation state or entity is not selected. No entity type or formation jurisdiction has been assumed."); + + const raText = input.entityType === "nonprofit" ? "The registered-agent discussion in the linked checklist concerns LLCs. Check the nonprofit-specific requirements for the selected state." : RA; + item("registered_agent", "registered_agent", "Check the registered-agent or registered-office rules", raText, [guide("guide:ra", "The founder legal checklist — registered agents", CHECKLIST, RA)]); + item("ein", "ein", "Determine whether an EIN is needed", input.entityType === "nonprofit" + ? "The single-member LLC exception in the linked guide is specific to LLCs. Check the IRS requirements for this nonprofit. Apply directly through the IRS for free." + : EIN + " Apply directly through the IRS for free.", [guide("guide:ein", "After forming an LLC — EIN", GUIDE + "#step-ein", EIN), guide("guide:irs", "IRS — single-member LLC guidance", "https://www.irs.gov/businesses/small-businesses-self-employed/single-member-limited-liability-companies", EIN)]); + item("annual_report", "annual_report", "Check reports and recurring state obligations", input.entityType === "nonprofit" + ? "Check the selected state's nonprofit reporting requirements; the linked checklist describes LLCs." : ANNUAL, + [guide("guide:annual", "The founder legal checklist — recurring fees", CHECKLIST, ANNUAL)]); + if (catalog && state) { + const duties = catalog.split(/\n\n/).find(s => s.startsWith(`AFTER-FORMATION DUTIES in ${state} —`)); + for (const [i, line] of (duties?.split("\n").filter(l => l.startsWith("- ")) ?? []).slice(0, 4).entries()) { + const body = line.slice(2); + if (!body || body.length > 3000) continue; + const id = `catalog:duty:${i}`; + ledger.set(id, { id, kind: "catalog", label: `${state} formation catalog — after-formation duty`, url: "https://corpuslaw.us/formation", excerpt: body }); + item(`formation_duty_${i}`, /annual|report|franchise/i.test(body) ? "annual_report" : "entity", `${state} formation catalog: after-formation duty`, body, [id]); + } + } + item("state_tax", "state_tax", "Check tax registration in the operating state", input.operatingState + ? `Operating state: ${input.operatingState}. Tax registration applicability has not been determined. Check the rules using the actual activity and tax situation.` + : "Operating state is not selected. Tax registration applicability has not been determined."); + + async function search(query: string, jurisdiction: string, jurisdictionName: string, itemId: string) { + const text = await call("law.search", { query, jurisdiction, mode: "keyword", limit: 2 }); + const sources = text ? parseSearch(text, jurisdictionName) : []; + for (const source of sources) ledger.set(source.id, source); + items.find(i => i.id === itemId)!.sourceIds.push(...sources.map(s => s.id)); + if (!sources.length) warnings.push(`No usable ${jurisdiction} source records were retrieved; this does not establish that no requirements apply.`); + return sources; + } + if (catalog && state) await search("annual report", state, STATES[state], "annual_report"); + const taxSources = input.operatingState ? await search("business tax registration", input.operatingState, STATES[input.operatingState], "state_tax") : []; + if (taxSources[0]?.nodeId && input.operatingState) { + const text = await call("law.get_node", { id: taxSources[0].nodeId }); + const detail = text ? parseNode(text, taxSources[0].nodeId, STATES[input.operatingState]) : null; + if (detail) ledger.set(detail.id, detail); + else warnings.push("Full provision detail could not be verified; the search excerpt remains unresolved."); + } + const coverage = input.operatingState && input.operatingCity ? await call("law.list_coverage", {}) : null; + const local = coverage && input.operatingState ? exactLocalCoverage(coverage, input.operatingState, input.operatingCity) : null; + item("local", "local", local ? "Review local source matches" : "Local coverage needs checking", local + ? `Coverage confirms a searchable entry for ${local.name} (${local.code}); it does not establish which rules apply to this activity.` + : "No exact, unambiguous, searchable coverage entry was confirmed for the operating city. Local requirements have not been determined."); + if (local) await search(input.activity || "business", local.code, local.name, "local"); + else warnings.push("Local coverage gap: no local search was run."); + + const lines = catalog?.split("\n").filter(l => /^(?:PRICE(?: \(live\))?:|FOUNDING DISCOUNT:|EXPEDITE \(|SKIP REVIEW \(|TENNESSEE MEMBERS:|EIN ADD-ON: temporarily unavailable)/.test(l)) ?? []; + const validPrice = lines.filter(l => /^PRICE \(live\): \$\d+(?:,\d{3})*\.\d{2} all-in — /.test(l)).length === 1 && + lines.filter(l => /^PRICE/.test(l)).length === 1 && lines.length <= 10 && + lines.every(l => l.length <= 6000) && Buffer.byteLength(lines.join("\n")) <= 16384; + const pricing = { available: input.mode === "ready" && validPrice, state, lines: input.mode === "ready" && validPrice ? lines : [], checkedAt: new Date().toISOString() }; + if (input.mode === "ready" && !pricing.available) warnings.push("Current pricing is unavailable; no price or founding offer has been assumed."); + + const selector = deps.select ?? (process.env.QWEN_API_KEY && process.env.QWEN_BASE_URL ? selectKnown : undefined); + if (selector) { + try { + const selected = await bounded(s => selector(items.map(i => i.id), s, { input, items: items.map(({ id, title, category }) => ({ id, title, category })) }), signal, Math.min(deps.modelTimeoutMs ?? 2000, 2000)); + if (!Array.isArray(selected) || selected.length > 20 || selected.some(id => typeof id !== "string")) throw new Error("Invalid selection"); + const known = new Set(items.map(i => i.id)); + const ids = [...new Set(selected.filter(id => known.has(id)))]; + if (ids.length !== selected.length) warnings.push("Unknown or repeated model item IDs were ignored."); + items.sort((a, b) => (ids.includes(a.id) ? ids.indexOf(a.id) : 100) - (ids.includes(b.id) ? ids.indexOf(b.id) : 100)); + tools.push("qwen.order_known_items"); + } catch { + signal.throwIfAborted(); + warnings.push("Qwen ordering was unavailable; deterministic sourced items are shown."); + } + } else warnings.push("Qwen ordering is not configured; deterministic sourced items are shown."); + return { version: 1, createdAt, items, sources: [...ledger.values()], pricing, tools, warnings }; + }, parent, Math.min(deps.timeoutMs ?? PLAN_TIMEOUT_MS, PLAN_TIMEOUT_MS)); +} + +export async function handoffPlan(raw: PlanInput, deps: PlanDeps = {}, parent = new AbortController().signal): Promise { + const input = validatePlanInput(raw); + if (input.mode !== "ready" || !input.formationState || !input.entityType) throw new PlanError(400, "Choose ready mode, a formation state and an entity before continuing."); + return bounded(async signal => { + const text = await requestTools(deps, signal, [], [])("formation.handoff", { entityType: input.entityType, state: input.formationState, ...(input.activity ? { businessActivity: input.activity } : {}) }); + if (!text) throw new PlanError(502, "Formation handoff is unavailable."); + // Only an entire, bare URL line from the successful hosted tool is acceptable. + const links = text.split("\n").map(l => l.trim()).filter(l => /^https?:\/\//.test(l)); + if (links.length !== 1) throw new PlanError(502, "Formation handoff is unavailable."); + const rawUrl = links[0]; + let url: URL; + try { url = new URL(rawUrl); } catch { throw new PlanError(502, "Formation handoff is unavailable."); } + if (url.origin !== "https://corpuslaw.us" || url.pathname !== "/formation" || url.username || url.password || url.port || + !/^https:\/\/corpuslaw\.us\/formation#prefill=[A-Za-z0-9_-]+(?:&(?:src|ref)=[A-Za-z0-9_.~-]+)*$/.test(rawUrl)) throw new PlanError(502, "Formation handoff is unavailable."); + const fragment = new URLSearchParams(url.hash.slice(1)); + if (["src", "ref"].some(key => fragment.getAll(key).length > 1)) throw new PlanError(502, "Formation handoff is unavailable."); + const missingBlock = text.match(/(?:^|\n)STILL MISSING:\n((?:- [^\n]+\n?)+)/)?.[1]; + const errorsBlock = text.match(/(?:^|\n)BLOCKING ERRORS \(fix these\):\n((?:- [^\n]+\n?)+)/)?.[1]; + const missing = [missingBlock, errorsBlock].flatMap(b => b ? b.trim().split("\n").map(l => l.slice(2)) : []); + if ((!missing.length && !/^✓ Draft COMPLETE — /m.test(text)) || missing.length > 40 || missing.some(l => l.length > 500)) throw new PlanError(502, "Formation handoff is unavailable."); + // Append query UTMs without reserializing the returned query/fragment attribution. + const hash = rawUrl.indexOf("#"); + const campaign = new URLSearchParams(); + for (const key of CAMPAIGN_KEYS) if (input.campaign[key]) campaign.set(key, input.campaign[key]!); + const before = rawUrl.slice(0, hash); + const query = campaign.toString(); + return { url: before + (query ? (before.includes("?") ? "&" : "?") + query : "") + rawUrl.slice(hash), missing }; + }, parent, Math.min(deps.timeoutMs ?? PLAN_TIMEOUT_MS, PLAN_TIMEOUT_MS)); +} diff --git a/autopilot/src/server.ts b/autopilot/src/server.ts index 65d74f4..6aac8d9 100644 --- a/autopilot/src/server.ts +++ b/autopilot/src/server.ts @@ -3,6 +3,9 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { runAutopilot } from "./agent.js"; +import { buildPlan, handoffPlan } from "./plan.js"; +import { PlanError, validatePlanInput } from "./plan-validation.js"; +import { bounded } from "./plan-tools.js"; const here = dirname(fileURLToPath(import.meta.url)); // dist/server.js → ../public/index.html @@ -10,11 +13,51 @@ const INDEX_HTML = readFileSync(join(here, "..", "public", "index.html"), "utf8" interface Deps { run?: typeof runAutopilot; + plan?: typeof buildPlan; + handoff?: typeof handoffPlan; + bodyTimeoutMs?: number; + requestTimeoutMs?: number; +} + +export const MAX_PLAN_BODY_BYTES = 16384; +export const MAX_PLAN_RESPONSE_BYTES = 98304; +const BODY_TIMEOUT_MS = 5000; + +/** Event-based reader stops buffering immediately on size, disconnect or deadline. */ +function readBody(req: http.IncomingMessage, limit: number, timeoutMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let bytes = 0; + const cleanup = () => { + clearTimeout(timer); + req.off("data", data); req.off("end", end); req.off("error", error); + signal.removeEventListener("abort", abort); + }; + const fail = (e: Error) => { cleanup(); req.pause(); reject(e); }; + const data = (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > limit) return fail(new PlanError(413, "Request body is too large.")); + chunks.push(chunk); + }; + const end = () => { + cleanup(); + try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { reject(new PlanError(400, "Body must be JSON.")); } + }; + const error = () => fail(new PlanError(400, "Request body could not be read.")); + const abort = () => fail(new PlanError(408, "Request body timed out.")); + const timer = setTimeout(abort, timeoutMs); + req.on("data", data); req.once("end", end); req.once("error", error); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) return abort(); + const length = req.headers["content-length"]; + if (length && Number(length) > limit) fail(new PlanError(413, "Request body is too large.")); + }); } export function createServer(deps: Deps = {}): http.Server { const run = deps.run ?? runAutopilot; - // Naive global budget guard: judges + demo traffic share ~$23 of credit. + // Shared per-process request guard; this is not a global spending/dollar cap. let windowStart = Date.now(); let turnsThisWindow = 0; function underCap(): boolean { @@ -26,11 +69,17 @@ export function createServer(deps: Deps = {}): http.Server { return ++turnsThisWindow <= cap; } return http.createServer(async (req, res) => { + const isPlan = req.url === "/api/plan" || req.url === "/api/plan/handoff"; + const controller = new AbortController(); + const disconnect = () => controller.abort(); + req.once("aborted", disconnect); + res.once("close", () => { if (!res.writableFinished) disconnect(); }); try { // The demo page may be hosted off-origin (FC's default fcapp.run domain // force-downloads text/html via an injected Content-Disposition header), // so the API must be callable cross-origin. Public read-only demo — "*". res.setHeader("Access-Control-Allow-Origin", "*"); + if (isPlan) res.setHeader("Cache-Control", "no-store"); if (req.method === "OPTIONS") { res .writeHead(204, { @@ -49,17 +98,30 @@ export function createServer(deps: Deps = {}): http.Server { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(INDEX_HTML); return; } + if (req.method === "POST" && isPlan) { + if (!underCap()) { + res.writeHead(429, { "Content-Type": "application/json", "Connection": "close" }).end(JSON.stringify({ error: "Hourly request cap reached — try again later." })); + return; + } + const body = await readBody(req, MAX_PLAN_BODY_BYTES, Math.min(deps.bodyTimeoutMs ?? BODY_TIMEOUT_MS, BODY_TIMEOUT_MS), controller.signal); + const input = validatePlanInput(body); + const execute = req.url === "/api/plan/handoff" ? deps.handoff ?? handoffPlan : deps.plan ?? buildPlan; + const result = await bounded(s => execute(input, {}, s), controller.signal, Math.min(deps.requestTimeoutMs ?? 25000, 25000)); + const json = JSON.stringify(result); + if (Buffer.byteLength(json) > MAX_PLAN_RESPONSE_BYTES) throw new PlanError(502, "Planning response is unavailable."); + if (!res.destroyed) res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }).end(json); + return; + } if (req.method === "POST" && req.url === "/api/chat") { if (!underCap()) { res.writeHead(429, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "Hourly budget cap reached — try again later." })); return; } - const chunks: Buffer[] = []; - for await (const c of req) chunks.push(c as Buffer); let body: any; try { - body = JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch { + body = await readBody(req, 65536, BODY_TIMEOUT_MS, controller.signal); + } catch (error) { + if (error instanceof PlanError && error.status !== 400) throw error; res.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "Body must be JSON." })); return; } @@ -74,7 +136,14 @@ export function createServer(deps: Deps = {}): http.Server { } res.writeHead(404).end(); } catch (err) { - res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ error: String(err instanceof Error ? err.message : err) })); + if (res.destroyed) return; + const status = err instanceof PlanError ? err.status : 500; + const error = isPlan ? (err instanceof PlanError ? err.message : "Planning request failed.") : String(err instanceof Error ? err.message : err); + res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", "Connection": "close" }).end(JSON.stringify({ error })); + // For a rejected/incomplete body, release the paused socket after sending the error. + if (!req.complete) res.once("finish", () => req.destroy()); + } finally { + req.off("aborted", disconnect); } }); } diff --git a/autopilot/test/plan.test.ts b/autopilot/test/plan.test.ts new file mode 100644 index 0000000..4b39e1f --- /dev/null +++ b/autopilot/test/plan.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildPlan, handoffPlan } from "../src/plan.js"; +import { validatePlanInput } from "../src/plan-validation.js"; +import type { PlanInput } from "../src/plan-contract.js"; +import { exactLocalCoverage, parseNode, parseSearch } from "../src/plan-evidence.js"; +import { hostedTool, bounded, MAX_TOOL_BYTES } from "../src/plan-tools.js"; +import type { ToolCall } from "../src/plan-tools.js"; + +const input: PlanInput = { mode: "ready", activity: "Fictional software business", formationState: "MS", operatingState: "TX", operatingCity: "Austin", entityType: "llc", campaign: {} }; +const catalog = `Formation requirements — LLC in MS (US-MS): + +REQUIRED (collect ALL of these from your user, in conversation): +- proposed name +- registered agent + +AFTER-FORMATION DUTIES in MS — tell your user about these BEFORE they pay: +- Annual report: confirm the current due date. + +EIN ADD-ON: needed for everyone. + +PRICE (live): $123.00 all-in — test quote only. + +FOUNDING DISCOUNT: $10 off only while slots remain; a quote does not reserve one.`; +const emptyTool = async () => "No results found."; +const uuid = "11111111-2222-3333-4444-555555555555"; +const otherId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const search = (name = "Texas", id = uuid) => `1 of 1 results for "business tax registration" in TX (keyword):\n\n1. Tax Code § 1 — Registration\n ${name} · Tax Code\n Test source excerpt, not a conclusion.\n node_id: ${id}`; +const coverage = "- TX-AUS — Austin (municipal): 100 provisions, 80 searchable chunks"; +const node = (id = uuid, name = "Texas") => JSON.stringify({ nodeId: id, citation: "Tax Code § 1", bodyText: "Full test provision.", jurisdiction: { name } }); +function fixture(overrides: Partial> = {}): ToolCall { + return async (name, args) => overrides[name] ?? (name === "formation.requirements" ? catalog : name === "law.list_coverage" ? coverage : name === "law.get_node" ? node() : args.jurisdiction === "TX" ? search() : args.jurisdiction === "TX-AUS" ? search("Austin", otherId) : "No results found."); +} +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); + +describe("sourced planning", () => { + it("rejects unsupported state and client-supplied sources", () => { + expect(() => validatePlanInput({ ...input, formationState: "ZZ" })).toThrow(); + expect(() => validatePlanInput({ ...input, sources: [] })).toThrow(); + }); + it("ignores injected model prose and fabricated item/citation IDs", async () => { + const p = await buildPlan(input, { tool: async n => n === "formation.requirements" ? catalog : emptyTool(), select: async () => ["invented-law", "entity", "https://evil.test"] }); + expect(p.items.some(i => i.id === "invented-law")).toBe(false); + expect(p.sources.some(s => s.kind === "law")).toBe(false); + expect(JSON.stringify(p)).not.toContain("needed for everyone"); + expect(p.items.find(i => i.category === "ein")?.body).toContain("no employees"); + }); + it("normalizes multiline descriptions and supplies bounded context to ordering", async () => { + expect(validatePlanInput({ ...input, activity: "software\nconsulting\tshop" }).activity).toBe("software consulting shop"); + const select = vi.fn(async () => ["entity"]); + const plan = await buildPlan(input, { tool: fixture(), select }); + expect(select.mock.calls[0]).toEqual([expect.any(Array), expect.any(AbortSignal), expect.objectContaining({ input, items: expect.arrayContaining([expect.objectContaining({ id: "entity", category: "entity" })]) })]); + expect(plan.items.find(i => i.id === "intake_0")?.body).toContain("proposed name"); + const formed = await buildPlan({ ...input, mode: "formed" }, { tool: fixture() }); + expect(formed.items.some(i => i.id.startsWith("intake_"))).toBe(false); + }); + it("never creates a handoff locally", async () => { + await expect(handoffPlan(input, { tool: emptyTool })).rejects.toThrow(); + }); + it.each([ + { mode: "other" }, { entityType: "corp" }, { operatingState: "PR" }, { formationState: "Mississippi" }, + { activity: "a".repeat(501) }, { operatingCity: "a".repeat(101) }, { activity: "line\u0000line" }, + { campaign: { ref: "x" } }, { campaign: { utm_source: "x".repeat(101) } }, + { campaign: { utm_source: "x&src=evil" } }, { campaign: [] }, { contactEmail: "a@b.test" }, + ])("rejects invalid input %j", delta => expect(() => validatePlanInput({ ...input, ...delta })).toThrow()); + + it.each(["exploring", "formed"] as const)("does not assume a state/entity in %s mode", async mode => { + const tool = vi.fn(emptyTool); + const p = await buildPlan({ ...input, mode, formationState: null, entityType: null, operatingState: null, operatingCity: "" }, { tool }); + expect(tool).not.toHaveBeenCalled(); + expect(p.pricing).toMatchObject({ available: false, state: null, lines: [] }); + expect(p.warnings.join(" ")).toContain("No entity type"); + expect(p.items.every(i => i.needsCheck)).toBe(true); + }); + it("binds only same-request sources and ignores model-injected citations", async () => { + const p = await buildPlan(input, { tool: fixture(), select: async () => ["ein", `law:${otherId}`, "MS § invented"] }); + expect(p.sources.filter(s => s.kind === "law").map(s => s.nodeId)).toEqual([uuid, otherId]); + expect(p.items[0].id).toBe("ein"); + expect(p.items.every(i => i.sourceIds.every(id => p.sources.some(s => s.id === id)))).toBe(true); + expect(p.sources.find(s => s.nodeId === uuid)).toMatchObject({ label: "Tax Code § 1", excerpt: "Full test provision.", url: `https://corpuslaw.us/code/${uuid}` }); + expect(JSON.stringify(p)).not.toContain("MS § invented"); + const second = await buildPlan(input, { tool: fixture({ "law.search": "No results found." }) }); + expect(second.sources.some(s => s.kind === "law")).toBe(false); + }); + it("keeps formation and operating jurisdictions separate", async () => { + const tool = vi.fn(fixture({ "law.search": search("Mississippi") })); + const p = await buildPlan(input, { tool }); + expect(tool.mock.calls.some(([n, a]) => n === "law.search" && a.jurisdiction === "TX")).toBe(true); + expect(p.items.find(i => i.id === "state_tax")?.sourceIds).toEqual([]); + expect(p.items.find(i => i.id === "state_tax")?.body).toContain("Operating state: TX"); + expect(p.sources.filter(s => s.kind === "law")).toHaveLength(1); // MS search only + expect(p.items.every(i => i.needsCheck)).toBe(true); + }); + it.each(["", "- TX-AUS — Austin (municipal): PLACEHOLDER — listed but not yet searchable", "- TX-AUS — Austin (municipal): 100 provisions, 0 searchable chunks", "- CA-AUS — Austin (municipal): 100 provisions, 10 searchable chunks", "- TX-AUS — Austin City (municipal): 100 provisions, 10 searchable chunks", coverage + "\n- TX-AUS2 — Austin (municipal): 100 provisions, 10 searchable chunks"])("does not search local law without exact unambiguous searchable coverage: %s", async cov => { + const tool = vi.fn(fixture({ "law.list_coverage": cov || "Coverage temporarily unavailable." })); + const p = await buildPlan(input, { tool }); + expect(tool.mock.calls.some(([n, a]) => n === "law.search" && a.jurisdiction === "TX-AUS")).toBe(false); + expect(p.warnings.join(" ")).toContain("Local coverage gap"); + }); + it("accepts exact delivered searchable city entries", () => { + expect(exactLocalCoverage("- TX-AUS — Austin (municipal): delivered after the last daily count — searchable now; counts appear after the next daily refresh", "TX", "Austin")?.code).toBe("TX-AUS"); + }); + it("degrades on search and model failure without provider diagnostics", async () => { + const p = await buildPlan(input, { tool: async (n, a, s) => { if (n === "law.search") throw new Error("secret provider diagnostic"); return fixture()(n, a, s); }, select: async () => { throw new Error("secret provider diagnostic"); } }); + expect(p.items.length).toBeGreaterThanOrEqual(6); + expect(p.sources.some(s => s.kind === "law")).toBe(false); + expect(p.warnings.join(" ")).toContain("Qwen ordering was unavailable"); + expect(JSON.stringify(p)).not.toContain("secret provider"); + }); + it.each([catalog.replace("PRICE (live): $123.00 all-in — test quote only.", "PRICE: temporarily unavailable"), catalog.replace("MS (US-MS)", "TX (US-TX)"), "Error: unsupported entity", catalog.replace("PRICE (live): $123.00 all-in — test quote only.", "PRICE (live): unknown")])("withholds unavailable or unverified pricing", async text => { + const p = await buildPlan(input, { tool: fixture({ "formation.requirements": text }) }); + expect(p.pricing).toMatchObject({ available: false, lines: [] }); + }); + it("preserves the complete current price and offer qualifier", async () => { + const p = await buildPlan(input, { tool: fixture() }); + expect(p.pricing).toMatchObject({ available: true, state: "MS", lines: ["PRICE (live): $123.00 all-in — test quote only.", "FOUNDING DISCOUNT: $10 off only while slots remain; a quote does not reserve one."] }); + expect(JSON.stringify(p)).not.toContain("needed for everyone"); + }); + it("uses a bounded six-tool budget", async () => { + const tool = vi.fn(fixture()); + await buildPlan(input, { tool }); + expect(tool).toHaveBeenCalledTimes(6); + }); + it("cancels a hung tool and degrades deterministically", async () => { + let observed: AbortSignal | undefined; + const p = await buildPlan({ ...input, operatingState: null, operatingCity: "" }, { toolTimeoutMs: 5, tool: async (_n, _a, signal) => { observed = signal; return new Promise(() => {}); } }); + expect(observed?.aborted).toBe(true); + expect(p.pricing.available).toBe(false); + expect(p.warnings.join(" ")).toContain("formation.requirements was unavailable"); + }); + it("cancels a hung model without losing the plan", async () => { + let observed: AbortSignal | undefined; + const p = await buildPlan(input, { tool: fixture(), modelTimeoutMs: 5, select: async (_ids, s) => { observed = s; return new Promise(() => {}); } }); + expect(observed?.aborted).toBe(true); + expect(p.items.length).toBeGreaterThanOrEqual(6); + expect(p.tools).not.toContain("qwen.order_known_items"); + }); + it("enforces total duration even with an uncooperative tool", async () => { + await expect(buildPlan(input, { timeoutMs: 5, tool: async () => new Promise(() => {}) })).rejects.toThrow("timed out"); + }); + it("parses node warnings and rejects mismatched/malformed evidence", () => { + expect(parseNode("NOT YET IN EFFECT: test warning.\n" + node(), uuid, "Texas")?.excerpt).toContain("NOT YET IN EFFECT"); + expect(parseNode(node(otherId), uuid, "Texas")).toBeNull(); + expect(parseNode(node(uuid, "Mississippi"), uuid, "Texas")).toBeNull(); + expect(parseNode("arbitrary prose " + node(), uuid, "Texas")).toBeNull(); + expect(parseNode(node() + " broken", uuid, "Texas")).toBeNull(); + expect(parseSearch(search("Texas", "fabricated"), "Texas")).toEqual([]); + expect(parseSearch(`node_id: ${uuid}`, "Texas")).toEqual([]); + expect(parseSearch("NOTE: results are PARTIAL\n\n" + search(), "Texas")[0].excerpt).toContain("PARTIAL"); + }); +}); + +describe("hosted formation handoff", () => { + const link = "https://corpuslaw.us/formation#prefill=abc_DEF-1&src=opaque.token-D&ref=Original_Code"; + const text = `Draft saved into the link, but NOT yet complete.\n\nSTILL MISSING:\n- proposed name\n- contact email\n\nOr hand off now:\n${link}`; + it("uses minimal hosted draft and preserves attribution byte-for-byte while appending UTMs before hash", async () => { + const tool = vi.fn(async () => text); + const result = await handoffPlan({ ...input, campaign: { utm_source: "qwen", utm_campaign: "founder_launch" } }, { tool }); + expect(tool.mock.calls[0]).toEqual(["formation.handoff", { entityType: "llc", state: "MS", businessActivity: input.activity }, expect.any(AbortSignal)]); + expect(result).toEqual({ url: link.replace("#", "?utm_source=qwen&utm_campaign=founder_launch#"), missing: ["proposed name", "contact email"] }); + }); + it("returns complete hosted draft without invented missing fields", async () => { + expect(await handoffPlan(input, { tool: async () => `✓ Draft COMPLETE — every required intake field is collected.\n\n${link}` })).toEqual({ url: link, missing: [] }); + }); + it.each(["exploring", "formed"] as const)("rejects %s without calling the tool", async mode => { + const tool = vi.fn(emptyTool); + await expect(handoffPlan({ ...input, mode }, { tool })).rejects.toThrow(); + expect(tool).not.toHaveBeenCalled(); + }); + it.each(["https://evil.test/formation#prefill=abc", "https://corpuslaw.us.evil.test/formation#prefill=abc", "https://corpuslaw.us/formation/other#prefill=abc", "https://corpuslaw.us:443/formation#prefill=abc", "https://user@corpuslaw.us/formation#prefill=abc", "http://corpuslaw.us/formation#prefill=abc", "https://corpuslaw.us/formation#prefill=abc&redirect=evil", "https://corpuslaw.us/x/../formation#prefill=abc"])("fails closed for malicious/noncanonical handoff URL %s", async url => { + await expect(handoffPlan(input, { tool: async () => text.replace(link, url) })).rejects.toThrow("unavailable"); + }); + it("fails closed when missing-field status cannot be parsed", async () => { + await expect(handoffPlan(input, { tool: async () => link })).rejects.toThrow("unavailable"); + }); + it.each(["?src=original&ref=old", "?redirect=https://evil.test"])("rejects unsupported hosted query %s", async query => { + await expect(handoffPlan(input, { tool: async () => text.replace(link, link.replace("#", query + "#")) })).rejects.toThrow("unavailable"); + }); + it("rejects duplicate attribution fragment values", async () => { + await expect(handoffPlan(input, { tool: async () => text.replace(link, link + "&src=other") })).rejects.toThrow("unavailable"); + }); + it.each([{ formationState: null }, { entityType: null }])("requires explicit formation choices: %j", async delta => { + const tool = vi.fn(emptyTool); + await expect(handoffPlan({ ...input, ...delta }, { tool })).rejects.toThrow(); + expect(tool).not.toHaveBeenCalled(); + }); +}); + +describe("MCP wire transport", () => { + it.each([true, false])("reads the real text envelope (structured=%s) and forwards configured API key", async structured => { + vi.stubEnv("CORPUS_API_KEY", "test-key-not-a-secret"); + const fetcher = vi.fn(async () => new Response(JSON.stringify({ result: structured ? { structuredContent: { text: catalog } } : { content: [{ type: "text", text: catalog }] } }))); + vi.stubGlobal("fetch", fetcher); + expect(await hostedTool("formation.requirements", { state: "MS", entityType: "llc" }, new AbortController().signal)).toBe(catalog); + const options = fetcher.mock.calls[0][1] as RequestInit; + expect(options.headers).toMatchObject({ Authorization: "Bearer test-key-not-a-secret" }); + expect(JSON.parse(options.body as string).params.name).toBe("formation.requirements"); + }); + it.each([{ error: { message: "provider diagnostics" } }, { result: { isError: true, content: [{ type: "text", text: catalog }] } }, { result: { structuredContent: { isError: true, text: catalog } } }, { result: { nodes: [{ node_id: uuid }] } }])("fails closed on malformed/failed tool envelopes", async body => { + vi.stubGlobal("fetch", async () => new Response(JSON.stringify(body))); + await expect(hostedTool("law.search", {}, new AbortController().signal)).rejects.toThrow(); + }); + it("bounds response bytes before JSON parsing", async () => { + vi.stubGlobal("fetch", async () => new Response("a".repeat(MAX_TOOL_BYTES + 1))); + await expect(hostedTool("law.search", {}, new AbortController().signal)).rejects.toThrow("too large"); + }); + it("handles already-cancelled requests", async () => { + const controller = new AbortController(); controller.abort(); + const work = vi.fn(async () => 1); + await expect(bounded(work, controller.signal, 10)).rejects.toThrow("timed out"); + expect(work).not.toHaveBeenCalled(); + }); +}); diff --git a/autopilot/test/server.test.ts b/autopilot/test/server.test.ts index f742d3c..0c6c6e3 100644 --- a/autopilot/test/server.test.ts +++ b/autopilot/test/server.test.ts @@ -1,71 +1,126 @@ -import { describe, it, expect, afterEach } from "vitest"; -import { createServer } from "../src/server.js"; -import type { AddressInfo } from "node:net"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { createServer, MAX_PLAN_BODY_BYTES, MAX_PLAN_RESPONSE_BYTES } from "../src/server.js"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; -let server: ReturnType | null = null; -afterEach(() => new Promise((r) => (server ? server.close(() => r()) : r()))); - -function start(deps?: any) { - server = createServer(deps); - return new Promise((resolve) => { - server!.listen(0, () => resolve(`http://127.0.0.1:${(server!.address() as AddressInfo).port}`)); - }); +// Invoke the real request listener without opening sockets or making network calls. +function client(deps: Parameters[0] = {}) { + const server = createServer(deps); + function request(path: string, options: { method?: string; body?: string; incomplete?: boolean; contentLength?: number } = {}) { + const req = new PassThrough() as PassThrough & { method: string; url: string; headers: Record; complete: boolean }; + req.method = options.method ?? "POST"; req.url = path; + req.headers = options.contentLength === undefined ? {} : { "content-length": String(options.contentLength) }; + req.complete = !options.incomplete; + const res = new EventEmitter() as any; + res.destroyed = false; res.writableFinished = false; + const headers = new Headers(); + res.setHeader = (key: string, value: string) => headers.set(key, value); + let status = 200; + res.writeHead = (code: number, extra: Record = {}) => { + status = code; Object.entries(extra).forEach(([k, v]) => headers.set(k, v)); return res; + }; + const response = new Promise(resolve => { + res.end = (body = "") => { + res.writableFinished = true; + resolve(new Response(status === 204 ? null : body, { status, headers })); + queueMicrotask(() => res.emit("finish")); + }; + }); + server.emit("request", req, res); + if (options.incomplete) { if (options.body) req.write(options.body); } + else req.end(options.body ?? ""); + return { response, req, res }; + } + const post = (path: string, body: unknown) => request(path, { body: JSON.stringify(body) }).response; + return { request, post }; } +const messages = { messages: [{ role: "user", content: "hello" }] }; +const input = { mode: "ready", activity: "Software", operatingState: "TX", operatingCity: "Austin", formationState: "MS", entityType: "llc", campaign: {} }; +afterEach(() => vi.unstubAllEnvs()); -describe("server", () => { +describe("server (no network)", () => { it("healthz responds ok", async () => { - const base = await start(); - expect(await (await fetch(`${base}/healthz`)).text()).toBe("ok"); + expect(await (await client().request("/healthz", { method: "GET" }).response).text()).toBe("ok"); }); - it("POST /api/chat runs the agent", async () => { - const base = await start({ run: async () => ({ reply: "hi", toolCalls: [] }) }); - const res = await fetch(`${base}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }), - }); + it("POST /api/chat runs the legacy agent", async () => { + const res = await client({ run: async () => ({ reply: "hi", toolCalls: [] }) }).post("/api/chat", messages); expect(res.status).toBe(200); expect((await res.json()).reply).toBe("hi"); }); - it("answers CORS preflight and sets allow-origin on responses", async () => { - const base = await start({ run: async () => ({ reply: "hi", toolCalls: [] }) }); - const pre = await fetch(`${base}/api/chat`, { method: "OPTIONS" }); + it("answers CORS preflight and sets allow-origin", async () => { + const c = client({ run: async () => ({ reply: "hi", toolCalls: [] }) }); + const pre = await c.request("/api/plan", { method: "OPTIONS" }).response; expect(pre.status).toBe(204); expect(pre.headers.get("access-control-allow-origin")).toBe("*"); expect(pre.headers.get("access-control-allow-headers")).toContain("Content-Type"); - const res = await fetch(`${base}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }), - }); - expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect((await c.post("/api/chat", messages)).headers.get("access-control-allow-origin")).toBe("*"); }); - it("rejects malformed bodies with 400", async () => { - const base = await start({ run: async () => ({ reply: "x", toolCalls: [] }) }); - const res = await fetch(`${base}/api/chat`, { method: "POST", body: "not json" }); - expect(res.status).toBe(400); + it.each(["/api/chat", "/api/plan"])("rejects malformed JSON on %s", async path => { + expect((await client().request(path, { body: "not json" }).response).status).toBe(400); }); - it("enforces the hourly spend cap with 429", async () => { - process.env.SPEND_CAP_TURNS_PER_HOUR = "1"; - const base = await start({ run: async () => ({ reply: "x", toolCalls: [] }) }); - const post = () => - fetch(`${base}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), - }); - expect((await post()).status).toBe(200); - expect((await post()).status).toBe(429); - delete process.env.SPEND_CAP_TURNS_PER_HOUR; - }); - it("preserves non-Error throw values in 500 error body", async () => { - const base = await start({ run: async () => { throw "oops"; } }); - const res = await fetch(`${base}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }), - }); + it("preserves legacy error behavior", async () => { + const res = await client({ run: async () => { throw "oops"; } }).post("/api/chat", messages); expect(res.status).toBe(500); expect((await res.json()).error).toBe("oops"); }); + it("returns PlanResponse and PlanHandoff directly", async () => { + const plan = { version: 1 as const, createdAt: "now", items: [], sources: [], pricing: { available: false, state: null, lines: [], checkedAt: "now" }, tools: [], warnings: [] }; + const handoff = { url: "https://corpuslaw.us/formation#prefill=test", missing: ["name"] }; + const c = client({ plan: async () => plan, handoff: async () => handoff }); + const res = await c.post("/api/plan", input); + expect(await res.json()).toEqual(plan); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await (await c.post("/api/plan/handoff", input)).json()).toEqual(handoff); + }); + it("rejects client source records before executing a plan", async () => { + const plan = vi.fn(); + expect((await client({ plan }).post("/api/plan", { ...input, sources: [] })).status).toBe(400); + expect(plan).not.toHaveBeenCalled(); + }); + it.each([false, true])("bounds body bytes (declared length=%s)", async declared => { + const plan = vi.fn(); + const res = await client({ plan }).request("/api/plan", { body: "x".repeat(MAX_PLAN_BODY_BYTES + 1), ...(declared ? { contentLength: MAX_PLAN_BODY_BYTES + 1 } : {}) }).response; + expect(res.status).toBe(413); expect(plan).not.toHaveBeenCalled(); + }); + it("bounds legacy body buffering", async () => { + expect((await client().request("/api/chat", { body: "x".repeat(65537) }).response).status).toBe(413); + }); + it("bounds serialized plan output", async () => { + const plan = async () => ({ version: 1 as const, createdAt: "now", items: [], sources: [], pricing: { available: false, state: null, lines: [], checkedAt: "now" }, tools: [], warnings: ["x".repeat(MAX_PLAN_RESPONSE_BYTES)] }); + expect((await client({ plan }).post("/api/plan", input)).status).toBe(502); + }); + it("times out a partial body", async () => { + expect((await client({ bodyTimeoutMs: 5 }).request("/api/plan", { body: "{", incomplete: true }).response).status).toBe(408); + }); + it("cancels timed-out handlers and hides diagnostics", async () => { + let signal: AbortSignal | undefined; + const res = await client({ requestTimeoutMs: 5, plan: async (_i, _d, s) => { signal = s; return new Promise(() => {}); } }).post("/api/plan", input); + expect(res.status).toBe(504); expect(signal?.aborted).toBe(true); + const failed = await client({ plan: async () => { throw new Error("private provider/key diagnostic"); } }).post("/api/plan", input); + expect(failed.status).toBe(500); expect(await failed.text()).not.toContain("private"); + }); + it("cancels downstream work when the client disconnects", async () => { + let signal: AbortSignal | undefined; + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + const { response, res } = client({ plan: async (_i, _d, s) => { signal = s; started(); return new Promise(() => {}); } }).request("/api/plan", { body: JSON.stringify(input) }); + await ready; res.emit("close"); await response; + expect(signal?.aborted).toBe(true); + }); + it("shares the existing cap across chat, plan and handoff", async () => { + vi.stubEnv("SPEND_CAP_TURNS_PER_HOUR", "1"); + const plan = vi.fn(); const handoff = vi.fn(); + const c = client({ run: async () => ({ reply: "x", toolCalls: [] }), plan, handoff }); + expect((await c.post("/api/chat", messages)).status).toBe(200); + expect((await c.post("/api/plan", input)).status).toBe(429); + expect((await c.post("/api/plan/handoff", input)).status).toBe(429); + expect(plan).not.toHaveBeenCalled(); expect(handoff).not.toHaveBeenCalled(); + }); + it("does not consume the cap for health and OPTIONS", async () => { + vi.stubEnv("SPEND_CAP_TURNS_PER_HOUR", "1"); + const c = client({ run: async () => ({ reply: "x", toolCalls: [] }) }); + await c.request("/healthz", { method: "GET" }).response; + await c.request("/api/plan", { method: "OPTIONS" }).response; + expect((await c.post("/api/chat", messages)).status).toBe(200); + }); }); diff --git a/docs/autopilot-phase1-release.md b/docs/autopilot-phase1-release.md new file mode 100644 index 0000000..1ecec90 --- /dev/null +++ b/docs/autopilot-phase1-release.md @@ -0,0 +1,68 @@ +# Phase 1 sourced planning backend + +The FC API adds `POST /api/plan` (`PlanInput` → `PlanResponse`) and +`POST /api/plan/handoff` (`PlanInput` → `PlanHandoff`). Wire types are mirrored +in `autopilot/src/plan-contract.ts`. `/api/chat` remains available. + +Deploy FC before the web frontend, under separate deployment authorization. +An older FC returns 404: the frontend must preserve input and show its unavailable +fallback, never render legacy generated legal prose as a sourced plan. + +Plans use the hosted requirements catalog and request-local law evidence. +Evidence binding establishes provenance, not applicability or completeness. +Unknown requirements remain Needs checking. No purchase or filing happens here. + +The plan accepts only the seven `PlanInput` fields, bare US state/DC codes or +null, and `llc`/`nonprofit`/null. All items remain `needsCheck: true`: there is no +applicability checker. Catalog items identify the selected formation state; +tax searches use the operating state. Exact, unambiguous searchable municipal +coverage is required before a local search. The inspected app's stable law URL +is `/code/`; there is no `/law/` route. Text-format drift fails closed. + +Pricing requires one valid current `PRICE (live)` line from the selected +formation catalog. Complete price/offer qualifiers are preserved; unavailable +prices and offers are withheld. `checkedAt` is a quote timestamp, not a reservation; +the web frontend must mark saved quotes stale and refresh before review. + +The handoff accepts ready mode with explicit state/entity, calls the hosted +`formation.handoff` only, and returns its missing fields. It appends sanitized +campaign query parameters before the fragment, preserving hosted `src`/`ref` +bytes. Only the exact HTTPS `corpuslaw.us/formation` destination is accepted. +There is no local prefill fallback. Normal hosted regulatory-note behavior may +occur; this backend starts no additional jobs, checkout or filing. + +Limits: 16 KiB plan request body, 5s body deadline, 24s planner deadline (25s HTTP +handler backstop), at most six hosted tool calls, three searches of two hits each, +4.5s per tool, 128 KiB per tool response, up to twelve plan items, at most eighteen source +records, ten pricing lines/16 KiB, and 96 KiB serialized HTTP response. Optional +Qwen ordering uses the existing fast lane, a 2s timeout, no retries and 256 output +tokens. Model IDs are intersected with candidates; all candidates remain present. +Failures retain deterministic items. Total planner timeout returns generic 504. +Disconnects cancel downstream requests. No diagnostics or plan text are logged. +The existing process-local hourly request counter is shared with `/api/chat`; +it is not a global dollar cap and its configuration is unchanged. Legacy chat +keeps its behavior, with a new 64 KiB/5s body-buffer safety limit. + +Validation (2026-10-25), from +`/Users/ty/dev/.worktrees/corpus-agent-kit/autopilot-phase1`, based on +`5740a9c9382fdebe46ed82acb067a1b881dd7ca0` plus this working diff: + +- `npm test` — exit 0: 114 autopilot tests and 6 MCP bridge tests (120 total), + plus plugin/Codex skill drift checks. Includes 64 sourced-planning tests and + 17 in-process HTTP tests. All tool/model calls mocked; no sockets or live calls. +- `npm run typecheck` — exit 0 across all three workspaces. +- `npm run build` — exit 0 across all three workspaces, including widget budgets. +- `git diff --check` and exact wire-contract `cmp` — exit 0. + +Logs: `/private/tmp/autopilot-phase1-kit-controller-{test,typecheck,build}.log`. +The initial socket-based HTTP tests failed with sandbox `listen EPERM`; they were +replaced by direct invocation of the real HTTP listener, and the full gate above +then passed. No dependencies, credentials, provider settings or billing changed. +This change requires independent review and has not been deployed or pushed. + +The controller took over after the delegate's sandbox could not write linked +Git metadata. Controller integration adds the actual catalog intake fields, +bounded intake/step context for Qwen ordering, multiline description normalization, +and stricter handoff query/fragment checks. The web tests consume an actual +`buildPlan` fixture including an IRS source and a 1,079-character offer qualifier. +No secret or live offer is contained in that fixture. From abf5a4922f1c56c1fb7a96067df6e7e6ddc59002 Mon Sep 17 00:00:00 2001 From: ty Date: Sun, 25 Oct 2026 17:37:48 -0500 Subject: [PATCH 2/9] docs(autopilot): record published review branch and web integration --- docs/autopilot-phase1-release.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/autopilot-phase1-release.md b/docs/autopilot-phase1-release.md index 1ecec90..0e03d41 100644 --- a/docs/autopilot-phase1-release.md +++ b/docs/autopilot-phase1-release.md @@ -44,8 +44,8 @@ it is not a global dollar cap and its configuration is unchanged. Legacy chat keeps its behavior, with a new 64 KiB/5s body-buffer safety limit. Validation (2026-10-25), from -`/Users/ty/dev/.worktrees/corpus-agent-kit/autopilot-phase1`, based on -`5740a9c9382fdebe46ed82acb067a1b881dd7ca0` plus this working diff: +`/Users/ty/dev/.worktrees/corpus-agent-kit/autopilot-phase1`, implementation commit +`536d1fb6f4943cb1bae5549a307c3a58889e04c3`: - `npm test` — exit 0: 114 autopilot tests and 6 MCP bridge tests (120 total), plus plugin/Codex skill drift checks. Includes 64 sourced-planning tests and @@ -54,11 +54,12 @@ Validation (2026-10-25), from - `npm run build` — exit 0 across all three workspaces, including widget budgets. - `git diff --check` and exact wire-contract `cmp` — exit 0. -Logs: `/private/tmp/autopilot-phase1-kit-controller-{test,typecheck,build}.log`. +Logs are preserved under `/Volumes/WorkSSD/Artifacts/corpus/autopilot-phase1-20261025`. The initial socket-based HTTP tests failed with sandbox `listen EPERM`; they were replaced by direct invocation of the real HTTP listener, and the full gate above then passed. No dependencies, credentials, provider settings or billing changed. -This change requires independent review and has not been deployed or pushed. +This change requires Ty-assigned independent review and has not been deployed. +The implementation branch is pushed; draft PR: https://github.com/teakesdev/corpus-agent-kit/pull/11. The controller took over after the delegate's sandbox could not write linked Git metadata. Controller integration adds the actual catalog intake fields, @@ -66,3 +67,7 @@ bounded intake/step context for Qwen ordering, multiline description normalizati and stricter handoff query/fragment checks. The web tests consume an actual `buildPlan` fixture including an IRS source and a 1,079-character offer qualifier. No secret or live offer is contained in that fixture. + +Corpus web implementation landed as `df44673fdab41da7ca497743d82da58e51886d01` after its +local unit/typecheck/docs gate. Its new route policy resolves the concurrent Phase 0 replay +finding with masks and a blocked prefill link. This is not independent review or deployment. From a4454e5a565e5df07bc548c19e77119fa189779f Mon Sep 17 00:00:00 2001 From: ty Date: Sun, 25 Oct 2026 18:30:46 -0500 Subject: [PATCH 3/9] fix(autopilot): retain sourced plans within request budget --- autopilot/src/plan-evidence.ts | 16 +- autopilot/src/plan-tools.ts | 17 +- autopilot/src/plan.ts | 35 +++- autopilot/test/fixtures/README.md | 10 + .../autopilot-hosted-transcripts.json | 6 + autopilot/test/plan.test.ts | 176 +++++++++++++++++- 6 files changed, 241 insertions(+), 19 deletions(-) create mode 100644 autopilot/test/fixtures/README.md create mode 100644 autopilot/test/fixtures/autopilot-hosted-transcripts.json diff --git a/autopilot/src/plan-evidence.ts b/autopilot/src/plan-evidence.ts index 8317a75..b678a2b 100644 --- a/autopilot/src/plan-evidence.ts +++ b/autopilot/src/plan-evidence.ts @@ -1,7 +1,7 @@ import type { PlanSource } from "./plan-contract.js"; import { record, STATES } from "./plan-validation.js"; -export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const clean = (s: string) => s.trim().toLowerCase(); export function lawSource(nodeId: string, label: string, excerpt: string): PlanSource { return { id: `law:${nodeId.toLowerCase()}`, kind: "law", nodeId: nodeId.toLowerCase(), label, @@ -42,12 +42,22 @@ export function parseNode(text: string, expectedId: string, jurisdictionName: st typeof node.citation !== "string" || !node.citation || node.citation.length > 400 || typeof node.bodyText !== "string" || !node.bodyText.trim() || !record(node.jurisdiction) || typeof node.jurisdiction.name !== "string" || clean(node.jurisdiction.name) !== clean(jurisdictionName)) return null; - return lawSource(node.nodeId, node.citation, (prefix ? prefix + "\n" : "") + node.bodyText.slice(0, 3000)); + const heading = prefix ? prefix + "\n" : ""; + const marker = "\n…truncated; open the source for the complete provision."; + const bodyLimit = Math.min(3000, 5000 - heading.length); + const body = node.bodyText.length > bodyLimit + ? node.bodyText.slice(0, bodyLimit - marker.length) + marker : node.bodyText; + return lawSource(node.nodeId, node.citation, heading + body); } catch { return null; } } export function exactLocalCoverage(text: string, state: string, city: string): { code: string; name: string } | null { - const names = [city, `${city}, ${state}`, `${city}, ${STATES[state]}`].map(clean); + // Strip only an exact selected-state suffix; another state or a second comma fails closed. + const parts = city.split(",").map(s => s.trim()); + if (parts.length > 2 || (parts.length === 2 && ![state, STATES[state]].some(s => clean(s) === clean(parts[1])))) return null; + const name = parts[0]; + if (!name) return null; + const names = [name, `${name}, ${state}`, `${name}, ${STATES[state]}`].map(clean); const matches: { code: string; name: string }[] = []; for (const line of text.split("\n")) { const row = line.match(/^- ([A-Z0-9-]+) — (.+) \((?:municipal|city)\): (.+)$/); diff --git a/autopilot/src/plan-tools.ts b/autopilot/src/plan-tools.ts index fd3f637..9d2bd86 100644 --- a/autopilot/src/plan-tools.ts +++ b/autopilot/src/plan-tools.ts @@ -7,6 +7,13 @@ export const TOOL_TIMEOUT_MS = 4500; export const PLAN_TIMEOUT_MS = 24000; export const MAX_TOOL_BYTES = 131072; +/** Only a known hosted semantic failure is exposed, using our own categorical copy. */ +export class UnsupportedFormationError extends PlanError { + constructor() { + super(400, "Corpus does not currently support formation for the selected state and entity. Choose another state or entity before continuing."); + } +} + /** Enforces a deadline even when an injected/third-party implementation ignores abort. */ export async function bounded(work: (signal: AbortSignal) => Promise, parent: AbortSignal, ms: number): Promise { const controller = new AbortController(); @@ -64,11 +71,19 @@ export const hostedTool: ToolCall = async (name, args, signal) => { const json: unknown = JSON.parse(await readBoundedResponse(response, signal)); if (!record(json) || json.error || !record(json.result)) throw new Error("Tool failed"); const result = json.result; - if (result.isError || (record(result.structuredContent) && result.structuredContent.isError)) throw new Error("Tool failed"); let text: unknown = record(result.structuredContent) ? result.structuredContent.text : undefined; if (text === undefined && Array.isArray(result.content)) { text = result.content.filter(c => record(c) && c.type === "text" && typeof c.text === "string").map(c => c.text).join("\n"); } + if (result.isError || (record(result.structuredContent) && result.structuredContent.isError)) { + const kind = args.entityType === "llc" ? "LLC" : args.entityType === "nonprofit" ? "Nonprofit" : null; + if ((name === "formation.requirements" || name === "formation.handoff") && kind && + typeof args.state === "string" && /^[A-Z]{2}$/.test(args.state) && typeof text === "string" && + text.startsWith(`${kind} formation is not available in US-${args.state}. LLC formation is available in `)) { + throw new UnsupportedFormationError(); + } + throw new Error("Tool failed"); + } if (typeof text !== "string" || !text.trim() || /^Error:/i.test(text.trim())) throw new Error("Tool text missing"); return text; }; diff --git a/autopilot/src/plan.ts b/autopilot/src/plan.ts index 4a02360..f1eeea4 100644 --- a/autopilot/src/plan.ts +++ b/autopilot/src/plan.ts @@ -1,5 +1,5 @@ import type { PlanInput, PlanResponse, PlanHandoff, PlanItem, PlanSource } from "./plan-contract.js"; -import { bounded, hostedTool, MAX_TOOL_BYTES, PLAN_TIMEOUT_MS, TOOL_TIMEOUT_MS, type ToolCall, type ToolName } from "./plan-tools.js"; +import { bounded, hostedTool, MAX_TOOL_BYTES, PLAN_TIMEOUT_MS, TOOL_TIMEOUT_MS, UnsupportedFormationError, type ToolCall, type ToolName } from "./plan-tools.js"; import { CAMPAIGN_KEYS, PlanError, STATES, validatePlanInput } from "./plan-validation.js"; import { exactLocalCoverage, parseNode, parseSearch } from "./plan-evidence.js"; import { getQwen } from "./qwen.js"; @@ -21,18 +21,31 @@ const RA = "Most states require an LLC to name a registered agent — it can be const ANNUAL = "Most states also charge a recurring fee to keep an LLC alive (often called an annual report or franchise tax) — the amount and due date vary by state."; const ENTITY = "An LLC, an EIN, and a registered agent are three different things: the LLC is the legal entity your state creates; the EIN is the federal tax ID the IRS issues; the registered agent is the person or company that receives legal mail for the entity at a street address in the formation state."; -function requestTools(deps: PlanDeps, signal: AbortSignal, tools: string[], warnings: string[]) { +const FINISH_RESERVE_MS = 500; +const MIN_TOOL_BUDGET_MS = 100; + +function requestTools(deps: PlanDeps, signal: AbortSignal, tools: string[], warnings: string[], deadline = Date.now() + PLAN_TIMEOUT_MS) { let calls = 0; return async (name: ToolName, args: Record): Promise => { signal.throwIfAborted(); + const budget = Math.min(deps.toolTimeoutMs ?? TOOL_TIMEOUT_MS, TOOL_TIMEOUT_MS, deadline - Date.now() - FINISH_RESERVE_MS); + if (budget < Math.min(MIN_TOOL_BUDGET_MS, deps.toolTimeoutMs ?? TOOL_TIMEOUT_MS)) { + warnings.push(`${name} was skipped to finish within the planning deadline; related items need checking.`); + return null; + } if (++calls > 6) throw new Error("Tool limit reached"); try { - const text = await bounded(s => (deps.tool ?? hostedTool)(name, args, s), signal, Math.min(deps.toolTimeoutMs ?? TOOL_TIMEOUT_MS, TOOL_TIMEOUT_MS)); + const text = await bounded(s => (deps.tool ?? hostedTool)(name, args, s), signal, budget); if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > MAX_TOOL_BYTES || /^Error:/i.test(text.trim())) throw new Error("Invalid tool text"); if (!tools.includes(name)) tools.push(name); return text; - } catch { + } catch (error) { signal.throwIfAborted(); + if (error instanceof UnsupportedFormationError) { + if (name === "formation.handoff") throw error; + warnings.push(error.message); + return null; + } warnings.push(`${name} was unavailable or returned an unreadable response; related items need checking.`); return null; } @@ -52,11 +65,13 @@ const selectKnown: Selector = async (ids, signal, context) => { /** No user source records or model legal prose can enter the request-local ledger. */ export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = new AbortController().signal): Promise { const input = validatePlanInput(raw); + const timeout = Math.min(deps.timeoutMs ?? PLAN_TIMEOUT_MS, PLAN_TIMEOUT_MS); + const deadline = Date.now() + timeout; return bounded(async signal => { const createdAt = new Date().toISOString(); const tools: string[] = []; const warnings = ["Source binding establishes provenance, not applicability or completeness. Search matches and unresolved items need checking."]; - const call = requestTools(deps, signal, tools, warnings); + const call = requestTools(deps, signal, tools, warnings, deadline); const ledger = new Map(); const items: PlanItem[] = []; function guide(id: string, label: string, url: string, excerpt: string) { @@ -100,7 +115,7 @@ export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = ne item("registered_agent", "registered_agent", "Check the registered-agent or registered-office rules", raText, [guide("guide:ra", "The founder legal checklist — registered agents", CHECKLIST, RA)]); item("ein", "ein", "Determine whether an EIN is needed", input.entityType === "nonprofit" ? "The single-member LLC exception in the linked guide is specific to LLCs. Check the IRS requirements for this nonprofit. Apply directly through the IRS for free." - : EIN + " Apply directly through the IRS for free.", [guide("guide:ein", "After forming an LLC — EIN", GUIDE + "#step-ein", EIN), guide("guide:irs", "IRS — single-member LLC guidance", "https://www.irs.gov/businesses/small-businesses-self-employed/single-member-limited-liability-companies", EIN)]); + : EIN + " Apply directly through the IRS for free.", [guide("guide:ein", "After forming an LLC — EIN", GUIDE + "#step-ein", EIN), guide("guide:irs", "IRS — single-member LLC guidance", "https://www.irs.gov/businesses/small-businesses-self-employed/single-member-limited-liability-companies", "Official IRS page. No excerpt was retrieved; open the page for the current guidance.")]); item("annual_report", "annual_report", "Check reports and recurring state obligations", input.entityType === "nonprofit" ? "Check the selected state's nonprofit reporting requirements; the linked checklist describes LLCs." : ANNUAL, [guide("guide:annual", "The founder legal checklist — recurring fees", CHECKLIST, ANNUAL)]); @@ -139,7 +154,7 @@ export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = ne item("local", "local", local ? "Review local source matches" : "Local coverage needs checking", local ? `Coverage confirms a searchable entry for ${local.name} (${local.code}); it does not establish which rules apply to this activity.` : "No exact, unambiguous, searchable coverage entry was confirmed for the operating city. Local requirements have not been determined."); - if (local) await search(input.activity || "business", local.code, local.name, "local"); + if (local) await search("business license", local.code, local.name, "local"); else warnings.push("Local coverage gap: no local search was run."); const lines = catalog?.split("\n").filter(l => /^(?:PRICE(?: \(live\))?:|FOUNDING DISCOUNT:|EXPEDITE \(|SKIP REVIEW \(|TENNESSEE MEMBERS:|EIN ADD-ON: temporarily unavailable)/.test(l)) ?? []; @@ -150,7 +165,9 @@ export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = ne if (input.mode === "ready" && !pricing.available) warnings.push("Current pricing is unavailable; no price or founding offer has been assumed."); const selector = deps.select ?? (process.env.QWEN_API_KEY && process.env.QWEN_BASE_URL ? selectKnown : undefined); - if (selector) { + if (selector && deadline - Date.now() < 2500) { + warnings.push("Qwen ordering was skipped to finish within the planning deadline; deterministic sourced items are shown."); + } else if (selector) { try { const selected = await bounded(s => selector(items.map(i => i.id), s, { input, items: items.map(({ id, title, category }) => ({ id, title, category })) }), signal, Math.min(deps.modelTimeoutMs ?? 2000, 2000)); if (!Array.isArray(selected) || selected.length > 20 || selected.some(id => typeof id !== "string")) throw new Error("Invalid selection"); @@ -165,7 +182,7 @@ export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = ne } } else warnings.push("Qwen ordering is not configured; deterministic sourced items are shown."); return { version: 1, createdAt, items, sources: [...ledger.values()], pricing, tools, warnings }; - }, parent, Math.min(deps.timeoutMs ?? PLAN_TIMEOUT_MS, PLAN_TIMEOUT_MS)); + }, parent, timeout); } export async function handoffPlan(raw: PlanInput, deps: PlanDeps = {}, parent = new AbortController().signal): Promise { diff --git a/autopilot/test/fixtures/README.md b/autopilot/test/fixtures/README.md new file mode 100644 index 0000000..df76999 --- /dev/null +++ b/autopilot/test/fixtures/README.md @@ -0,0 +1,10 @@ +# Hosted transcript fixture + +`autopilot-hosted-transcripts.json` was generated through the real Corpus +`dispatch`/`runFormationRequirements`, `law.search`, `buildCoverageTranscript`, and +`formation.handoff` formatters using fictional mocked data, without network calls. +The source is Corpus base `bf1ed5de` plus the registered-agent quote correction +("included in the Corpus bundle; state requirements vary"). Corpus's `mcp.test.ts` +pins these exact transcripts and requires regeneration when their format changes. +Keep this copy synchronized with `apps/web/src/__tests__/fixtures/autopilot-hosted-transcripts.json` +in Corpus; do not replace it with hand-written approximations. diff --git a/autopilot/test/fixtures/autopilot-hosted-transcripts.json b/autopilot/test/fixtures/autopilot-hosted-transcripts.json new file mode 100644 index 0000000..e09cf67 --- /dev/null +++ b/autopilot/test/fixtures/autopilot-hosted-transcripts.json @@ -0,0 +1,6 @@ +{ + "requirements": "Formation requirements — LLC in MS (US-MS):\n\nREQUIRED (collect ALL of these from your user, in conversation):\n- proposed business name\n- principal office address (street, city, state)\n- contact email\n- NAICS industry code\n- management type (member- or manager-managed)\n- at least one member\n- an organizer\n\nSTATE-SPECIFIC QUESTIONS: none — MS has no extra LLC fields beyond the baseline.\n\nEIN ADD-ON (optional, $75.00): the federal tax ID, needed to open a bank account or hire. Offer it once. If accepted, also collect: responsible party (name + phone), start date (MM/DD/YYYY), principal business activity, and whether W-2 employees are expected in the next 12 months (if yes: first wage date, employee count, and whether employment tax liability will be ≤$1,000/yr). NEVER ask for a Social Security Number — the founder enters it in a secure panel on corpuslaw.us.\n\nPRICE (live): $246.00 all-in — $50.00 state fees + $100.00 registered agent year 1 (included in the Corpus bundle; state requirements vary) + $96.00 Corpus service. Optional EIN add-on: +$75.00.\n\nHOW TO ASK (one question at a time; skip any askIf that is not met):\nIf this is a single-owner LLC, the visible questions are: business name, what it does, where it operates, and who owns it. Assume member-managed. Do not ask the management question.\n- proposedName\n ask: What do you want to name the company?\n why: This becomes the legal name on the state filing. You can change it later, but it costs a fee.\n default: none — always ask\n askIf: always\n- businessActivity\n ask: In a sentence, what will the business actually do?\n why: Used to pick the NAICS industry code the state records. Describe the work, not the company name.\n default: none — always ask\n askIf: always\n- principalOffice\n ask: Where should official mail go — street, city, state, ZIP?\n why: This is the principal office on the filing. A home address is fine in most states. Put it in the URL hash (#street=&city=&zip=), never the query string.\n default: none — ask if unknown; the page chat will ask if you skip it\n askIf: not already known\n- contactEmail\n ask: What email should we use for this order?\n why: Order receipts and filing updates go here. Put it in the URL hash (#email=), never the query string.\n default: none — ask if unknown; the page chat will ask if you skip it\n askIf: not already known\n- naicsCode\n ask: Which industry code fits this business?\n why: Call formation.lookup_naics, or fetch https://corpuslaw.us/api/naics?q= plus their words. Confirm one code before recording it.\n default: derived from the business description when possible\n askIf: always, but offer 2–6 options rather than asking them to invent a number\n- management\n ask: Besides you, will anyone else have authority to make decisions for the business?\n why: If it's just you, member-managed is standard and simpler. Manager-managed is for when someone who is not an owner will run it.\n default: member-managed\n askIf: memberCount > 1\n- members\n ask: Who owns this company?\n why: Every LLC needs at least one member. If it's just the founder, record them as member and organizer and stop.\n default: the founder is the only member and the organizer\n askIf: always — but if they say it's just them, do not ask for more names\n- ein\n ask: Do you want Corpus to file for an EIN (federal tax ID) for $75?\n why: Needed to open a bank account or hire. You can also get one free at irs.gov after formation. Never ask for a Social Security Number.\n default: no\n askIf: only if the tool says the EIN add-on is available\n\nHOW TO FINISH: collect the answers above in conversation (formation.lookup_naics finds the industry code), then call formation.handoff with everything — it validates the draft for free and returns what is still missing. When it reports COMPLETE, give the user the returned link: they review the prefilled draft, sign in, and pay.", + "search": "1 of 1 results for \"business tax\" in MS (hybrid):\n\n1. Miss. Code § 67-1-5 — Definitions\n Mississippi · Mississippi Code\n As used in this chapter the following words shall have the meanings ascribed...\n node_id: 089a2104-d704-4e93-8072-5bbbabc7ace6", + "coverage": "Corpus covers 3 jurisdictions (2 fully searchable), 79,000 provisions (law with body text; 85,447 indexed sections and headings in total), and 320,000 searchable chunks (2 with published GIS). Rows flagged PLACEHOLDER are listed but not yet searchable — an empty search there is expected.\n\n- US — United States (federal): 54,000 provisions, 305,936 searchable chunks\n- CA-SF — San Francisco (municipal): 25,000 provisions, 12,785 searchable chunks · GIS: published (linked)\n- CA-SD — San Diego (municipal): PLACEHOLDER — listed but not yet searchable · GIS: published (pending)", + "handoff": "Draft saved into the link, but NOT yet complete.\n\nSTILL MISSING:\n- proposed business name\n- principal office address (street, city, state)\n- contact email\n- NAICS industry code\n- management type (member- or manager-managed)\n- at least one member\n- an organizer\n\nPRICE (live): $246.00 all-in — $50.00 state fees + $100.00 registered agent year 1 (included in the Corpus bundle; state requirements vary) + $96.00 Corpus service. Optional EIN add-on: +$75.00.\n\nBest path: collect the missing items from your user and call formation.handoff again — validation is free. Or hand off now and Corpus's agent will finish the intake in chat:\nhttps://corpuslaw.us/formation#prefill=eyJ2IjoyLCJlbnRpdHlUeXBlIjoibGxjIiwianVyaXNkaWN0aW9uIjoiVVMtTVMifQ\n\nPass businessActivity (one plain sentence, in the founder's words, of what the business does) so Corpus can research the licenses and permits that apply." +} diff --git a/autopilot/test/plan.test.ts b/autopilot/test/plan.test.ts index 4b39e1f..abdb267 100644 --- a/autopilot/test/plan.test.ts +++ b/autopilot/test/plan.test.ts @@ -3,8 +3,9 @@ import { buildPlan, handoffPlan } from "../src/plan.js"; import { validatePlanInput } from "../src/plan-validation.js"; import type { PlanInput } from "../src/plan-contract.js"; import { exactLocalCoverage, parseNode, parseSearch } from "../src/plan-evidence.js"; -import { hostedTool, bounded, MAX_TOOL_BYTES } from "../src/plan-tools.js"; +import { hostedTool, bounded, MAX_TOOL_BYTES, UnsupportedFormationError } from "../src/plan-tools.js"; import type { ToolCall } from "../src/plan-tools.js"; +import hostedTranscripts from "./fixtures/autopilot-hosted-transcripts.json"; const input: PlanInput = { mode: "ready", activity: "Fictional software business", formationState: "MS", operatingState: "TX", operatingCity: "Austin", entityType: "llc", campaign: {} }; const catalog = `Formation requirements — LLC in MS (US-MS): @@ -22,15 +23,15 @@ PRICE (live): $123.00 all-in — test quote only. FOUNDING DISCOUNT: $10 off only while slots remain; a quote does not reserve one.`; const emptyTool = async () => "No results found."; -const uuid = "11111111-2222-3333-4444-555555555555"; -const otherId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const uuid = "11111111-2222-4333-8444-555555555555"; +const otherId = "aaaaaaaa-bbbb-4ccc-addd-eeeeeeeeeeee"; const search = (name = "Texas", id = uuid) => `1 of 1 results for "business tax registration" in TX (keyword):\n\n1. Tax Code § 1 — Registration\n ${name} · Tax Code\n Test source excerpt, not a conclusion.\n node_id: ${id}`; const coverage = "- TX-AUS — Austin (municipal): 100 provisions, 80 searchable chunks"; const node = (id = uuid, name = "Texas") => JSON.stringify({ nodeId: id, citation: "Tax Code § 1", bodyText: "Full test provision.", jurisdiction: { name } }); function fixture(overrides: Partial> = {}): ToolCall { return async (name, args) => overrides[name] ?? (name === "formation.requirements" ? catalog : name === "law.list_coverage" ? coverage : name === "law.get_node" ? node() : args.jurisdiction === "TX" ? search() : args.jurisdiction === "TX-AUS" ? search("Austin", otherId) : "No results found."); } -afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.useRealTimers(); }); describe("sourced planning", () => { it("rejects unsupported state and client-supplied sources", () => { @@ -96,6 +97,13 @@ describe("sourced planning", () => { expect(tool.mock.calls.some(([n, a]) => n === "law.search" && a.jurisdiction === "TX-AUS")).toBe(false); expect(p.warnings.join(" ")).toContain("Local coverage gap"); }); + it.each(["Austin", "Austin, TX", "Austin, Texas", "Austin, tx"])("matches only selected-state city suffixes against confirmed coverage: %s", city => { + expect(exactLocalCoverage(coverage, "TX", city)?.code).toBe("TX-AUS"); + expect(exactLocalCoverage(coverage.replace("Austin (", "Austin, Texas ("), "TX", city)?.code).toBe("TX-AUS"); + }); + it.each(["Austin, CA", "Austin, California", "Austin, TX, USA", "Austin City, TX", "Austin, Tex", ", TX"])("rejects mismatched or fuzzy city input: %s", city => { + expect(exactLocalCoverage(coverage, "TX", city)).toBeNull(); + }); it("accepts exact delivered searchable city entries", () => { expect(exactLocalCoverage("- TX-AUS — Austin (municipal): delivered after the last daily count — searchable now; counts appear after the next daily refresh", "TX", "Austin")?.code).toBe("TX-AUS"); }); @@ -110,11 +118,27 @@ describe("sourced planning", () => { const p = await buildPlan(input, { tool: fixture({ "formation.requirements": text }) }); expect(p.pricing).toMatchObject({ available: false, lines: [] }); }); + it("describes the unfetched IRS page without attributing Corpus copy to it", async () => { + const plan = await buildPlan(input, { tool: fixture() }); + expect(plan.sources.find(s => s.id === "guide:irs")?.excerpt).toBe("Official IRS page. No excerpt was retrieved; open the page for the current guidance."); + expect(plan.sources.find(s => s.id === "guide:ein")?.excerpt).toContain("A bank or state requirement"); + }); it("preserves the complete current price and offer qualifier", async () => { const p = await buildPlan(input, { tool: fixture() }); expect(p.pricing).toMatchObject({ available: true, state: "MS", lines: ["PRICE (live): $123.00 all-in — test quote only.", "FOUNDING DISCOUNT: $10 off only while slots remain; a quote does not reserve one."] }); expect(JSON.stringify(p)).not.toContain("needed for everyone"); }); + it("uses a fixed local keyword query without sending the founder activity to law.search", async () => { + const tool = vi.fn(fixture()); + const plan = await buildPlan(input, { tool }); + const searches = tool.mock.calls.filter(([name]) => name === "law.search"); + expect(searches.every(([, args]) => !JSON.stringify(args).includes(input.activity))).toBe(true); + expect(searches.find(([, args]) => args.jurisdiction === "TX-AUS")?.[1]).toEqual({ + query: "business license", jurisdiction: "TX-AUS", mode: "keyword", limit: 2, + }); + expect(plan.items.find(i => i.id === "local")?.needsCheck).toBe(true); + expect(plan.items.find(i => i.id === "local")?.body).toContain("does not establish which rules apply"); + }); it("uses a bounded six-tool budget", async () => { const tool = vi.fn(fixture()); await buildPlan(input, { tool }); @@ -134,8 +158,90 @@ describe("sourced planning", () => { expect(p.items.length).toBeGreaterThanOrEqual(6); expect(p.tools).not.toContain("qwen.order_known_items"); }); - it("enforces total duration even with an uncooperative tool", async () => { - await expect(buildPlan(input, { timeoutMs: 5, tool: async () => new Promise(() => {}) })).rejects.toThrow("timed out"); + it("returns partial sourced output within 24 seconds for six 4.4-second tools and skips ordering", async () => { + vi.useFakeTimers(); + const started = Date.now(); + const signals: AbortSignal[] = []; + const select = vi.fn(async () => ["ein"]); + const tool = vi.fn(async (name, args, signal) => { + signals.push(signal); + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 4400); + signal.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("cancelled")); }, { once: true }); + }); + return fixture()(name, args, signal); + }) as ReturnType>; + const pending = buildPlan(input, { tool, select }); + await vi.advanceTimersByTimeAsync(23500); + const plan = await pending; + expect(Date.now() - started).toBe(23500); + expect(tool).toHaveBeenCalledTimes(6); + expect(signals.at(-1)?.aborted).toBe(true); + expect(signals.slice(0, -1).every(s => !s.aborted)).toBe(true); + expect(plan.pricing.available).toBe(true); + expect(plan.sources.find(s => s.nodeId === uuid)?.excerpt).toBe("Full test provision."); + expect(plan.items.find(i => i.id === "local")?.sourceIds).toEqual([]); + expect(plan.items.every(i => i.needsCheck)).toBe(true); + expect(plan.warnings.join(" ")).toContain("Qwen ordering was skipped"); + expect(select).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + it("skips calls without a useful remaining budget and retains deterministic items", async () => { + vi.useFakeTimers(); + const tool = vi.fn(async () => new Promise(() => {})); + const pending = buildPlan(input, { tool, timeoutMs: 5000 }); + await vi.advanceTimersByTimeAsync(4500); + const plan = await pending; + expect(tool).toHaveBeenCalledTimes(1); + expect(plan.items.length).toBeGreaterThanOrEqual(6); + expect(plan.warnings.join(" ")).toContain("was skipped to finish"); + expect(vi.getTimerCount()).toBe(0); + }); + it("caps tools at 4.5 seconds even when a larger per-tool limit is requested", async () => { + vi.useFakeTimers(); + let observed: AbortSignal | undefined; + const pending = buildPlan({ ...input, operatingState: null, operatingCity: "" }, { + toolTimeoutMs: 9000, tool: async (_n, _a, signal) => { observed = signal; return new Promise(() => {}); }, + }); + await vi.advanceTimersByTimeAsync(4500); + expect((await pending).items.length).toBeGreaterThanOrEqual(6); + expect(observed?.aborted).toBe(true); + }); + it("preserves caller cancellation even when tools ignore abort", async () => { + vi.useFakeTimers(); + const parent = new AbortController(); + let observed: AbortSignal | undefined; + const tool = vi.fn(async (_n, _a, signal) => { observed = signal; return new Promise(() => {}); }); + const pending = buildPlan(input, { tool }, parent.signal); + const rejected = expect(pending).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(100); + parent.abort(); + await rejected; + expect(observed?.aborted).toBe(true); + expect(tool).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + it("enforces the absolute deadline even when work ignores abort", async () => { + vi.useFakeTimers(); + const pending = bounded(async () => new Promise(() => {}), new AbortController().signal, 24000); + const rejected = expect(pending).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(24000); + await rejected; + expect(vi.getTimerCount()).toBe(0); + }); + it.each(["11111111-2222-0333-8444-555555555555", "11111111-2222-9333-8444-555555555555", "11111111-2222-4333-4444-555555555555"])("rejects law UUIDs incompatible with the web validator: %s", id => { + expect(parseSearch(search("Texas", id), "Texas")).toEqual([]); + expect(parseNode(node(id), id, "Texas")).toBeNull(); + }); + it("bounds node warning plus excerpt to 5000 characters and marks truncation", () => { + const prefix = "WARNING: " + "w".repeat(1991); + const bodyText = "b".repeat(7000); + const text = prefix + "\n" + JSON.stringify({ nodeId: uuid, citation: "Tax Code § 1", bodyText, jurisdiction: { name: "Texas" } }); + const source = parseNode(text, uuid, "Texas")!; + expect(source.excerpt).toHaveLength(5000); + expect(source.excerpt).toContain(prefix + "\n"); + expect(source.excerpt).toMatch(/…truncated; open the source for the complete provision\.$/); + expect(parseNode(node(), uuid, "Texas")?.excerpt).toBe("Full test provision."); }); it("parses node warnings and rejects mismatched/malformed evidence", () => { expect(parseNode("NOT YET IN EFFECT: test warning.\n" + node(), uuid, "Texas")?.excerpt).toContain("NOT YET IN EFFECT"); @@ -199,6 +305,31 @@ describe("MCP wire transport", () => { vi.stubGlobal("fetch", async () => new Response(JSON.stringify(body))); await expect(hostedTool("law.search", {}, new AbortController().signal)).rejects.toThrow(); }); + it.each([true, false])("categorizes unsupported formation isError without disclosing hosted diagnostics (structured=%s)", async structured => { + const diagnostic = "Nonprofit formation is not available in US-MS. LLC formation is available in supported states; internal diagnostic marker."; + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ result: structured + ? { structuredContent: { isError: true, text: diagnostic } } + : { isError: true, content: [{ type: "text", text: diagnostic }] } }))); + const plan = await buildPlan({ ...input, entityType: "nonprofit", operatingState: null, operatingCity: "" }); + expect(plan.warnings.join(" ")).toContain("does not currently support formation"); + expect(plan.pricing.available).toBe(false); + expect(JSON.stringify(plan)).not.toContain("internal diagnostic"); + await expect(handoffPlan({ ...input, entityType: "nonprofit" })).rejects.toBeInstanceOf(UnsupportedFormationError); + await expect(handoffPlan({ ...input, entityType: "nonprofit" })).rejects.toThrow("Choose another state or entity"); + }); + it.each([ + { state: "TX", isError: true, name: "formation.requirements" }, + { state: "MS", isError: false, name: "formation.requirements" }, + { state: "MS", isError: true, name: "law.search" }, + ] as const)("never promotes unverified unsupported text to a semantic error: %j", async ({ state, isError, name }) => { + const diagnostic = "Nonprofit formation is not available in US-MS. LLC formation is available in supported states; private details."; + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ result: { isError, structuredContent: { text: diagnostic } } }))); + const pending = hostedTool(name, { state, entityType: "nonprofit" }, new AbortController().signal); + if (isError) { + await expect(pending).rejects.not.toBeInstanceOf(UnsupportedFormationError); + await expect(pending).rejects.toThrow("Tool failed"); + } else await expect(pending).resolves.toBe(diagnostic); + }); it("bounds response bytes before JSON parsing", async () => { vi.stubGlobal("fetch", async () => new Response("a".repeat(MAX_TOOL_BYTES + 1))); await expect(hostedTool("law.search", {}, new AbortController().signal)).rejects.toThrow("too large"); @@ -210,3 +341,36 @@ describe("MCP wire transport", () => { expect(work).not.toHaveBeenCalled(); }); }); + + +describe("real hosted formatter contract", () => { + it("reads actual law.search text and preserves the hosted node ID", () => { + expect(parseSearch(hostedTranscripts.search, "Mississippi")).toEqual([expect.objectContaining({ + nodeId: "089a2104-d704-4e93-8072-5bbbabc7ace6", label: "Miss. Code § 67-1-5 — Definitions", + excerpt: "As used in this chapter the following words shall have the meanings ascribed...", + })]); + expect(parseSearch(hostedTranscripts.search, "Texas")).toEqual([]); + }); + it("reads actual coverage text and rejects its San Diego placeholder", () => { + expect(exactLocalCoverage(hostedTranscripts.coverage, "CA", "San Francisco, CA")).toEqual({ code: "CA-SF", name: "San Francisco" }); + expect(exactLocalCoverage(hostedTranscripts.coverage, "CA", "San Diego")).toBeNull(); + expect(exactLocalCoverage(hostedTranscripts.coverage, "TX", "San Francisco, CA")).toBeNull(); + }); + it("builds a sourced ready-mode plan from actual requirements and law transcripts", async () => { + const plan = await buildPlan({ ...input, operatingState: "MS", operatingCity: "" }, { + tool: async name => name === "formation.requirements" ? hostedTranscripts.requirements + : name === "law.search" ? hostedTranscripts.search : "No results found.", + }); + expect(plan.items.find(i => i.id === "intake_0")?.body).toContain("- proposed business name"); + expect(plan.items.find(i => i.id === "state_tax")?.sourceIds).toEqual(["law:089a2104-d704-4e93-8072-5bbbabc7ace6"]); + expect(plan.pricing.available).toBe(true); + expect(plan.pricing.lines).toEqual(hostedTranscripts.requirements.split("\n").filter(line => line.startsWith("PRICE (live):"))); + expect(plan.pricing.lines[0]).toContain("included in the Corpus bundle; state requirements vary"); + expect(plan.items.every(i => i.needsCheck)).toBe(true); + }); + it("reads the actual hosted handoff URL and its missing fields", async () => { + const result = await handoffPlan(input, { tool: async () => hostedTranscripts.handoff }); + expect(result.url).toBe(hostedTranscripts.handoff.split("\n").find(line => line.startsWith("https://"))); + expect(result.missing).toEqual(["proposed business name", "principal office address (street, city, state)", "contact email", "NAICS industry code", "management type (member- or manager-managed)", "at least one member", "an organizer"]); + }); +}); From 6e7b79c359e7ea1f72f18c3c09ee3b2507aea275 Mon Sep 17 00:00:00 2001 From: ty Date: Sun, 25 Oct 2026 18:43:48 -0500 Subject: [PATCH 4/9] docs(autopilot): record reviewed planner corrections and validation --- docs/autopilot-phase1-release.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/autopilot-phase1-release.md b/docs/autopilot-phase1-release.md index 0e03d41..3deb202 100644 --- a/docs/autopilot-phase1-release.md +++ b/docs/autopilot-phase1-release.md @@ -58,7 +58,8 @@ Logs are preserved under `/Volumes/WorkSSD/Artifacts/corpus/autopilot-phase1-202 The initial socket-based HTTP tests failed with sandbox `listen EPERM`; they were replaced by direct invocation of the real HTTP listener, and the full gate above then passed. No dependencies, credentials, provider settings or billing changed. -This change requires Ty-assigned independent review and has not been deployed. +Ty-assigned Opus 5.5 reviewed the initial implementation (APPROVE_WITH_NITS, no P0/P1); +the four P2 corrections below are under follow-up review. Nothing has been deployed. The implementation branch is pushed; draft PR: https://github.com/teakesdev/corpus-agent-kit/pull/11. The controller took over after the delegate's sandbox could not write linked @@ -71,3 +72,27 @@ No secret or live offer is contained in that fixture. Corpus web implementation landed as `df44673fdab41da7ca497743d82da58e51886d01` after its local unit/typecheck/docs gate. Its new route policy resolves the concurrent Phase 0 replay finding with masks and a blocked prefill link. This is not independent review or deployment. + + +## Review corrections — 2026-10-25 + +Code commit `a4454e5a565e5df07bc548c19e77119fa189779f`, tested from +`/Users/ty/dev/.worktrees/corpus-agent-kit/autopilot-review-fixes`: + +- IRS guide entry states that no excerpt was retrieved; Corpus guide text keeps its own source. +- Tool budgets account for remaining overall time and reserve 500 ms to return partial results; + optional model ordering is skipped when time is short. Parent disconnect still cancels work. +- UUID rules match the web; full-provision excerpts are bounded and visibly truncated. +- Unsupported combinations get categorical errors, exact city/state suffixes are accepted, and + local search uses fixed keywords instead of the founder's activity sentence. +- Actual Corpus formatter fixtures cover requirements, search, coverage and handoff parsing. + +Controller independently reran `npm test` (143 autopilot + 6 MCP bridge = 149 tests, no skips), +`npm run typecheck` and `npm run build`; all exited 0. Wire contracts and the shared hosted fixture +compare byte-for-byte. Tools/models are mocked; no live metered call or filing occurred. + +Web fixes separately preserve progress through refresh, use monotonic quote age and respect the +public order-pause signal. Deploy FC before the matching web revision, under separate authorization. +Monitor shared free-tier credits and regulatory-notes cost during the article pilot; coverage caching +is deferred. The 20-plan qualitative review remains open. Phase 2 token/email helpers in Corpus are +foundations only; no public email or durable-resume endpoint is enabled. From c25bbed0bba850528715901798509243926dd0d3 Mon Sep 17 00:00:00 2001 From: ty Date: Sun, 25 Oct 2026 18:50:38 -0500 Subject: [PATCH 5/9] docs(autopilot): record Opus confirmation of review fixes --- docs/autopilot-phase1-release.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/autopilot-phase1-release.md b/docs/autopilot-phase1-release.md index 3deb202..4cc650b 100644 --- a/docs/autopilot-phase1-release.md +++ b/docs/autopilot-phase1-release.md @@ -59,7 +59,8 @@ The initial socket-based HTTP tests failed with sandbox `listen EPERM`; they wer replaced by direct invocation of the real HTTP listener, and the full gate above then passed. No dependencies, credentials, provider settings or billing changed. Ty-assigned Opus 5.5 reviewed the initial implementation (APPROVE_WITH_NITS, no P0/P1); -the four P2 corrections below are under follow-up review. Nothing has been deployed. +the follow-up review confirmed all four P2 corrections resolved, with no P0/P1/P2. +Nothing has been deployed. The implementation branch is pushed; draft PR: https://github.com/teakesdev/corpus-agent-kit/pull/11. The controller took over after the delegate's sandbox could not write linked From 90f0b4696bc286a90ddffd5b7c359656d629dc4f Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 26 Oct 2026 00:01:13 -0500 Subject: [PATCH 6/9] fix(autopilot): withhold unvetted keyword law sources Broad annual-report, tax-registration and local-license hits prove provenance, not relevance to the founder plan. Keep retrieval in the tool trace, but omit all such records from sources and item bindings, remove the first-tax full-text promotion, retain reviewed guides/catalog duties and exact local coverage, and state the unverified-law gaps explicitly. Regression first reproduced four unrelated source bindings from education, insurance, aircraft and local-license fixtures. Correct the prior hosted alcohol-law expectation. Validation: npm test (144 Autopilot and 6 MCP tests plus plugin drift), npm run build, npm run typecheck, git diff --check. --- autopilot/src/plan.ts | 34 +++++------- autopilot/test/fixtures/README.md | 5 ++ .../fixtures/irrelevant-plan-searches.json | 5 ++ autopilot/test/plan.test.ts | 54 +++++++++++++------ 4 files changed, 63 insertions(+), 35 deletions(-) create mode 100644 autopilot/test/fixtures/irrelevant-plan-searches.json diff --git a/autopilot/src/plan.ts b/autopilot/src/plan.ts index f1eeea4..35abdf3 100644 --- a/autopilot/src/plan.ts +++ b/autopilot/src/plan.ts @@ -1,7 +1,7 @@ import type { PlanInput, PlanResponse, PlanHandoff, PlanItem, PlanSource } from "./plan-contract.js"; import { bounded, hostedTool, MAX_TOOL_BYTES, PLAN_TIMEOUT_MS, TOOL_TIMEOUT_MS, UnsupportedFormationError, type ToolCall, type ToolName } from "./plan-tools.js"; -import { CAMPAIGN_KEYS, PlanError, STATES, validatePlanInput } from "./plan-validation.js"; -import { exactLocalCoverage, parseNode, parseSearch } from "./plan-evidence.js"; +import { CAMPAIGN_KEYS, PlanError, validatePlanInput } from "./plan-validation.js"; +import { exactLocalCoverage } from "./plan-evidence.js"; import { getQwen } from "./qwen.js"; import { lane } from "./routing.js"; @@ -133,28 +133,22 @@ export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = ne ? `Operating state: ${input.operatingState}. Tax registration applicability has not been determined. Check the rules using the actual activity and tax situation.` : "Operating state is not selected. Tax registration applicability has not been determined."); - async function search(query: string, jurisdiction: string, jurisdictionName: string, itemId: string) { - const text = await call("law.search", { query, jurisdiction, mode: "keyword", limit: 2 }); - const sources = text ? parseSearch(text, jurisdictionName) : []; - for (const source of sources) ledger.set(source.id, source); - items.find(i => i.id === itemId)!.sourceIds.push(...sources.map(s => s.id)); - if (!sources.length) warnings.push(`No usable ${jurisdiction} source records were retrieved; this does not establish that no requirements apply.`); - return sources; - } - if (catalog && state) await search("annual report", state, STATES[state], "annual_report"); - const taxSources = input.operatingState ? await search("business tax registration", input.operatingState, STATES[input.operatingState], "state_tax") : []; - if (taxSources[0]?.nodeId && input.operatingState) { - const text = await call("law.get_node", { id: taxSources[0].nodeId }); - const detail = text ? parseNode(text, taxSources[0].nodeId, STATES[input.operatingState]) : null; - if (detail) ledger.set(detail.id, detail); - else warnings.push("Full provision detail could not be verified; the search excerpt remains unresolved."); + async function search(query: string, jurisdiction: string, itemId: string) { + await call("law.search", { query, jurisdiction, mode: "keyword", limit: 2 }); + // A real node in the selected jurisdiction proves provenance only. Broad + // keyword matches have not been vetted for relevance to this plan, so do + // not bind them as supporting law or promote them with get_node. + const title = items.find(i => i.id === itemId)!.title; + warnings.push(`No relevant provision was verified for “${title}” (${jurisdiction}). Broad keyword matches are withheld; this does not establish that no requirements apply.`); } + if (catalog && state) await search("annual report", state, "annual_report"); + if (input.operatingState) await search("business tax registration", input.operatingState, "state_tax"); const coverage = input.operatingState && input.operatingCity ? await call("law.list_coverage", {}) : null; const local = coverage && input.operatingState ? exactLocalCoverage(coverage, input.operatingState, input.operatingCity) : null; - item("local", "local", local ? "Review local source matches" : "Local coverage needs checking", local - ? `Coverage confirms a searchable entry for ${local.name} (${local.code}); it does not establish which rules apply to this activity.` + item("local", "local", local ? "Local requirements need checking" : "Local coverage needs checking", local + ? `Coverage confirms a searchable entry for ${local.name} (${local.code}); it does not establish which rules apply to this activity. No relevant local provision was verified.` : "No exact, unambiguous, searchable coverage entry was confirmed for the operating city. Local requirements have not been determined."); - if (local) await search("business license", local.code, local.name, "local"); + if (local) await search("business license", local.code, "local"); else warnings.push("Local coverage gap: no local search was run."); const lines = catalog?.split("\n").filter(l => /^(?:PRICE(?: \(live\))?:|FOUNDING DISCOUNT:|EXPEDITE \(|SKIP REVIEW \(|TENNESSEE MEMBERS:|EIN ADD-ON: temporarily unavailable)/.test(l)) ?? []; diff --git a/autopilot/test/fixtures/README.md b/autopilot/test/fixtures/README.md index df76999..8915f1d 100644 --- a/autopilot/test/fixtures/README.md +++ b/autopilot/test/fixtures/README.md @@ -8,3 +8,8 @@ The source is Corpus base `bf1ed5de` plus the registered-agent quote correction pins these exact transcripts and requires regeneration when their format changes. Keep this copy synchronized with `apps/web/src/__tests__/fixtures/autopilot-hosted-transcripts.json` in Corpus; do not replace it with hand-written approximations. + +`irrelevant-plan-searches.json` contains synthetic negative controls shaped like +hosted search transcripts. Education and insurance reports, aircraft registration, +and dog licensing must never become supporting law for a software-business plan. +These are parser-compatible test records, not verified legal citations or excerpts. diff --git a/autopilot/test/fixtures/irrelevant-plan-searches.json b/autopilot/test/fixtures/irrelevant-plan-searches.json new file mode 100644 index 0000000..b75a358 --- /dev/null +++ b/autopilot/test/fixtures/irrelevant-plan-searches.json @@ -0,0 +1,5 @@ +{ + "annual_report": "2 of 2 results for \"annual report\" in MS (keyword):\n\n1. Education achievement council — Annual report\n Mississippi · Synthetic negative-control code\n The education achievement council submits an annual report on its activities.\n node_id: 11111111-2222-4333-8444-555555555555\n\n2. Insurance society — Annual report\n Mississippi · Synthetic negative-control code\n An insurance society submits an annual report to the commissioner.\n node_id: aaaaaaaa-bbbb-4ccc-addd-eeeeeeeeeeee", + "state_tax": "1 of 1 results for \"business tax registration\" in MS (keyword):\n\n1. Aircraft registration\n Mississippi · Synthetic negative-control code\n Aircraft registration for business aircraft requires payment of the applicable tax.\n node_id: 22222222-3333-4444-8555-666666666666", + "local": "1 of 1 results for \"business license\" in MS-JXN (keyword):\n\n1. Dog license\n Jackson · Synthetic negative-control ordinance\n A dog owner must obtain a license.\n node_id: 33333333-4444-4555-8666-777777777777" +} diff --git a/autopilot/test/plan.test.ts b/autopilot/test/plan.test.ts index abdb267..3854b8c 100644 --- a/autopilot/test/plan.test.ts +++ b/autopilot/test/plan.test.ts @@ -6,6 +6,7 @@ import { exactLocalCoverage, parseNode, parseSearch } from "../src/plan-evidence import { hostedTool, bounded, MAX_TOOL_BYTES, UnsupportedFormationError } from "../src/plan-tools.js"; import type { ToolCall } from "../src/plan-tools.js"; import hostedTranscripts from "./fixtures/autopilot-hosted-transcripts.json"; +import irrelevantSearches from "./fixtures/irrelevant-plan-searches.json"; const input: PlanInput = { mode: "ready", activity: "Fictional software business", formationState: "MS", operatingState: "TX", operatingCity: "Austin", entityType: "llc", campaign: {} }; const catalog = `Formation requirements — LLC in MS (US-MS): @@ -34,6 +35,25 @@ function fixture(overrides: Partial> = {}): ToolCall { afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.useRealTimers(); }); describe("sourced planning", () => { + it("withholds unvetted education, insurance, aircraft and local matches while preserving source gaps", async () => { + const tool = vi.fn(async (name, args) => name === "formation.requirements" ? catalog + : name === "law.list_coverage" ? "- MS-JXN — Jackson (municipal): 100 provisions, 80 searchable chunks" + : name === "law.search" ? args.query === "annual report" ? irrelevantSearches.annual_report + : args.query === "business tax registration" ? irrelevantSearches.state_tax : irrelevantSearches.local + : node()); + const plan = await buildPlan({ ...input, operatingState: "MS", operatingCity: "Jackson" }, { tool }); + expect(plan.sources.filter(s => s.kind === "law")).toEqual([]); + expect(plan.items.find(i => i.id === "annual_report")?.sourceIds).toEqual(["guide:annual"]); + expect(plan.items.find(i => i.id === "formation_duty_0")?.sourceIds).toEqual(["catalog:duty:0"]); + expect(plan.items.find(i => i.id === "state_tax")?.sourceIds).toEqual([]); + expect(plan.items.find(i => i.id === "local")?.sourceIds).toEqual([]); + expect(plan.items.find(i => i.id === "local")?.body).toContain("searchable entry for Jackson"); + expect(plan.warnings.filter(w => w.includes("No relevant provision was verified"))).toHaveLength(3); + expect(plan.tools).toContain("law.list_coverage"); + expect(tool.mock.calls.some(([name]) => name === "law.get_node")).toBe(false); + expect(plan.items.every(i => i.needsCheck)).toBe(true); + expect(JSON.stringify(plan)).not.toMatch(/Education achievement|Insurance society|Aircraft registration|Dog license/); + }); it("rejects unsupported state and client-supplied sources", () => { expect(() => validatePlanInput({ ...input, formationState: "ZZ" })).toThrow(); expect(() => validatePlanInput({ ...input, sources: [] })).toThrow(); @@ -72,12 +92,12 @@ describe("sourced planning", () => { expect(p.warnings.join(" ")).toContain("No entity type"); expect(p.items.every(i => i.needsCheck)).toBe(true); }); - it("binds only same-request sources and ignores model-injected citations", async () => { + it("binds curated sources only and ignores model-injected citations", async () => { const p = await buildPlan(input, { tool: fixture(), select: async () => ["ein", `law:${otherId}`, "MS § invented"] }); - expect(p.sources.filter(s => s.kind === "law").map(s => s.nodeId)).toEqual([uuid, otherId]); + expect(p.sources.filter(s => s.kind === "law")).toEqual([]); expect(p.items[0].id).toBe("ein"); expect(p.items.every(i => i.sourceIds.every(id => p.sources.some(s => s.id === id)))).toBe(true); - expect(p.sources.find(s => s.nodeId === uuid)).toMatchObject({ label: "Tax Code § 1", excerpt: "Full test provision.", url: `https://corpuslaw.us/code/${uuid}` }); + expect(p.sources.find(s => s.id === "catalog:duty:0")?.excerpt).toBe("Annual report: confirm the current due date."); expect(JSON.stringify(p)).not.toContain("MS § invented"); const second = await buildPlan(input, { tool: fixture({ "law.search": "No results found." }) }); expect(second.sources.some(s => s.kind === "law")).toBe(false); @@ -88,7 +108,8 @@ describe("sourced planning", () => { expect(tool.mock.calls.some(([n, a]) => n === "law.search" && a.jurisdiction === "TX")).toBe(true); expect(p.items.find(i => i.id === "state_tax")?.sourceIds).toEqual([]); expect(p.items.find(i => i.id === "state_tax")?.body).toContain("Operating state: TX"); - expect(p.sources.filter(s => s.kind === "law")).toHaveLength(1); // MS search only + expect(tool.mock.calls.some(([n, a]) => n === "law.search" && a.jurisdiction === "MS")).toBe(true); + expect(p.sources.filter(s => s.kind === "law")).toHaveLength(0); expect(p.items.every(i => i.needsCheck)).toBe(true); }); it.each(["", "- TX-AUS — Austin (municipal): PLACEHOLDER — listed but not yet searchable", "- TX-AUS — Austin (municipal): 100 provisions, 0 searchable chunks", "- CA-AUS — Austin (municipal): 100 provisions, 10 searchable chunks", "- TX-AUS — Austin City (municipal): 100 provisions, 10 searchable chunks", coverage + "\n- TX-AUS2 — Austin (municipal): 100 provisions, 10 searchable chunks"])("does not search local law without exact unambiguous searchable coverage: %s", async cov => { @@ -139,10 +160,11 @@ describe("sourced planning", () => { expect(plan.items.find(i => i.id === "local")?.needsCheck).toBe(true); expect(plan.items.find(i => i.id === "local")?.body).toContain("does not establish which rules apply"); }); - it("uses a bounded six-tool budget", async () => { + it("uses five bounded tools without promoting a search hit to full provision evidence", async () => { const tool = vi.fn(fixture()); await buildPlan(input, { tool }); - expect(tool).toHaveBeenCalledTimes(6); + expect(tool).toHaveBeenCalledTimes(5); + expect(tool.mock.calls.some(([name]) => name === "law.get_node")).toBe(false); }); it("cancels a hung tool and degrades deterministically", async () => { let observed: AbortSignal | undefined; @@ -158,7 +180,7 @@ describe("sourced planning", () => { expect(p.items.length).toBeGreaterThanOrEqual(6); expect(p.tools).not.toContain("qwen.order_known_items"); }); - it("returns partial sourced output within 24 seconds for six 4.4-second tools and skips ordering", async () => { + it("returns curated output within 24 seconds for five 4.4-second tools and skips ordering", async () => { vi.useFakeTimers(); const started = Date.now(); const signals: AbortSignal[] = []; @@ -171,15 +193,15 @@ describe("sourced planning", () => { }); return fixture()(name, args, signal); }) as ReturnType>; - const pending = buildPlan(input, { tool, select }); + let completedAt = 0; + const pending = buildPlan(input, { tool, select }).then(plan => { completedAt = Date.now(); return plan; }); await vi.advanceTimersByTimeAsync(23500); const plan = await pending; - expect(Date.now() - started).toBe(23500); - expect(tool).toHaveBeenCalledTimes(6); - expect(signals.at(-1)?.aborted).toBe(true); - expect(signals.slice(0, -1).every(s => !s.aborted)).toBe(true); + expect(completedAt - started).toBe(22000); + expect(tool).toHaveBeenCalledTimes(5); + expect(signals.every(s => !s.aborted)).toBe(true); expect(plan.pricing.available).toBe(true); - expect(plan.sources.find(s => s.nodeId === uuid)?.excerpt).toBe("Full test provision."); + expect(plan.sources.some(s => s.kind === "law")).toBe(false); expect(plan.items.find(i => i.id === "local")?.sourceIds).toEqual([]); expect(plan.items.every(i => i.needsCheck)).toBe(true); expect(plan.warnings.join(" ")).toContain("Qwen ordering was skipped"); @@ -356,13 +378,15 @@ describe("real hosted formatter contract", () => { expect(exactLocalCoverage(hostedTranscripts.coverage, "CA", "San Diego")).toBeNull(); expect(exactLocalCoverage(hostedTranscripts.coverage, "TX", "San Francisco, CA")).toBeNull(); }); - it("builds a sourced ready-mode plan from actual requirements and law transcripts", async () => { + it("keeps actual requirements but withholds unvetted alcohol-law search matches", async () => { const plan = await buildPlan({ ...input, operatingState: "MS", operatingCity: "" }, { tool: async name => name === "formation.requirements" ? hostedTranscripts.requirements : name === "law.search" ? hostedTranscripts.search : "No results found.", }); expect(plan.items.find(i => i.id === "intake_0")?.body).toContain("- proposed business name"); - expect(plan.items.find(i => i.id === "state_tax")?.sourceIds).toEqual(["law:089a2104-d704-4e93-8072-5bbbabc7ace6"]); + expect(plan.items.find(i => i.id === "state_tax")?.sourceIds).toEqual([]); + expect(plan.sources.some(s => s.kind === "law")).toBe(false); + expect(plan.warnings.join(" ")).toContain("No relevant provision was verified"); expect(plan.pricing.available).toBe(true); expect(plan.pricing.lines).toEqual(hostedTranscripts.requirements.split("\n").filter(line => line.startsWith("PRICE (live):"))); expect(plan.pricing.lines[0]).toContain("included in the Corpus bundle; state requirements vary"); From cb3999ba87ebf914e01407902aaea1716be946f0 Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 26 Oct 2026 00:03:33 -0500 Subject: [PATCH 7/9] fix(autopilot): clarify unresolved-plan warning Address approved review LOW-2: the opening warning no longer suggests search matches are displayed. Keep the reviewed diagnostic calls and existing guard comment in this patch. Follow-up suggestion: remove diagnostic-only broad searches after separately reviewing tool-trace and deadline behavior. --- autopilot/src/plan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autopilot/src/plan.ts b/autopilot/src/plan.ts index 35abdf3..b6e2435 100644 --- a/autopilot/src/plan.ts +++ b/autopilot/src/plan.ts @@ -70,7 +70,7 @@ export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = ne return bounded(async signal => { const createdAt = new Date().toISOString(); const tools: string[] = []; - const warnings = ["Source binding establishes provenance, not applicability or completeness. Search matches and unresolved items need checking."]; + const warnings = ["Source binding establishes provenance, not applicability or completeness. Unresolved items need checking."]; const call = requestTools(deps, signal, tools, warnings, deadline); const ledger = new Map(); const items: PlanItem[] = []; From bbb408d18bc0c5c711f133f5a4bc666490dc253a Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 26 Oct 2026 00:06:39 -0500 Subject: [PATCH 8/9] docs(autopilot): record deployed relevance mitigation Record approved FC source cb3999ba87ebf914e01407902aaea1716be946f0, its deployed checksum and narrow backend smoke, plus live web source/deployment. Keep email false and final postchecks and pilot validation pending. Correct directly affected README claims about plan law sources and distinguish optional plan ordering from legacy chat routing. No code or deployment changes. --- README.md | 15 ++++++-- docs/autopilot-phase1-release.md | 64 +++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8c1cf41..3c69b0b 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,10 @@ Running work log for this integration: [`WORKLOG.md`](WORKLOG.md). **[corpuslaw.us/autopilot](https://corpuslaw.us/autopilot)** — the Formation Autopilot from this repo (`autopilot/`), running on Alibaba Cloud Function -Compute. Describe a business in plain English; get a cited launch checklist and -a prefilled formation handoff. +Compute. Describe a business in plain English; get a launch checklist linked to +reviewed guides and the formation catalog, with unresolved requirements marked +Needs checking, and a prefilled formation handoff. Broad keyword law matches are +withheld until their relevance can be verified. ### 3. Drop a skill folder — or install a plugin @@ -167,7 +169,8 @@ jurisdiction is not covered rather than guessing. ## What's in this repo - **`autopilot/`** — Formation Autopilot: a Qwen Cloud agent that turns an - ambiguous founder description into a cited launch checklist and a prefilled, + ambiguous founder description into a launch checklist with guide and catalog + sources and a prefilled, human-approved formation handoff. *(Qwen Cloud Hackathon Track 4 winner.)* - **`mcp-server/`** — zero-dependency stdio MCP server, for clients that cannot speak HTTP MCP. @@ -205,7 +208,7 @@ See [docs/architecture.md](docs/architecture.md) for the Mermaid source. ## Two-lane model routing -The autopilot uses two Qwen models with different cost/quality profiles: +Legacy `/api/chat` uses two Qwen models with different cost/quality profiles: - **Fast lane** (`QWEN_MODEL_FAST`, default `qwen-flash-2025-07-28`): all standard turns — intent parsing, law search, checklist generation. Low latency, low spend. @@ -215,6 +218,8 @@ The autopilot uses two Qwen models with different cost/quality profiles: The lane switch is automatic. Routing logic lives in `autopilot/src/agent.ts`. The hourly spend cap (`SPEND_CAP_TURNS_PER_HOUR`) applies across both lanes. +The sourced `/api/plan` path builds deterministic guide/catalog items and uses +the fast lane only for optional ordering; model output cannot add legal sources. ## Human in the loop (gates) @@ -287,6 +292,8 @@ The server exposes: - `GET /healthz` — liveness probe - `POST /api/chat` — agent turn endpoint +- `POST /api/plan` — planning with guide and catalog sources; unresolved law remains Needs checking +- `POST /api/plan/handoff` — validate a ready-mode draft through the hosted handoff - `GET /` — chat UI Deploy to Alibaba Cloud Function Compute: see diff --git a/docs/autopilot-phase1-release.md b/docs/autopilot-phase1-release.md index 4cc650b..910acbf 100644 --- a/docs/autopilot-phase1-release.md +++ b/docs/autopilot-phase1-release.md @@ -1,5 +1,42 @@ # Phase 1 sourced planning backend +## Deployment state — 2026-10-26 + +The approved Function Compute code-only deployment is complete at source commit +`cb3999ba87ebf914e01407902aaea1716be946f0`, with FC code checksum +`13529343312431775614`. Runtime, environment and function configuration were verified +unchanged. The matching Corpus web is live at source +`c31b01adb307ad37302292f25ef03a67855f5bbd`, deployment +`dpl_8wG2Rqp8yZqYdbAaWxkkk8VeagaA` +([deployment URL](https://corpus-4hw3j0gf6-teakesdev-7793s-projects.vercel.app)). +Final production postchecks are pending; +the Autopilot email feature flag remains `false`. This is not a completed email launch. + +Production smoke exposed unrelated annual-report and aircraft-registration search +matches in an MS LLC software-consulting plan. The deployed relevance mitigation +withholds every broad annual-report, tax-registration and local-license search +result from the plan's source ledger and item bindings. Search calls remain only +in the diagnostic tool trace; they do not verify a plan statement. The first tax +hit is no longer promoted through `law.get_node`. Reviewed guide/catalog sources, +exact local coverage status and explicit unverified-provision gaps remain. +There is no heuristic or model-based applicability approval. + +The initial postdeployment FC smoke returned HTTP 200 in 3.85 seconds, with +7 items, 7 guide/catalog sources, zero law sources and all item bindings resolving. +The safe deployment receipt is +`/Volumes/WorkSSD/Artifacts/corpus/autopilot-production-rollout/fc-relevance-deployment.json`. +This check does not complete the email, alert or qualitative source-review pilot. + +The mitigation received independent Opus approval. At the exact deployed source +commit, `npm test` passed 144 Autopilot tests and 6 MCP bridge tests plus plugin/Codex +skill drift checks; `npm run build`, `npm run typecheck` and `git diff --check` all +exited 0. Logs and the commit/checkout/command manifest are preserved under +`/Volumes/WorkSSD/Artifacts/corpus/autopilot-production-rollout/source-relevance/cb3999ba87ebf914e01407902aaea1716be946f0/`. +Removing the remaining diagnostic-only searches is a follow-up suggestion, not +part of this deployed mitigation. The 20-plan qualitative review remains open. + +## Backend contract + The FC API adds `POST /api/plan` (`PlanInput` → `PlanResponse`) and `POST /api/plan/handoff` (`PlanInput` → `PlanHandoff`). Wire types are mirrored in `autopilot/src/plan-contract.ts`. `/api/chat` remains available. @@ -8,7 +45,8 @@ Deploy FC before the web frontend, under separate deployment authorization. An older FC returns 404: the frontend must preserve input and show its unavailable fallback, never render legacy generated legal prose as a sourced plan. -Plans use the hosted requirements catalog and request-local law evidence. +Plans use the hosted requirements catalog and reviewed guide sources. Broad law +search results are withheld because their relevance to the plan is unverified. Evidence binding establishes provenance, not applicability or completeness. Unknown requirements remain Needs checking. No purchase or filing happens here. @@ -16,8 +54,9 @@ The plan accepts only the seven `PlanInput` fields, bare US state/DC codes or null, and `llc`/`nonprofit`/null. All items remain `needsCheck: true`: there is no applicability checker. Catalog items identify the selected formation state; tax searches use the operating state. Exact, unambiguous searchable municipal -coverage is required before a local search. The inspected app's stable law URL -is `/code/`; there is no `/law/` route. Text-format drift fails closed. +coverage is required before a diagnostic local search. Neither coverage nor a +successful search establishes applicable requirements. Catalog text-format drift +fails closed. Pricing requires one valid current `PRICE (live)` line from the selected formation catalog. Complete price/offer qualifiers are preserved; unavailable @@ -32,7 +71,8 @@ There is no local prefill fallback. Normal hosted regulatory-note behavior may occur; this backend starts no additional jobs, checkout or filing. Limits: 16 KiB plan request body, 5s body deadline, 24s planner deadline (25s HTTP -handler backstop), at most six hosted tool calls, three searches of two hits each, +handler backstop), at most five hosted tool calls on the current path (the six-call +guard remains), including three diagnostic searches of two hits each, 4.5s per tool, 128 KiB per tool response, up to twelve plan items, at most eighteen source records, ten pricing lines/16 KiB, and 96 KiB serialized HTTP response. Optional Qwen ordering uses the existing fast lane, a 2s timeout, no retries and 256 output @@ -43,7 +83,9 @@ The existing process-local hourly request counter is shared with `/api/chat`; it is not a global dollar cap and its configuration is unchanged. Legacy chat keeps its behavior, with a new 64 KiB/5s body-buffer safety limit. -Validation (2026-10-25), from +## Initial validation history — 2026-10-25 + +Initial validation, from `/Users/ty/dev/.worktrees/corpus-agent-kit/autopilot-phase1`, implementation commit `536d1fb6f4943cb1bae5549a307c3a58889e04c3`: @@ -60,8 +102,9 @@ replaced by direct invocation of the real HTTP listener, and the full gate above then passed. No dependencies, credentials, provider settings or billing changed. Ty-assigned Opus 5.5 reviewed the initial implementation (APPROVE_WITH_NITS, no P0/P1); the follow-up review confirmed all four P2 corrections resolved, with no P0/P1/P2. -Nothing has been deployed. -The implementation branch is pushed; draft PR: https://github.com/teakesdev/corpus-agent-kit/pull/11. +Deployment had not occurred at this initial validation; the current deployed +state is recorded above. The implementation and mitigation are pushed on existing +PR [#11](https://github.com/teakesdev/corpus-agent-kit/pull/11); the PR is not merged. The controller took over after the delegate's sandbox could not write linked Git metadata. Controller integration adds the actual catalog intake fields, @@ -70,9 +113,10 @@ and stricter handoff query/fragment checks. The web tests consume an actual `buildPlan` fixture including an IRS source and a 1,079-character offer qualifier. No secret or live offer is contained in that fixture. -Corpus web implementation landed as `df44673fdab41da7ca497743d82da58e51886d01` after its +The initial Corpus web implementation landed as `df44673fdab41da7ca497743d82da58e51886d01` after its local unit/typecheck/docs gate. Its new route policy resolves the concurrent Phase 0 replay -finding with masks and a blocked prefill link. This is not independent review or deployment. +finding with masks and a blocked prefill link. That initial landing was not deployment; +the current live web source is recorded above. ## Review corrections — 2026-10-25 @@ -93,7 +137,7 @@ Controller independently reran `npm test` (143 autopilot + 6 MCP bridge = 149 te compare byte-for-byte. Tools/models are mocked; no live metered call or filing occurred. Web fixes separately preserve progress through refresh, use monotonic quote age and respect the -public order-pause signal. Deploy FC before the matching web revision, under separate authorization. +public order-pause signal. The approved FC and matching web deployments are recorded above. Monitor shared free-tier credits and regulatory-notes cost during the article pilot; coverage caching is deferred. The 20-plan qualitative review remains open. Phase 2 token/email helpers in Corpus are foundations only; no public email or durable-resume endpoint is enabled. From 7e3832fabed53d3ff5480579a059667f6dd14244 Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 26 Oct 2026 00:07:42 -0500 Subject: [PATCH 9/9] docs(autopilot): correct deployed Phase 2 endpoint status Record deployed resume/delete endpoints and passing public fake-token smoke, disabled email status and new sending, and registered cleanup cron with scheduled success still pending. Documentation correction only. --- docs/autopilot-phase1-release.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/autopilot-phase1-release.md b/docs/autopilot-phase1-release.md index 910acbf..cc23030 100644 --- a/docs/autopilot-phase1-release.md +++ b/docs/autopilot-phase1-release.md @@ -139,5 +139,7 @@ compare byte-for-byte. Tools/models are mocked; no live metered call or filing o Web fixes separately preserve progress through refresh, use monotonic quote age and respect the public order-pause signal. The approved FC and matching web deployments are recorded above. Monitor shared free-tier credits and regulatory-notes cost during the article pilot; coverage caching -is deferred. The 20-plan qualitative review remains open. Phase 2 token/email helpers in Corpus are -foundations only; no public email or durable-resume endpoint is enabled. +is deferred. The 20-plan qualitative review remains open. Corpus Phase 2 resume/delete +endpoints are deployed and passed the public 404/200 fake-token smoke checks; +the email-status endpoint reports disabled. The cleanup cron is registered, with +scheduled-run success still pending. New email sending remains disabled pending checks.