diff --git a/apps/hub/src/projects/deploy-tools.test.ts b/apps/hub/src/projects/deploy-tools.test.ts index 6e0ec6b4..618d036c 100644 --- a/apps/hub/src/projects/deploy-tools.test.ts +++ b/apps/hub/src/projects/deploy-tools.test.ts @@ -222,6 +222,7 @@ function writerTools(preferences: Record = {}) { ].map((item) => ({ ...item, sha256: "a".repeat(64) })); const deployed: string[] = []; const approvals: unknown[] = []; + const inFlight: Record[] = []; const fetch = async (path: string, init: RequestInit) => { const body = init.body ? JSON.parse(String(init.body)) : undefined; if (path.endsWith("/workflows/deployments")) { @@ -235,7 +236,7 @@ function writerTools(preferences: Record = {}) { }); } if (path.endsWith("/artifacts") && init.method === "GET") - return Response.json({ artifacts: documents, inFlight: [], preferences }); + return Response.json({ artifacts: documents, inFlight, preferences }); if (path.includes("/artifacts/") && init.method === "GET") { const source = documents.find((item) => path.includes("/artifacts/" + item["artifactId"]), @@ -287,7 +288,7 @@ function writerTools(preferences: Record = {}) { { id: "call", name, arguments: args }, new AbortController().signal, ); - return { run, deployed, approvals, documents }; + return { run, deployed, approvals, documents, inFlight }; } const writer = (kind: string) => ({ @@ -296,6 +297,48 @@ const writer = (kind: string) => ({ "Write the " + kind + " from the filed documents and the person's answers.", }); +test("an approval reports the steps whose gates it opened, unless one is under way", async () => { + const { run, documents, inFlight } = writerTools(); + const canStart = async (kinds: string[]) => { + const result = await run("request_approval", { kinds }); + return [JSON.parse(String(result.content)).canStart, result.detail]; + }; + + // The filed constraints are not approved yet, so approving the brief opens nothing. + expect(await canStart(["problem_brief"])).toEqual([[], undefined]); + inFlight.push({ runId: "w", role: "writer", status: "running" }); + expect(await canStart(["constraints"])).toEqual([[], undefined]); + inFlight.pop(); + expect(await canStart(["constraints"])).toEqual([ + ["approaches"], + { canStart: ["approaches"] }, + ]); + + for (const kind of ["approaches", "product_requirements"]) + documents.push({ + kind, + artifactId: kind, + title: kind, + version: 1, + sha256: "a".repeat(64), + }); + expect(await canStart(["approaches", "product_requirements"])).toEqual([ + ["design"], + { canStart: ["design"] }, + ]); + inFlight.push({ runId: "d", role: "designer", status: "running" }); + expect(await canStart(["product_requirements"])).toEqual([[], undefined]); + inFlight.pop(); + documents.push({ + kind: "design_option", + artifactId: "o", + title: "Calm", + version: 1, + sha256: "a".repeat(64), + }); + expect(await canStart(["product_requirements"])).toEqual([[], undefined]); +}); + test("one ask approves several documents, and a writer starts once every earlier one is approved", async () => { const { run, deployed, approvals } = writerTools(); diff --git a/apps/hub/src/projects/deploy-tools.ts b/apps/hub/src/projects/deploy-tools.ts index d7f152ec..aab3e421 100644 --- a/apps/hub/src/projects/deploy-tools.ts +++ b/apps/hub/src/projects/deploy-tools.ts @@ -380,9 +380,14 @@ export function createDeployTools(options?: { const materials: ArtifactRef[] = []; const approved = new Map(); const openQuestions = new Map(); + // Options a person has yet to choose from mean the design is already under way. + let designOptions = false; for (const item of list.artifacts) { const { kind, artifactId, version, sha256, approvedVersion } = item; - if (kind === "design_option") continue; + if (kind === "design_option") { + designOptions = true; + continue; + } if (kind === MATERIAL_KIND) materials.push({ kind, artifactId, version, sha256 }); byKind.set(kind, { artifactId, version, sha256 }); @@ -396,6 +401,7 @@ export function createDeployTools(options?: { materials, approved, openQuestions, + designOptions, inFlight: list.inFlight, preferences: list.preferences, }; @@ -430,18 +436,96 @@ export function createDeployTools(options?: { ) => hub(projectPath + "/artifact-approvals", { tool, artifacts }); const refsOf = (byKind: Map) => [...byKind].map(([kind, ref]) => ({ kind, ...ref })); + const notFiled = (kind: string) => + new Refusal( + kind + + "_not_filed: " + + (kind === "design" + ? "have the designer make it with design_app first" + : "write and file the " + kind + " first"), + ); const required = (byKind: Map, kind: string) => { const ref = byKind.get(kind); - if (!ref) - throw new Refusal( - kind + - "_not_filed: " + - (kind === "design" - ? "have the designer make it with design_app first" - : "write and file the " + kind + " first"), - ); + if (!ref) throw notFiled(kind); return ref; }; + type Listed = Awaited>; + /* + * Each start tool refuses with its gate's answer, and an approval reports the steps whose + * gates then pass. The coordinator learns what it may start from the rule that enforces it, + * so loosening a gate loosens what it is told. + */ + const writerGate = ( + { byKind, approved, preferences }: Listed, + kind: (typeof DOCUMENT_ORDER)[number], + ): Refusal | undefined => { + if (!byKind.has("problem_brief")) return notFiled("problem_brief"); + const earlier = DOCUMENT_ORDER.slice(0, DOCUMENT_ORDER.indexOf(kind)); + // An imported project may lack an earlier document that its later ones were written without. + const writtenWithout = (index: number) => + DOCUMENT_ORDER.slice(index + 1).some((later) => byKind.has(later)); + for (const [index, before] of earlier.entries()) { + const ref = byKind.get(before); + if (!ref) { + if (writtenWithout(index)) continue; + return new Refusal( + before + + "_missing: " + + (before === "design" + ? "have the designer make it with design_app" + : "write and file the " + before) + + " before the " + + kind, + ); + } + if ( + !agentDecidesApprovals(preferences) && + approved.get(before) !== ref.version + ) + return notApproved(before, ref.version); + } + return undefined; + }; + const designGate = ({ byKind }: Listed) => { + const missing = DESIGNER_INPUTS.find((kind) => !byKind.has(kind)); + return missing === undefined ? undefined : notFiled(missing); + }; + const coderGate = ({ byKind, approved, preferences }: Listed) => { + const missing = CODER_DOCUMENTS.find((kind) => !byKind.has(kind)); + if (missing !== undefined) return notFiled(missing); + for (const kind of DOCUMENT_ORDER) { + const ref = byKind.get(kind); + if (!ref) return notFiled(kind); + // The design is approved by choosing it, whoever decides the other documents. + if ( + (kind === "design" || !agentDecidesApprovals(preferences)) && + approved.get(kind) !== ref.version + ) + return notApproved(kind, ref.version); + } + return undefined; + }; + // A step already made or under way is not offered again, whatever its gate says. + const startable = (state: Listed) => { + const busy = new Set(state.inFlight.map((run) => run.role)); + const open = ( + step: string, + role: string, + gate: Refusal | undefined, + ) => !state.byKind.has(step) && !busy.has(role) && gate === undefined; + return [ + ...DOCUMENT_ORDER.filter( + (kind) => + kind in WRITER_PACKAGES && + open(kind, "writer", writerGate(state, kind)), + ), + ...(!state.designOptions && + open("design", "designer", designGate(state)) + ? ["design"] + : []), + ...(open("app", "coder", coderGate(state)) ? ["app"] : []), + ]; + }; // A fresh deployment warms its workspace with the current versions, the ones named here. const inputs = ( kinds: readonly InputKind[], @@ -504,10 +588,11 @@ export function createDeployTools(options?: { try { signal.throwIfAborted(); let result: unknown; + let canStart: readonly string[] = []; if (call.name === "request_approval") { const { kinds } = validArguments(RequestApproval, call.arguments); - const { byKind, materials, openQuestions, preferences } = - await listed(); + const state = await listed(); + const { byKind, materials, openQuestions, preferences } = state; const open = [...new Set(kinds)].flatMap((kind) => { const count = openQuestions.get(kind); return count @@ -530,38 +615,17 @@ export function createDeployTools(options?: { return materials; }); await recordApproval("request_approval", documents); - result = { approved: documents }; + for (const { kind, version } of documents) + state.approved.set(kind, version); + canStart = startable(state); + result = { approved: documents, canStart }; } else if (call.name === "deploy_writer") { const input = validArguments(DeployWriter, call.arguments); - const { byKind, approved, preferences } = await listed(); + const state = await listed(); + const { byKind, preferences } = state; + const refusal = writerGate(state, input.kind); + if (refusal) throw refusal; const briefRef = required(byKind, "problem_brief"); - const earlier = DOCUMENT_ORDER.slice( - 0, - DOCUMENT_ORDER.indexOf(input.kind), - ); - // An imported project may lack an earlier document that its later ones were written without. - const writtenWithout = (index: number) => - DOCUMENT_ORDER.slice(index + 1).some((kind) => byKind.has(kind)); - for (const [index, kind] of earlier.entries()) { - const ref = byKind.get(kind); - if (!ref) { - if (writtenWithout(index)) continue; - throw new Refusal( - kind + - "_missing: " + - (kind === "design" - ? "have the designer make it with design_app" - : "write and file the " + kind) + - " before the " + - input.kind, - ); - } - if ( - !agentDecidesApprovals(preferences) && - approved.get(kind) !== ref.version - ) - throw notApproved(kind, ref.version); - } const target = byKind.get(input.kind); const sourceRefs = [...byKind] .filter( @@ -623,9 +687,8 @@ export function createDeployTools(options?: { options.projectId + ":coder", "coder", async () => { - const { byKind, approved, inFlight, preferences } = - await listed(); - for (const kind of CODER_DOCUMENTS) required(byKind, kind); + const state = await listed(); + const { byKind, inFlight, preferences } = state; const live = inFlight.find((run) => run.role === "coder"); if (live) throw new Refusal( @@ -633,16 +696,8 @@ export function createDeployTools(options?: { live.runId + "; send it changes with instruct_coder", ); - // The design is approved by choosing it, whoever decides the other documents. - for (const kind of DOCUMENT_ORDER) { - const { version } = required(byKind, kind); - if ( - (kind === "design" || - !agentDecidesApprovals(preferences)) && - approved.get(kind) !== version - ) - throw notApproved(kind, version); - } + const refusal = coderGate(state); + if (refusal) throw refusal; const design = required(byKind, "design"); return deploy( "coder", @@ -696,8 +751,10 @@ export function createDeployTools(options?: { options.projectId + ":designer:" + concept.label, "designer", async () => { - const { byKind, inFlight, preferences } = await listed(); - for (const kind of DESIGNER_INPUTS) required(byKind, kind); + const state = await listed(); + const { byKind, inFlight, preferences } = state; + const refusal = designGate(state); + if (refusal) throw refusal; const message = designLookIdeas(input.lookIdeas) + coderTaskMessage(input.instruction); @@ -832,7 +889,11 @@ export function createDeployTools(options?: { content: "unknown_deploy_tool", isError: true, }; - return { callId: call.id, content: JSON.stringify(result) }; + return { + callId: call.id, + content: JSON.stringify(result), + ...(canStart.length ? { detail: { canStart } } : {}), + }; } catch (error) { return { callId: call.id, diff --git a/apps/hub/src/workflows/director.test.ts b/apps/hub/src/workflows/director.test.ts index 00a74f15..ccf01ae3 100644 --- a/apps/hub/src/workflows/director.test.ts +++ b/apps/hub/src/workflows/director.test.ts @@ -297,6 +297,125 @@ test("designer director holds replies until the latest edits pass check_design a expect(accepted.some((action) => action.type === "reply")).toBe(true); }); +test("coordinator director reminds once after an approval that opened a step, unless the reply asks the person", async () => { + const subject = () => + compactingDirector("@corbits-builder/coordinator-test").factory( + { contextWindow: 200_000, compactAt: 0.8, startAfterApproval: true }, + {} as never, + { systemPrompt: "Base prompt.", toolDefinitions: [], compactorNames: [] }, + ) as ReactorDirector; + const reply = (text: string) => ({ + role: "assistant" as const, + model: "gpt-6.1-terra", + content: [{ type: "text" as const, text }], + timestamp: 5, + }); + const replied = (text: string): ReactorInboundEvent => ({ + type: "inference.done", + turn: reply(text), + usage: state.tokenUsage, + source: { sourceId: "openai", provider: "openai", model: "gpt-6.1-terra" }, + }); + const approval = ( + extra: { detail?: unknown; isError?: boolean } = {}, + ): ConversationTurn[] => [ + { + role: "user", + content: [{ type: "text", text: "Go with your recommendation." }], + timestamp: 0, + }, + { + role: "assistant", + content: [ + { type: "tool_call", id: "a", name: "request_approval", arguments: {} }, + ], + timestamp: 1, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "a", + content: [{ type: "text", text: "{}" }], + ...extra, + }, + ], + timestamp: 2, + }, + ]; + const opened = { detail: { canStart: ["product_requirements"] } }; + const then = (name: string): ConversationTurn[] => [ + { + role: "assistant", + content: [{ type: "tool_call", id: "r", name, arguments: {} }], + timestamp: 3, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "r", + content: [{ type: "text", text: "{}" }], + }, + ], + timestamp: 4, + }, + ]; + const holds = async ( + turns: ConversationTurn[], + text = "It is ready for approval.", + director = subject(), + ) => { + const actions = await director.decide( + replied(text), + { ...state, turns: [...turns, reply(text)] }, + createCapabilities(), + ); + return (Array.isArray(actions) ? actions : [actions]).some( + (action) => action.type === "compact", + ); + }; + + expect(await holds([...approval(opened), ...then("read_artifact")])).toBe( + true, + ); + expect(await holds(approval())).toBe(false); + expect(await holds([...approval(opened), ...then("deploy_writer")])).toBe( + false, + ); + expect(await holds(approval({ ...opened, isError: true }))).toBe(false); + expect( + await holds( + approval(opened), + "Do you want one design or several? Tell me any apps or colours you like.", + ), + ).toBe(false); + + const director = subject(); + const turns = approval(opened); + expect(await holds(turns, undefined, director)).toBe(true); + const retried = await director.decide( + { + type: "compaction.done", + compactor: "@corbits-builder/drop-degenerate-turn", + reason: "reply before done", + }, + { ...state, turns }, + createCapabilities(), + ); + const infer = (Array.isArray(retried) ? retried : [retried]).find( + (action) => action.type === "infer", + ); + expect(infer?.type === "infer" ? infer.options?.systemPrompt : "").toContain( + "these can start now: product_requirements", + ); + expect(await holds(turns, "I will wait until you are ready.", director)).toBe( + false, + ); +}); + test("coder director takes a check-app verdict from the result's detail after trimming drops its text", async () => { const subject = compactingDirector("@corbits-builder/coder-test").factory( { contextWindow: 200_000, compactAt: 0.8, checkBeforeDone: true }, diff --git a/apps/hub/src/workflows/director.ts b/apps/hub/src/workflows/director.ts index 20132d36..f44b7176 100644 --- a/apps/hub/src/workflows/director.ts +++ b/apps/hub/src/workflows/director.ts @@ -142,6 +142,7 @@ function taskCalls(turns: readonly ConversationTurn[]) { name: string; command: unknown; check: CheckDetail | undefined; + detail: unknown; isError: boolean; }[] = []; for (let index = turns.length - 1; index >= 0; index--) { @@ -163,6 +164,7 @@ function taskCalls(turns: readonly ConversationTurn[]) { name: block.name, command: (block.arguments as Record)["command"], check: readCheckDetail(result?.detail), + detail: result?.detail, isError: result?.isError ?? true, }); } @@ -209,9 +211,43 @@ function unfiled(turns: readonly ConversationTurn[]): boolean { return true; } +const READ_TOOLS = new Set(["read_artifact", "read_skill", "read_file"]); +const ApprovalDetail = type({ canStart: "string[] > 0" }); + +/* + * A model often answers an approval by saying the document is ready for approval and starting + * nothing. The steps the approval opened are still waiting while only reads follow it. + */ +function unstartedSteps( + turns: readonly ConversationTurn[], +): string[] | undefined { + // A reply that asks the person something hands the turn to them on purpose. + const asks = turns + .at(-1) + ?.content.some( + (block) => block.type === "text" && /[??]/.test(block.text), + ); + if (asks) return undefined; + for (const call of taskCalls(turns)) { + if (call.name === "request_approval") { + const detail = ApprovalDetail(call.detail); + return call.isError || detail instanceof type.errors + ? undefined + : detail.canStart; + } + if (!READ_TOOLS.has(call.name)) return undefined; + } + return undefined; +} + function holdReply( - config: { checkBeforeDone?: boolean; fileBeforeDone?: boolean }, + config: { + checkBeforeDone?: boolean; + fileBeforeDone?: boolean; + startAfterApproval?: boolean; + }, turns: readonly ConversationTurn[], + held: number, ): string | undefined { if (config.checkBeforeDone && unchecked(turns)) return "Files changed after the last passing check-app, so the work is not done yet."; @@ -220,6 +256,11 @@ function holdReply( return "The design option is not filed yet; check_design or file_design_option files it."; return "The document is not filed yet; write_artifact files it."; } + // The person may have asked to wait, so the approval is a reminder once rather than a rule. + const steps = + config.startAfterApproval && held === 0 ? unstartedSteps(turns) : undefined; + if (steps) + return `The approval went through, so these can start now: ${steps.join(", ")}. Start what the person wants next, unless they asked to wait.`; return undefined; } @@ -230,10 +271,11 @@ function holdReply( * turn that only repeats one sentence is dropped and sampled again, as is a stream the source adapter * cut for repeating one passage. The third such turn in one task fails the run, or with * replyOnFailure is the reply. With checkBeforeDone, a final reply waits for a passing check-app - * after the last file change; with fileBeforeDone, it waits for a filed document. Every inference - * retries an overloaded, slow or rate-limited model a few times; once that gives up, the run - * fails, or with replyOnFailure the error is the reply. The model-specific numbers come from the - * agent definition's director config. + * after the last file change; with fileBeforeDone, it waits for a filed document; with + * startAfterApproval, a reply after an approval that starts and asks nothing is held once. Every + * inference retries an overloaded, slow or rate-limited model a few times; once that gives up, the + * run fails, or with replyOnFailure the error is the reply. The model-specific numbers come from + * the agent definition's director config. */ export const compactingDirector = (packageName: string) => defineDirector<{ @@ -243,6 +285,8 @@ export const compactingDirector = (packageName: string) => checkBeforeDone?: boolean; // Whether a final reply waits for a write_artifact that filed the task's document. fileBeforeDone?: boolean; + // Whether a reply after an approval that starts and asks nothing is held once. + startAfterApproval?: boolean; // Whether a runaway or a model failure is the reply rather than a failed run. replyOnFailure?: boolean; }>({ @@ -252,6 +296,7 @@ export const compactingDirector = (packageName: string) => compactAt: "0 < number < 1", "checkBeforeDone?": "boolean", "fileBeforeDone?": "boolean", + "startAfterApproval?": "boolean", "replyOnFailure?": "boolean", }), factory: (config, env, agent) => { @@ -334,7 +379,7 @@ export const compactingDirector = (packageName: string) => (block) => block.type === "tool_call", ); const notDone = replying - ? holdReply(config, state.turns) + ? holdReply(config, state.turns, uncheckedReplies) : undefined; if (notDone) { uncheckedReplies += 1; diff --git a/apps/hub/src/workflows/entries/coordinator.ts b/apps/hub/src/workflows/entries/coordinator.ts index 66289b1c..c72ea639 100644 --- a/apps/hub/src/workflows/entries/coordinator.ts +++ b/apps/hub/src/workflows/entries/coordinator.ts @@ -16,6 +16,7 @@ const agent = createCoordinatorAgent({ director: compacting.build({ contextWindow, compactAt: 0.6, + startAfterApproval: true, // The coordinator's run is the project's; nothing starts a failed one again. replyOnFailure: true, }), diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 9de7e4b4..d9a2d8ae 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -510,21 +510,6 @@ export const wording = { review_artifact: (document?: string) => document ? `Asked for a review · ${document}` : "Asked for a review", } as Record string>, - /** A hand-off the hub refused, by tool name: who it did not start. */ - handoffRefused: { - deploy_writer: (document?: string) => - document - ? `Could not start the writer · ${document}` - : "Could not start the writer", - design_app: () => "Could not start the designer", - deploy_coder: () => "Could not start the app builder", - deploy_presenter: () => "Could not start the presenter", - instruct_coder: () => "Could not send the app builder a change", - review_artifact: (document?: string) => - document - ? `Could not ask for a review · ${document}` - : "Could not ask for a review", - } as Record string>, /** What a tool call is doing, by tool name, given its one identifying argument. */ tool: { read_file: (subject?: string) => @@ -723,8 +708,6 @@ export const wording = { timeout: "Not started — the go-ahead ran out of time", expired: "Not started — the go-ahead expired", }, - /** The hub refused the hand-off, so nothing started. */ - refused: "Not started", starting: "Starting…", /** At work before its run reports a step of its own. */ working: (subject: string) => `Working on ${subject}`, @@ -996,7 +979,7 @@ export const wording = { coordinatorNotStarted: "The coordinator for this project did not start.", projectNotOpened: "The project could not be opened.", projectLoading: "Loading this project…", - projectEmpty: "Describe what you want built to start a project.", + projectTooShort: "At least ten characters to start a project.", stepFailed: "That step could not finish.", }, /** The coordinator's questions as a card the person answers in the conversation. */ diff --git a/apps/web/src/pages/home-view.ts b/apps/web/src/pages/home-view.ts index bfd751d1..d32d7256 100644 --- a/apps/web/src/pages/home-view.ts +++ b/apps/web/src/pages/home-view.ts @@ -2,7 +2,7 @@ export const HOME_COMPOSER_PLACEHOLDER = "Describe the thing you want built…"; -/** Any idea starts a project; only an empty box does not. */ +/** Ten characters is the create gate. */ export function canStartProject(problem: string): boolean { - return problem.trim().length > 0; + return problem.trim().length >= 10; } diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index f07df24e..a3032f9c 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -51,7 +51,7 @@ export function Projects({ if (busy !== null) return; if (shell.answers(problem)) return setProblem(""); if (!canStartProject(problem)) { - toast.error(wording.message.projectEmpty); + toast.error(wording.message.projectTooShort); return; } setBusy("start"); diff --git a/apps/web/src/pages/workspace/work-unit-view.tsx b/apps/web/src/pages/workspace/work-unit-view.tsx index c3b6a746..19e92a16 100644 --- a/apps/web/src/pages/workspace/work-unit-view.tsx +++ b/apps/web/src/pages/workspace/work-unit-view.tsx @@ -109,7 +109,6 @@ function WorkLine({ return text.steps(text.done[dispatch.role](subject), steps); case "finished": case "failed": - if (dispatch.refused !== undefined) return text.refused; return text.steps(text.ended(subject), steps); } } diff --git a/apps/web/src/pages/workspace/work-unit.ts b/apps/web/src/pages/workspace/work-unit.ts index e131973b..d82195ea 100644 --- a/apps/web/src/pages/workspace/work-unit.ts +++ b/apps/web/src/pages/workspace/work-unit.ts @@ -200,11 +200,7 @@ export function workUnitMessage( !dispatch.pending && status.state !== "waiting" && status.state !== "declined"; - const handoff = ( - dispatch.refused === undefined - ? wording.activity.handoff - : wording.activity.handoffRefused - )[dispatch.tool]; + const handoff = wording.activity.handoff[dispatch.tool]; const parts: ConversationPart[] = [ { kind: "tool-trace", diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 9e1832db..663f3278 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -98,8 +98,8 @@ tips**) and your **Account**, with **Sign out**. From **New project** in the sidebar you see "What do you want to build today?" -1. Type your idea in the box that says "Describe the thing you want built…". - You can also dictate with the microphone. +1. Type your idea in the box that says "Describe the thing you want built…". It + needs at least ten characters. You can also dictate with the microphone. 2. The **Model profile** picker in the box shows the profile the project will use. Change it here if you want a different one. 3. Send it. You see "Starting your project…" and then the project opens. @@ -379,8 +379,8 @@ provider's status in **Settings → Providers**, then send your message again. whoever runs it to restart it. - **"Builder is responding slowly"**: everything waits on the server. If it stays slow, restart it. -- **"Describe what you want built to start a project."**: the box is empty; - type your idea first. +- **"At least ten characters to start a project."**: describe the idea in a + little more detail. - **"No model provider connected"**: choose **Connect a model provider**. - **"The provider refused this key."**: check the key and test again. - **"The provider did not answer with a model list."**: check the **Base URL**