1 Commits

Author SHA1 Message Date
Alexander Whitestone
ac553cb6b4 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>
2026-03-23 16:37:50 -04:00
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);

View File

@@ -0,0 +1,4 @@
-- Task decomposition view (#5)
-- Stores the Haiku-generated step labels for a job execution as a JSON array.
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS decomposition_steps TEXT;

View File

@@ -52,6 +52,10 @@ export const jobs = pgTable("jobs", {
refundState: text("refund_state").$type<"not_applicable" | "pending" | "paid">(),
refundPaymentHash: text("refund_payment_hash"),
// ── Task decomposition (#5) ──────────────────────────────────────────────
// JSON array of 2-4 step labels generated by Haiku at execution start.
decompositionSteps: text("decomposition_steps"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});

View File

@@ -305,3 +305,129 @@ export function appendDebateMessage(agent, argument, isVerdict, accepted) {
export function loadChatHistory() { return []; }
export function saveChatHistory() {}
// ── Task decomposition checklist panel (#5) ───────────────────────────────────
// Appears near the active Gamma agent during job execution.
// Steps are checked off as streaming progresses, then collapses to "Done".
let $stepsPanel = null;
let _stepElements = [];
let _collapseTimer = null;
function _ensureStepsPanel() {
if ($stepsPanel) return $stepsPanel;
$stepsPanel = document.getElementById('timmy-steps-panel');
if (!$stepsPanel) {
$stepsPanel = document.createElement('div');
$stepsPanel.id = 'timmy-steps-panel';
$stepsPanel.style.cssText = [
'position:fixed;bottom:80px;right:16px',
'background:rgba(0,20,40,0.88);border:1px solid #224466',
'border-radius:6px;padding:8px 12px',
'font-size:11px;font-family:"Courier New",monospace',
'color:#88bbdd;min-width:200px;max-width:280px',
'pointer-events:none;z-index:10',
'transition:opacity .4s;opacity:0',
].join(';');
document.body.appendChild($stepsPanel);
}
return $stepsPanel;
}
/**
* Show the checklist panel with the given step labels.
* Called on `job_steps` WebSocket message.
*/
export function showJobSteps(steps) {
clearTimeout(_collapseTimer);
const panel = _ensureStepsPanel();
// Clear previous content
panel.innerHTML = '';
_stepElements = [];
const header = document.createElement('div');
header.textContent = '⚙ Working…';
header.style.cssText = 'color:#aaccee;font-weight:bold;margin-bottom:5px;font-size:10px;letter-spacing:1px;';
panel.appendChild(header);
steps.forEach((label, i) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:5px;margin:2px 0;transition:color .3s;';
const check = document.createElement('span');
check.textContent = '○';
check.style.cssText = 'min-width:12px;color:#336655;';
const text = document.createElement('span');
text.textContent = label;
row.appendChild(check);
row.appendChild(text);
panel.appendChild(row);
_stepElements.push({ row, check, text, index: i });
});
panel.style.opacity = '1';
}
/**
* Advance the visual state of steps.
* activeStep = currently running step index (-1 = all done).
* completedSteps = array of step indices that are finished.
* Called on `job_step_update` WebSocket message.
*/
export function updateJobStep(activeStep, completedSteps) {
if (!$stepsPanel || _stepElements.length === 0) return;
const completedSet = new Set(completedSteps);
_stepElements.forEach(({ row, check, text, index }) => {
if (completedSet.has(index)) {
// Completed: green checkmark
check.textContent = '✓';
check.style.color = '#44cc88';
text.style.color = '#667788';
row.style.opacity = '0.7';
} else if (index === activeStep) {
// Active: pulsing indicator
check.textContent = '▶';
check.style.color = '#ffcc44';
text.style.color = '#eeddaa';
row.style.opacity = '1';
// Pulse animation via inline keyframe trick
row.style.animation = 'none';
void row.offsetHeight; // reflow
check.style.transition = 'opacity 0.5s';
} else {
// Pending
check.textContent = '○';
check.style.color = '#336655';
text.style.color = '#88bbdd';
row.style.opacity = '1';
}
});
}
/**
* Collapse the checklist panel to a single "Done" line, then fade out.
* Called on `job_completed` WebSocket message.
*/
export function collapseJobSteps() {
if (!$stepsPanel) return;
clearTimeout(_collapseTimer);
// Replace content with a Done summary
$stepsPanel.innerHTML = '';
const done = document.createElement('div');
done.textContent = '✓ Done';
done.style.cssText = 'color:#44cc88;font-weight:bold;letter-spacing:1px;';
$stepsPanel.appendChild(done);
$stepsPanel.style.opacity = '1';
// Fade out after 2.5 s
_collapseTimer = setTimeout(() => {
if ($stepsPanel) $stepsPanel.style.opacity = '0';
_stepElements = [];
}, 2500);
}

View File

@@ -1,5 +1,5 @@
import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js';
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js';
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker, showJobSteps, updateJobStep, collapseJobSteps } from './ui.js';
import { sentiment } from './edge-worker-client.js';
import { setLabelState } from './hud-labels.js';
@@ -105,6 +105,7 @@ function handleMessage(msg) {
setLabelState(msg.agentId, 'idle');
}
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`);
collapseJobSteps();
break;
}
@@ -140,6 +141,20 @@ function handleMessage(msg) {
break;
}
case 'job_steps': {
// Task decomposition (#5): render checklist panel with step labels
if (Array.isArray(msg.steps) && msg.steps.length > 0) {
showJobSteps(msg.steps);
}
break;
}
case 'job_step_update': {
// Task decomposition (#5): advance active step and mark completed ones
updateJobStep(msg.activeStep, msg.completedSteps || []);
break;
}
case 'cost_update': {
// Real-time cost ticker (#68): show estimated cost when job starts,
// update to final charged amount when job completes.