diff --git a/package-lock.json b/package-lock.json index 78c2b4ec..dde37ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dreb", - "version": "2.70.0", + "version": "2.70.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dreb", - "version": "2.70.0", + "version": "2.70.1", "workspaces": [ "packages/*", "packages/coding-agent/examples/extensions/with-deps", @@ -10934,7 +10934,7 @@ }, "packages/agent": { "name": "@dreb/agent-core", - "version": "2.70.0", + "version": "2.70.1", "license": "MIT", "dependencies": { "@dreb/ai": "*" @@ -10963,7 +10963,7 @@ }, "packages/ai": { "name": "@dreb/ai", - "version": "2.70.0", + "version": "2.70.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -11109,7 +11109,7 @@ }, "packages/coding-agent": { "name": "@dreb/coding-agent", - "version": "2.70.0", + "version": "2.70.1", "license": "MIT", "dependencies": { "@dreb/agent-core": "*", @@ -11238,7 +11238,7 @@ }, "packages/dashboard": { "name": "@dreb/dashboard", - "version": "2.70.0", + "version": "2.70.1", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11470,7 +11470,7 @@ }, "packages/semantic-search": { "name": "@dreb/semantic-search", - "version": "2.70.0", + "version": "2.70.1", "license": "MIT", "dependencies": { "@huggingface/transformers": "^4.0.1", @@ -11519,7 +11519,7 @@ }, "packages/telegram": { "name": "@dreb/telegram", - "version": "2.70.0", + "version": "2.70.1", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11552,7 +11552,7 @@ }, "packages/tui": { "name": "@dreb/tui", - "version": "2.70.0", + "version": "2.70.1", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", diff --git a/package.json b/package.json index 50cf74e2..6412a0de 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "node": "22.x" }, "packageManager": "npm@11.5.1", - "version": "2.70.0", + "version": "2.70.1", "dependencies": { "@dreb/coding-agent": "*", "@mariozechner/jiti": "^2.6.5", diff --git a/packages/agent/package.json b/packages/agent/package.json index f26634f9..a42e8c6f 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/agent-core", - "version": "2.70.0", + "version": "2.70.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/package.json b/packages/ai/package.json index 7e5d027a..84820426 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/ai", - "version": "2.70.0", + "version": "2.70.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 5de2eb23..6540fbbc 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -678,7 +678,7 @@ cat README.md | dreb -p "Summarize this text" Available standard tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `watch_github_ci`, `ask_user` -`ask_user` pauses the turn and asks you one or more structured clarifying questions in a single call — batched together and answered as one wizard. Each question has Markdown-formatted question text, optional 2-4 multiple-choice options (single- or multi-select), and a "type your own answer" field — rendered natively in the TUI and the Dashboard, and over RPC. Submitting sends the whole batch of structured answers back at once; choosing **Stop agent** or pressing `Esc` aborts the whole current turn rather than continuing without an answer. Options must contain nonblank text. When a question has no options its free-text field is always shown, so every question is answerable. Unanswered questions come back flagged as skipped rather than blocking the turn. An optional `timeoutSeconds` auto-stops the turn after the given number of seconds with a live countdown on every surface; recovered Dashboard sessions retain the original deadline rather than restarting it. Tool abort, timeout, host teardown, and headless/no-UI modes settle cleanly, so the agent never deadlocks waiting on an absent user. UI or response-protocol failures remain distinct from question closure. In the TUI: `↑`/`↓` move between questions, their options, and the free-text fields, `Space` toggles a checkbox (multi-select), `Enter` submits the batch, and `Esc` stops the turn. In the Dashboard: native radios/checkboxes plus a text field per question, an in-card **Stop agent** button that remains accessible on mobile, and `Esc` to stop. +`ask_user` pauses the turn and asks you one or more structured clarifying questions in a single call — batched together and answered as one wizard. Each question has Markdown-formatted question text, optional 2-4 multiple-choice options (single- or multi-select), and a "type your own answer" field — rendered natively in the TUI and the Dashboard, and over RPC. Submitting sends the whole batch of structured answers back at once; choosing **Stop agent** or pressing `Esc` aborts the whole current turn rather than continuing without an answer. Options must contain nonblank text. Options may also be sent as `{label, description}` objects (shown as `label — description`), `header` is accepted in place of a question's `title`, and `questions` may arrive as a JSON-encoded string; all are converted to the standard shape before display. When a question has no options its free-text field is always shown, so every question is answerable. Unanswered questions come back flagged as skipped rather than blocking the turn. An optional `timeoutSeconds` auto-stops the turn after the given number of seconds with a live countdown on every surface; recovered Dashboard sessions retain the original deadline rather than restarting it. Tool abort, timeout, host teardown, and headless/no-UI modes settle cleanly, so the agent never deadlocks waiting on an absent user. UI or response-protocol failures remain distinct from question closure. In the TUI: `↑`/`↓` move between questions, their options, and the free-text fields, `Space` toggles a checkbox (multi-select), `Enter` submits the batch, and `Esc` stops the turn. In the Dashboard: native radios/checkboxes plus a text field per question, an in-card **Stop agent** button that remains accessible on mobile, and `Esc` to stop. Three additional tools are always active and don't need to appear in `--tools`: - `search` — semantic codebase search diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index ed8549e5..e04a7e09 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/coding-agent", - "version": "2.70.0", + "version": "2.70.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "drebConfig": { diff --git a/packages/coding-agent/src/core/tools/ask-user.ts b/packages/coding-agent/src/core/tools/ask-user.ts index 20fab464..903283dc 100644 --- a/packages/coding-agent/src/core/tools/ask-user.ts +++ b/packages/coding-agent/src/core/tools/ask-user.ts @@ -13,6 +13,7 @@ import { Text } from "@dreb/tui"; import { type Static, Type } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; import type { AskQuestion, AskRequest, AskResult, ExtensionContext, ToolDefinition } from "../extensions/types.js"; // ============================================================================ @@ -40,17 +41,37 @@ export interface AskUserDetails { // ============================================================================ // Schema +/** Nonblank text: at least one non-whitespace character (llama.cpp-parseable pattern). */ +const NONBLANK_PATTERN = "^.*[^ \\t\\r\\n].*$"; + +/** + * One option: plain text, or a labeled object (the shape many models are + * trained on). Objects are folded back to plain text before reaching any UI. + */ +const optionSchema = Type.Union([ + Type.String({ minLength: 1, pattern: NONBLANK_PATTERN }), + Type.Object({ + label: Type.String({ minLength: 1, pattern: NONBLANK_PATTERN }), + description: Type.Optional(Type.String()), + }), +]); + const questionSchema = Type.Object({ question: Type.String({ description: "The Markdown-formatted question to ask the user. Be specific about what you need to decide.", }), title: Type.Optional( Type.String({ - description: "Short bold header shown above this question.", + description: "Short bold title shown above this question.", + }), + ), + header: Type.Optional( + Type.String({ + description: "Alias for title; title wins when both are given.", }), ), options: Type.Optional( - Type.Array(Type.String({ minLength: 1, pattern: "^.*[^ \\t\\r\\n].*$" }), { + Type.Array(optionSchema, { minItems: 2, maxItems: 4, description: "2-4 nonblank suggested answers the user can pick from.", @@ -73,14 +94,18 @@ const questionSchema = Type.Object({ ), }); +const questionsArraySchema = Type.Array(questionSchema, { + minItems: 1, + maxItems: 10, + description: + "One or more clarifying questions to ask together in a single wizard. " + + "Batch every question you need answered in one call — the user answers them all and submits once.", +}); + const askUserSchema = Type.Object({ - questions: Type.Array(questionSchema, { - minItems: 1, - maxItems: 10, - description: - "One or more clarifying questions to ask together in a single wizard. " + - "Batch every question you need answered in one call — the user answers them all and submits once.", - }), + // A JSON-encoded string of the array is also accepted; it is parsed and + // checked against the same array schema in execute. + questions: Type.Union([questionsArraySchema, Type.String()]), timeoutSeconds: Type.Optional( Type.Number({ minimum: 5, @@ -94,20 +119,55 @@ const askUserSchema = Type.Object({ export type AskUserInput = Static; type AskUserQuestion = Static; +type AskUserOption = Static; // ============================================================================ // Normalization +/** Fold an option to plain text: "label" or "label — description". */ +function optionText(option: AskUserOption): string { + if (typeof option === "string") return option; + const description = option.description?.trim(); + return description ? `${option.label} — ${description}` : option.label; +} + +/** + * Resolve `questions` to an array. A JSON-encoded string is parsed and checked + * against the same schema as a real array; anything invalid throws. + */ +function resolveQuestions(questions: AskUserInput["questions"]): AskUserQuestion[] { + if (typeof questions !== "string") return questions; + let parsed: unknown; + try { + parsed = JSON.parse(questions); + } catch (error) { + throw new Error( + `ask_user: \`questions\` was a string but not valid JSON (${error instanceof Error ? error.message : String(error)}). Pass \`questions\` as an array of question objects.`, + ); + } + if (!Value.Check(questionsArraySchema, parsed)) { + const errors = [...Value.Errors(questionsArraySchema, parsed)] + .slice(0, 10) + .map((e) => ` - ${e.path || "questions"}: ${e.message}`) + .join("\n"); + throw new Error( + `ask_user: \`questions\` was a JSON string that is not a valid questions array:\n${errors}\nPass \`questions\` as an array of question objects.`, + ); + } + return parsed; +} + /** * Normalize a single question so every rendered surface has at least one usable * answer control and no impossible flag combinations survive. */ function normalizeQuestion(q: AskUserQuestion): AskQuestion { - const hasOptions = (q.options?.length ?? 0) > 0; + const options = q.options?.map(optionText); + const hasOptions = (options?.length ?? 0) > 0; return { question: q.question, - title: q.title, - options: q.options, + title: q.title ?? q.header, + options, // Guarantee at least one answer control: with no options, free text must // be offered regardless of the requested flag, otherwise a question would // render only a Skip control and no way to answer. @@ -186,21 +246,22 @@ function answeredResult(questions: AskQuestion[], result: AskResult) { // ============================================================================ // Render helpers -function callLabel(args: { questions?: Array<{ title?: string; question?: string }> } | undefined): string { - const questions = args?.questions ?? []; +type CallArgs = { questions?: Array<{ title?: string; header?: string; question?: string }> | string } | undefined; + +function callLabel(args: CallArgs): string { + // While streaming (or for a JSON-string batch) questions may not be an array yet. + const questions = Array.isArray(args?.questions) ? args.questions : []; + if (typeof args?.questions === "string") return "questions"; const count = questions.length; if (count === 1) { const first = questions[0]; - const label = (first?.title || first?.question || "").replace(/\s+/g, " ").trim(); + const label = (first?.title || first?.header || first?.question || "").replace(/\s+/g, " ").trim(); return label.length > 80 ? `${label.slice(0, 79)}…` : label; } return `${count} questions`; } -function formatCall( - args: { questions?: Array<{ title?: string; question?: string }> } | undefined, - theme: any, -): string { +function formatCall(args: CallArgs, theme: any): string { return `${theme.fg("toolTitle", theme.bold("ask_user"))} ${theme.fg("accent", callLabel(args))}`; } @@ -247,13 +308,13 @@ export function createAskUserToolDefinition(): ToolDefinition { it("validates the questions batch schema (1-10 questions, 2-4 nonblank options)", () => { const def = createAskUserToolDefinition(); const props = (def.parameters as any).properties; - expect(props.questions.minItems).toBe(1); - expect(props.questions.maxItems).toBe(10); - const question = props.questions.items; + // questions is the array, or a JSON-encoded string of it (parsed and checked in execute). + const [questionsArray, questionsString] = props.questions.anyOf; + expect(questionsString.type).toBe("string"); + expect(questionsArray.minItems).toBe(1); + expect(questionsArray.maxItems).toBe(10); + const question = questionsArray.items; expect(question.properties.options.minItems).toBe(2); expect(question.properties.options.maxItems).toBe(4); - expect(question.properties.options.items.pattern).toBe("^.*[^ \\t\\r\\n].*$"); + // Each option is a nonblank string or a {label, description?} object; both + // use the llama.cpp-parseable nonblank pattern. + const [stringOption, objectOption] = question.properties.options.items.anyOf; + expect(stringOption.pattern).toBe("^.*[^ \\t\\r\\n].*$"); + expect(objectOption.properties.label.pattern).toBe("^.*[^ \\t\\r\\n].*$"); expect(question.properties.question.type).toBe("string"); expect(Value.Check(def.parameters, { questions: [{ question: "Pick", options: ["A", "B"] }] })).toBe(true); expect(Value.Check(def.parameters, { questions: [{ question: "Pick", options: ["", "B"] }] })).toBe(false); @@ -60,6 +67,119 @@ describe("ask_user tool", () => { expect(Value.Check(def.parameters, { questions: [] })).toBe(false); }); + it("accepts labeled-object options and a header alias in its schema", () => { + const def = createAskUserToolDefinition(); + const ok = (q: Record) => Value.Check(def.parameters, { questions: [q] }); + expect(ok({ question: "Pick", options: [{ label: "A" }, { label: "B", description: "why B" }] })).toBe(true); + expect(ok({ question: "Pick", options: ["A", { label: "B" }] })).toBe(true); + expect(ok({ question: "Pick", header: "Choice" })).toBe(true); + // Invalid shapes still fail. + expect(ok({ question: "Pick", options: [{ label: "" }, { label: "B" }] })).toBe(false); + expect(ok({ question: "Pick", options: [{ label: " " }, { label: "B" }] })).toBe(false); + expect(ok({ question: "Pick", options: [{ description: "no label" }, { label: "B" }] })).toBe(false); + expect(ok({ question: "Pick", options: [{ label: "A" }] })).toBe(false); + expect(ok({ question: "Pick", options: ["A", "B", "C", "D", { label: "E" }] })).toBe(false); + expect(ok({ question: "Pick", options: [42, "B"] })).toBe(false); + }); + + it("folds labeled-object options into plain text before they reach the UI", async () => { + const def = createAskUserToolDefinition(); + let received: AskRequest | undefined; + const ctx = makeCtx(async (request) => { + received = request; + return { answers: [{ selected: ["PostgreSQL — Full SQL server"] }] }; + }); + const result = await run( + def, + { + questions: [ + { + question: "DB?", + options: [ + { label: "SQLite" }, + { label: "PostgreSQL", description: "Full SQL server" }, + { label: "JSON", description: " " }, + "Other", + ], + }, + ], + }, + ctx, + ); + expect(received?.questions[0].options).toEqual(["SQLite", "PostgreSQL — Full SQL server", "JSON", "Other"]); + expect(result.details?.answers[0].selected).toEqual(["PostgreSQL — Full SQL server"]); + }); + + it("leaves plain-string options unchanged", async () => { + const def = createAskUserToolDefinition(); + let received: AskRequest | undefined; + const ctx = makeCtx(async (request) => { + received = request; + return { answers: [{ selected: ["A"] }] }; + }); + await run(def, { questions: [{ question: "?", options: ["A", "B"] }] }, ctx); + expect(received?.questions[0].options).toEqual(["A", "B"]); + }); + + it("uses header as the title, with title winning when both are given", async () => { + const def = createAskUserToolDefinition(); + let received: AskRequest | undefined; + const ctx = makeCtx(async (request) => { + received = request; + return { + answers: [ + { selected: [], customText: "x" }, + { selected: [], customText: "y" }, + ], + }; + }); + await run( + def, + { + questions: [ + { question: "One?", header: "From header" }, + { question: "Two?", header: "Ignored", title: "From title" }, + ], + }, + ctx, + ); + expect(received?.questions[0].title).toBe("From header"); + expect(received?.questions[1].title).toBe("From title"); + expect(received?.questions[0]).not.toHaveProperty("header"); + }); + + it("accepts questions sent as a JSON-encoded string", async () => { + const def = createAskUserToolDefinition(); + expect(Value.Check(def.parameters, { questions: "[]" })).toBe(true); + let received: AskRequest | undefined; + const ctx = makeCtx(async (request) => { + received = request; + return { answers: [{ selected: ["B"] }] }; + }); + const questions = JSON.stringify([{ question: "Pick", header: "H", options: [{ label: "A" }, "B"] }]); + const result = await run(def, { questions }, ctx); + expect(received?.questions).toEqual([ + expect.objectContaining({ question: "Pick", title: "H", options: ["A", "B"] }), + ]); + expect(result.details?.answers[0]).toMatchObject({ selected: ["B"], skipped: false }); + }); + + it("fails loudly when a JSON-string questions value is invalid", async () => { + const def = createAskUserToolDefinition(); + const ask = vi.fn(); + const ctx = makeCtx(ask); + await expect(run(def, { questions: "not json" }, ctx)).rejects.toThrow(/not valid JSON/); + await expect(run(def, { questions: '{"question":"x"}' }, ctx)).rejects.toThrow(/not a valid questions array/); + await expect(run(def, { questions: "[]" }, ctx)).rejects.toThrow(/not a valid questions array/); + await expect( + run(def, { questions: JSON.stringify([{ question: "x", options: ["only one"] }]) }, ctx), + ).rejects.toThrow(/not a valid questions array/); + await expect( + run(def, { questions: JSON.stringify([{ question: "x", options: [{ label: "" }, "B"] }]) }, ctx), + ).rejects.toThrow(/not a valid questions array/); + expect(ask).not.toHaveBeenCalled(); + }); + it("exposes a validated optional timeoutSeconds field in its schema", () => { const def = createAskUserToolDefinition(); const props = (def.parameters as any).properties; @@ -328,6 +448,18 @@ describe("ask_user tool", () => { expect(result).toBeDefined(); }); + it("renders call labels for header and JSON-string questions without throwing", () => { + const def = createAskUserToolDefinition(); + const render = (args: unknown) => + def + .renderCall?.(args as any, mockTheme as any, { lastComponent: undefined } as any) + .render(80) + .join("\n"); + expect(render({ questions: [{ question: "Long question text", header: "Short" }] })).toContain("Short"); + expect(render({ questions: '[{"question":"x"}]' })).toContain("ask_user"); + expect(render({ questions: '[{"quest' })).toContain("ask_user"); + }); + it("renders multi-question call label and per-batch result summary", () => { const def = createAskUserToolDefinition(); const call = def.renderCall?.( diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index f7c80858..6c1f7d45 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/dashboard", - "version": "2.70.0", + "version": "2.70.1", "description": "Web dashboard for dreb — fleet overview, chat parity, subagent observability", "license": "MIT", "type": "module", diff --git a/packages/semantic-search/.claude-plugin/plugin.json b/packages/semantic-search/.claude-plugin/plugin.json index f502dd3f..3eab5e65 100644 --- a/packages/semantic-search/.claude-plugin/plugin.json +++ b/packages/semantic-search/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "semantic-search", "description": "Semantic codebase search — natural language queries over code and docs using embeddings, tree-sitter parsing, and POEM multi-signal ranking", - "version": "2.70.0", + "version": "2.70.1", "author": { "name": "Drew Brereton" }, diff --git a/packages/semantic-search/package.json b/packages/semantic-search/package.json index fc22a43a..ba4e425d 100644 --- a/packages/semantic-search/package.json +++ b/packages/semantic-search/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/semantic-search", - "version": "2.70.0", + "version": "2.70.1", "description": "Semantic codebase search engine with embedding-based ranking and MCP server", "publishConfig": { "access": "public" diff --git a/packages/telegram/package.json b/packages/telegram/package.json index 6686ab92..fca2e023 100644 --- a/packages/telegram/package.json +++ b/packages/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/telegram", - "version": "2.70.0", + "version": "2.70.1", "description": "Telegram bot frontend for dreb coding agent", "license": "MIT", "type": "module", diff --git a/packages/tui/package.json b/packages/tui/package.json index 21b4a9e3..c5a3b687 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/tui", - "version": "2.70.0", + "version": "2.70.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js",