diff --git a/apps/coder/web/src/api/client.ts b/apps/coder/web/src/api/client.ts
index 77c407e..43c7b2e 100644
--- a/apps/coder/web/src/api/client.ts
+++ b/apps/coder/web/src/api/client.ts
@@ -1,4 +1,4 @@
-import type { Project, Session, Chat, Message, PendingChange } from './types';
+import type { Project, Session, Chat, Message, PendingChange, AskUserAnswer } from './types';
export class ApiError extends Error {
constructor(
@@ -52,6 +52,14 @@ export const api = {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
+ answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
+ request<{ tool_message_id: string; assistant_message_id: string }>(
+ `/api/chats/${chatId}/answer_user_input`,
+ {
+ method: 'POST',
+ body: JSON.stringify({ tool_call_id: toolCallId, answers }),
+ },
+ ),
},
messages: {
diff --git a/apps/coder/web/src/api/types.ts b/apps/coder/web/src/api/types.ts
index fc9d114..34d5b74 100644
--- a/apps/coder/web/src/api/types.ts
+++ b/apps/coder/web/src/api/types.ts
@@ -32,16 +32,37 @@ export interface Chat {
export interface ToolCall {
id: string;
name: string;
- arguments: string;
+ args: unknown;
}
export interface ToolResult {
tool_call_id: string;
- output: string;
+ output: unknown;
truncated?: boolean;
error?: boolean;
}
+// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
+// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
+// same order. AskUserInputCard renders questions and POSTs answers.
+export type AskUserQuestionType = 'single_select' | 'multi_select';
+
+export interface AskUserQuestion {
+ question: string;
+ type: AskUserQuestionType;
+ options: string[];
+}
+
+export interface AskUserAnswer {
+ question: string;
+ selected_options: string[];
+ free_text: string | null;
+}
+
+export interface AskUserAnswerSet {
+ answers: AskUserAnswer[];
+}
+
export interface Message {
id: string;
session_id: string;
diff --git a/apps/coder/web/src/components/AskUserInputCard.tsx b/apps/coder/web/src/components/AskUserInputCard.tsx
new file mode 100644
index 0000000..0fac253
--- /dev/null
+++ b/apps/coder/web/src/components/AskUserInputCard.tsx
@@ -0,0 +1,323 @@
+import { useMemo, useState } from 'react';
+import { Check } from 'lucide-react';
+import { api } from '@/api/client';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Button } from '@/components/ui/button';
+import type {
+ AskUserAnswer,
+ AskUserAnswerSet,
+ AskUserQuestion,
+ ToolCall,
+ ToolResult,
+} from '@/api/types';
+
+// Batch 9.7: Inline interactive picker. Renders inside MessageList in place of
+// the standard ToolCallLine when the assistant emits an ask_user_input tool
+// call. While the tool result is null (server pre-stamps a sentinel with
+// output=null), shows the form; once the WS tool_result frame arrives with a
+// real AnswerSet, flips to read-only review mode.
+
+interface Props {
+ toolCall: ToolCall;
+ toolResult: ToolResult | null;
+ chatId: string;
+}
+
+function parseQuestions(raw: unknown): AskUserQuestion[] {
+ if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
+ const arr = (raw as { questions: unknown }).questions;
+ if (!Array.isArray(arr)) return [];
+ const out: AskUserQuestion[] = [];
+ for (const item of arr) {
+ if (!item || typeof item !== 'object') continue;
+ const q = item as { question?: unknown; type?: unknown; options?: unknown };
+ if (typeof q.question !== 'string') continue;
+ if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
+ if (!Array.isArray(q.options)) continue;
+ const opts = q.options.filter((o): o is string => typeof o === 'string');
+ if (opts.length < 2) continue;
+ out.push({ question: q.question, type: q.type, options: opts });
+ }
+ return out;
+}
+
+function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
+ if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
+ const arr = (raw as { answers: unknown }).answers;
+ if (!Array.isArray(arr)) return null;
+ const answers: AskUserAnswer[] = [];
+ for (const item of arr) {
+ if (!item || typeof item !== 'object') continue;
+ const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
+ if (typeof a.question !== 'string') continue;
+ if (!Array.isArray(a.selected_options)) continue;
+ if (a.free_text !== null && typeof a.free_text !== 'string') continue;
+ const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
+ answers.push({
+ question: a.question,
+ selected_options: sel,
+ free_text: (a.free_text as string | null) ?? null,
+ });
+ }
+ return { answers };
+}
+
+export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
+ const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
+
+ if (questions.length === 0) {
+ return (
+
+ ask_user_input: malformed tool args
+
+ );
+ }
+
+ // Tool result with a non-null output means the answer is already submitted.
+ // The pending sentinel uses output=null, so this branch only triggers after
+ // the real WS tool_result frame lands.
+ const answered = toolResult && toolResult.output !== null;
+ if (answered) {
+ const answerSet = parseAnswerSet(toolResult!.output);
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+function PendingView({
+ questions,
+ toolCallId,
+ chatId,
+}: {
+ questions: AskUserQuestion[];
+ toolCallId: string;
+ chatId: string;
+}) {
+ // Per-question selections + free text. Selections are option arrays so the
+ // multi_select case is uniform; single_select just constrains to length 1.
+ const [selections, setSelections] = useState(() => questions.map(() => []));
+ const [freeTexts, setFreeTexts] = useState(() => questions.map(() => ''));
+ const [submitting, setSubmitting] = useState(false);
+
+ const singleQuestion = questions.length === 1;
+ const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
+
+ // Submit button shows when:
+ // - more than one question (always batched), OR
+ // - one question and the user has typed free text (committing it needs an
+ // explicit Submit so an accidental Tab/click doesn't lose it).
+ // For one question with no free text, clicking an option submits inline.
+ const showSubmitButton = !singleQuestion || anyFreeText;
+
+ // Every question must have at least one of (option, free text).
+ const allComplete = questions.every((_, i) => {
+ return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
+ });
+
+ function buildAnswers(): AskUserAnswer[] {
+ return questions.map((q, i) => {
+ const freeText = freeTexts[i]!.trim();
+ return {
+ question: q.question,
+ selected_options: selections[i]!,
+ free_text: freeText.length > 0 ? freeText : null,
+ };
+ });
+ }
+
+ async function submit(answers: AskUserAnswer[]) {
+ if (submitting) return;
+ setSubmitting(true);
+ try {
+ await api.chats.answerUserInput(chatId, toolCallId, answers);
+ // Card stays mounted; the incoming WS tool_result frame will flip it
+ // into AnsweredView via the parent prop change.
+ } catch (err) {
+ console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err);
+ setSubmitting(false);
+ }
+ }
+
+ function pickSingle(qIdx: number, option: string) {
+ setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
+ // Immediate submit for the single-question single-select shortcut. Only
+ // fires when no free text exists anywhere — once the user typed, the
+ // Submit button takes over so the typed text isn't silently dropped.
+ if (singleQuestion && !anyFreeText) {
+ const answers: AskUserAnswer[] = [
+ {
+ question: questions[0]!.question,
+ selected_options: [option],
+ free_text: null,
+ },
+ ];
+ void submit(answers);
+ }
+ }
+
+ function toggleMulti(qIdx: number, option: string) {
+ setSelections((prev) =>
+ prev.map((arr, i) => {
+ if (i !== qIdx) return arr;
+ return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
+ }),
+ );
+ }
+
+ function setFreeText(qIdx: number, value: string) {
+ setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
+ }
+
+ return (
+
+
+ {questions.map((q, i) => (
+
+ {questions.length > 1 && (
+
+ Question {i + 1}
+
+ )}
+
{q.question}
+ {q.type === 'single_select' ? (
+
pickSingle(i, v)}
+ disabled={submitting}
+ className="gap-1.5"
+ >
+ {q.options.map((opt, j) => {
+ const id = `q${i}-opt${j}`;
+ return (
+
+ );
+ })}
+
+ ) : (
+
+ {q.options.map((opt, j) => {
+ const id = `q${i}-opt${j}`;
+ const checked = selections[i]!.includes(opt);
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ Or type a custom answer
+
+
setFreeText(i, e.target.value)}
+ className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
+ />
+
+
+ ))}
+
+ {showSubmitButton && (
+
+
+
+ )}
+
+ );
+}
+
+function AnsweredView({
+ questions,
+ answers,
+}: {
+ questions: AskUserQuestion[];
+ answers: AskUserAnswerSet | null;
+}) {
+ if (!answers) {
+ return (
+
+ ask_user_input: answers unavailable
+
+ );
+ }
+
+ return (
+
+
+ {questions.map((q, i) => {
+ const a = answers.answers[i];
+ if (!a) return null;
+ return (
+
+ {questions.length > 1 && (
+
+ Question {i + 1}
+
+ )}
+
{q.question}
+
+ {q.options.map((opt, j) => {
+ const selected = a.selected_options.includes(opt);
+ return (
+
+
+ {selected && }
+
+ {opt}
+
+ );
+ })}
+
+ {a.free_text && (
+
+ {a.free_text}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/coder/web/src/components/ChatPane.tsx b/apps/coder/web/src/components/ChatPane.tsx
index c56fdda..7f4f003 100644
--- a/apps/coder/web/src/components/ChatPane.tsx
+++ b/apps/coder/web/src/components/ChatPane.tsx
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Square } from 'lucide-react';
-import type { Message } from '@/api/types';
+import type { Message, ToolResult } from '@/api/types';
import { api } from '@/api/client';
import { MessageBubble } from './MessageBubble';
@@ -66,6 +66,14 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
// Filter out system messages for display (sentinels)
const visibleMessages = messages.filter((m) => m.role !== 'system');
+ // Build a lookup map from tool_call_id -> ToolResult for all messages
+ const toolResultsMap: Record = {};
+ for (const msg of messages) {
+ if (msg.tool_results) {
+ toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results;
+ }
+ }
+
return (
{/* Connection indicator */}
@@ -88,7 +96,7 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
)}
{visibleMessages.map((msg) => (
-
+
))}
diff --git a/apps/coder/web/src/components/MessageBubble.tsx b/apps/coder/web/src/components/MessageBubble.tsx
index 08e0098..2a99dec 100644
--- a/apps/coder/web/src/components/MessageBubble.tsx
+++ b/apps/coder/web/src/components/MessageBubble.tsx
@@ -1,13 +1,16 @@
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
-import type { Message } from '@/api/types';
+import type { Message, ToolResult } from '@/api/types';
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
+import { AskUserInputCard } from './AskUserInputCard';
interface Props {
message: Message;
+ chatId: string;
+ toolResultsMap: Record;
}
-export function MessageBubble({ message }: Props) {
+export function MessageBubble({ message, chatId }: Props) {
if (message.role === 'tool') {
return ;
}
@@ -34,18 +37,31 @@ export function MessageBubble({ message }: Props) {
{message.tool_calls && message.tool_calls.length > 0 && (
- {message.tool_calls.map((tc) => (
-
-
- {tc.name}
-
- {truncateArgs(tc.arguments)}
-
-
- ))}
+ {message.tool_calls.map((tc) => {
+ if (tc.name === 'ask_user_input') {
+ const result = message.tool_results ?? null;
+ return (
+
+ );
+ }
+ return (
+
+
+ {tc.name}
+
+ {truncateArgs(tc.args)}
+
+
+ );
+ })}
)}
@@ -70,12 +86,12 @@ export function MessageBubble({ message }: Props) {
);
}
-function ToolResultBubble({ message }: Props) {
+function ToolResultBubble({ message }: { message: Message }) {
const result = message.tool_results;
if (!result) return null;
const isError = result.error;
- const output = result.output || '';
+ const output = result.output != null ? String(result.output) : '';
const displayOutput =
output.length > 300 ? output.slice(0, 300) + '...' : output;
@@ -99,17 +115,21 @@ function ToolResultBubble({ message }: Props) {
);
}
-function truncateArgs(args: string): string {
+function truncateArgs(args: unknown): string {
if (!args) return '';
try {
- const parsed = JSON.parse(args);
- const keys = Object.keys(parsed);
- if (keys.length === 0) return '';
- const first = keys[0]!;
- const val = String(parsed[first]);
- const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
- return `${first}: ${display}`;
+ if (typeof args === 'object' && args !== null) {
+ const obj = args as Record;
+ const keys = Object.keys(obj);
+ if (keys.length === 0) return '';
+ const first = keys[0]!;
+ const val = String(obj[first] ?? '');
+ const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
+ return `${first}: ${display}`;
+ }
+ const str = String(args);
+ return str.length > 50 ? str.slice(0, 50) + '...' : str;
} catch {
- return args.length > 50 ? args.slice(0, 50) + '...' : args;
+ return String(args).length > 50 ? String(args).slice(0, 50) + '...' : String(args);
}
}
diff --git a/apps/coder/web/src/components/ui/button.tsx b/apps/coder/web/src/components/ui/button.tsx
new file mode 100644
index 0000000..64ae057
--- /dev/null
+++ b/apps/coder/web/src/components/ui/button.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react';
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes {
+ variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
+ size?: 'default' | 'sm' | 'lg' | 'icon';
+}
+
+const variantClasses: Record = {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+ outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+};
+
+const sizeClasses: Record = {
+ default: 'h-9 px-4 py-2',
+ sm: 'h-8 rounded-md px-3 text-xs',
+ lg: 'h-10 rounded-md px-8',
+ icon: 'h-9 w-9',
+};
+
+const Button = React.forwardRef(
+ ({ className, variant = 'default', size = 'default', ...props }, ref) => {
+ const base =
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-60';
+ const cls = [base, variantClasses[variant] ?? '', sizeClasses[size] ?? '', className ?? ''].join(' ');
+ return ;
+ },
+);
+Button.displayName = 'Button';
+
+export { Button };
diff --git a/apps/coder/web/src/components/ui/radio-group.tsx b/apps/coder/web/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..62f1ea9
--- /dev/null
+++ b/apps/coder/web/src/components/ui/radio-group.tsx
@@ -0,0 +1,56 @@
+import * as React from 'react';
+
+const RadioGroupContext = React.createContext<{
+ value: string | undefined;
+ onValueChange: (v: string) => void;
+ disabled?: boolean;
+} | null>(null);
+
+interface RadioGroupProps extends React.HTMLAttributes {
+ value?: string;
+ onValueChange?: (value: string) => void;
+ disabled?: boolean;
+}
+
+const RadioGroup = React.forwardRef(
+ ({ className, value, onValueChange, disabled, ...props }, ref) => {
+ const ctx = React.useMemo(() => ({ value, onValueChange: onValueChange ?? (() => {}), disabled }), [value, onValueChange, disabled]);
+ return (
+
+
+
+ );
+ },
+);
+RadioGroup.displayName = 'RadioGroup';
+
+interface RadioGroupItemProps extends React.InputHTMLAttributes {
+ value: string;
+}
+
+const RadioGroupItem = React.forwardRef(
+ ({ className, value, ...props }, ref) => {
+ const ctx = React.useContext(RadioGroupContext);
+ if (!ctx) return ;
+ const checked = ctx.value === value;
+ return (
+ ctx.onValueChange(value)}
+ className={className}
+ {...props}
+ />
+ );
+ },
+);
+RadioGroupItem.displayName = 'RadioGroupItem';
+
+export { RadioGroup, RadioGroupItem };
diff --git a/apps/server/src/services/agents.ts b/apps/server/src/services/agents.ts
index 2729a5b..c986bd8 100644
--- a/apps/server/src/services/agents.ts
+++ b/apps/server/src/services/agents.ts
@@ -83,6 +83,10 @@ export function slugify(name: string): string {
interface ParsedFrontmatter {
temperature?: number;
+ top_p?: number;
+ top_k?: number;
+ min_p?: number;
+ presence_penalty?: number;
tools?: string[];
description?: string;
model?: string;
@@ -132,6 +136,46 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
const n = Number(valueRaw);
if (Number.isFinite(n)) data.temperature = n;
else errors.push(`temperature must be a number (got "${valueRaw}")`);
+ } else if (key === 'top_p') {
+ const n = Number(valueRaw);
+ if (Number.isFinite(n)) {
+ data.top_p = n;
+ if (n < 0 || n > 1) {
+ console.warn(`agents: top_p ${n} out of range 0-1, ignoring (falling back to default)`);
+ }
+ } else {
+ errors.push(`top_p must be a number (got "${valueRaw}")`);
+ }
+ } else if (key === 'top_k') {
+ const n = Number(valueRaw);
+ if (Number.isInteger(n)) {
+ data.top_k = n;
+ if (n < 0 || n > 200) {
+ console.warn(`agents: top_k ${n} out of range 0-200, ignoring (falling back to default)`);
+ }
+ } else {
+ errors.push(`top_k must be an integer (got "${valueRaw}")`);
+ }
+ } else if (key === 'min_p') {
+ const n = Number(valueRaw);
+ if (Number.isFinite(n)) {
+ data.min_p = n;
+ if (n < 0 || n > 1) {
+ console.warn(`agents: min_p ${n} out of range 0-1, ignoring (falling back to default)`);
+ }
+ } else {
+ errors.push(`min_p must be a number (got "${valueRaw}")`);
+ }
+ } else if (key === 'presence_penalty') {
+ const n = Number(valueRaw);
+ if (Number.isFinite(n)) {
+ data.presence_penalty = n;
+ if (n < -2 || n > 2) {
+ console.warn(`agents: presence_penalty ${n} out of range -2-2, ignoring (falling back to default)`);
+ }
+ } else {
+ errors.push(`presence_penalty must be a number (got "${valueRaw}")`);
+ }
} else if (key === 'tools') {
if (valueRaw === '') {
data.tools = [];
@@ -276,6 +320,10 @@ function parseAgentSection(section: RawSection): Omit {
description: fm.description ?? '',
system_prompt: systemPrompt,
temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE,
+ top_p: typeof fm.top_p === 'number' ? fm.top_p : null,
+ top_k: typeof fm.top_k === 'number' ? fm.top_k : null,
+ min_p: typeof fm.min_p === 'number' ? fm.min_p : null,
+ presence_penalty: typeof fm.presence_penalty === 'number' ? fm.presence_penalty : null,
tools: filteredTools,
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
diff --git a/apps/server/src/services/inference/sentinel-summaries.ts b/apps/server/src/services/inference/sentinel-summaries.ts
index 9890337..033a406 100644
--- a/apps/server/src/services/inference/sentinel-summaries.ts
+++ b/apps/server/src/services/inference/sentinel-summaries.ts
@@ -86,7 +86,7 @@ export async function runCapHitSummary(
ctx,
session.model,
messages,
- { tools: null, temperature: agent?.temperature },
+ { tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
(delta) => {
accumulated += delta;
ctx.publish(sessionId, {
@@ -346,7 +346,7 @@ export async function runDoomLoopSummary(
ctx,
session.model,
messages,
- { tools: null, temperature: agent?.temperature },
+ { tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
(delta) => {
accumulated += delta;
ctx.publish(sessionId, {
@@ -545,7 +545,7 @@ export async function runStepCapSummary(
ctx,
session.model,
messages,
- { tools: null, temperature: agent?.temperature },
+ { tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
(delta) => {
accumulated += delta;
ctx.publish(sessionId, {
diff --git a/apps/server/src/services/inference/stream-phase.ts b/apps/server/src/services/inference/stream-phase.ts
index 5d748d2..63a9899 100644
--- a/apps/server/src/services/inference/stream-phase.ts
+++ b/apps/server/src/services/inference/stream-phase.ts
@@ -31,6 +31,10 @@ interface StreamOptions {
// (rare; we still omit from the request body to avoid OpenAI 400).
tools: ToolJsonSchema[] | null;
temperature?: number;
+ top_p?: number | null;
+ top_k?: number | null;
+ min_p?: number | null;
+ presence_penalty?: number | null;
}
// v1.13.1-A: convert BooCode's OpenAI-shaped history into AI SDK
@@ -199,6 +203,9 @@ export async function streamCompletion(
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
: {}),
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
+ ...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}),
+ ...(typeof opts.top_k === 'number' ? { topK: opts.top_k } : {}),
+ ...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}),
abortSignal: signal,
});
@@ -388,6 +395,10 @@ export async function executeStreamPhase(
: toolJsonSchemas()
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
const effectiveTemperature = agent?.temperature;
+ const effectiveTopP = agent?.top_p ?? undefined;
+ const effectiveTopK = agent?.top_k ?? undefined;
+ const effectiveMinP = agent?.min_p ?? undefined;
+ const effectivePresencePenalty = agent?.presence_penalty ?? undefined;
// v1.12.2: ctx_max lookup is cached after the first hit per model, so this
// is a Map probe in steady state. We capture nCtx once at the top of the
@@ -425,7 +436,7 @@ export async function executeStreamPhase(
ctx,
session.model,
messages,
- { tools: effectiveTools, temperature: effectiveTemperature },
+ { tools: effectiveTools, temperature: effectiveTemperature, top_p: effectiveTopP, top_k: effectiveTopK, min_p: effectiveMinP, presence_penalty: effectivePresencePenalty },
(delta) => {
state.accumulated += delta;
ctx.publish(sessionId, {
diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts
index 5bd99f5..3353293 100644
--- a/apps/server/src/types/api.ts
+++ b/apps/server/src/types/api.ts
@@ -99,6 +99,10 @@ export interface Agent {
description: string;
system_prompt: string;
temperature: number;
+ top_p: number | null; // null means omit from request body
+ top_k: number | null; // null means omit from request body
+ min_p: number | null; // null means omit from request body
+ presence_penalty: number | null; // null means omit from request body
tools: string[]; // whitelist of tool names; empty = no tools allowed
model: string | null; // null means "session.model wins"
source: AgentSource;
diff --git a/apps/web/src/components/AgentPicker.tsx b/apps/web/src/components/AgentPicker.tsx
index f0cbe69..ef7d99a 100644
--- a/apps/web/src/components/AgentPicker.tsx
+++ b/apps/web/src/components/AgentPicker.tsx
@@ -96,7 +96,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
-
+
{error && (
{error}
)}
@@ -128,7 +128,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
{a.name}
{a.description && (
-
+
{a.description}
)}
diff --git a/apps/web/src/components/ModelPicker.tsx b/apps/web/src/components/ModelPicker.tsx
index 7c99b43..a2f56bf 100644
--- a/apps/web/src/components/ModelPicker.tsx
+++ b/apps/web/src/components/ModelPicker.tsx
@@ -107,7 +107,7 @@ export function ModelPicker({ value, onChange }: Props) {
-
+
{error && (
{error}
)}
diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx
index 5f30d82..67559db 100644
--- a/apps/web/src/components/SessionLandingPage.tsx
+++ b/apps/web/src/components/SessionLandingPage.tsx
@@ -1,8 +1,10 @@
-import { useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
import type { Chat } from '@/api/types';
+import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
-import { Textarea } from '@/components/ui/textarea';
+import { ChatInput } from '@/components/ChatInput';
import {
ContextMenu,
ContextMenuContent,
@@ -25,6 +27,8 @@ interface Props {
chats: Chat[];
onOpenChat: (chatId: string) => void;
onSend: (content: string) => void;
+ /** Create a chat and return its id. Used by slash-command handler. */
+ createChat: () => Promise<{ id: string }>;
onReopenChat: (chatId: string) => Promise;
onArchiveChat: (chatId: string) => Promise;
onRenameChat: (chatId: string, name: string) => Promise;
@@ -153,12 +157,15 @@ export function SessionLandingPage({
chats,
onOpenChat,
onSend,
+ projectId,
+ createChat,
onReopenChat,
onArchiveChat,
onRenameChat,
onDeleteChat,
}: Props) {
const [composerValue, setComposerValue] = useState('');
+ const [chatId, setChatId] = useState(null);
const [showArchived, setShowArchived] = useState(false);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
@@ -172,13 +179,43 @@ export function SessionLandingPage({
.filter((c) => c.status === 'archived')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
- function handleSend() {
+ // Create a chat lazily on first send or slash command.
+ const ensureChat = useCallback(async (): Promise => {
+ if (chatId) return chatId;
+ try {
+ const chat = await createChat();
+ setChatId(chat.id);
+ return chat.id;
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to create chat');
+ throw err;
+ }
+ }, [chatId, createChat]);
+
+ async function handleSend() {
const text = composerValue.trim();
if (!text) return;
- onSend(text);
- setComposerValue('');
+ try {
+ const cid = await ensureChat();
+ onSend(text);
+ setComposerValue('');
+ } catch {
+ // Error already surfaced via toast.
+ }
}
+ // v2.3: slash-command dispatch on landing page. Creates a chat first if
+ // one doesn't exist, then invokes the skill on that chat.
+ const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
+ try {
+ const cid = await ensureChat();
+ await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
+ setComposerValue('');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
+ }
+ }, [ensureChat]);
+
function startRename(chat: Chat) {
setRenamingId(chat.id);
setRenameValue(chat.name ?? '');
@@ -293,33 +330,17 @@ export function SessionLandingPage({
)}
-