feat: task decomposition view during execution (#5)
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user