feat: task decomposition view during execution (#5)
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s

At the start of job execution, a Haiku call decomposes the user's
request into 2-4 named sub-steps. Steps are broadcast via WebSocket
as `job_steps` and `job_step_update` events. The Workshop renders a
live checklist panel near Gamma that checks off steps as streaming
progresses using a character-count heuristic, then collapses to
"Done" on completion. Steps are stored with the job record.

- agent.ts: add `decomposeRequest` (Haiku, stub-safe)
- event-bus.ts: add `DecompositionEvent` types (job:steps, job:step_update)
- jobs.ts: call decompose before streaming; advance steps heuristically
- events.ts: translate new bus events to WS messages
- websocket.js: handle job_steps / job_step_update / collapse on complete
- ui.js: showJobSteps / updateJobStep / collapseJobSteps panel
- jobs schema: decomposition_steps text column
- migration 0010: add decomposition_steps column

Fixes #5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 16:37:50 -04:00
parent e41d30d308
commit ac553cb6b4
8 changed files with 271 additions and 2 deletions

View File

@@ -53,6 +53,13 @@ const STUB_RESULT =
"Stub response: Timmy is running in stub mode (no Anthropic API key). " +
"Configure AI_INTEGRATIONS_ANTHROPIC_API_KEY to enable real AI responses.";
const STUB_DECOMPOSITION_STEPS = [
"Understand the request",
"Research and gather information",
"Draft a comprehensive response",
"Review and finalise output",
];
const STUB_CHAT_REPLIES = [
"Ah, a visitor! *adjusts hat* The crystal ball sensed your presence. What do you seek?",
"By the ancient runes! In stub mode I cannot reach the stars, but my wisdom remains. Ask away!",
@@ -248,6 +255,40 @@ If the user asks how to run their own Timmy or self-host this service, enthusias
return { result: fullText, inputTokens, outputTokens };
}
/**
* Decompose a request into 2-4 named sub-steps (#5).
* Uses the cheap eval model (Haiku) so this adds minimal latency at job start.
* In stub mode, returns canned steps so the full checklist UI is exercised.
*/
async decomposeRequest(requestText: string): Promise<string[]> {
if (STUB_MODE) {
await new Promise((r) => setTimeout(r, 150));
return [...STUB_DECOMPOSITION_STEPS];
}
const client = await getClient();
const message = await client.messages.create({
model: this.evalModel,
max_tokens: 256,
system: `You are a task decomposition assistant. Break the user's request into 2-4 short, specific sub-step labels that describe how you will fulfil it. Each label should be 3-8 words. Respond ONLY with a valid JSON array of strings, e.g. ["Step one label", "Step two label"].`,
messages: [{ role: "user", content: `Decompose this request into sub-steps: ${requestText}` }],
});
const block = message.content[0];
if (block.type !== "text") return [...STUB_DECOMPOSITION_STEPS];
try {
const raw = block.text!.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed) && parsed.length >= 2 && parsed.length <= 4) {
return (parsed as unknown[]).map(String).slice(0, 4);
}
} catch {
logger.warn("decomposeRequest parse failed, using stubs", { text: block.text });
}
return [...STUB_DECOMPOSITION_STEPS];
}
/**
* Quick free chat reply — called for visitor messages in the Workshop.
* Uses the cheaper eval model with a wizard persona and a 150-token limit

View File

@@ -18,7 +18,11 @@ export type DebateEvent =
export type CostEvent =
| { type: "cost:update"; jobId: string; sats: number; phase: "eval" | "work" | "session"; isFinal: boolean };
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent;
export type DecompositionEvent =
| { type: "job:steps"; jobId: string; steps: string[] }
| { type: "job:step_update"; jobId: string; activeStep: number; completedSteps: number[] };
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | DecompositionEvent;
class EventBus extends EventEmitter {
emit(event: "bus", data: BusEvent): boolean;

View File

@@ -257,6 +257,21 @@ function translateEvent(ev: BusEvent): object | null {
isFinal: ev.isFinal,
};
// ── Task decomposition (#5) ───────────────────────────────────────────────
case "job:steps":
return {
type: "job_steps",
jobId: ev.jobId,
steps: ev.steps,
};
case "job:step_update":
return {
type: "job_step_update",
jobId: ev.jobId,
activeStep: ev.activeStep,
completedSteps: ev.completedSteps,
};
default:
return null;
}

View File

@@ -254,10 +254,70 @@ async function runWorkInBackground(
try {
eventBus.publish({ type: "job:state", jobId, state: "executing" });
// ── Task decomposition (#5) ─────────────────────────────────────────────
// Decompose the request into 2-4 named sub-steps via Haiku, then broadcast
// the step list so the Workshop checklist panel can appear immediately.
let decompositionSteps: string[] = [];
try {
decompositionSteps = await agentService.decomposeRequest(request);
// Persist steps in job record (non-fatal if it fails)
await db
.update(jobs)
.set({ decompositionSteps: JSON.stringify(decompositionSteps), updatedAt: new Date() })
.where(eq(jobs.id, jobId));
eventBus.publish({ type: "job:steps", jobId, steps: decompositionSteps });
// Start with step 0 active
eventBus.publish({ type: "job:step_update", jobId, activeStep: 0, completedSteps: [] });
} catch (decompErr) {
logger.warn("decomposeRequest failed, continuing without steps", { jobId, err: String(decompErr) });
}
// ── Step advancement heuristic ──────────────────────────────────────────
// Divide the expected output into equal chunks; advance the active step
// each time that fraction of streamed characters has been received.
let charCount = 0;
let currentStep = 0;
const numSteps = decompositionSteps.length;
const completedStepsSoFar: number[] = [];
// Rough expected output length — anything over ~200 chars triggers first step.
// Steps are advanced at equal intervals: 25% / 50% / 75% of 600 estimated chars.
const ESTIMATED_TOTAL_CHARS = 600;
const stepThreshold = numSteps > 0 ? ESTIMATED_TOTAL_CHARS / numSteps : Infinity;
const workResult = await agentService.executeWorkStreaming(request, (delta) => {
streamRegistry.write(jobId, delta);
if (numSteps > 0) {
charCount += delta.length;
const expectedStep = Math.min(
Math.floor(charCount / stepThreshold),
numSteps - 1,
);
if (expectedStep > currentStep) {
completedStepsSoFar.push(currentStep);
currentStep = expectedStep;
eventBus.publish({
type: "job:step_update",
jobId,
activeStep: currentStep,
completedSteps: [...completedStepsSoFar],
});
}
}
});
// Mark all steps complete when streaming finishes
if (numSteps > 0) {
const allCompleted = Array.from({ length: numSteps }, (_, i) => i);
eventBus.publish({
type: "job:step_update",
jobId,
activeStep: -1, // -1 = all done, no active step
completedSteps: allCompleted,
});
}
streamRegistry.end(jobId);
latencyHistogram.record("work_phase", Date.now() - workStart);